diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 35631555..e3096a71 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -11,6 +11,7 @@ #include "Runtime.h" #include "StructuredSerialization.h" #include "TextEncoding.h" +#include "Worker.h" using namespace v8; @@ -25,6 +26,7 @@ constexpr const char* kNodePrefix = "node:"; MaybeLocal NsModuleBinding(Local context); MaybeLocal NsRuntimeBinding(Local context); MaybeLocal NsUtilBinding(Local context); +MaybeLocal NsWorkerThreadsBinding(Local context); struct Registration { const char* specifier; @@ -46,6 +48,7 @@ constexpr Registration kRegistry[] = { {"ns:module", BuiltinId::kNsModule, NsModuleBinding}, {"ns:runtime", BuiltinId::kNsRuntime, NsRuntimeBinding}, {"ns:util", BuiltinId::kNsUtil, NsUtilBinding}, + {"ns:worker_threads", BuiltinId::kNsWorkerThreads, NsWorkerThreadsBinding}, {"node:module", BuiltinId::kNodeModule, nullptr}, {"node:url", BuiltinId::kNodeUrl, nullptr}, {"node:util", BuiltinId::kNodeUtil, nullptr}, @@ -197,6 +200,21 @@ MaybeLocal NsRuntimeBinding(Local context) { return binding; } +// The Worker constructor for ns:worker_threads: the context's own, from the +// template Worker::Init installed, rather than whatever `globalThis.Worker` +// names by the time the module is first required. +MaybeLocal NsWorkerThreadsBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + Local worker; + if (!Worker::Constructor(context).ToLocal(&worker) || + !binding->Set(context, tns::ToV8String(isolate, "Worker"), worker) + .FromMaybe(false)) { + return MaybeLocal(); + } + return binding; +} + // TextEncoder / TextDecoder for ns:util, read straight out of the // text-encoding builtin's per-isolate run, so the module's classes are the // objects the globals of the same name expose. diff --git a/NativeScript/runtime/Worker.h b/NativeScript/runtime/Worker.h index 70c81467..bdae3262 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -14,6 +14,13 @@ class Worker { static void Init(v8::Isolate* isolate, v8::Local globalTemplate); + // The Worker constructor of `context`: the function Init's template + // produces for it, which is the object the global of that name was created + // with, whatever `globalThis.Worker` names by now. Empty before Init has + // run for the isolate. + static v8::MaybeLocal Constructor( + v8::Local context); + // Turns Worker and the worker global scope into EventTargets and caches the // builtin's delivery callout for this isolate. Runs during Runtime::Init, // after Events::Init has installed the event primitives it builds on. diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index a0af0bc0..6eac6e88 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -30,6 +30,13 @@ Global emitEnded; }; +// The constructor template Init put on the global template. A template's +// function is cached per context, so instantiating it again yields the very +// function the global was created with. +struct WorkerConstructorState { + Global constructor; +}; + } // namespace std::vector Worker::GlobalFunctions = {"postMessage", "close"}; @@ -275,6 +282,19 @@ bool ParseResourceLimits(Isolate* isolate, Local context, Local prototype->Set(ToV8String(isolate, "terminate"), terminateWorkerFuncTemplate); globalTemplate->Set(workerFuncName, workerFuncTemplate); + + if (WorkerConstructorState* state = Caches::StateFor(isolate)) { + state->constructor.Reset(isolate, workerFuncTemplate); + } +} + +MaybeLocal Worker::Constructor(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + WorkerConstructorState* state = Caches::StateFor(isolate); + if (state == nullptr || state->constructor.IsEmpty()) { + return MaybeLocal(); + } + return state->constructor.Get(isolate)->GetFunction(context); } void Worker::InitEvents(Local context) { diff --git a/NativeScript/runtime/js/node-worker-threads.js b/NativeScript/runtime/js/node-worker-threads.js index e4b8ed1d..46b13d6c 100644 --- a/NativeScript/runtime/js/node-worker-threads.js +++ b/NativeScript/runtime/js/node-worker-threads.js @@ -57,9 +57,11 @@ function getCreateMessageEvent() { } const g = globalThis; -// The platform constructor this shim wraps, and the worker scope's channel -// back to its parent. -const NativeWorker = g.Worker; +// The platform constructor this shim wraps, taken from the standard module +// rather than from the global so an app that reassigns `globalThis.Worker` +// does not redirect the shim, and the worker scope's channel back to its +// parent. +const { Worker: NativeWorker } = require("ns:worker_threads"); const globalPostMessage = g.postMessage; const addEventListener = EventTarget.prototype.addEventListener; diff --git a/NativeScript/runtime/js/ns-worker-threads.js b/NativeScript/runtime/js/ns-worker-threads.js new file mode 100644 index 00000000..488439bf --- /dev/null +++ b/NativeScript/runtime/js/ns-worker-threads.js @@ -0,0 +1,12 @@ +"use strict"; + +// The `ns:worker_threads` builtin module: the runtime's own Worker, the very +// function the global of that name was created with. See +// docs/ns-builtin-modules.md for the contract and docs/worker-threads.md for +// the constructor's options. + +const { Worker } = binding; +const { ObjectFreeze } = primordials; + +exports.Worker = Worker; +ObjectFreeze(exports); diff --git a/TestRunner/app/tests/NsWorkerThreadsTests.js b/TestRunner/app/tests/NsWorkerThreadsTests.js new file mode 100644 index 00000000..db84b3a2 --- /dev/null +++ b/TestRunner/app/tests/NsWorkerThreadsTests.js @@ -0,0 +1,66 @@ +describe("ns:worker_threads", function () { + var nsWorkerThreads = require("ns:worker_threads"); + + it("exposes frozen exports", function () { + expect(Object.isFrozen(nsWorkerThreads)).toBe(true); + expect(typeof nsWorkerThreads.Worker).toBe("function"); + }); + + // The export set is public API, declared in types/ns-worker-threads.d.ts + // and docs/ns-builtin-modules.md — all three must change together. + it("exposes exactly the declared surface", function () { + expect(Object.keys(nsWorkerThreads).sort()).toEqual(["Worker"]); + }); + + it("exports the Worker the global holds", function () { + expect(nsWorkerThreads.Worker).toBe(globalThis.Worker); + }); + + it("is a singleton per realm", function () { + expect(require("ns:worker_threads")).toBe(nsWorkerThreads); + }); + + it("is a distinct module object from the node:worker_threads shim", function () { + var shim = require("node:worker_threads"); + expect(shim).not.toBe(nsWorkerThreads); + expect(Object.isFrozen(shim)).toBe(true); + }); + + it("constructs a worker with the runtime's options", function (done) { + var worker = new nsWorkerThreads.Worker("./workerResourceLimits/echoWorker.js", { + ios: { priority: "utility" }, + resourceLimits: { maxOldGenerationSizeMb: 64 }, + }); + var settled = false; + var finish = function () { + if (settled) { + return; + } + settled = true; + worker.terminate(); + done(); + }; + worker.onmessage = function (event) { + expect(event.data.started).toBe(true); + finish(); + }; + worker.onerror = function (event) { + expect(String(event.message)).toBe(""); + finish(); + }; + }); + + it("hands a worker realm its own constructor, not whatever the global names", function (done) { + var worker = new Worker("./nsWorkerThreadsIdentityWorker.js"); + worker.onmessage = function (event) { + expect(event.data).toEqual({ isOriginal: true, isImpostor: false, frozen: true }); + worker.terminate(); + done(); + }; + worker.onerror = function (event) { + worker.terminate(); + fail("worker error: " + event.message); + done(); + }; + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index f2dee09c..bdd14c62 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -129,6 +129,7 @@ require("./MetadataTests"); // require("./ApiTests"); require("./NsRuntimeTests"); +require("./NsWorkerThreadsTests"); require("./GCFinalizerTests"); require("./WorkerConcurrentStartupTests"); require("./WorkerOptionsTests"); diff --git a/TestRunner/app/tests/nsWorkerThreadsIdentityWorker.js b/TestRunner/app/tests/nsWorkerThreadsIdentityWorker.js new file mode 100644 index 00000000..28a42de9 --- /dev/null +++ b/TestRunner/app/tests/nsWorkerThreadsIdentityWorker.js @@ -0,0 +1,10 @@ +// Reassigns the global before the module is first required in this realm, so +// a module that merely read `globalThis.Worker` would report the impostor. +var original = globalThis.Worker; +globalThis.Worker = function Impostor() {}; +var nsWorkerThreads = require("ns:worker_threads"); +postMessage({ + isOriginal: nsWorkerThreads.Worker === original, + isImpostor: nsWorkerThreads.Worker === globalThis.Worker, + frozen: Object.isFrozen(nsWorkerThreads), +}); diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 99da558c..87384363 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -399,6 +399,33 @@ workers for the change to reach them.** A worker started after the `configureLoader` call resolves through the new vocabulary; a live worker never observes a later reconfiguration. +### `ns:worker_threads` + +The runtime's own `Worker`, reachable by specifier; the `node:worker_threads` +shim adapts it. **Experimental, iOS-only** until the Android runtime ships it. + +| export | description | +|---|---| +| `Worker` | The runtime's `Worker` constructor: the very function the global of that name was created with, so `require("ns:worker_threads").Worker === globalThis.Worker` unless the app has reassigned the global. Its constructor options — `ios.priority` and Node's `resourceLimits` — are described in [worker-threads.md](worker-threads.md#worker-options). | + +```js +import { Worker } from "ns:worker_threads"; + +const worker = new Worker("./heavy.js", { + ios: { priority: "utility" }, + resourceLimits: { maxOldGenerationSizeMb: 64 }, +}); +``` + +The module exists so that typed code can name the runtime's constructor and +its options without depending on what `globalThis.Worker` resolves to in the +program's type environment. `types/ns-worker-threads.d.ts` declares the module and, +at script level, merges the two options into the global `WorkerOptions` and +declares the global `Worker` the way `@types/node` declares its own globals: +a program with a DOM lib keeps the lib's declaration, so +`new Worker(path, { ios })` type-checks either way without a conflicting +redeclaration. + ### `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so @@ -440,7 +467,7 @@ unmodified where a shim exists: | `node:util` | `inspect`, `format`, `TextEncoder`, `TextDecoder` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. `TextEncoder`/`TextDecoder` are the globals of those names, as they are in Node. Documented as partial. | | `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | | `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. | -| `node:worker_threads` | the messaging and thread surface — see [worker-threads.md](worker-threads.md) | The channel half (`MessagePort`, `MessageChannel`, `BroadcastChannel`, `receiveMessageOnPort`) is the real implementation, the same objects the globals of those names hold; the thread half is a bridge over the runtime's own `Worker`. It has no `ns:` counterpart — the surface tracks Node's, so there is nothing for a standard module to own. The one place it breaks the absent-not-throwing rule below is deliberate: `postMessageToThread` and `moveMessagePortToContext` are present and throw an `Error` naming themselves, because silently missing thread-addressed messaging reads as a delivery bug rather than as an unsupported call. Documented as partial. | +| `node:worker_threads` | the messaging and thread surface — see [worker-threads.md](worker-threads.md) | The channel half (`MessagePort`, `MessageChannel`, `BroadcastChannel`, `receiveMessageOnPort`) is the real implementation, the same objects the globals of those names hold; the thread half is a bridge over the `Worker` that `ns:worker_threads` exports. The channel half has no `ns:` counterpart — its surface tracks Node's, so there is nothing for a standard module to own. The one place it breaks the absent-not-throwing rule below is deliberate: `postMessageToThread` and `moveMessagePortToContext` are present and throw an `Error` naming themselves, because silently missing thread-addressed messaging reads as a delivery bug rather than as an unsupported call. Documented as partial. | `node:url`'s parsing goes through the URL intrinsic, so `file://localhost/x` is accepted (the URL spec folds a `localhost` authority to none) while any other @@ -755,8 +782,9 @@ resolvers read the same table differently: - The **`ns:`/`node:` resolver** — the app-facing one, behind `require()`, `import` and `import()` — serves only rows *not* marked internal-only. An internal-only specifier fails exactly as a name absent from the table does. - Seven rows are public today: `ns:module`, `ns:runtime`, `ns:util`, - `node:module`, `node:url`, `node:util`, `node:worker_threads`. + Eight rows are public today: `ns:module`, `ns:runtime`, `ns:util`, + `ns:worker_threads`, `node:module`, `node:url`, `node:util`, + `node:worker_threads`. - The **internal require** builtins receive (previous section) is the only thing that can name an internal-only row. Five rows are marked that way: `internal/broadcast-channel`, `internal/dom-exception`, `internal/events`, diff --git a/docs/worker-threads.md b/docs/worker-threads.md index be4f0262..4184fb1a 100644 --- a/docs/worker-threads.md +++ b/docs/worker-threads.md @@ -274,7 +274,9 @@ transfer would strand the port's sibling. The runtime's `Worker` constructor takes two options of its own, and the `node:worker_threads` shim passes both through unchanged. Unknown keys inside either object are ignored, so a later runtime can add more without breaking an -older one. +older one. The constructor is also exported by the +[`ns:worker_threads`](ns-builtin-modules.md#nsworker_threads) builtin, whose type declarations +describe both options and merge them into the global `WorkerOptions`. ### `ios.priority` diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index e5929531..cd46992c 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -19,6 +19,7 @@ $(SRCROOT)/NativeScript/runtime/js/node-worker-threads.js $(SRCROOT)/NativeScript/runtime/js/ns-module.js $(SRCROOT)/NativeScript/runtime/js/ns-runtime.js $(SRCROOT)/NativeScript/runtime/js/ns-util.js +$(SRCROOT)/NativeScript/runtime/js/ns-worker-threads.js $(SRCROOT)/NativeScript/runtime/js/performance.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js diff --git a/types/index.d.ts b/types/index.d.ts index a7a65a94..b1aff29b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,3 +28,4 @@ /// /// /// +/// diff --git a/types/ns-worker-threads.d.ts b/types/ns-worker-threads.d.ts new file mode 100644 index 00000000..bc5a2ed6 --- /dev/null +++ b/types/ns-worker-threads.d.ts @@ -0,0 +1,161 @@ +declare module "ns:worker_threads" { + /** + * The quality of service of a worker's thread, in Apple's terms. Omitting + * it leaves the runtime's operation queue at its own default. + */ + export type WorkerPriority = + | "userInteractive" + | "userInitiated" + | "default" + | "utility" + | "background"; + + /** + * iOS-specific worker options. A non-object value throws a `TypeError`; + * keys the runtime does not know are ignored. + */ + export interface WorkerIosOptions { + /** + * A non-string or unrecognized name throws a `TypeError`. Wins over the + * deprecated top-level `iosPriority` when both are given. + */ + priority?: WorkerPriority; + } + + /** + * Node's `resourceLimits`, in megabytes. A value that is not a number + * throws a `TypeError`; a non-finite one, one worth less than a byte, or + * one too large to hold in bytes throws a `RangeError`. Node's + * `stackSizeMb` and `codeRangeSizeMb` are ignored, like any other key the + * runtime does not know. + */ + export interface WorkerResourceLimits { + /** Caps the worker isolate's old generation. */ + maxOldGenerationSizeMb?: number; + /** Caps the worker isolate's young generation. */ + maxYoungGenerationSizeMb?: number; + /** + * NativeScript extension: the isolate's JS dispatch table reservation, a + * whole number of megabytes from 1 to 256 (a fractional or out-of-range + * value throws a `RangeError`). Worker isolates reserve 64 MB when it is + * omitted; the main isolate keeps V8's default. + */ + jsDispatchTableSizeMb?: number; + } + + /** + * The options the runtime's `Worker` constructor understands. `null` for + * either object means the same as leaving it out. + */ + export interface WorkerOptions { + ios?: WorkerIosOptions | null; + resourceLimits?: WorkerResourceLimits | null; + /** @deprecated Use `ios.priority`, which wins when both are given. */ + iosPriority?: WorkerPriority; + } + + /** The subset of the DOM `Event` surface a worker event carries. */ + export interface WorkerEvent { + readonly type: string; + readonly target: Worker | null; + readonly defaultPrevented: boolean; + preventDefault(): void; + } + + /** `message` and `messageerror`; for the latter, `data` is the failure. */ + export interface WorkerMessageEvent extends WorkerEvent { + readonly data: unknown; + readonly ports: readonly object[]; + } + + /** + * `error`. Only primitives cross the isolate boundary, so `error` is always + * `null` and the worker's stack arrives as the `stackTrace` string. + */ + export interface WorkerErrorEvent extends WorkerEvent { + readonly message: string; + readonly filename: string; + readonly lineno: number; + readonly error: null; + readonly stackTrace: string; + } + + export interface WorkerEventListenerOptions { + capture?: boolean; + once?: boolean; + passive?: boolean; + } + + export interface Worker { + /** + * `transfer` lists the `ArrayBuffer`s and `MessagePort`s in the message + * to move rather than clone; it must be an array when given. + */ + postMessage(message: unknown, transfer?: readonly object[]): void; + /** + * Stops the worker wherever it is, its entry script included, without + * reporting an error. + */ + terminate(): void; + onmessage: ((this: Worker, event: WorkerMessageEvent) => unknown) | null; + onmessageerror: + | ((this: Worker, event: WorkerMessageEvent) => unknown) + | null; + /** Returning a truthy value handles the error, like `preventDefault()`. */ + onerror: ((this: Worker, event: WorkerErrorEvent) => unknown) | null; + addEventListener( + type: "message" | "messageerror", + listener: (this: Worker, event: WorkerMessageEvent) => unknown, + options?: boolean | WorkerEventListenerOptions + ): void; + addEventListener( + type: "error", + listener: (this: Worker, event: WorkerErrorEvent) => unknown, + options?: boolean | WorkerEventListenerOptions + ): void; + addEventListener( + type: string, + listener: (this: Worker, event: WorkerEvent) => unknown, + options?: boolean | WorkerEventListenerOptions + ): void; + removeEventListener( + type: string, + listener: (this: Worker, event: never) => unknown, + options?: boolean | { capture?: boolean } + ): void; + dispatchEvent(event: object): boolean; + } + + /** + * The runtime's `Worker` constructor: the very function the global of that + * name was created with, however `globalThis.Worker` has been reassigned + * since. `scriptPath` is resolved the way the global constructor resolves + * it (`~/` for the app root, `./` relative to the calling script). + */ + export const Worker: { + readonly prototype: Worker; + new (scriptPath: string, options?: WorkerOptions | null): Worker; + }; +} + +// Script-level declarations are global. A program with a DOM lib already has +// a `WorkerOptions`, which these members merge into, so the runtime's options +// type-check on the global constructor too; a program without one gets just +// these members. The `Worker` variable follows @types/node's rule for its own +// globals: when a DOM lib declares the global (detected through `onmessage`, +// which only the DOM libs put on `globalThis`), that declaration is reused +// verbatim so the two `declare var`s agree, and otherwise the module's +// constructor becomes the global. +interface WorkerOptions { + ios?: import("ns:worker_threads").WorkerIosOptions | null; + resourceLimits?: import("ns:worker_threads").WorkerResourceLimits | null; + /** @deprecated Use `ios.priority`, which wins when both are given. */ + iosPriority?: import("ns:worker_threads").WorkerPriority; +} + +declare var Worker: typeof globalThis extends { + onmessage: any; + Worker: infer T; +} + ? T + : typeof import("ns:worker_threads").Worker;