HomeiOS DevelopmentLook What JavaScriptCore Has Been Doing in My Pocket

Look What JavaScriptCore Has Been Doing in My Pocket


I’ll be sincere. Once I began serious about which different languages SwiftBash ought to run, JavaScript was about fifth on my listing. I’m a Swift particular person. I’m a Cocoa particular person. I’m someplace between detached and faintly hostile to npm. The concept of “let’s drop a Node-compatible runtime into the bash shell” sounded precisely just like the sort of undertaking I might shake my head at on another person’s GitHub.

However it stored nagging. After SwiftScript and SwiftPorts, the apparent subsequent transfer was one other scripting language. And once I began enumerating them out loud — Python, Ruby, Lua, Perl, JavaScript — there was precisely a kind 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 gradual accumulation of small surprises that ultimately had me writing a weblog put up that I used to be fairly certain 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 outcome = ctx.evaluateScript("1 + 2 + 3")
print(outcome?.toInt32() ?? -1)
// 6

That’s the whole engine. No exterior dependencies, no package deal supervisor, no construct script. Identical engine Safari makes use of. Accessible on each Apple system I personal.

OK, high quality. Including numbers in JavaScript is just not a function.

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('hey from JavaScript')")
// [js] hey from JavaScript

After which I sat there for a minute, as a result of what simply occurred is {that a} JavaScript program referred to 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 important 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. Identical trick I’d simply executed in ten traces of Swift, besides scaled as much as be the substrate of half the App Retailer.

Then I seen Microsoft CodePush (now largely succeeded by Expo’s EAS Replace), which exists for one cause: in case your iOS app’s logic is JavaScript, you possibly can change the JavaScript over the air, with out an App Retailer evaluate, 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 enable. 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 strategy to ship stay 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 basically an entire shell-environment-in-an-app that lives completely on high of this identical primitive.

So someplace between “let me do this factor” and “wait, that is the whole React Native enterprise mannequin”, my opinion of the undertaking shifted from “amusing weekend toy” to “truly, why shouldn’t SwiftBash have the ability to run JavaScript?”

Shock 4: you possibly can re-emulate Node from inside

Right here’s the place it obtained 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 features a runtime uncovered. And we have now a bridge for exposing features.

So the recipe is mechanical:

let readFileSync: @conference(block) (String) -> String = { path in
    (attempt? 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 so forth/hosts').cut 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). Every one is fifty to 100 traces. After a few thousand traces of this type of plumbing, you’ve got a runtime the place current 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 ?? 'Whats up';
console.log(`${greeting}, ${args[0] ?? course of.env.USER}!`);

That’s a script anybody may write. It makes use of require, course of.argv, course of.env, console.log. Drop it on disk, chmod +x, run. Identical supply on the desktop, identical supply on my iPad embedded inside an app, identical supply underneath 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 — nevertheless it’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 mates, 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 method node does, and we’re again to “wants a Unix course of mannequin”. Which I can not 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 best way to run printf | grep | wc -l with out forking — each command is a registered Swift kind, 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 contemporary BashInterpreter.Shell, which runs the pipeline as atypical AsyncStream channels, and the JS will get "3n" again. There is no such thing as a fork. There is no such thing as a /bin/sh. printf, grep, and wc all stay as Swift instructions inside this identical course of.

I feel the second I actually fell for this undertaking was once 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 working twenty BashInterpreter.Shell situations on the identical thread pool. Swift’s structured concurrency is the precise primitive when your “baby course of” is a worth kind. It appears like a quiet violation of the legal guidelines of POSIX, in a great way.

I’ve benchmarks someplace that present this scaling cleanly to lots of 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 the whole 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 might be a undertaking I’d begin, get tired of, and abandon. What I’ve as a substitute 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 via 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 typically — 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 virtually no one outdoors the React Native crowd appears to make use of it. That’s a wierd state of affairs. It appears like leaving cash on the desk.

The repo is at Cocoanetics/SwiftBash. The total SwiftJS write-up — each layer, each cross-runtime parity take a look at, 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 alternative, so you possibly can attempt working an current Node script underneath 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 difficulty on the repo, or write to me, and I’ll have Opus check out your script.


Classes: Updates

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments