I’ll be honest. When I started thinking about which other languages SwiftBash should run, JavaScript was about fifth on my list. I’m a Swift person. I’m a Cocoa person. I’m somewhere between indifferent and faintly hostile to npm. The idea of “let’s drop a Node-compatible runtime into the bash shell” sounded exactly like the kind of project I would shake my head at on someone else’s GitHub.
But it kept nagging. After SwiftScript and SwiftPorts, the obvious next move was another scripting language. And when I started enumerating them out loud — Python, Ruby, Lua, Perl, JavaScript — there was exactly one of those that Apple ships a complete, JIT-tuned interpreter for, on every platform, right out of the box.
$ ls /System/Library/Frameworks | grep JavaScriptCore
JavaScriptCore.framework
So I went to look at what was actually in there. And from there it was a slow accumulation of small surprises that eventually had me writing a blog post that I was pretty sure I was never going to write.
Surprise one: the engine is right there
I knew JavaScriptCore existed. I’d seen it linked from WebKit-shaped places. I had a vague memory of it powering the JS in Safari content blockers. What I hadn’t quite registered was that the Swift bindings for it have been sitting in the SDK since iOS 7, that they’re three lines, and that they actually work:
import JavaScriptCore
let ctx = JSContext()!
let result = ctx.evaluateScript("1 + 2 + 3")
print(result?.toInt32() ?? -1)
// 6
That’s the entire engine. No external dependencies, no package manager, no build script. Same engine Safari uses. Available on every Apple device I own.
OK, fine. Adding numbers in JavaScript is not a feature.
Surprise two: the bridging is honest
I wrote a tiny console.log:
let log: @convention(block) (String) -> Void = { msg in
print("[js]", msg)
}
ctx.setObject(log, forKeyedSubscript: "log" as NSString)
ctx.evaluateScript("log('hello from JavaScript')")
// [js] hello from JavaScript
And then I sat there for a minute, because what just happened is that a JavaScript program called a Swift closure. There was no IPC. No serialisation. No JSON.stringify. The closure captured normally, the JS context handed it a String, the Swift code printed. They are the same process. They are sharing memory.
And it goes both ways. JS can hand objects back to Swift, JS can build dictionaries that come out as [String: Any], Swift can hold a JSValue reference and call into it later. The bridge is so quiet you have to keep reminding yourself there’s a bridge there at all.
I dimly remembered that this is, more or less, exactly how React Native works. So I went to check.
Surprise three: this is a Whole Pattern
When React Native shipped in 2015, the iOS app was a thin native shell. The actual app — the views, the state, the buttons that say ‘Buy’ — was JavaScript code that ran inside a JavaScriptCore context that the shell embedded. Same trick I’d just done in ten lines of Swift, except scaled up to be the substrate of half the App Store.
Then I noticed Microsoft CodePush (now mostly succeeded by Expo’s EAS Update), which exists for one reason: if your iOS app’s logic is JavaScript, you can replace the JavaScript over the air, without an App Store review, because Apple’s clause 3.3.2 specifically blesses interpreted code. The native shell is fixed. The interpreted code can change.
This was a quiet thing to discover. I had been thinking of “download a binary plugin and run it” as something iOS just doesn’t allow. And it doesn’t, if “binary” means machine code. But “download a JavaScript file and feed it to JSC” is — and has been for a decade — the documented, sanctioned way to ship live 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 essentially a whole shell-environment-in-an-app that lives entirely on top of this same primitive.
So somewhere between “let me try this thing” and “wait, this is the entire React Native business model”, my opinion of the project shifted from “amusing weekend toy” to “actually, why shouldn’t SwiftBash be able to run JavaScript?”
Surprise four: you can re-emulate Node from inside
Here’s where it got fun. JavaScriptCore is just the language — no console, no process, no fs. JS scripts written for real-world use don’t talk to “the language”, they talk to Node’s API surface: console.log, process.argv, require('fs').readFileSync(...), fetch, setTimeout.
Which means: anything Node calls a “module” is just a string of JavaScript that has access to functions a runtime exposed. And we have a bridge for exposing functions.
So the recipe is mechanical:
let readFileSync: @convention(block) (String) -> String = { path in
(try? 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('/etc/hosts').split('\n').length);
You repeat that for console, for process, for path, for os, for crypto (Apple gives you CryptoKit), for zlib (the host has libz), for fetch (URLSession), for timers (DispatchSourceTimer). Each one is fifty to a hundred lines. After about a thousand lines of this kind of plumbing, you have a runtime where existing Node CLI scripts run completely unchanged:
#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const greeting = process.env.GREETING ?? 'Hello';
console.log(`${greeting}, ${args[0] ?? process.env.USER}!`);
That’s a script anyone might write. It uses require, process.argv, process.env, console.log. Drop it on disk, chmod +x, run. Same source on the desktop, same source on my iPad embedded inside an app, same source under the real node. The shebang says node, and as long as the binary that env finds first is ours, the script doesn’t know or care which engine just ran it. (The trick to make our binary shadow node is mildly amusing — argv[0] dispatch and a swift-js install subcommand that lays down symlinks for node and bun — but it’s not the interesting part.)
Surprise five: Swift Tasks make child_process weird
This was the part I genuinely did not see coming.
Existing JavaScript scripts use child_process.execSync and friends, because that’s how you call out to git/grep/curl from Node. The naïve port forks /bin/sh, same way node does, and we’re back to “needs a Unix process model”. Which I cannot have on iOS.
But I have something node and bun don’t: I have BashInterpreter sitting next to the JS engine in the same Swift process. SwiftBash already knows how to run printf | grep | wc -l without forking — every command is a registered Swift type, the pipeline is AsyncStream<Data> between them. So when a JavaScript program does
require('node:child_process').execSync('printf "alpha\\nbeta\\ngamma\\n" | grep a | wc -l');
// → 3
…the JS engine calls into a Swift bridge, which hands the string to a fresh BashInterpreter.Shell, which runs the pipeline as ordinary AsyncStream<Data> channels, and the JS gets "3\n" back. There is no fork. There is no /bin/sh. printf, grep, and wc all live as Swift commands inside this same process.
I think the moment I really fell for this project was when I realised JS could “spawn” twenty concurrent bash pipelines:
await Promise.all(
Array.from({length: 20}, () => cp.exec('echo something'))
);
…in two milliseconds. Not because the engine is fast (node is fast too) but because there are no twenty processes involved. There are twenty Task.detached running twenty BashInterpreter.Shell instances on the same thread pool. Swift’s structured concurrency is the right primitive when your “child process” is a value type. It feels like a quiet violation of the laws of POSIX, in a good way.
I have benchmarks somewhere that show this scaling cleanly to hundreds of concurrent in-process pipelines, where node and bun are bottlenecked on fork. But the thing I want to sit with is just the conceptual frame: a JavaScript program that thinks it’s spawning subprocesses, where every “process” is actually a Swift Task, and the entire thing runs inside one sandboxed app.
Where this leaves me
I started this with a flat skeptical “JavaScript? really?” and a vague sense that it would be a project I’d start, get bored with, and abandon. What I have instead is a thing that lets a JS shebang script run on macOS, iOS, the iPad, in a sandboxed app, and inside SwiftBash, with the same source. That can pipe through bash commands without spawning. That can be downloaded over the air the way React Native bundles have been for a decade. That is faster than node on cold start, smaller than node on disk, and surprisingly close to node on actual scripts.
The honest takeaway, the one I keep coming back to: I had been treating JavaScriptCore the way you treat the /System/Library/Frameworks folder in general — as infrastructure for someone else’s app. It isn’t. It’s a fully-tuned scripting engine that has been sitting on every device I’ve ever owned, with first-class Swift bindings, explicitly blessed by Apple for executing untrusted / downloaded code, and almost nobody outside the React Native crowd seems to use it. That’s a strange situation. It feels like leaving money on the table.
The repo is at Cocoanetics/SwiftBash. The full SwiftJS write-up — every layer, every cross-runtime parity test, the multi-call-binary trick, the --sandbox-env flag, the streaming spawn() follow-up — lives in Docs/SwiftJS.md. The swift-js install command will drop node/bun symlinks into a directory of your choice, so you can try running an existing Node script under it without changing anything.
I’m especially curious whether anyone reading this has an iOS app where they’d want to ship downloadable JS as behaviour-on-demand. That’s the use case I have not yet gotten to play with, and it’s the one that turns this from a “fun shebang interpreter” into something with actual product shape. Open an issue on the repo, or write to me, and I’ll have Opus take a look at your script.
Categories: Updates