I’ll be sincere. Once I began occupied with which different languages SwiftBash ought to run, JavaScript was about fifth on my listing. I’m a Swift individual. I’m a Cocoa individual. I’m someplace between detached and faintly hostile to npm. The thought of “let’s drop a Node-compatible runtime into the bash shell” sounded precisely just like the type of challenge I’d shake my head at on another person’s GitHub.
But it surely saved nagging. After SwiftScript and SwiftPorts, the plain subsequent transfer was one other scripting language. And after I began enumerating them out loud — Python, Ruby, Lua, Perl, JavaScript — there was precisely a type of that Apple ships an entire, JIT-tuned interpreter for, on each platform, proper out of the field.
$ ls /System/Library/Frameworks | grep JavaScriptCore
JavaScriptCore.framework
So I went to take a look at what was truly in there. And from there it was a sluggish accumulation of small surprises that finally had me writing a weblog put up that I used to be fairly positive I used to be by no means going to write down.
Shock one: the engine is proper there
I knew JavaScriptCore existed. I’d seen it linked from WebKit-shaped locations. I had a imprecise reminiscence of it powering the JS in Safari content material blockers. What I hadn’t fairly registered was that the Swift bindings for it have been sitting within the SDK since iOS 7, that they’re three traces, and that they really work:
import JavaScriptCore
let ctx = JSContext()!
let end result = ctx.evaluateScript("1 + 2 + 3")
print(end result?.toInt32() ?? -1)
// 6
That’s your entire engine. No exterior dependencies, no package deal supervisor, no construct script. Similar engine Safari makes use of. Out there on each Apple system I personal.
OK, fantastic. Including numbers in JavaScript isn’t a characteristic.
Shock two: the bridging is sincere
I wrote a tiny console.log:
let log: @conference(block) (String) -> Void = { msg in
print("[js]", msg)
}
ctx.setObject(log, forKeyedSubscript: "log" as NSString)
ctx.evaluateScript("log('good day from JavaScript')")
// [js] good day from JavaScript
After which I sat there for a minute, as a result of what simply occurred is {that a} JavaScript program known as a Swift closure. There was no IPC. No serialisation. No JSON.stringify. The closure captured usually, the JS context handed it a String, the Swift code printed. They’re the identical course of. They’re sharing reminiscence.
And it goes each methods. JS can hand objects again to Swift, JS can construct dictionaries that come out as [String: Any], Swift can maintain a JSValue reference and name into it later. The bridge is so quiet it’s a must to hold reminding your self there’s a bridge there in any respect.
I dimly remembered that that is, kind of, precisely how React Native works. So I went to examine.
Shock three: it is a Complete Sample
When React Native shipped in 2015, the iOS app was a skinny native shell. The precise app — the views, the state, the buttons that say ‘Purchase’ — was JavaScript code that ran inside a JavaScriptCore context that the shell embedded. Similar trick I’d simply performed in ten traces of Swift, besides scaled as much as be the substrate of half the App Retailer.
Then I seen Microsoft CodePush (now principally succeeded by Expo’s EAS Replace), which exists for one motive: in case your iOS app’s logic is JavaScript, you’ll be able to change the JavaScript over the air, with out an App Retailer overview, as a result of Apple’s clause 3.3.2 particularly blesses interpreted code. The native shell is fastened. The interpreted code can change.
This was a quiet factor to find. I had been considering of “obtain a binary plugin and run it” as one thing iOS simply doesn’t permit. And it doesn’t, if “binary” means machine code. However “obtain a JavaScript file and feed it to JSC” is — and has been for a decade — the documented, sanctioned approach to ship dwell code to a sandboxed app on iOS. Discord does it. Shopify does it. Coinbase does it. The official JavaScript for Automation, the one you get with osascript -l JavaScript, does it. Scriptable on iOS is actually an entire shell-environment-in-an-app that lives totally on prime of this identical primitive.
So someplace between “let me do this factor” and “wait, that is your entire React Native enterprise mannequin”, my opinion of the challenge shifted from “amusing weekend toy” to “truly, why shouldn’t SwiftBash be capable to run JavaScript?”
Shock 4: you’ll be able to re-emulate Node from inside
Right here’s the place it bought enjoyable. JavaScriptCore is simply the language — no console, no course of, no fs. JS scripts written for real-world use don’t discuss to “the language”, they discuss to Node’s API floor: console.log, course of.argv, require('fs').readFileSync(...), fetch, setTimeout.
Which suggests: something Node calls a “module” is only a string of JavaScript that has entry to capabilities a runtime uncovered. And we’ve got a bridge for exposing capabilities.
So the recipe is mechanical:
let readFileSync: @conference(block) (String) -> String = { path in
(strive? String(contentsOfFile: path, encoding: .utf8)) ?? ""
}
let fs = JSValue(newObjectIn: ctx)!
fs.setObject(readFileSync, forKeyedSubscript: "readFileSync" as NSString)
ctx.setObject(fs, forKeyedSubscript: "fs" as NSString)
…and now JavaScript can:
console.log(fs.readFileSync('/and many others/hosts').break up('n').size);
You repeat that for console, for course of, for path, for os, for crypto (Apple offers you CryptoKit), for zlib (the host has libz), for fetch (URLSession), for timers (DispatchSourceTimer). Each is fifty to 100 traces. After a couple of thousand traces of this sort of plumbing, you might have a runtime the place present Node CLI scripts run fully unchanged:
#!/usr/bin/env node
const fs = require('node:fs');
const args = course of.argv.slice(2);
const greeting = course of.env.GREETING ?? 'Good day';
console.log(`${greeting}, ${args[0] ?? course of.env.USER}!`);
That’s a script anybody would possibly write. It makes use of require, course of.argv, course of.env, console.log. Drop it on disk, chmod +x, run. Similar supply on the desktop, identical supply on my iPad embedded inside an app, identical supply beneath the actual node. The shebang says node, and so long as the binary that env finds first is ours, the script doesn’t know or care which engine simply ran it. (The trick to make our binary shadow node is mildly amusing — argv[0] dispatch and a swift-js set up subcommand that lays down symlinks for node and bun — but it surely’s not the attention-grabbing half.)
Shock 5: Swift Duties make child_process bizarre
This was the half I genuinely didn’t see coming.
Present JavaScript scripts use child_process.execSync and associates, as a result of that’s the way you name out to git/grep/curl from Node. The naïve port forks /bin/sh, identical manner node does, and we’re again to “wants a Unix course of mannequin”. Which I can’t have on iOS.
However I’ve one thing node and bun don’t: I’ve BashInterpreter sitting subsequent to the JS engine in the identical Swift course of. SwiftBash already is aware of the way to run printf | grep | wc -l with out forking — each command is a registered Swift sort, the pipeline is AsyncStream between them. So when a JavaScript program does
require('node:child_process').execSync('printf "alphanbetangamman" | grep a | wc -l');
// → 3
…the JS engine calls right into a Swift bridge, which fingers the string to a recent BashInterpreter.Shell, which runs the pipeline as abnormal AsyncStream channels, and the JS will get "3n" again. There isn’t a fork. There isn’t a /bin/sh. printf, grep, and wc all dwell as Swift instructions inside this identical course of.
I believe the second I actually fell for this challenge was after I realised JS may “spawn” twenty concurrent bash pipelines:
await Promise.all(
Array.from({size: 20}, () => cp.exec('echo one thing'))
);
…in two milliseconds. Not as a result of the engine is quick (node is quick too) however as a result of there aren’t any twenty processes concerned. There are twenty Job.indifferent operating twenty BashInterpreter.Shell situations on the identical thread pool. Swift’s structured concurrency is the precise primitive when your “youngster course of” is a worth sort. It seems like a quiet violation of the legal guidelines of POSIX, in a great way.
I’ve benchmarks someplace that present this scaling cleanly to a whole lot of concurrent in-process pipelines, the place node and bun are bottlenecked on fork. However the factor I need to sit with is simply the conceptual body: a JavaScript program that thinks it’s spawning subprocesses, the place each “course of” is definitely a Swift Job, and your entire factor runs inside one sandboxed app.
The place this leaves me
I began this with a flat skeptical “JavaScript? actually?” and a imprecise sense that it could be a challenge I’d begin, get tired of, and abandon. What I’ve as an alternative is a factor that lets a JS shebang script run on macOS, iOS, the iPad, in a sandboxed app, and inside SwiftBash, with the identical supply. That may pipe by bash instructions with out spawning. That may be downloaded over the air the way in which React Native bundles have been for a decade. That’s sooner than node on chilly begin, smaller than node on disk, and surprisingly near node on precise scripts.
The sincere takeaway, the one I hold coming again to: I had been treating JavaScriptCore the way in which you deal with the /System/Library/Frameworks folder normally — as infrastructure for another person’s app. It isn’t. It’s a fully-tuned scripting engine that has been sitting on each system I’ve ever owned, with first-class Swift bindings, explicitly blessed by Apple for executing untrusted / downloaded code, and nearly no one outdoors the React Native crowd appears to make use of it. That’s a wierd state of affairs. It seems like leaving cash on the desk.
The repo is at Cocoanetics/SwiftBash. The total SwiftJS write-up — each layer, each cross-runtime parity check, the multi-call-binary trick, the --sandbox-env flag, the streaming spawn() follow-up — lives in Docs/SwiftJS.md. The swift-js set up command will drop node/bun symlinks right into a listing of your selection, so you’ll be able to strive operating an present Node script beneath it with out altering something.
I’m particularly curious whether or not anybody studying this has an iOS app the place they’d need to ship downloadable JS as behaviour-on-demand. That’s the use case I’ve not but gotten to play with, and it’s the one which turns this from a “enjoyable shebang interpreter” into one thing with precise product form. Open a problem on the repo, or write to me, and I’ll have Opus check out your script.
Associated
Classes: Updates
