Skip to content

feat(runtime): ns:swift calls Swift through generated thunks (Swift interop 2/7) - #519

Draft
NathanWalker wants to merge 10 commits into
swift-interop/1-generatorfrom
swift-interop/2-runtime
Draft

NathanWalker wants to merge 10 commits into
swift-interop/1-generatorfrom
swift-interop/2-runtime

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Swift interop stack — review bottom-up; each PR shows only its own commits. Merge in order; after a squash merge, rebase the next branch onto main. Design discussion: RFC NativeScript/rfcs#57.

  1. feat(metadata-generator): Swift symbol graphs to thunks and TypeScript declarations (Swift interop 1/7) #518 generator: symbol graphs → IR → thunks + .d.ts
  2. feat(runtime): ns:swift calls Swift through generated thunks (Swift interop 2/7) #519 runtime: ns:swift core ← this PR
  3. feat(swift-interop): modules, generics, protocols, Observation and SwiftUI (3/7) #520 modules, generics, protocols, Observation, SwiftUI
  4. feat(swift-interop): generate and link thunks in app builds (4/7) #521 app builds
  5. feat(swift-interop): returning closures, generic functions, tuples, Result and C structs (5/7) #522 more values: returning closures, generic functions, tuples, Result, C structs
  6. feat(swift-interop): adapter lifetime, cancellation, subscripts, Combine and operators (6/7) #523 adapter lifetime, cancellation, subscripts, Combine, operators
  7. feat(swift-interop): Any, UUID and Decimal, performance, and docs (Swift interop 7/7) #517 Any/UUID/Decimal, performance, docs

A new ns:swift builtin loads the generated thunks and calls Swift from JS.

require("ns:swift/SwiftDemo")
  dlsym ns_swift_register_SwiftDemo → types, members, signatures (ABI-versioned)
  per isolate: JS classes mirroring Swift inheritance
  call → 24-byte argument slots → thunk → typed Swift call → result entries
new SwiftDemo.Demo().greet("NativeScript");   // classes, structs, methods, properties
await SwiftDemo.delayedEcho("later", 0.1);    // async → Promise; actors, @MainActor
SwiftDemo.measure(2, 3);                      // enums with associated values as tagged objects
SwiftDemo.countdown(3, (value, last) => {});  // closures take JS functions

This PR covers async functions and actors, collections and Foundation values, nested types, closures, enums with payloads, inout parameters, and Objective-C objects in Swift signatures. NativeScript/runtime/SwiftInterop.h is the binary contract between thunks and runtime. TestRunner links the SwiftDemo fixture. A new CI job builds swift-symbolgraph-tests and runs it with tsc, so the generator tests gate this PR and every PR above it.

Evidence

  • Before: require("ns:swift") does not exist.
    After: 70 ns:swift TestRunner specs, plus the generator tests, which now also verify the thunks compile.

Merge Danger

Door: two-way

Blast Radius: runtime-wide

  • The runtime gains SwiftInterop.mm, an ns:swift row in the builtin registry, and WrapperType::SwiftObject; IsolateTracked gains a protected accessor.
  • Nothing runs until JS imports ns:swift.
  • The thunk ABI changes later in the stack and is unreleased until the stack merges.

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: df391e2d-a707-45ad-be23-0927eacb4e1d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

PR 3-5 of the Swift interop plan, runtime side.

- SwiftInterop.h is the versioned binary contract with generated thunks:
  24-byte value slots, the API table thunks report through, member and
  type kinds, signature codes and NS_SWIFT_ABI_VERSION.
- SwiftInterop.mm loads a module on first use by dlsym-ing
  ns_swift_register_<Module>, validates and keeps its registration
  process-wide, and builds JS classes per isolate: constructor templates
  following the Swift class hierarchy, prototype and static members,
  enum namespace objects and module-level functions and variables.
  Calls go straight to the thunk (no libffi) after per-argument tag,
  integer-range and object-type checks; overloads resolve by arity and
  accepted JS kinds, ambiguous calls name the signature keys that reach
  each overload, and initializers also take a { label: value } object.
- One JS wrapper per Swift object (identity cache) owns a retain that a
  weak finalizer releases; isolate teardown sweeps survivors through
  IsolateTracked. Swift errors become JS errors with name SwiftError and
  swiftType; out-of-range integers throw RangeError.
- ns:swift exposes module(name), info(name) and abiVersion, and
  ns:swift/<Module> resolves to the module's exports for both require and
  import. types/ns-swift.d.ts declares ns:swift; generated declarations
  augment its SwiftModules interface.
- TestRunner links the SwiftDemo fixture with its generated thunks (new
  SwiftDemo static library target) and SwiftInteropTests.js covers
  classes, inheritance, structs, enums, values, errors, overloads,
  identity and GC-driven release. TestRunner gains a Swift source so
  Xcode links the Swift runtime libraries the fixture needs.
- docs/swift-interop.md documents the workflow and the JavaScript
  semantics.
Swift interop ABI 2: calls that complete later settle a JS Promise, and
isolation decides how a member is called rather than whether it can be.

- The API table gains beginAsync, which makes the call's result a Promise
  and returns a token, and complete* entries that may be called from any
  thread. Outcomes carry no V8 handles; they are posted to the calling
  isolate's event loop and settle a resolver kept in per-isolate state, or
  are dropped (releasing any Swift object) if the isolate is gone.
- Async thunks read their arguments synchronously, then run the call in a
  Task and report through the token; values reach the Task through an
  @unchecked Sendable box, so the thunks also compile in Swift 6 mode.
- Actor members are awaited from outside the actor (and actor state is
  never assignable from JS); nonisolated members stay synchronous.
- Synchronous @mainactor members run under MainActor.assumeIsolated after
  rejecting calls from other threads (Workers get an error, not a trap).
- async initializers are rejected (async-initializer): new cannot await.
- Probes declare async/@mainactor so verification checks these calls, and
  the TypeScript declarations type async members as Promise<T>.
- TestRunner covers async results, rejections, wrapped objects, actors,
  main-actor members and a Worker using the module.

Verified thunks for SwiftUI, Foundation, Charts, SwiftData, RegexBuilder,
WeatherKit and Observation still all type-check.
Swift interop ABI 3 bridges [T], [String: T], Date, URL and Data.

- Arguments: arrays and maps arrive as nested slot arrays (NSSwiftTagArray,
  NSSwiftTagMap with alternating key/value slots); Date as milliseconds,
  URL as its string (a JS string or anything with an href), Data as a
  pointer into the ArrayBuffer for the call. The runtime keeps every
  string and element buffer in deques so slot pointers stay valid while
  nested conversions add more.
- Results: collections are reported element by element between
  returnArrayBegin/End and returnMapBegin/End (and the complete* async
  counterparts), through the ordinary return entries, so they nest; the
  value code in effect turns doubles into Dates, strings into URLs and
  bytes into Uint8Arrays. Async outcomes become a tree delivered once the
  top-level value is complete.
- The classifier bridges these types (optional elements and non-String
  dictionary keys are reported as optional-element / dictionary-key), the
  thunks read elements through closures shared with top-level arguments,
  and the TypeScript declarations use readonly arrays, Records, Date, URL
  and Uint8Array.
- TestRunner covers round trips, nesting, element type errors, Date, URL,
  Data views and an async array result.

Verified thunks for the seven SDK modules still all type-check; WeatherKit
goes from 177 to 212 generated members.
- Nested types are bridged and registered under their dotted JS path
  ("Polygon.Style"); the runtime hangs each off its parent's constructor
  or enum object, or off a frozen namespace object when the parent is
  not bridged. A type nested in a generic type stays swift-specialized;
  one nested in an external type's extension stays unsupported.
- TypeScript declarations follow the same tree: classes merge with a
  namespace of their nested types, and enums become a case-name union
  plus a namespace (which, unlike a const, can hold nested types).
- Enum cases newer than their enum are reached under #available in the
  name/case helpers.
- --swift-verify now type-checks the generated thunks along with the
  probes, attributing thunk errors to the symbol whose `// <id>` comment
  precedes them, so helpers the probes do not exercise are verified too.

Across the seven SDK modules the generated surface grows from 1,925 to
4,425 callable members (Foundation 793 -> 2,961, SwiftUI 694 -> 849,
WeatherKit 212 -> 265, SwiftData 90 -> 184); all thunks type-check.
Swift closure parameters that return Void now take JS functions (ABI 4).

Generator: closures bridge when every parameter bridges and the closure
neither throws nor is async. The signature code lists the closure's
parameter codes, "(i64,b)". Thunks wrap the JS function in a Swift closure
that reports its arguments through new callback entries in the API table.
The probe and thunk declarations keep @escaping, @sendable and @mainactor,
and the TypeScript output types the parameter as a function.

Runtime: a JS function passed for a closure is held by a reference-counted
callback handle. The call holds one reference and each Swift closure that
captures the function holds another.
- An invocation on the JS thread runs the function immediately. If it
  throws while a Swift call is in progress, that call rethrows the
  exception and later invocations in the same call are skipped.
- An invocation from another thread is queued to the isolate's event
  loop, and Swift does not wait for it.

The fixture adds non-escaping, escaping and background-queue closures.
The TestRunner specs cover sync calls, exceptions, thread hops, optional
arguments, worker isolates, and releasing the JS function. The SDK thunks
for all seven modules still type-check. A snapshot output tracked by
mistake is removed, and *.actual is now ignored.
Enums whose cases carry values now cross as plain JS objects that name
their case (ABI 5), e.g. { case: "area", width: 3, height: 4 } or
{ case: "length", value: 2 }. Values are copied in both directions, like
collections. Each value is keyed by its label. A case's only, unlabeled
value is keyed "value", and other unlabeled values by position.

Generator:
- An enum bridges only when every case JS can see bridges. Recursive
  (indirect) enums bridge too.
- Cases register as the enum's members, with their values as parameters.
  The registration order is the case index that the runtime and the
  thunks share.
- Thunks get a read helper and a report helper per enum. The report helper
  is written once for both calls and async completions, through a shared
  reporter protocol.
- Probes construct every case, so the verifier checks each payload.
- TypeScript declares the enum as a discriminated union.

Runtime: a new case slot tag, plus case begin and end entries for sync and
async results. Overload resolution treats case objects as records.

At SDK scale, 13 enums bridge, including
URLSessionWebSocketTask.Message. All seven modules' thunks still
type-check.
An inout parameter now takes a reference object (ABI 6). JS passes
{ value }. The thunk reads `value`, calls with &local, and after a
successful call writes the final value back into `value` before
returning the result. A throw leaves the reference unchanged. Structs
come back as new copies, and class references can be reassigned. Async
members with inout parameters stay unsupported, because the call returns
before the final values exist. So do inout closures.

Signature codes mark inout parameters with '&'. The runtime keeps each
call's reference objects, and two new entries report a write-back.
Overload resolution treats reference objects as records. TypeScript
types the parameter as { value: T }.
Swift parameters and results whose type is an Objective-C class now cross
as NativeScript's ordinary Objective-C wrappers (ABI 7). That covers UIKit
and Foundation classes, and Swift NSObject subclasses. Results go through
the same cache Objective-C calls use, so an object keeps one JS identity.
Arguments are checked against the class with isKindOfClass:.

The signature code is @<runtime name>. A Swift class without
@objc(Name) is named Module.Class, which NSClassFromString resolves.
Symbol graphs do not record which framework declares an Objective-C
class, so the thunks and probes import a framework guessed from the
class prefix, guarded by #if canImport. Compiler verification drops any
declaration whose types still do not resolve.

The SwiftDemo fixture gains Views.swift, which is also added to the
TestRunner's SwiftDemo target.
A new job builds swift-symbolgraph-tests and runs it with tsc, so the
generator's Swift tests gate pull requests: loading, classification,
compiler verification, thunk and TypeScript snapshots, and the usage file
tsc checks.

The release build of the metadata generator now builds only the
generator, which keeps the two test binaries (4.5 MB each) out of the npm
package.
@NathanWalker
NathanWalker force-pushed the swift-interop/2-runtime branch from 5efedd7 to 63b3a51 Compare October 21, 2026 14:47
@NathanWalker
NathanWalker force-pushed the swift-interop/1-generator branch from 0bba23d to 22f8e56 Compare October 21, 2026 14:47
@NathanWalker
NathanWalker marked this pull request as draft October 21, 2026 16:32
@NathanWalker NathanWalker added this to the 10.0 milestone Oct 21, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant