Import one script from another?

The latest beta version is 1.0.2 (not v1.0.1) and it is working for me.

@BoxOfSnoo The note about that bug fix had accidentally snuck into the release notes for 1.0.1 while it’s actually part of 1.0.2. The good news is that Apple already approved 1.0.2 and it’s currently propagating to the App Stores.

That got it! Thanks.

Thanks for sharing this awesome workaround @sylumer! Implementing it in my project now.

Worth noting that only variables defined with var in the import file will be available after import, not const or let.

Very much a library of functions not data :wink:

If you wanted data, I’d wrap them in a function call and maybe even use them to read from a ‘settings’ file depending upon different use cases.

Hi folks, having delved into the rabbit hole that is home rolled import mechanisms before (in JavaScript for Automation, aka JXA), I’d thought I would share an improved version of @sylumer’s solution. This is basically a poor man’s version of the CommonJS module system (which the NodeJS module logic built and expanded on).

You will need this snippet of code in your script:

function require (path) {
  try { var fm = FileManager.iCloud() } catch (e) { var fm = FileManager.local() }
  let code = fm.readString(fm.joinPath(fm.documentsDirectory(), path))
  if (code == null) throw new Error(`Module '${path}' not found.`)
  return Function(`${code}; return exports`)()
}

Now, given a module script test-module that declares a top level variable exports like this:

const exports = {
  foo: function () { console.log('foo') },
  bar: 123,
  Baz: class Baz { 
    constructor (qux) {
      this.out = () => { console.log(qux) }
    }
  }
}

you can do:

const mod = require('test-module.js')
mod.foo()                // logs 'foo'
console.log(mod.bar)     // logs 123
new mod.Baz('qux').out() // logs 'qux'

Even better, you can use JavaScript’s object destructuring syntax to only import selected parts of the module into your script scope. For instance, if you only need the Baz class, do:

const {Baz} = require('test-module.js')
new Baz('qux').out()     // logs 'qux'

What are the advantages of this over the original solution? Well:

  1. Function is faster and safer than eval() – in fact, it is the recommended alternative to eval() on MDN;
  2. the defined module structure allows for exporting constant literals and classes defined via the class keyword, which is not possible with eval(), and it allows for partial imports (see above);
  3. error handling and proper fallback to local storage if iCloud is not active (assuming that Scriptable does the same; I haven’t checked).
8 Likes

Thanks for this. May I suggest to modify it a bit to support calling require without the .js extension to make it a bit closer to the CommonJS/NodeJS convention.

@kopischke Ver nice! Thanks for sharing this :raised_hands:

Yeah, I thought about that and my first implementation actually had a very naive stab at it, but I eventually opted out of it. I‘ll explain my thinking:

  1. The semantics of the module path in my hack are fundamentally different from those used by CommonJS / NodeJS. For technical reasons (both iOS sandbox and Scriptable API limitations), the module path is always anchored at Scriptable’s document directory, while the big players anchor it at the call point’s location in the file system. require(‘../lib/module.js’) will not work the way you’d expect it to coming from Node. In such cases, I find it best to not paper over differences by trying to match syntax.
  2. Now you might object that even then, the convenience of not having to type three characters (“.js”) could trump semantics, but consider this:
    • you can’t simply slap on the extension unconditionally because that would just shift the annoyance from always having to type the extension to always having to omit the extension, while also precluding people from using any extension but “.js”;
    • you can’t say “OK, just slap it on if there is no extension already” because there is no easy way to say what actually constitutes an extension if the file name contains a dot anywhere (it’s 2018, even Windows allows extensions of arbitrary length by now, and Apple systems have for ages), which, by the way, they do if you use the Reverse Domain Notation naming scheme common to Apple’s OS. In short, you would end up either dictating a module naming scheme orthogonal to platform conventions (notice anything about the Node module naming conventions?) or having to maintain a list of “allowed alternate extensions” you’d have to update inline in all scripts whenever it changes.

All of this is solvable, but not within six lines of inlined code in a way that scales safely. I’m not opposed to “magic” functionality as such, but in this case, I feel the convenience of not adding the extension to the call is not worth the hassle. Feel free to try to change my mind, of course!

1 Like

I agree, there’s really a lot more to it.
My line of thought is to actually to only replace path with (path.replace(/\.js$/,'') + '.js'). This handles both having/not having the extension and reverse domain name notation.
But yes, this would be limited to only using .js as the extension.

That actually was exactly my first naive implementation … however, as I stated, all this achieves is creating a restriction on file naming, all at the cost of one extra line of code in a snippet that needs to be inlined everywhere. I also happen to think that using the extension more clearly communicates intent, i.e. that you require a file, not some abstract module.

Anyway, lots of words to say I won’t be adding this into my reference snippet. You are of course free to modify it for your own usage!

CAVEAT: the following is just a technical tidbit without practical usage. For a module importing system that actually works, see my require() code above.

For those among you interested in the wider context, the whole thing may end up a non-issue in the foreseeable future: the dynamic import() spec is currently in stage 3 of the TC39 process, meaning it’s pretty much slated for inclusion in the JS language spec. In fact, import() has been in Chrome since build 64 and, more to the point, in WebKit and hence JavaScript Core, since January of, hold onto your hats, 2017.

However, as of today, the functionality is only active inside the JSC Shell and the WebKit inspector. You can do

let module = await import('./foo')

today in Scriptable, but all that nets you is a failed Promise. Let’s hope that changes soon-ish.

2 Likes

@kopischke Thanks for sharing your thoughts about this. I expect to add support for importing other scripts in a future update and I think your implementation and the thoughts surrounding it is a great inspiration.

2 Likes

I’m a complete noob in general and just trying to wrap my head around this…Can you help me understand what’s happening here?

let module = import((foo) = foo => console.log(foo));
foo(7) // '7'
console.log(module) // '{}'
console.log(module.foo) // 'undefined'

Can you help me understand what’s happening here?

Short version: nothing at all.

Long version: you are trying to import() a module, which returns an empty object because, as I wrote, the import() function exists but is not actually doing anything – as of the iOS APIs Scriptable is currently using – but returning a Promise that always fails. Promises are asynchronous, and as you don’t await the resolution, you get an empty placeholder object (if you did wait, you’d get an error). Hence your second log line, the empty placeholder object, and your third one, because all properties of empty objects are undefined by definition.

Even if import() did anything, your code would still fail, because you pass it a Function object, which is not executed and would not return anything even if it was, and that is not a path import() can work with. However, you do successfully define a function foo while doing so, meaning you can call it, hence your first log line.

I might have muddied the waters with my digression on import(). If you want to import an external module file, you currently should stick to the code and module format I posted above.

Thanks I’m more interested to learn the concept rather than actually import anything lol

Importing / requiring is meant to pull code stored outside your current script into it. If you just want to group related functions into a namespace like “module”, put them into an object:

const module = {
  foo: bar => console.log(bar)
}

module.foo('baz') // logs “baz”

If you want this to be a template you can create different objects from, use a class instead.

Sorry to bump an old thread, but I’m just now resurfacing Scriptable.

I’m trying out the fm.readString() function and it works okay, but Scriptable is injecting some comments at the top of my scripts that appear in the output.

I can regex my way around, but it would be nice if I didn’t have to. Any thoughts?

Output:

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: teal; icon-glyph: magic;
theThingIAmInterestedIn

Take a look at importModule().

https://docs.scriptable.app/importmodule/

3 Likes

There we go. Sorry, been out of it for a while. Thank you!