Wrapping JavaScript Libraries in Swift with JavaScriptCore
Published on Feb 7, 2026
Table of Contents
This guide walks through wrapping JavaScript libraries within Swift packages using JavaScriptCore. The patterns are derived from HighlighterSwift, which wraps the Highlight.js library, but apply to any JavaScript library you want to use from Swift.
Basic JavaScript understanding (no advanced JS required)
Familiarity with NSAttributedString (if processing rich text output)
Required Files
File
Purpose
Package.swift
SPM manifest with resource declarations
YourWrapper.swift
Main Swift wrapper class
Shims.swift
Cross-platform type aliases — only if your wrapper produces rich text (NSColor/UIFont). A JSON-bridge wrapper (Section 4.7) needs no shim.
your-library.min.js
The JavaScript library to wrap
Required Imports
import JavaScriptCore // For JSContext, JSValueimport Foundation // For Bundle, String, etc.// Platform-specific (handled via Shims.swift):#if os(macOS)import AppKit // For NSColor, NSFont#elseimport UIKit // For UIColor, UIFont#endif
Supported Platforms
The reference implementation supports:
macOS 11.0+ (Big Sur)
iOS 12.0+
tvOS 12.0+
visionOS 1.0+
Do not copy these numbers into your own package. They are a property of the
JavaScript that implementation happens to bundle. Your floor is set by the
language features in your bundle, and a modern build can easily require
iOS 16.4+. See Section 2.7
for how to derive it instead of guessing.
watchOS is absent for a reason: Apple does not ship the JavaScriptCore
framework there at all. No deployment-target tuning makes this approach work on
watchOS — you need a different strategy entirely.
This approach is Apple-platforms-only.import JavaScriptCore does not
resolve on Linux or Windows, so a library built this way cannot be added to a
server-side Swift target. If someone might try, say so in your README rather
than letting them find out at link time.
2. JavaScript Preparation
2.1 File Architecture
Rule: Use a single, self-contained JavaScript file.
The reference implementation uses a single minified file (highlight.min.js). The file must be completely self-contained with no external dependencies, import, or require statements.
Acceptable:
// Single file with all logic bundledvar hljs = (function() { /* ... */ })();
Not Acceptable:
import { something } from './other-file.js'; // Will not workconst lib = require('external-lib'); // Will not work
2.2 Scope & Namespace Requirements
Rule: The JavaScript library MUST expose its API via a global variable.
JavaScriptCore accesses JavaScript objects through the global namespace. The library must attach its public API to a global variable.
Correct Pattern (used by Highlight.js):
// The library assigns itself to a global variablevar hljs = { /* methods and properties */ };// Or using an IIFE that assigns to globalvar hljs = (function() { return { highlight: function(code, options) { /* ... */ }, listLanguages: function() { /* ... */ } };})();
Dependency Note: Some bundled/minified builds expose a global but also expect other globals to be present. For example, an HTML formatter may expect js_beautify and css_beautify to be loaded first. In these cases, load order matters: evaluate dependencies before the dependent file.
2.3 Entry Point Requirements
Rule: There is no special "entry point" function. Swift accesses methods directly on the global object.
After evaluating the JavaScript file, Swift extracts the global object and calls its methods directly:
// Swift accesses the global objectlet hljs = context.globalObject.objectForKeyedSubscript("hljs")// Swift calls methods on ithljs.invokeMethod("highlight", withArguments: [code, options])
What you need: A predictable, documented API on the global object. Know which methods to call and what arguments they expect.
UMD Note: Some UMD builds include require(...) paths for Node.js. In JavaScriptCore, those branches won't work. Ensure the bundle can run via the global namespace (often window/global) and add the global polyfills from section 3.2 if needed.
2.4 Syntax Constraints
Allowed JavaScript Features:
ES5 syntax (var, function, prototype)
ES6 features supported by JavaScriptCore (let, const, arrow functions, classes, template literals)
Use bundled/UMD builds: Look for files named library.min.js or library.umd.js
Verify global exposure: Check that the library creates a global variable
Test in isolation: Run the JS file in a JavaScriptCore context to verify it works
Include the license: Place the library's license file alongside the JS file
2.6 Directory Structure
Place JavaScript files in a dedicated Assets directory within your Sources:
Sources/
└── YourModule/
├── YourWrapper.swift
├── ... other Swift files
└── Assets/
├── your-library.min.js
├── LICENCE // License for the JS library
└── (any additional resources)
Assets/ must live inside the target directory. SwiftPM resolves the paths
in resources: relative to the target's own path, so .copy("Assets/…") for
target YourModule means Sources/YourModule/Assets/…. Putting Assets/ next
to YourModule/ instead is a hard build error — an invalid-resource failure for
the missing file, plus a warning about unhandled files in Sources/Assets.
2.7 Deriving Your Deployment Floor From the Bundle
Your package's minimum OS versions are a property of the JavaScript you ship, not of JavaScriptCore in the abstract. JavaScriptCore's language support tracks Safari's, so a bundle using a recent JS feature silently requires a recent OS.
Scan the bundle before choosing platform minimums:
for f in 'Object.hasOwn' '.at(' '.replaceAll(' 'structuredClone' \ '(?<=' '(?<!' '??=' '||=' '&&=' \ '.findLast' '.toSorted(' 'Object.groupBy'; do printf '%-18s %s\n' "$f" "$(grep -o -F -- "$f" your-library.min.js | wc -l)"done
Two things about that command, both of which will silently mislead you:
grep -o … | wc -l, not grep -c.grep -c counts matching lines. A minified bundle is one line, so every row reports 0 or 1 and the numbers mean nothing.
grep -F. Several of those strings are regex metacharacters. Matched as patterns, (?< is a group construct in some dialects — you get a parse error or a zero instead of a hit. Worth checking what grep actually is on your machine, too: if it is ugrep or ripgrep-in-disguise, the default dialect differs from BSD grep and the counts change under you.
Rough mapping (verify anything you find against MDN or caniuse — these move). The macOS column is the release that shipped with that Safari version; Safari can later outrun the OS, but an app linking JavaScriptCore.framework gets the OS's engine, so read the column as a floor:
A trap worth knowing about. Lookbehind is the feature that most often forces a modern floor, and how it appears in the bundle changes when it bites you:
As a regex literal — /(?<=\s)x/ — an unsupported construct is a syntax error at parse time, so the whole script fails to evaluate. Loud, obvious, caught by any smoke test.
Built at runtime — new RegExp("(?<=\\s)x") — it only throws when that line first executes. A smoke test that checks "did the global appear?" passes cleanly, and the failure surfaces later, on one specific input, on older devices only.
The second form is common in bundled Markdown and HTML parsers. Grep for the construct itself rather than relying on evaluation succeeding, and make sure your tests exercise a code path that actually builds it.
Finally: SwiftPM accepts point releases as strings, which the .v16-style enum cases cannot express:
// swift-tools-version: 5.9import PackageDescriptionlet package = Package( name: "YourPackageName", // Derive these from the JavaScript you bundle -- see Section 2.7. // Use string form when you need a point release: .iOS("16.4") platforms: [ .macOS(.v11), .iOS(.v12), .tvOS(.v12), .visionOS(.v1) ], products: [ .library( name: "YourModule", targets: ["YourModule"] ), ], targets: [ .target( name: "YourModule", dependencies: [], resources: [ // CRITICAL: Use .copy() for JavaScript files .copy("Assets/your-library.min.js"), // Include the JS library's license file .copy("Assets/LICENCE"), // Add any other resources (CSS, JSON, etc.) .copy("Assets/config.json"), ] ), .testTarget( name: "YourModuleTests", dependencies: ["YourModule"] ), ])
Key Points:
Use .copy() (not .process()) for JavaScript files to preserve them exactly
Resources are bundled into Bundle.module at runtime
List each resource file explicitly
Step 2: Create Cross-Platform Shims
Create Shims.swift to abstract platform differences:
// Shims.swift#if os(macOS)import AppKitpublic typealias PlatformColor = NSColorpublic typealias PlatformFont = NSFont#elseimport UIKitpublic typealias PlatformColor = UIColorpublic typealias PlatformFont = UIFont#endifpublic typealias AttributedStringKey = NSAttributedString.Key// OPTIONAL: Only needed if your wrapper works with NSTextStorage (rich text editing).#if os(macOS)public typealias TextStorageEditActions = NSTextStorageEditActions#elsepublic typealias TextStorageEditActions = NSTextStorage.EditActions#endif
Purpose: Isolate all #if os() conditionals to this single file. The rest of your codebase uses the type aliases.
Note: The PlatformColor and PlatformFont aliases are universally useful. The TextStorageEditActions alias is specific to rich text processing and can be omitted if your wrapper doesn't integrate with NSTextStorage.
Step 3: Create the Main Wrapper Class
// YourWrapper.swiftimport JavaScriptCoreimport Foundation/// Everything the wrapper can fail with. A single `nil` cannot tell a missing/// resource apart from a corrupt one, a syntax error, or a missing global --/// and those have completely different fixes.public enum WrapperError: Error { case resourceMissing(name: String) case resourceUnreadable(name: String, underlying: any Error) case evaluationFailed(message: String) case globalMissing(name: String) case methodMissing(global: String, method: String) case javaScriptException(message: String) case unexpectedReturnValue(description: String)}/// Records the most recent JavaScript exception seen by a context.////// This is a separate object rather than a property on the wrapper, for a/// specific reason: the exception handler must be installed *before* the script/// is evaluated, which is before `self` is fully initialized. A closure cannot/// capture `self` at that point, so it captures this instead.final class ExceptionRecorder { var message: String? /// Reads and clears in one step, so a stale exception is never reported /// against a later call. func take() -> String? { defer { message = nil } return message }}public final class YourWrapper { // MARK: - Private Properties private let context: JSContext /// The JavaScript global object (e.g., "hljs", "marked", "prism") private let jsLibrary: JSValue private let recorder = ExceptionRecorder() /// Bundle reference for loading resources private let bundle: Bundle // MARK: - Initialization /// Throws a `WrapperError` describing exactly what went wrong. public init() throws { // 1. Resolve the bundle (handles both SPM and framework contexts) // NOTE: `Bundle.module` is a static property synthesized by Swift Package Manager. // It only exists when your code is compiled as part of a Swift Package. // If you copy this code into a standard Xcode project (not a package), // `Bundle.module` will be undefined - that's why we use the #if check. #if SWIFT_PACKAGE let bundle = Bundle.module #else let bundle = Bundle(for: YourWrapper.self) #endif self.bundle = bundle // 2. Locate the JavaScript file guard let url = bundle.url(forResource: "your-library.min", withExtension: "js") else { throw WrapperError.resourceMissing(name: "your-library.min.js") } // 3. Load JavaScript source code let jsSource: String do { jsSource = try String(contentsOf: url, encoding: .utf8) } catch { throw WrapperError.resourceUnreadable( name: "your-library.min.js", underlying: error ) } // 4. Create the context guard let context = JSContext() else { throw WrapperError.evaluationFailed( message: "JavaScriptCore did not provide a JSContext." ) } self.context = context // 5. Install the exception handler BEFORE evaluating anything. // Install it afterwards and a syntax error in the bundle is // invisible to you. let recorder = self.recorder context.exceptionHandler = { context, exception in recorder.message = exception?.toString() ?? "Unknown JavaScript error" // Hygiene, not correctness: drops a stale JSValue (and the object // graph it retains) that anyone reading `context.exception` later // would otherwise see. See Section 5.5 for what this does *not* do. context?.exception = nil } // 6. Evaluate once. Passing the URL gives better source attribution. context.evaluateScript(jsSource, withSourceURL: url) if let message = recorder.take() { throw WrapperError.evaluationFailed(message: message) } // 7. Verify the global exists. // IMPORTANT: Replace "yourGlobalName" with your library's actual global variable guard let jsLibrary = context.globalObject?.objectForKeyedSubscript("yourGlobalName"), !jsLibrary.isUndefined, !jsLibrary.isNull else { throw WrapperError.globalMissing(name: "yourGlobalName") } self.jsLibrary = jsLibrary // 8. Verify the methods you actually depend on. A global that exists // but is the wrong build is a real failure mode, and it is much // cheaper to catch here than at the first call site. // // Note: `JSValue` has no `isFunction`. Check that the property is a // present object -- functions are objects in JavaScript. guard let method = jsLibrary.objectForKeyedSubscript("process"), !method.isUndefined, !method.isNull, method.isObject else { throw WrapperError.methodMissing(global: "yourGlobalName", method: "process") } }}
Why not init?()?
A failable initializer collapses five distinct failures into one nil:
resource missing, resource unreadable, script syntax error, global missing,
method missing. The first means a broken build configuration, the third means
a bad vendored artifact, the fourth means you have the wrong global name.
They need different fixes, and nil tells the caller none of it. The typed
enum above costs about forty lines and turns every one of them into a
readable message. Section 5 uses this style throughout.
By default, JavaScript console.log() calls produce no output in Xcode. To capture JavaScript logs during development, inject a console polyfill.
Order matters: add it after the exception handler is installed (Step 3, item 5) and before the library is evaluated (item 6). Inject it earlier and a mistake in your own polyfill JavaScript is silently swallowed — which is exactly the failure the handler exists to prevent.
// MARK: - Debug Logging Setup (add after JSContext creation)// Create a Swift function that JavaScript can calllet consoleLog: @convention(block) (String) -> Void = { message in print("[JS Log]: \(message)")}// Inject the function into the JavaScript global scopecontext.setObject(consoleLog, forKeyedSubscript: "swiftLog" as NSString)// Create a console.log polyfill that calls our Swift function// Note: JavaScript console.log accepts variadic arguments (e.g., console.log("a", "b", obj))// The JS wrapper collects all arguments, converts objects to JSON, joins them with spaces,// and passes the final single string to Swift. Swift only sees the combined result.context.evaluateScript(""" var console = { log: function() { var args = Array.prototype.slice.call(arguments); swiftLog(args.map(function(arg) { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }).join(' ')); }, warn: function() { console.log.apply(console, ['[WARN]'].concat(Array.prototype.slice.call(arguments))); }, error: function() { console.log.apply(console, ['[ERROR]'].concat(Array.prototype.slice.call(arguments))); } }; """)
Note the apply/concat in warn and error. The obvious-looking
console.log('[WARN]', arguments) passes the argumentsobject, which
log then JSON.stringifys — so you get
[WARN] {"0":"Deprecated function called"} instead of the message.
What This Does:
Creates a Swift closure that prints to the Xcode console
Injects it into the JavaScript context as swiftLog
Defines a JavaScript console object that routes log, warn, and error calls to Swift
Handles multiple arguments and object serialization
Example Output:
[JS Log]: Processing input: hello world
[JS Log]: [WARN] Deprecated function called
[JS Log]: Result: {"status": "success", "count": 42}
Production Consideration: You may want to conditionally enable this only in DEBUG builds:
#if DEBUG// ... console polyfill code ...#endif
Retain Cycle Warning: If you move this logging setup into a method that references self (e.g., to log to a custom logger instance), remember to capture self weakly:
// If your closure references self (e.g., self.logger.log(message)):let consoleLog: @convention(block) (String) -> Void = { [weak self] message in self?.logger.log("[JS]: \(message)")}
See Section 4.5 for full details on retain cycles with JavaScript callbacks.
Step 3.2: Add Common Polyfills (If Needed)
Many JavaScript libraries (especially UMD builds) assume the existence of browser globals like window or self. If a library fails to load with errors about missing globals, add these polyfills immediately after creating the context:
// Polyfill browser globals that many JS libraries expectcontext.evaluateScript(""" var window = this; var self = this; var global = this; """)
When You Need This:
Library throws "window is not defined" or "self is not defined"
Library was built for browser/Node.js and uses UMD module format
Library checks typeof window !== 'undefined' for environment detection
When You Don't Need This:
Library was specifically built for JavaScriptCore
Library works correctly without these globals (like Highlight.js)
Add polyfills before evaluating the library script:
guard let context = JSContext() else { throw WrapperError.evaluationFailed(message: "JavaScriptCore did not provide a JSContext.")}// 1. Exception handler first, so failures below are not silentcontext.exceptionHandler = { context, exception in recorder.message = exception?.toString() ?? "Unknown JavaScript error" context?.exception = nil}// 2. Then polyfillscontext.evaluateScript("var window = this; var self = this;")// 3. Then the librarycontext.evaluateScript(jsSource, withSourceURL: url)
Step 4: Implement Method Wrappers
For each JavaScript method you need to call, create a Swift wrapper:
// Continuing in YourWrapper.swiftextension YourWrapper { // MARK: - The one place that calls JavaScript /// Invokes a JavaScript method and turns a thrown JS exception into a /// Swift error. /// /// Route every call through a helper like this. See Section 5.5 for why /// checking the return value is not enough. private func invoke(_ method: String, _ arguments: [Any]) throws -> JSValue { _ = recorder.take() // discard anything stale before calling let returned = jsLibrary.invokeMethod(method, withArguments: arguments) // CRITICAL: check the recorder BEFORE inspecting the return value. // A method that throws returns `undefined`, not nil. if let message = recorder.take() { throw WrapperError.javaScriptException(message: message) } guard let returned else { throw WrapperError.javaScriptException( message: "\(method) returned no value and reported no exception." ) } return returned } // MARK: - Public Methods /// Example: Wrapping a JavaScript function with one argument /// JavaScript: yourLib.process(input) -> { result: string, status: number } public func process(_ input: String) throws -> String { let result = try invoke("process", [input]) guard let value = result.objectForKeyedSubscript("result"), !value.isUndefined, !value.isNull, let string = value.toString() else { throw WrapperError.unexpectedReturnValue( description: "process() did not return an object with a string `result`." ) } return string } /// Example: Wrapping a function with options dictionary /// JavaScript: yourLib.transform(code, { option1: value, option2: value }) public func transform(_ code: String, option1: Bool, option2: String) throws -> String { let options: [String: Any] = [ "option1": option1, "option2": option2 ] let result = try invoke("transform", [code, options]) guard result.isString, let string = result.toString() else { throw WrapperError.unexpectedReturnValue( description: "transform() returned \(result) instead of a string." ) } return string } /// Example: Wrapping a function that returns an array /// JavaScript: yourLib.listItems() -> ["item1", "item2", ...] public func listItems() throws -> [String] { let result = try invoke("listItems", []) guard let array = result.toArray() as? [String] else { throw WrapperError.unexpectedReturnValue( description: "listItems() did not return an array of strings." ) } return array }}
Omitting an optional argument is not the same as passing null.
If a JavaScript function distinguishes "argument omitted" from "argument was
null" — which any function using a default parameter or an === undefined
check does — then the argument array length matters:
// JS sees arguments.length == 1, `options` is undefined -> defaults applytry invoke("convert", [input])// JS sees arguments.length == 2, `options` is null -> may be rejected!try invoke("convert", [input, NSNull()])
Build the array conditionally rather than always passing a placeholder:
If your wrapper needs to load additional resources (CSS, JSON, etc.):
extension YourWrapper { /// Load a resource file from the bundle public func loadResource(named name: String, ofType type: String) -> String? { guard let path = bundle.path(forResource: name, ofType: type) else { return nil } return try? String(contentsOfFile: path, encoding: .utf8) } /// List all resources of a specific type public func availableResources(ofType type: String) -> [String] { let paths = bundle.paths(forResourcesOfType: type, inDirectory: nil) as [NSString] return paths.map { $0.lastPathComponent.replacingOccurrences(of: ".\(type)", with: "") } }}
Step 6: Verify the Artifact You Vendored
Do not assume a bundle is suitable just because it loads. Two cheap checks pay
for themselves, and both belong in your test suite rather than in a README:
// 1. It must be a classic script that installs the global you expect.// An IIFE assigned to a top-level `var` is the shape you want.// Paste in the first line of YOUR artifact -- the preamble is// bundler-specific. esbuild emits `"use strict";` plus an arrow IIFE:// "use strict";// var YourGlobal = (() => {// Rollup and webpack differ. The point is not the exact text; it is that a// later `.js` swap cannot silently change the module shape on you.XCTAssertTrue(source.hasPrefix("\"use strict\";\nvar YourGlobal = (() => {"))// 2. It must not contain anything a bare JSContext cannot resolve.for forbidden in ["require(\"", "require('", "from \"node:", "import(", "XMLHttpRequest(", "fetch("] { XCTAssertFalse( source.contains(forbidden), "Bundle contains \(forbidden), which cannot work in a bare JSContext." )}
Section 2.4 lists these constraints; this is how you actually enforce them. A
bundle that violates one will often still evaluate without error and then fail
on one specific input months later.
If you are vendoring a UMD build, drop require( from that list — Section
2.3 explains why a UMD bundle can carry a Node branch it never takes, and this
assertion would fail on nearly every one of them. Assert instead that the
global-namespace branch is the one taken, which the empty-vm parity run in
Section 6.1 demonstrates far better than any string search.
4. Pattern Reference
4.1 Type Conversion: Swift to JavaScript
When passing Swift values to invokeMethod(_:withArguments:):
Swift Type
JavaScript Type
Example
String
string
"hello" -> "hello"
Int
number
42 -> 42
Double
number
3.14 -> 3.14
Bool
boolean
true -> true
Date
Date
Date() -> new Date()
[Any]
array
[1, "a", true] -> [1, "a", true]
[String: Any]
object
["key": "value"] -> {key: "value"}
NSNull()
null
NSNull() -> null
A bare nil is not in that table on purpose: let args: [Any] = [nil] does not
compile, because Any is not ExpressibleByNilLiteral. You reach null either
through NSNull() or via an Any? that is .none. Before you do either, read
the "omit, don't null" note in Step 4 — passing NSNull() changes
arguments.length on the JavaScript side, and for a function with a default
parameter that is a different call entirely.
CRITICAL: JSON-Safe Types Only
The Any type in [String: Any] or [Any] must be limited to JSON-compatible primitives:
String
Int, Double, Float (numbers)
Bool
[Any] (nested arrays of primitives)
[String: Any] (nested dictionaries of primitives)
nil / NSNull()
Custom Swift Structs and Classes will NOT work — and the way they fail is worse than you might expect. They are not converted to null. They are bridged as an opaque wrapper object: typeof x === "object", the value is truthy, and Object.keys(x) is empty.
That means every if (x) and x != null guard on the JavaScript side passes, and only the individual property reads come back undefined — so the failure surfaces far away from its cause. Do not go looking for null; you will not find it.
If you need to pass a custom type, convert it to a Dictionary first:
// WRONG - This will fail silentlystruct User { let name: String; let age: Int }let user = User(name: "Alice", age: 30)jsLibrary.invokeMethod("process", withArguments: [user]) // user becomes null!// CORRECT - Convert to Dictionarylet userDict: [String: Any] = ["name": user.name, "age": user.age]jsLibrary.invokeMethod("process", withArguments: [userDict]) // Works!// ALTERNATIVE - Use Codable for complex typeslet userData = try JSONEncoder().encode(user)let userJSON = String(data: userData, encoding: .utf8)!jsLibrary.invokeMethod("processJSON", withArguments: [userJSON])
Performance Note for Large Data — with measurements, because the folklore here is wrong.
You will read that for very large strings you should avoid invokeMethod and instead set a global, then evaluateScript, "to reduce bridging overhead":
// The commonly recommended "faster for large data" version.// It is not faster. See the numbers below.context.globalObject.setValue(largeString, forProperty: "inputData")let result = context.evaluateScript("yourLib.process(inputData)")
I benchmarked all three calling conventions against a JavaScript function that only measures the crossing, across three orders of magnitude of payload. Apple silicon, release build, p50 of 20,000 iterations (2,000 at 1 MB):
Variant
~1 KB
~100 KB
~1 MB
invokeMethod(withArguments:)
875 ns
6 µs
44 µs
setValue + evaluateScript
1,167 ns
8 µs
77 µs
Cached JSValue + .call(withArguments:)
667 ns
6 µs
49 µs
Setting a global is never faster. At 1 KB it is ~33% slower than invokeMethod and ~75% slower than a cached function value. At 100 KB the gap is small enough to be within noise. At 1 MB the three are indistinguishable — a megabyte memcpy swamps everything else, and the run-to-run variance exceeds the difference between variants. There is no crossover, at any size, in either direction.
The reason is simple once measured. The String → JSString conversion is the size-dependent cost, and it is identical in both paths — setting the global costs the same as passing the argument. The evaluateScript call is then pure addition. It is not expensive (JavaScriptCore code-caches the source, so re-evaluating the same one-line script is a flat ~0.54 µs regardless of payload size), but it buys you nothing.
Then stop optimizing this. Behind a real workload the entire question is noise: a conversion that takes 5.8 ms spends about 0.3 µs on bridging — under 0.1% of the call. Cache the function as a JSValue at init and use .call(withArguments:), because it is both the fastest and the clearest, and then go measure the JavaScript instead. That is where your time actually goes.
JavaScript functions may return undefined or null. Always check:
func safeExtract(_ jsValue: JSValue?) -> String? { // Check if JSValue itself is nil guard let value = jsValue else { return nil } // Check for JavaScript undefined if value.isUndefined { return nil } // Check for JavaScript null if value.isNull { return nil } // Check for string "undefined" (some libs return this) let str = value.toString() if str == "undefined" { return nil } return str}
Treat that last check as a smell, not a pattern.
str == "undefined" also rejects a function that legitimately returned the
five-letter string "undefined", and it exists mainly to paper over a
missing exception handler. If you record exceptions properly (Section 5.5),
you can distinguish "it threw", "it returned undefined", and "it returned the
string undefined" — which are three different things. Prefer
value.isString over string-comparing the result of toString(), which
stringifies everything, including objects and undefined itself.
If the JavaScript library uses callbacks, you can pass Swift closures:
// Define a Swift closurelet callback: @convention(block) (String) -> Void = { result in print("Callback received: \(result)")}// Create a JSValue from the closurelet context = JSContext()!let jsCallback = JSValue(object: callback, in: context)// Pass to JavaScriptjsLibrary.invokeMethod("processAsync", withArguments: [input, jsCallback as Any])
CRITICAL: Retain Cycle Warning
When using @convention(block) closures that capture self, you can easily create memory leaks:
If the closure references self (e.g., to update UI or call instance methods), and the JSContext is owned by self, the wrapper will never deallocate.
Always capture self weakly in callbacks:
// WRONG - Creates retain cycle if self owns the JSContextlet callback: @convention(block) (String) -> Void = { result in self.handleResult(result) // Strong capture of self}// CORRECT - Use [weak self] to break the cyclelet callback: @convention(block) (String) -> Void = { [weak self] result in guard let self = self else { return } self.handleResult(result)}// ALTERNATIVE - Use [unowned self] if you guarantee self outlives the callbacklet callback: @convention(block) (String) -> Void = { [unowned self] result in self.handleResult(result)}
Note: Callbacks add complexity. Prefer synchronous APIs when available.
4.6 Bundle Resolution Pattern
Always use this pattern for cross-context bundle resolution:
Bundle.module resolves per target, and this will bite you in tests.
SwiftPM synthesizes a separate Bundle.module accessor for every target that
has resources. Inside a test target, Bundle.module is the test
bundle — even under @testable import YourModule. So a test that tries to
locate your JavaScript through Bundle.module looks in the wrong bundle and
fails with a confusing "resource missing" error while the library itself
works fine.
Expose the library's own bundle through an internal accessor:
// In the library target:enum PackageResources { static var bundle: Bundle { Bundle.module } // resolves to YourModule's bundle}
// In the test target:@testable import YourModulelet url = PackageResources.bundle.url(forResource: "your-library.min", withExtension: "js")
Use that accessor everywhere in the library too, so there is exactly one place
that knows how resources are found.
4.7 The JSON Bridge (Recommended for Structured Data)
Sections 4.1–4.4 walk JSValue trees by hand. That is fine for a method
returning a string or a flat array. It stops being fine the moment the library
returns anything structured.
toDictionary() gives you [AnyHashable: Any] — nested NSNumber,
NSDictionary, NSNull — and every field access becomes an unchecked cast. A
discriminated union ({ kind: "text", ... } | { kind: "pause", ... }) is
especially miserable to unpack this way, and nothing tells you when the upstream
shape changes.
If the library can hand you JSON, take it. Send JSON in, get JSON out, and
let Codable do the work:
struct ConversionResult: Codable { let text: String let diagnostics: [Diagnostic]}public func convert(_ input: String, options: Options? = nil) throws -> ConversionResult { // Encode options to a JSON string. Swift omits nil properties entirely, // which matters if the JS side rejects unknown or null keys. let optionsJSON = try options.map { options -> String in let data = try JSONEncoder().encode(options) return String(decoding: data, as: UTF8.self) } // Omit the argument when there are no options -- see the note in Step 4. let arguments: [Any] = optionsJSON.map { [input, $0] } ?? [input] let returned = try invoke("convertJSON", arguments) guard returned.isString, let json = returned.toString() else { throw WrapperError.unexpectedReturnValue(description: "Expected a JSON string.") } return try JSONDecoder().decode(ConversionResult.self, from: Data(json.utf8))}
What this buys you:
The JSValue surface shrinks to one string in and one string out. No
nested bridging, no unchecked casts, no JSValue escaping the call.
Typed models with real errors. A shape change surfaces as a
DecodingError naming the exact key path, not a silent nil.
Testability. Models round-trip through Codable with no JavaScript
involved, so most of your model tests do not need a JSContext at all.
Thread safety gets easier. Strings are Sendable; JSValue is not.
If the library does not already expose a JSON entry point, adding a small one to
the bundle is usually easier than hand-decoding on the Swift side:
Two caveats. Very large payloads pay a serialization cost on both sides —
measure before assuming it matters. And JSON cannot carry functions, so any
callback-shaped configuration the library accepts is out of reach through this
route; document that rather than pretending it is supported.
Dictionary keys: the trap that produces valid JSON of the wrong shape
If your options include a dictionary keyed by anything other than String or
Int, Codable will silently encode it as a flat array instead of an
object. Verified behaviour:
enum Level: Int, Codable { case one = 1, two }try JSONEncoder().encode(["a": "x"]) // {"a":"x"} <- objecttry JSONEncoder().encode([1: "x"]) // {"1":"x"} <- objecttry JSONEncoder().encode([Level.one: "x"]) // [1,"x"] <- ARRAY!
String and Int keys get the special treatment; your own key type does not,
even when its raw value is Int. The JS side receives a well-formed JSON array
where it expected {"1": …}, and the error you get back is about the shape, not
about your Swift model.
The fix is CodingKeyRepresentable:
extension Level: CodingKeyRepresentable { private struct Key: CodingKey { let stringValue: String let intValue: Int? init(_ level: Level) { stringValue = String(level.rawValue); intValue = level.rawValue } init?(stringValue: String) { guard let value = Int(stringValue) else { return nil } self.stringValue = stringValue; self.intValue = value } init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } } var codingKey: any CodingKey { Key(self) } init?<T: CodingKey>(codingKey: T) { guard let value = codingKey.intValue ?? Int(codingKey.stringValue), let level = Level(rawValue: value) else { return nil } self = level }}// Now: try JSONEncoder().encode([Level.one: "x"]) // {"1":"x"}
Write a test that asserts the encoded JSON is an object, not merely that it
round-trips through Swift — a round-trip test passes happily on the array form.
Modelling discriminated unions
A union tagged by a kind field is the other place JSValue walking falls
apart, and Codable handles it cleanly:
enum Fragment: Codable { case text(TextFragment) case pause(PauseFragment) private enum Keys: String, CodingKey { case kind } private enum Kind: String, Codable { case text, pause } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: Keys.self) switch try container.decode(Kind.self, forKey: .kind) { case .text: self = .text(try TextFragment(from: decoder)) case .pause: self = .pause(try PauseFragment(from: decoder)) } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: Keys.self) switch self { case let .text(value): try container.encode(Kind.text, forKey: .kind) try value.encode(to: encoder) case let .pause(value): try container.encode(Kind.pause, forKey: .kind) try value.encode(to: encoder) } }}
An unknown tag throws a DecodingError naming the offending key, which is
exactly what you want when upstream adds a case. If upstream's TypeScript types
the union as open — "a" | "b" | (string & {}) — mirror that with a
RawRepresentable struct wrapping String plus static constants, so an unknown
value round-trips instead of failing to decode.
5. Error Handling Reference
5.1 Initialization Failures
Use a throwing initializer with a typed error (see Step 3). Each failure
mode has a different cause and a different fix, so each gets its own case:
Case
What it actually means
resourceMissing
Resources were not copied into the bundle — a build/packaging problem
resourceUnreadable
The file is there but corrupt or not UTF-8
evaluationFailed
The bundle has a syntax error, or uses syntax this JSC does not support (Section 2.7)
globalMissing
Wrong global name, or the bundle is a module build rather than a classic script
methodMissing
Right global, wrong version of the library
Consumer Usage:
do { let wrapper = try YourWrapper() // ...} catch { // The error says which of the five it was, and why. print("Failed to initialize JavaScript wrapper: \(error)")}
Add CustomStringConvertible and LocalizedError conformances so those
messages are readable when they reach a log or a UI.
5.2 Method Call Failures
Throw rather than returning nil, so the JavaScript exception message survives:
public func process(_ input: String) throws -> String { let result = try invoke("process", [input]) // throws on a JS exception guard let value = result.objectForKeyedSubscript("output"), !value.isUndefined, !value.isNull, value.isString, let string = value.toString() else { throw WrapperError.unexpectedReturnValue( description: "process() did not return an object with a string `output`." ) } return string}
The distinction that matters: invoke throwing means JavaScript raised an
error and here is its message; unexpectedReturnValue means JavaScript
succeeded but returned a shape we do not understand. Collapsing both into nil
throws away the more useful half.
5.3 Boolean Return for Operations
For operations where the caller genuinely does not care why something failed,
a Bool is acceptable — but derive it from the recorder, not from isUndefined
(Section 5.5), or you will report success for a call that threw:
For non-critical operations, provide sensible defaults:
public func getColor(for key: String) -> PlatformColor { guard let result = try? invoke("getColor", [key]), result.isString, let hexString = result.toString(), let color = parseHexColor(hexString) else { return PlatformColor.gray // Fallback to neutral color } return color}
Note result.isString rather than comparing toString() against "undefined":
toString() stringifies everything, so it happily turns undefined, null and
objects into strings that then fail to parse further down.
5.5 Exception Handling (Read This One Twice)
JavaScriptCore does not throw Swift errors for JavaScript errors. The exception
handler is the only place the message exists — and this is where most wrappers
quietly lose information.
The naive version loses every error message:
// DON'T: the message goes to the console and nowhere elsecontext.exceptionHandler = { context, exception in print("JavaScript Error: \(exception?.toString() ?? "Unknown error")")}
Two separate problems:
Nothing records the exception. The caller gets nil and the actual
reason is in a console you cannot read in production.
The handler cannot capture self during init — it must be installed
before evaluateScript, when self is not yet fully initialized. There is a
second reason too: JSContextretains its exceptionHandler block, so a
handler that captures self creates self → context → block → self and the
wrapper never deallocates (Section 4.5 covers the same cycle for callbacks).
Both problems are solved by the separate ExceptionRecorder object in Step 3.
A myth worth killing: you will read that you must set
context.exception = nil or the context is left "in an exception state" that
poisons later calls. That is not how the Objective-C API works, and it is
worth knowing because it sends people hunting for the wrong bug.
Exceptions are delivered per call, through an out-parameter internally —
not through context state. Two things follow, both easy to verify:
Once you install a custom handler, JavaScriptCore never writes
context.exception at all. Only the default handler does that. A
print-only handler leaves the property nil, so there is nothing to clear.
Even if your handler sets it and never clears it, the context is fine.
Calling another method afterwards returns its correct value, and
evaluateScript("2+2") still returns 4.
Clearing it is still worth doing — it drops a stale JSValue and the whole
object graph it retains, and it matters inside @convention(block) callbacks,
where assigning context.exception is precisely how you throw back into
JavaScript. Just do not mistake it for what keeps the context usable. The
thing that actually prevents a stale error being reported against the next
call is taking and clearing your own recorder — the _ = recorder.take()
at the top of invoke.
The detail that causes real bugs:
A JavaScript method that throws does not make invokeMethod return nil.
It returns a JSValue holding undefined.
So this extremely common pattern is wrong:
// DON'T: conflates "JS threw a TypeError" with "JS returned undefined"guard let result = jsLibrary.invokeMethod("process", withArguments: [input]), !result.isUndefined else { return nil}
Both cases produce the identical nil, and the exception's message — often the
single most useful string in the whole failure, something like
TypeError: options.mode must be one of "a", "b", "c" — is discarded.
Check the recorder before you look at the return value:
_ = recorder.take() // clear anything stalelet returned = jsLibrary.invokeMethod("process", withArguments: [input])// FIRST: did JavaScript throw?if let message = recorder.take() { throw WrapperError.javaScriptException(message: message)}// ONLY THEN: is the returned value the shape we expect?guard let returned, returned.isString, let string = returned.toString() else { throw WrapperError.unexpectedReturnValue(description: "\(String(describing: returned))")}
On what to put in the message.exception?.toString() gives you the useful
part — "TypeError: message". Resist attaching the JS stack property: for a
bundled, minified library it is generated-code noise that is not stable across
upstream releases, and it tends to leak into user-visible error text.
Testing this is straightforward — deliberately induce a JS exception and
assert you get a useful Swift error, not a nil:
That second test earns its place, but be clear about what it proves. It does
not detect a missing context?.exception = nil — remove that line and it
still passes. What it catches is a recorder you forgot to clear: if invoke
does not call recorder.take() before invoking, the previous call's message is
still sitting there and this test throws instead of returning a value. That is a
real and easy mistake, and this is the test that finds it.
6. Testing Guidelines
6.1 Test Categories
Category
Purpose
Example
Initialization
Verify wrapper loads correctly
testInit()
Artifact integrity
Bundle is present, unmodified, classic-script shaped
testChecksumMatchesProvenance()
Valid Input
Verify correct behavior
testProcessValidInput()
Invalid Input
Verify graceful failure
testProcessInvalidLanguage()
Edge Cases
Boundary conditions
testEmptyInput(), testLargeInput()
Unicode
Non-ASCII, emoji, combining marks
testEmojiRoundTrip()
Errors
JS exceptions become useful Swift errors
testInvalidOptionsThrow()
Repeated calls
No state leaks between calls
testRepeatedCalls()
Resource Loading
Verify bundled resources
testAvailableThemes()
Concurrency
Documented threading guarantee holds
testConcurrentAccess()
Your Swift tests running the real bundled resource through JavaScriptCore are the authoritative integration check. Nothing else proves the artifact you ship actually works on the runtime you ship it to.
Unicode deserves its own tests. JavaScriptCore stores strings as UTF-16, so astral-plane characters cross the bridge as surrogate pairs and are the most likely thing to be corrupted. Test emoji, combining marks, and right-to-left text explicitly. Two things that will trip you up when writing these assertions:
Compare by scalar when a variation selector is involved. "🏳️".contains("🏳") is false, because the grapheme cluster includes U+FE0F and the bare flag character does not.
Some libraries strip Unicode category Cf (invisible formatting) characters, and U+200D ZERO WIDTH JOINER is one of them — so 👩💻 can legitimately come back as 👩 💻. That is upstream behaviour, not a bridging bug. Pin it with a test so you notice if it changes, rather than "fixing" it.
Reference Output Tests (Strongly Recommended): compare your Swift output against the upstream JavaScript library using a Node-based runner. Load the same.js file you vendored — in a bare vm context with no Node globals, which approximates a JSContext closely — run the library's own test fixtures through it, and record the results as a JSON file your Swift tests replay:
// Scripts/generate-parity-fixtures.mjsimport { readFile, writeFile } from "node:fs/promises";import vm from "node:vm";import { fixtures } from "../path/to/upstream/fixtures.mjs";const packageRoot = new URL("..", import.meta.url); // resolve against the script,const source = await readFile( // not the caller's cwd new URL("Sources/YourModule/Assets/your-library.min.js", packageRoot), "utf8");const context = vm.createContext({}); // empty realm: no process, no require, no windowvm.runInContext(source, context, { filename: "your-library.min.js" });const cases = fixtures.map((f) => ({ ...f, expected: JSON.parse(context.yourGlobalName.convertJSON(f.input)),}));await writeFile(new URL("Tests/Fixtures/parity.json", packageRoot), JSON.stringify({ cases }, null, 2));
This gives you two things at once. The empty vm context is itself a test — if the bundle needs a host global, it fails here rather than mysteriously on device. And replaying those cases through JavaScriptCore holds your wrapper to the same expected outputs as the upstream package, which is exactly what you want after a version bump. If upstream publishes a cross-runtime corpus, reuse it rather than inventing your own (and attribute it).
Have CI regenerate the fixtures and fail on a diff — that catches "someone updated the JS and forgot to refresh the expectations."
One SwiftPM detail this needs. The generator script and any corpus you vendor
live under Tests/, where SwiftPM will refuse to build until every file is
either declared a resource or excluded. The generated JSON is a resource; the
.mjs and any README are development-time inputs:
And if you vendor someone else's corpus, record where it came from and under
what license — the same obligation Section 8.3 describes for the bundle itself.
6.2 Example Test Structure
import XCTest@testable import YourModulefinal class YourWrapperTests: XCTestCase { var wrapper: YourWrapper! override func setUpWithError() throws { // A throwing initializer means a setup failure names its own cause. wrapper = try YourWrapper() } override func tearDown() { wrapper = nil } // MARK: - Initialization Tests func testInit() throws { XCTAssertNoThrow(try YourWrapper()) } // MARK: - Valid Input Tests func testProcessValidInput() throws { let result = try wrapper.process("valid input") XCTAssertFalse(result.isEmpty) } // MARK: - Invalid Input Tests func testProcessInvalidInput() { XCTAssertThrowsError(try wrapper.process(invalidInput)) { error in // Assert on the *kind* of failure, not merely that one occurred. guard case WrapperError.javaScriptException = error else { return XCTFail("Expected .javaScriptException, got \(error)") } } } // MARK: - Edge Cases func testEmptyInput() throws { // Define expected behavior for empty input XCTAssertEqual(try wrapper.process(""), "") } // MARK: - State Isolation /// One context reused across many calls must not accumulate state. /// Interleave inputs so a leak shows up as a result that depends on /// whatever ran before it. func testRepeatedCallsDoNotLeakState() throws { let samples = [("a", "A"), ("b", "B"), ("c", "C")] for _ in 0..<40 { for (input, expected) in samples.shuffled() { XCTAssertEqual(try wrapper.process(input), expected) } } } // MARK: - Resource Tests func testAvailableResources() { let resources = wrapper.availableResources(ofType: "json") XCTAssertFalse(resources.isEmpty, "Should have at least one resource") }}
A JSContext belongs to a JSVirtualMachine, and Apple's JSVirtualMachine.h states the model plainly:
Thread safety is supported by locking the virtual machine, with concurrent JavaScript execution supported by allocating separate instances of JSVirtualMachine.
So calls into one virtual machine from several threads serialize on a lock;
they do not crash. That is a more useful mental model than "it will crash,"
because it tells you what you actually get: correctness, and no parallelism.
You should still confine each context to a single queue or actor, for three
concrete reasons:
JSContext and JSValue are not Sendable. Under Swift 6 strict
concurrency the compiler will reject them crossing an isolation boundary, and
silencing that with @unchecked buys you nothing.
A JSValue that escapes keeps its whole context alive, including every
object it transitively references.
Contention on the VM lock is invisible latency. Threads pile up behind a
lock you did not know was there, and adding threads never makes it faster. If
you need real parallelism, allocate separate JSVirtualMachine instances —
that is the only thing that provides it.
This is a critical consideration because:
UI code typically runs on the main thread
Background processing (network callbacks, user-initiated tasks) runs on other threads
A wrapper shared across threads without a confinement strategy gives you unpredictable latency, Sendable errors, and lifetime surprises
Threading Strategies
You must choose one of the following patterns based on your use case:
Pattern 1: Transient Instances (Safest)
Create a new wrapper instance for each operation. Each instance has its own JSContext, eliminating thread conflicts.
// Each call creates a fresh context - thread-safe by isolationfunc processInBackground(_ input: String, completion: @escaping (String?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { // New instance = new JSContext = no conflicts let result = try? YourWrapper().process(input) DispatchQueue.main.async { completion(result) } }}
Trade-offs:
Pros
Cons
Completely thread-safe
Re-evaluates the entire bundle on every call
No locking complexity
Higher memory usage (multiple contexts)
Simple to reason about
Slower for high-frequency operations
Measure that first row before choosing this. For a 726 KB bundle, measured
on Apple silicon in release: creating a context and evaluating the script costs
~14 ms raw, or ~16 ms through a wrapper that also locates and reads the
resource. A conversion on an existing instance costs 0.5 ms for a short
document and 6 ms for a 1.2 KB one.
Note the second number is not a constant — conversion scales with the document,
so quoting a single "cost per call" is meaningless. The ratio is what matters,
and it is worst exactly where it hurts most: for short inputs, setup is 30x
the work you came to do. Even for larger inputs you are burning 16 ms and a
fresh multi-megabyte JS heap, per call, to recreate something immutable.
Best For: Genuinely infrequent operations, and tests that specifically
exercise initialization or state isolation. If you are calling into JavaScript
in a loop, this is the wrong pattern.
Pattern 2: Shared Instance with Serial Queue (Recommended)
Use a single wrapper instance but serialize all access through a dedicated DispatchQueue.
public final class ThreadSafeWrapper { private let queue = DispatchQueue(label: "com.yourapp.jswrapper", qos: .userInitiated) /// Confined to `queue`. Never escapes it, and neither does any `JSValue`. private let wrapper: YourWrapper public init() throws { // Build the context ON the queue you will use it from: it costs // nothing and removes any question about which thread owns what. // Caveat, from JSVirtualMachine.h: a VM runs deferred tasks (GC, // WebAssembly compilation) on the run loop of the thread it was // initialized on, and that cannot be changed afterwards. A GCD queue // has no run loop of its own, which is fine for synchronous, // non-WebAssembly work like this -- but if your bundle uses WASM, // initialize on a thread with a live run loop instead. let queue = self.queue self.wrapper = try queue.sync { try YourWrapper() } } /// Synchronous processing (blocks until complete) public func processSync(_ input: String) throws -> String { try queue.sync { try wrapper.process(input) } } /// Asynchronous processing (returns immediately) /// /// Captures `self` rather than `wrapper`: `YourWrapper` is not `Sendable`, /// and capturing it in an escaping `@Sendable` closure is an error under /// Swift 6 strict concurrency. Capturing `self` is fine, because `self` is /// `@unchecked Sendable` below. /// /// Note it calls `wrapper.process` directly and NOT `processSync` — we are /// already on `queue`, and `queue.sync` from within its own serial queue /// deadlocks. public func processAsync(_ input: String, completion: @escaping (Result<String, Error>) -> Void) { queue.async { let result = Result { try self.wrapper.process(input) } DispatchQueue.main.async { completion(result) } } }}// Safe because: the only mutable state is `wrapper`, which is reachable// exclusively from inside `queue` -- a private serial queue nothing else can// submit to -- and no JSValue ever crosses that boundary.extension ThreadSafeWrapper: @unchecked Sendable {}
Trade-offs:
Pros
Cons
Single context initialization
All JS calls are serialized (no parallelism)
Lower memory usage
Sync calls block the calling thread
Predictable performance
Slightly more complex API
Best For: Most applications, especially those with frequent JS calls.
Pattern 3: Actor-Based Isolation (Swift 5.5+)
For modern Swift codebases, use an Actor to provide compile-time thread safety:
@available(macOS 10.15, iOS 13.0, *)public actor JSWrapperActor { private let wrapper: YourWrapper public init() throws { self.wrapper = try YourWrapper() } // Returns String, not JSValue: the conversion happens on the actor. public func process(_ input: String) throws -> String { try wrapper.process(input) } public func listItems() throws -> [String] { try wrapper.listItems() }}// UsageTask { let actor = try JSWrapperActor() let result = try await actor.process("hello")}
WARNING: JSValue is not Sendable. Never let one escape your confinement boundary.
A JSValue belongs to a JSContext, which belongs to a JSVirtualMachine.
Once a JSValue escapes the actor (or the serial queue) that owns its
context, you lose the guarantee that Swift's concurrency checking is
describing your actual access pattern, and the value silently keeps its entire
context alive.
Do not:
Return a JSValue from an actor method
Store a JSValue and use it from elsewhere
Call .toString(), .toArray() or anything else on a JSValue outside the confinement that owns it
Convert to native Swift types inside the boundary and return only those.
On the precise reason — it is worth being accurate here, because the
imprecise version contradicts Pattern 2 above, which deliberately builds a
context in one place and calls it from a queue. A JSValue is bound to its
context and virtual machine, not to the thread that happened to create it.
Apple's guidance is that a JSVirtualMachine serializes access internally,
and that executing JavaScript concurrently requires separate
JSVirtualMachine instances — which is also why "use more threads" never
makes a single context faster.
The practical rule is unchanged and still the important part: pick one
confinement per context, keep every JSValue inside it, and hand out only
Sendable values. Build the context on the same queue you will call it from
and the question stops arising at all.
// WRONG - hands a JSValue out of the actorpublic func rawResult(_ input: String) throws -> JSValue { try wrapper.invokeRaw("process", [input])}// Caller: let value = try await actor.rawResult("x")// value.toString() // Compiles only by defeating Sendable checking.// // Blocks on the VM lock, and keeps the whole context alive.// CORRECT - convert inside the actor, return a Sendable valuepublic func result(_ input: String) throws -> String { try wrapper.process(input) // returns String}public func items() throws -> [String] { try wrapper.listItems() // returns [String]}
Rule: All JSValue -> Swift conversions happen inside the actor. Return only Sendable values (String, Int, Bool, [String], your own Codable models). Note [String: Any] is notSendable — decode into a typed model instead (Section 4.7).
Trade-offs:
Pros
Cons
Compile-time safety
Requires Swift 5.5+ / iOS 13+
Clean async/await API
All access becomes async
No manual locking
Learning curve for actors
Best For: Modern async/await codebases, new projects targeting iOS 13+.
Pattern 4: Lock-Based Protection (Low-Level)
Use NSLock for explicit mutual exclusion:
public final class LockedWrapper { private let wrapper: YourWrapper private let lock = NSLock() public init() throws { self.wrapper = try YourWrapper() } public func process(_ input: String) throws -> String { lock.lock() defer { lock.unlock() } return try wrapper.process(input) }}
Note the defer placement: it must come before anything that can throw, or an
error path leaves the lock held and the next caller deadlocks.
Trade-offs:
Pros
Cons
Fine-grained control
Risk of deadlocks if misused
Works on all OS versions
Manual lock management
Minimal overhead
Easy to forget unlock on error paths
Best For: Performance-critical code, legacy codebases, when DispatchQueue overhead matters.
Choosing a Strategy
Scenario
Recommended Pattern
Simple CLI tool, scripts
Pattern 1 (Transient) — but reuse one instance if you call in a loop
iOS/macOS app with occasional JS calls
Pattern 2 (Serial Queue)
High-frequency calls, modern codebase
Pattern 3 (Actor)
Performance-critical, legacy code
Pattern 4 (Lock)
Unit tests
One instance per test class via setUpWithError; a fresh instance only where you are testing initialization or state isolation
Genuine parallelism required
Separate instances — each gets its own JSVirtualMachine. Nothing else provides it.
Testing Thread Safety
Add a stress test to verify your threading strategy — but assert correctness,
not just non-nil. A test that only checks "we got something back" passes even
if concurrency scrambles every result, which is the failure you are actually
worried about.
Compute the expected answers serially first, then check them under load:
func testConcurrentAccessProducesCorrectResults() throws { let wrapper = try ThreadSafeWrapper() let inputs = (0..<64).map { "input-\($0)" } // Ground truth, computed with no concurrency involved. let expected = try inputs.map { try wrapper.processSync($0) } let results = NSMutableArray() let lock = NSLock() DispatchQueue.concurrentPerform(iterations: inputs.count) { index in guard let output = try? wrapper.processSync(inputs[index]) else { return XCTFail("Conversion \(index) failed") } lock.lock() results.add([index, output] as [Any]) lock.unlock() } XCTAssertEqual(results.count, inputs.count) for entry in results { guard let pair = entry as? [Any], let index = pair.first as? Int, let output = pair.last as? String else { return XCTFail("Malformed result") } // The point of the test: input i still maps to output i. XCTAssertEqual(output, expected[index]) }}
DispatchQueue.concurrentPerform is simpler than expectations here — it blocks
until every iteration finishes, so there is no timeout to tune.
If your public type is Sendable, test it from structured concurrency too, since
that exercises a different scheduling path:
func testConcurrentUseFromSwiftConcurrency() async throws { let wrapper = try ThreadSafeWrapper() let expected = try wrapper.processSync("shared") try await withThrowingTaskGroup(of: String.self) { group in for _ in 0..<32 { group.addTask { try wrapper.processSync("shared") } } for try await output in group { XCTAssertEqual(output, expected) } }}
A note on Sendable. If you wrap a JSContext in a type you mark
@unchecked Sendable, you owe the reader a concrete justification, in a comment,
of why it is actually safe. "All mutable state is reachable only from inside a
private serial queue that nothing else can submit to, and no JSValue ever
crosses that boundary" is a justification. Silence is not — and @unchecked Sendable on a type that merely usually gets called from one thread is how
these crashes ship.
8. Vendoring, Provenance & Updates
Everything above treats the JavaScript file as a given. In practice you are
redistributing someone else's code inside your package, and that carries
obligations the rest of this guide does not cover.
8.1 Record What You Vendored
Six months from now, "which version of the library is this?" must have an
answer that is not "check the git log." Record it in a machine-readable file
next to the artifact:
Expose the version through your public API — YourWrapper.upstreamVersion —
so an app can log it in a crash report or an about screen.
Do not type the version into several files by hand. Generate the Swift
constant and the JSON from one place, in one script run. A README that says
0.2.0 while the bundle is 0.3.1 is worse than no README.
8.2 Write an Update Script
Manual upgrade steps rot. A script that takes either an explicit version or
latest should:
resolve the requested version against the registry;
download and extract the published tarball;
verify the expected artifacts exist, and fail loudly if not;
copy the JavaScript and every license file into your resources;
rewrite the provenance JSON, the generated Swift constants, and the README;
recompute the SHA-256;
clean up temporary files.
Step 3 is the one people skip, and it is the one that saves you. A version that
does not ship the build you need should stop the script cold — with the
resources left untouched — rather than silently vendoring the wrong file:
for path in "${EXPECTED_FILES[@]}"; do [[ -f "${work_dir}/${path}" ]] || die \ "${PACKAGE}@${version} does not contain '${path}'. Refusing to vendor an incomplete distribution."done
Add a --verify mode that re-hashes the checked-in file against the recorded
SHA-256 and asserts every license file is present. It needs no network and no
package manager, so it runs in CI on every commit and catches both accidental
edits and a half-finished upgrade.
8.3 Licenses Are Not Optional
Bundling someone's JavaScript into your package means shipping their license.
Two files, not one:
The upstream project's own LICENSE.
The third-party license file for the bundle — a bundled artifact usually
contains a dozen transitive dependencies, each with its own terms. Good build
tooling emits a combined THIRD_PARTY_LICENSES.txt; if upstream ships one,
vendor it.
Put both in the resource bundle and expose them at runtime, so an app embedding
your package can surface attribution without vendoring the files a second time: