@orkestrel/worker 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_worker_threads = require("node:worker_threads");
3
+ let _orkestrel_contract = require("@orkestrel/contract");
4
+ let _src_core = require("../core/index.cjs");
5
+ let _orkestrel_database_server = require("@orkestrel/database/server");
6
+ let _orkestrel_queue = require("@orkestrel/queue");
7
+ //#region src/server/helpers.ts
8
+ /**
9
+ * Spawn one worker thread and resolve a live {@link NodeThread} once it comes online.
10
+ *
11
+ * @remarks
12
+ * Constructs the thread with the `script` module and the cloned `workerData`, then
13
+ * resolves on the thread's `online` event (rejecting on an early `error` OR an `exit`
14
+ * that arrives before `online`, so the spawn promise is total — it can never dangle on a
15
+ * thread that died without erroring). The wrapper attaches persistent `error` / `exit`
16
+ * listeners that flip `alive` to `false` AND latch the first terminal event on
17
+ * {@link NodeThread.death}: a crash is observable to an in-flight {@link dispatch} (via
18
+ * its own listeners), to the pool's `validate` (via `alive`), and — crucially — to a
19
+ * dispatch that attaches AFTER the death (via the latch). The latch closes a real race:
20
+ * under event-loop pressure a dead thread's `online` + `error` + `exit` are delivered in
21
+ * ONE synchronous exit-drain batch, so every death event fires before the microtask chain
22
+ * resolving this spawn can hand the thread to `dispatch` — without the latch that job
23
+ * would await events that already fired, forever. The pool's `create` hook calls this.
24
+ *
25
+ * @param script - The worker module each thread runs (must call `serveWorker`)
26
+ * @param workerData - Opaque, structured-cloneable data handed to the thread at spawn
27
+ * @returns A promise resolving the online {@link NodeThread}
28
+ */
29
+ function spawnThread(script, workerData) {
30
+ const worker = new node_worker_threads.Worker(script, { workerData });
31
+ const thread = {
32
+ worker,
33
+ alive: true,
34
+ death: void 0
35
+ };
36
+ worker.on("error", (error) => {
37
+ thread.alive = false;
38
+ if (thread.death === void 0) thread.death = error;
39
+ });
40
+ worker.on("exit", (code) => {
41
+ thread.alive = false;
42
+ if (thread.death === void 0) thread.death = /* @__PURE__ */ new Error(`worker thread exited (code ${code})`);
43
+ });
44
+ return new Promise((resolve, reject) => {
45
+ const onOnline = () => {
46
+ worker.off("error", onError);
47
+ worker.off("exit", onExit);
48
+ resolve(thread);
49
+ };
50
+ const onError = (error) => {
51
+ worker.off("online", onOnline);
52
+ worker.off("exit", onExit);
53
+ reject(error);
54
+ };
55
+ const onExit = (code) => {
56
+ worker.off("online", onOnline);
57
+ worker.off("error", onError);
58
+ reject(/* @__PURE__ */ new Error(`worker thread exited before coming online (code ${code})`));
59
+ };
60
+ worker.once("online", onOnline);
61
+ worker.once("error", onError);
62
+ worker.once("exit", onExit);
63
+ });
64
+ }
65
+ /**
66
+ * Narrow an inbound `message` to a {@link Reply} for a given job `id` — no assertion.
67
+ *
68
+ * @remarks
69
+ * A total {@link Guard}-style predicate (never throws): a record whose `id` matches and
70
+ * whose `ok` discriminant is well-formed (a `true` carries any `value`; a `false` carries a
71
+ * string `error`). Anything else — another job's reply, a malformed payload — is `false`, so
72
+ * a {@link dispatch} listener ignores it (a thread that chatters on the channel can't corrupt
73
+ * a job).
74
+ *
75
+ * @param value - The inbound message to narrow
76
+ * @param id - The job id a matching reply must carry
77
+ * @returns `true` (narrowing `value` to {@link Reply}) when it is this job's well-formed reply
78
+ */
79
+ function isReply(value, id) {
80
+ if (!(0, _orkestrel_contract.isRecord)(value)) return false;
81
+ if (value.id !== id) return false;
82
+ if (value.ok === true) return true;
83
+ return value.ok === false && typeof value.error === "string";
84
+ }
85
+ /**
86
+ * Dispatch one job to a leased {@link NodeThread} and await its narrowed reply.
87
+ *
88
+ * @remarks
89
+ * Mints a fresh `id`, posts a `run` envelope, and resolves when the thread replies for
90
+ * that id: a success `value` is narrowed through `result` (a value that fails the guard
91
+ * rejects — the zero-`as` type bridge), a failure rejects with the thread's error string.
92
+ * A thread that ALREADY died rejects synchronously at entry from the latched
93
+ * {@link NodeThread.death} — its death events fired before this dispatch existed (under
94
+ * load they arrive in one batched exit drain) and will never fire again, so waiting on
95
+ * the listeners below would dangle forever; the latch makes the death total across every
96
+ * event ordering. If the thread `error`s / `exit`s mid-flight it is marked dead and the
97
+ * job rejects. On `execution.signal` abort it posts an `abort` envelope (cooperative) AND
98
+ * evicts the thread — `alive = false` + `terminate()` — because CPU-bound work cannot
99
+ * honour the signal; the freed pool slot then gets a fresh thread. Every listener (the
100
+ * thread's `message` / `error` / `exit` and the signal's `abort`) is removed on settle,
101
+ * and a `settled` guard prevents a double-settle.
102
+ *
103
+ * @typeParam TResult - The reply type the `result` guard narrows to
104
+ * @param thread - The leased thread to run the job on
105
+ * @param input - The work payload (structured-cloned to the thread)
106
+ * @param execution - The per-attempt handle; its `signal` aborts → terminate + evict
107
+ * @param result - The {@link Guard} narrowing the reply value with no assertion
108
+ * @returns A promise resolving the narrowed `TResult`, or rejecting on error / abort
109
+ */
110
+ function dispatch(thread, input, execution, result) {
111
+ const id = crypto.randomUUID();
112
+ const worker = thread.worker;
113
+ return new Promise((resolve, reject) => {
114
+ if (thread.death !== void 0 || !thread.alive) {
115
+ reject(thread.death ?? /* @__PURE__ */ new Error("worker thread is dead"));
116
+ return;
117
+ }
118
+ let settled = false;
119
+ let detach = () => {};
120
+ const settle = (action) => {
121
+ if (settled) return;
122
+ settled = true;
123
+ detach();
124
+ action();
125
+ };
126
+ const onMessage = (value) => {
127
+ if (!isReply(value, id)) return;
128
+ if (value.ok) {
129
+ const reply = value.value;
130
+ if (result(reply)) settle(() => resolve(reply));
131
+ else settle(() => reject(/* @__PURE__ */ new Error("reply did not satisfy result guard")));
132
+ return;
133
+ }
134
+ const message = value.error;
135
+ settle(() => reject(new Error(message)));
136
+ };
137
+ const onError = (error) => {
138
+ thread.alive = false;
139
+ settle(() => reject(error));
140
+ };
141
+ const onExit = () => {
142
+ thread.alive = false;
143
+ settle(() => reject(/* @__PURE__ */ new Error("worker thread exited")));
144
+ };
145
+ const onAbort = () => {
146
+ worker.postMessage({
147
+ id,
148
+ command: "abort"
149
+ });
150
+ thread.alive = false;
151
+ worker.terminate();
152
+ settle(() => reject(/* @__PURE__ */ new Error("job aborted")));
153
+ };
154
+ detach = () => {
155
+ worker.off("message", onMessage);
156
+ worker.off("error", onError);
157
+ worker.off("exit", onExit);
158
+ execution.signal.removeEventListener("abort", onAbort);
159
+ };
160
+ worker.on("message", onMessage);
161
+ worker.on("error", onError);
162
+ worker.on("exit", onExit);
163
+ if (execution.signal.aborted) {
164
+ onAbort();
165
+ return;
166
+ }
167
+ execution.signal.addEventListener("abort", onAbort, { once: true });
168
+ try {
169
+ worker.postMessage({
170
+ id,
171
+ command: "run",
172
+ input
173
+ });
174
+ } catch (error) {
175
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
176
+ }
177
+ });
178
+ }
179
+ //#endregion
180
+ //#region src/server/serve.ts
181
+ function isRecord(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
184
+ function isRun(value) {
185
+ return isRecord(value) && typeof value.id === "string" && value.command === "run" && "input" in value;
186
+ }
187
+ function isAbort(value) {
188
+ return isRecord(value) && typeof value.id === "string" && value.command === "abort";
189
+ }
190
+ /**
191
+ * Register a worker-thread handler — the worker-side half of {@link createNodeWorker}.
192
+ *
193
+ * @remarks
194
+ * Must be the spawned thread's module entry. It listens on the parent port for the
195
+ * run/abort protocol: a `run` message narrows its `input` through `options.input` (an
196
+ * invalid payload replies with an error envelope, never running the handler), then runs
197
+ * `options.handler(input, { signal })` and replies `{ id, ok: true, value }` on success or
198
+ * `{ id, ok: false, error }` on throw. Each in-flight job has its own `AbortController`,
199
+ * so an `abort` message for that id fires the handler's `signal` (cooperative — the main
200
+ * side ALSO terminates the thread, so a handler that ignores its signal is still stopped).
201
+ * Every inbound message is narrowed with the inlined guards — no `as`. On the main thread
202
+ * (`parentPort === null`) it is a no-op.
203
+ *
204
+ * @typeParam TInput - The work payload (inferred from `options.input`)
205
+ * @typeParam TResult - The value the handler resolves (the reply payload)
206
+ * @param options - The `input` guard and the `handler` (see {@link ServeWorkerOptions})
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * // double.ts — a worker script
211
+ * import { serveWorker } from '@src/server'
212
+ *
213
+ * serveWorker<number, number>({
214
+ * input: (value): value is number => typeof value === 'number',
215
+ * handler: (value) => value * 2,
216
+ * })
217
+ * ```
218
+ */
219
+ function serveWorker(options) {
220
+ const port = node_worker_threads.parentPort;
221
+ if (port === null) return;
222
+ const controllers = /* @__PURE__ */ new Map();
223
+ port.on("message", (raw) => {
224
+ if (isAbort(raw)) {
225
+ controllers.get(raw.id)?.abort();
226
+ return;
227
+ }
228
+ if (!isRun(raw)) return;
229
+ const id = raw.id;
230
+ if (!options.input(raw.input)) {
231
+ port.postMessage({
232
+ id,
233
+ ok: false,
234
+ error: "input did not satisfy input guard"
235
+ });
236
+ return;
237
+ }
238
+ const input = raw.input;
239
+ const controller = new AbortController();
240
+ controllers.set(id, controller);
241
+ Promise.resolve().then(() => options.handler(input, { signal: controller.signal })).then((value) => {
242
+ controllers.delete(id);
243
+ port.postMessage({
244
+ id,
245
+ ok: true,
246
+ value
247
+ });
248
+ }, (error) => {
249
+ controllers.delete(id);
250
+ port.postMessage({
251
+ id,
252
+ ok: false,
253
+ error: error instanceof Error ? error.message : String(error)
254
+ });
255
+ });
256
+ });
257
+ }
258
+ //#endregion
259
+ //#region src/server/factories.ts
260
+ /**
261
+ * Create a persistent JSON-file {@link QueueStoreInterface} — the core
262
+ * `createDatabaseQueueStore` over a server {@link createJSONDriver}.
263
+ *
264
+ * @remarks
265
+ * A queue's durable state is just a database table, so JSON persistence reuses the
266
+ * existing JSON-file driver rather than a bespoke store: the entries are written to
267
+ * (and reloaded from) the file at `path`, surviving a process restart. There is no new
268
+ * class — the store engine ({@link createDatabaseQueueStore}) is shared, and only the
269
+ * driver changes where the bytes live. The `input` shape must be JSON-serializable
270
+ * (the JSON driver round-trips it as JSON). Build a second store over the SAME `path` to
271
+ * resume the outstanding entries a prior store persisted.
272
+ *
273
+ * @typeParam TInput - The contract shape of each entry's `input` payload
274
+ * @param path - The JSON file the entries are loaded from and flushed to
275
+ * @param input - The {@link ContractShape} for the work payload (the `input` column)
276
+ * @returns A JSON-file-backed {@link QueueStoreInterface}, typed by `input`
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * import { stringShape } from '@src/core'
281
+ * import { createJSONQueueStore } from '@src/server'
282
+ *
283
+ * const store = createJSONQueueStore('data/queue.json', stringShape())
284
+ * await store.save({ id: 'job-1', input: 'https://example.com', attempts: 0 })
285
+ * // A later process resumes the outstanding work:
286
+ * const resumed = createJSONQueueStore('data/queue.json', stringShape())
287
+ * const outstanding = await resumed.load()
288
+ * ```
289
+ */
290
+ function createJSONQueueStore(path, input) {
291
+ return (0, _orkestrel_queue.createDatabaseQueueStore)(input, (0, _orkestrel_database_server.createJSONDriver)(path));
292
+ }
293
+ /**
294
+ * Create a CPU-parallel worker over `node:worker_threads` — a thin specialization of the
295
+ * core `createWorker` whose pooled resource is a worker THREAD.
296
+ *
297
+ * @remarks
298
+ * Composition, not reimplementation: all concurrency, retries, per-attempt timeout,
299
+ * lifecycle, and durability are the core `Worker`'s (a `Queue` ⨉ `Pool`). This factory
300
+ * supplies only the thread pairing — the pool `create`s a thread (via `spawnThread`),
301
+ * `destroy`s it with `terminate()`, and `validate`s it by `alive && threadId > 0` (so an
302
+ * evicted / crashed thread is dropped and replaced) — and an internal handler that
303
+ * narrows the input through `options.input` (fail-fast before the structured-clone
304
+ * boundary) then `dispatch`es the job to the leased thread, narrowing the reply through
305
+ * `options.result`. Both generics INFER from the `input` / `result` guards, so call sites
306
+ * need no explicit type arguments. The boundary is crossed with ZERO `as`: the guards
307
+ * reconstruct `TInput` / `TResult` by validation (AGENTS §14). An `abort` / `timeout`
308
+ * TERMINATES the in-flight thread (CPU-bound work can't honour a signal) and evicts it; a
309
+ * subsequent job spawns a fresh thread. The worker script's module must call
310
+ * `serveWorker`. Returns the plain {@link WorkerInterface} — its methods are the Worker's.
311
+ *
312
+ * @typeParam TInput - The work payload each job carries (inferred from `input`)
313
+ * @typeParam TResult - The value a thread resolves for a job (inferred from `result`)
314
+ * @param options - The `script` plus the `input` / `result` guards and optional
315
+ * `workerData` / `concurrency` / `retries` / `timeout` / `store`
316
+ * (see {@link NodeWorkerOptions})
317
+ * @returns A working {@link WorkerInterface} backed by a thread pool
318
+ *
319
+ * @example
320
+ * ```ts
321
+ * import { createNodeWorker } from '@src/server'
322
+ *
323
+ * const worker = createNodeWorker({
324
+ * script: new URL('./double.js', import.meta.url),
325
+ * input: (value): value is number => typeof value === 'number',
326
+ * result: (value): value is number => typeof value === 'number',
327
+ * concurrency: 4,
328
+ * })
329
+ *
330
+ * const doubled = await worker.enqueue(21) // 42, computed on a worker thread
331
+ * worker.destroy() // terminates every thread
332
+ * ```
333
+ */
334
+ function createNodeWorker(options) {
335
+ return (0, _src_core.createWorker)({
336
+ pool: {
337
+ create: () => spawnThread(options.script, options.workerData),
338
+ destroy: (thread) => thread.worker.terminate().then(() => {}),
339
+ validate: (thread) => thread.alive && thread.worker.threadId > 0,
340
+ max: options.concurrency
341
+ },
342
+ handler: (input, thread, execution) => {
343
+ if (!options.input(input)) return Promise.reject(/* @__PURE__ */ new Error("input did not satisfy input guard"));
344
+ return dispatch(thread, input, execution, options.result);
345
+ },
346
+ concurrency: options.concurrency,
347
+ retries: options.retries,
348
+ timeout: options.timeout,
349
+ store: options.store
350
+ });
351
+ }
352
+ //#endregion
353
+ exports.createJSONQueueStore = createJSONQueueStore;
354
+ exports.createNodeWorker = createNodeWorker;
355
+ exports.dispatch = dispatch;
356
+ exports.isReply = isReply;
357
+ exports.serveWorker = serveWorker;
358
+ exports.spawnThread = spawnThread;
359
+
360
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/server/helpers.ts","../../../src/server/serve.ts","../../../src/server/factories.ts"],"sourcesContent":["import type { QueueExecution } from '@orkestrel/queue'\nimport type { Guard, NodeThread, Reply } from './types.js'\nimport { Worker as ThreadWorker } from 'node:worker_threads'\nimport { isRecord } from '@orkestrel/contract'\n\n// === The wire protocol (main ↔ thread)\n//\n// The main-side half of the run/abort/reply protocol `serveWorker` answers — spawning a\n// pooled thread, narrowing its replies, and dispatching one job at a time. The envelope\n// types ({@link Reply}, {@link NodeThread}) live in `./types.js` (AGENTS §5); the public\n// bridge across the structured-clone boundary is the `input` / `result` `Guard`s, which\n// narrow the envelopes' opaque `unknown` payloads with no assertion (AGENTS §14).\n\n/**\n * Spawn one worker thread and resolve a live {@link NodeThread} once it comes online.\n *\n * @remarks\n * Constructs the thread with the `script` module and the cloned `workerData`, then\n * resolves on the thread's `online` event (rejecting on an early `error` OR an `exit`\n * that arrives before `online`, so the spawn promise is total — it can never dangle on a\n * thread that died without erroring). The wrapper attaches persistent `error` / `exit`\n * listeners that flip `alive` to `false` AND latch the first terminal event on\n * {@link NodeThread.death}: a crash is observable to an in-flight {@link dispatch} (via\n * its own listeners), to the pool's `validate` (via `alive`), and — crucially — to a\n * dispatch that attaches AFTER the death (via the latch). The latch closes a real race:\n * under event-loop pressure a dead thread's `online` + `error` + `exit` are delivered in\n * ONE synchronous exit-drain batch, so every death event fires before the microtask chain\n * resolving this spawn can hand the thread to `dispatch` — without the latch that job\n * would await events that already fired, forever. The pool's `create` hook calls this.\n *\n * @param script - The worker module each thread runs (must call `serveWorker`)\n * @param workerData - Opaque, structured-cloneable data handed to the thread at spawn\n * @returns A promise resolving the online {@link NodeThread}\n */\nexport function spawnThread(script: string | URL, workerData: unknown): Promise<NodeThread> {\n\tconst worker = new ThreadWorker(script, { workerData })\n\tconst thread: NodeThread = { worker, alive: true, death: undefined }\n\t// The persistent death latch — attached BEFORE any once-listener, so the first terminal\n\t// event records its cause on the record even when it fires inside a batched exit drain.\n\tworker.on('error', (error: Error) => {\n\t\tthread.alive = false\n\t\tif (thread.death === undefined) thread.death = error\n\t})\n\tworker.on('exit', (code: number) => {\n\t\tthread.alive = false\n\t\tif (thread.death === undefined) thread.death = new Error(`worker thread exited (code ${code})`)\n\t})\n\treturn new Promise<NodeThread>((resolve, reject) => {\n\t\tconst onOnline = (): void => {\n\t\t\tworker.off('error', onError)\n\t\t\tworker.off('exit', onExit)\n\t\t\tresolve(thread)\n\t\t}\n\t\tconst onError = (error: Error): void => {\n\t\t\tworker.off('online', onOnline)\n\t\t\tworker.off('exit', onExit)\n\t\t\treject(error)\n\t\t}\n\t\tconst onExit = (code: number): void => {\n\t\t\tworker.off('online', onOnline)\n\t\t\tworker.off('error', onError)\n\t\t\treject(new Error(`worker thread exited before coming online (code ${code})`))\n\t\t}\n\t\tworker.once('online', onOnline)\n\t\tworker.once('error', onError)\n\t\tworker.once('exit', onExit)\n\t})\n}\n\n/**\n * Narrow an inbound `message` to a {@link Reply} for a given job `id` — no assertion.\n *\n * @remarks\n * A total {@link Guard}-style predicate (never throws): a record whose `id` matches and\n * whose `ok` discriminant is well-formed (a `true` carries any `value`; a `false` carries a\n * string `error`). Anything else — another job's reply, a malformed payload — is `false`, so\n * a {@link dispatch} listener ignores it (a thread that chatters on the channel can't corrupt\n * a job).\n *\n * @param value - The inbound message to narrow\n * @param id - The job id a matching reply must carry\n * @returns `true` (narrowing `value` to {@link Reply}) when it is this job's well-formed reply\n */\nexport function isReply(value: unknown, id: string): value is Reply {\n\tif (!isRecord(value)) return false\n\tif (value.id !== id) return false\n\tif (value.ok === true) return true\n\treturn value.ok === false && typeof value.error === 'string'\n}\n\n/**\n * Dispatch one job to a leased {@link NodeThread} and await its narrowed reply.\n *\n * @remarks\n * Mints a fresh `id`, posts a `run` envelope, and resolves when the thread replies for\n * that id: a success `value` is narrowed through `result` (a value that fails the guard\n * rejects — the zero-`as` type bridge), a failure rejects with the thread's error string.\n * A thread that ALREADY died rejects synchronously at entry from the latched\n * {@link NodeThread.death} — its death events fired before this dispatch existed (under\n * load they arrive in one batched exit drain) and will never fire again, so waiting on\n * the listeners below would dangle forever; the latch makes the death total across every\n * event ordering. If the thread `error`s / `exit`s mid-flight it is marked dead and the\n * job rejects. On `execution.signal` abort it posts an `abort` envelope (cooperative) AND\n * evicts the thread — `alive = false` + `terminate()` — because CPU-bound work cannot\n * honour the signal; the freed pool slot then gets a fresh thread. Every listener (the\n * thread's `message` / `error` / `exit` and the signal's `abort`) is removed on settle,\n * and a `settled` guard prevents a double-settle.\n *\n * @typeParam TResult - The reply type the `result` guard narrows to\n * @param thread - The leased thread to run the job on\n * @param input - The work payload (structured-cloned to the thread)\n * @param execution - The per-attempt handle; its `signal` aborts → terminate + evict\n * @param result - The {@link Guard} narrowing the reply value with no assertion\n * @returns A promise resolving the narrowed `TResult`, or rejecting on error / abort\n */\nexport function dispatch<TResult>(\n\tthread: NodeThread,\n\tinput: unknown,\n\texecution: QueueExecution,\n\tresult: Guard<TResult>,\n): Promise<TResult> {\n\tconst id = crypto.randomUUID()\n\tconst worker = thread.worker\n\treturn new Promise<TResult>((resolve, reject) => {\n\t\t// The latched-death entry check: a thread that died BEFORE this dispatch attached has\n\t\t// already emitted its `error` / `exit` (a batched exit drain delivers them before this\n\t\t// microtask runs) — no listener below will ever fire, and a `postMessage` to it is a\n\t\t// silent no-op. Reject NOW from the latch; this check + the attaches are synchronous,\n\t\t// so there is no gap a death can slip through.\n\t\tif (thread.death !== undefined || !thread.alive) {\n\t\t\treject(thread.death ?? new Error('worker thread is dead'))\n\t\t\treturn\n\t\t}\n\t\tlet settled = false\n\t\tlet detach = (): void => {}\n\t\tconst settle = (action: () => void): void => {\n\t\t\tif (settled) return\n\t\t\tsettled = true\n\t\t\tdetach()\n\t\t\taction()\n\t\t}\n\t\tconst onMessage = (value: unknown): void => {\n\t\t\tif (!isReply(value, id)) return\n\t\t\tif (value.ok) {\n\t\t\t\tconst reply = value.value\n\t\t\t\tif (result(reply)) settle(() => resolve(reply))\n\t\t\t\telse settle(() => reject(new Error('reply did not satisfy result guard')))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst message = value.error\n\t\t\tsettle(() => reject(new Error(message)))\n\t\t}\n\t\tconst onError = (error: Error): void => {\n\t\t\tthread.alive = false\n\t\t\tsettle(() => reject(error))\n\t\t}\n\t\tconst onExit = (): void => {\n\t\t\tthread.alive = false\n\t\t\tsettle(() => reject(new Error('worker thread exited')))\n\t\t}\n\t\t// Cooperative abort first, then EVICT: CPU-bound work won't honour the signal, so\n\t\t// terminate the thread and mark it dead — the pool replaces it on the next acquire.\n\t\t// NOTE: this `terminate()` may run TWICE — once here, and again when the pool's\n\t\t// `destroy` hook (`thread.worker.terminate()`) tears down the now-dead thread its\n\t\t// `validate` evicts. A second `terminate()` on an already-terminated Node thread is a\n\t\t// safe, idempotent no-op (it resolves with the prior exit code), so do NOT \"dedupe\" it\n\t\t// by gating on `alive` — that would skip the pool's eviction and reuse a tainted thread.\n\t\tconst onAbort = (): void => {\n\t\t\tworker.postMessage({ id, command: 'abort' })\n\t\t\tthread.alive = false\n\t\t\tvoid worker.terminate()\n\t\t\tsettle(() => reject(new Error('job aborted')))\n\t\t}\n\t\tdetach = (): void => {\n\t\t\tworker.off('message', onMessage)\n\t\t\tworker.off('error', onError)\n\t\t\tworker.off('exit', onExit)\n\t\t\texecution.signal.removeEventListener('abort', onAbort)\n\t\t}\n\t\tworker.on('message', onMessage)\n\t\tworker.on('error', onError)\n\t\tworker.on('exit', onExit)\n\t\tif (execution.signal.aborted) {\n\t\t\tonAbort()\n\t\t\treturn\n\t\t}\n\t\texecution.signal.addEventListener('abort', onAbort, { once: true })\n\t\t// `postMessage` structured-clones `input`; a non-cloneable payload throws here —\n\t\t// settle-reject so the listeners detach (no leak) rather than escaping the executor.\n\t\ttry {\n\t\t\tworker.postMessage({ id, command: 'run', input })\n\t\t} catch (error: unknown) {\n\t\t\tsettle(() => reject(error instanceof Error ? error : new Error(String(error))))\n\t\t}\n\t})\n}\n","import type { ServeWorkerOptions } from './types.js'\nimport { parentPort } from 'node:worker_threads'\n\n// The worker-side entry. SELF-CONTAINED by necessity: this module loads as RAW `.ts`\n// inside a spawned thread (Node ≥ 23.6 type-stripping), so it imports ONLY\n// `node:worker_threads` at runtime — no `@src/*`, no `.js`-relative value imports (the\n// only non-node import is the type-only `ServeWorkerOptions`, fully erased at runtime).\n// Its guards are inlined for the same reason. A worker script that needs the cloned\n// `workerData` reads it directly from `node:worker_threads` (it is in a thread already).\n\n// Inlined record guard (do NOT import `isRecord` from `@src/core` — see above). Total:\n// adversarial input returns `false`, never throws (AGENTS §14).\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n// Narrow an inbound message to a `run` envelope (a string `id` + a `'run'` command + an\n// `input` payload) — no assertion.\nfunction isRun(value: unknown): value is { readonly id: string; readonly input: unknown } {\n\treturn (\n\t\tisRecord(value) && typeof value.id === 'string' && value.command === 'run' && 'input' in value\n\t)\n}\n\n// Narrow an inbound message to an `abort` envelope (a string `id` + an `'abort'` command).\nfunction isAbort(value: unknown): value is { readonly id: string } {\n\treturn isRecord(value) && typeof value.id === 'string' && value.command === 'abort'\n}\n\n/**\n * Register a worker-thread handler — the worker-side half of {@link createNodeWorker}.\n *\n * @remarks\n * Must be the spawned thread's module entry. It listens on the parent port for the\n * run/abort protocol: a `run` message narrows its `input` through `options.input` (an\n * invalid payload replies with an error envelope, never running the handler), then runs\n * `options.handler(input, { signal })` and replies `{ id, ok: true, value }` on success or\n * `{ id, ok: false, error }` on throw. Each in-flight job has its own `AbortController`,\n * so an `abort` message for that id fires the handler's `signal` (cooperative — the main\n * side ALSO terminates the thread, so a handler that ignores its signal is still stopped).\n * Every inbound message is narrowed with the inlined guards — no `as`. On the main thread\n * (`parentPort === null`) it is a no-op.\n *\n * @typeParam TInput - The work payload (inferred from `options.input`)\n * @typeParam TResult - The value the handler resolves (the reply payload)\n * @param options - The `input` guard and the `handler` (see {@link ServeWorkerOptions})\n *\n * @example\n * ```ts\n * // double.ts — a worker script\n * import { serveWorker } from '@src/server'\n *\n * serveWorker<number, number>({\n * \tinput: (value): value is number => typeof value === 'number',\n * \thandler: (value) => value * 2,\n * })\n * ```\n */\nexport function serveWorker<TInput, TResult>(options: ServeWorkerOptions<TInput, TResult>): void {\n\tconst port = parentPort\n\tif (port === null) return\n\tconst controllers = new Map<string, AbortController>()\n\tport.on('message', (raw: unknown) => {\n\t\tif (isAbort(raw)) {\n\t\t\tcontrollers.get(raw.id)?.abort()\n\t\t\treturn\n\t\t}\n\t\tif (!isRun(raw)) return\n\t\tconst id = raw.id\n\t\tif (!options.input(raw.input)) {\n\t\t\tport.postMessage({ id, ok: false, error: 'input did not satisfy input guard' })\n\t\t\treturn\n\t\t}\n\t\tconst input = raw.input\n\t\tconst controller = new AbortController()\n\t\tcontrollers.set(id, controller)\n\t\t// Defer the handler call into the `then` so a SYNCHRONOUS throw becomes a rejection\n\t\t// (not an uncaught thread exception) and is reported as an error reply.\n\t\tPromise.resolve()\n\t\t\t.then(() => options.handler(input, { signal: controller.signal }))\n\t\t\t.then(\n\t\t\t\t(value) => {\n\t\t\t\t\tcontrollers.delete(id)\n\t\t\t\t\tport.postMessage({ id, ok: true, value })\n\t\t\t\t},\n\t\t\t\t(error: unknown) => {\n\t\t\t\t\tcontrollers.delete(id)\n\t\t\t\t\tport.postMessage({\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tok: false,\n\t\t\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t)\n\t})\n}\n","import type { WorkerInterface } from '@src/core'\nimport type { ContractShape, Infer } from '@orkestrel/contract'\nimport type { QueueStoreInterface } from '@orkestrel/queue'\nimport type { NodeThread, NodeWorkerOptions } from './types.js'\nimport { createWorker } from '@src/core'\nimport { createJSONDriver } from '@orkestrel/database/server'\nimport { createDatabaseQueueStore } from '@orkestrel/queue'\nimport { dispatch, spawnThread } from './helpers.js'\n\n/**\n * Create a persistent JSON-file {@link QueueStoreInterface} — the core\n * `createDatabaseQueueStore` over a server {@link createJSONDriver}.\n *\n * @remarks\n * A queue's durable state is just a database table, so JSON persistence reuses the\n * existing JSON-file driver rather than a bespoke store: the entries are written to\n * (and reloaded from) the file at `path`, surviving a process restart. There is no new\n * class — the store engine ({@link createDatabaseQueueStore}) is shared, and only the\n * driver changes where the bytes live. The `input` shape must be JSON-serializable\n * (the JSON driver round-trips it as JSON). Build a second store over the SAME `path` to\n * resume the outstanding entries a prior store persisted.\n *\n * @typeParam TInput - The contract shape of each entry's `input` payload\n * @param path - The JSON file the entries are loaded from and flushed to\n * @param input - The {@link ContractShape} for the work payload (the `input` column)\n * @returns A JSON-file-backed {@link QueueStoreInterface}, typed by `input`\n *\n * @example\n * ```ts\n * import { stringShape } from '@src/core'\n * import { createJSONQueueStore } from '@src/server'\n *\n * const store = createJSONQueueStore('data/queue.json', stringShape())\n * await store.save({ id: 'job-1', input: 'https://example.com', attempts: 0 })\n * // A later process resumes the outstanding work:\n * const resumed = createJSONQueueStore('data/queue.json', stringShape())\n * const outstanding = await resumed.load()\n * ```\n */\nexport function createJSONQueueStore<TInput extends ContractShape>(\n\tpath: string,\n\tinput: TInput,\n): QueueStoreInterface<Infer<TInput>> {\n\treturn createDatabaseQueueStore(input, createJSONDriver(path))\n}\n\n/**\n * Create a CPU-parallel worker over `node:worker_threads` — a thin specialization of the\n * core `createWorker` whose pooled resource is a worker THREAD.\n *\n * @remarks\n * Composition, not reimplementation: all concurrency, retries, per-attempt timeout,\n * lifecycle, and durability are the core `Worker`'s (a `Queue` ⨉ `Pool`). This factory\n * supplies only the thread pairing — the pool `create`s a thread (via `spawnThread`),\n * `destroy`s it with `terminate()`, and `validate`s it by `alive && threadId > 0` (so an\n * evicted / crashed thread is dropped and replaced) — and an internal handler that\n * narrows the input through `options.input` (fail-fast before the structured-clone\n * boundary) then `dispatch`es the job to the leased thread, narrowing the reply through\n * `options.result`. Both generics INFER from the `input` / `result` guards, so call sites\n * need no explicit type arguments. The boundary is crossed with ZERO `as`: the guards\n * reconstruct `TInput` / `TResult` by validation (AGENTS §14). An `abort` / `timeout`\n * TERMINATES the in-flight thread (CPU-bound work can't honour a signal) and evicts it; a\n * subsequent job spawns a fresh thread. The worker script's module must call\n * `serveWorker`. Returns the plain {@link WorkerInterface} — its methods are the Worker's.\n *\n * @typeParam TInput - The work payload each job carries (inferred from `input`)\n * @typeParam TResult - The value a thread resolves for a job (inferred from `result`)\n * @param options - The `script` plus the `input` / `result` guards and optional\n * `workerData` / `concurrency` / `retries` / `timeout` / `store`\n * (see {@link NodeWorkerOptions})\n * @returns A working {@link WorkerInterface} backed by a thread pool\n *\n * @example\n * ```ts\n * import { createNodeWorker } from '@src/server'\n *\n * const worker = createNodeWorker({\n * \tscript: new URL('./double.js', import.meta.url),\n * \tinput: (value): value is number => typeof value === 'number',\n * \tresult: (value): value is number => typeof value === 'number',\n * \tconcurrency: 4,\n * })\n *\n * const doubled = await worker.enqueue(21) // 42, computed on a worker thread\n * worker.destroy() // terminates every thread\n * ```\n */\nexport function createNodeWorker<TInput, TResult>(\n\toptions: NodeWorkerOptions<TInput, TResult>,\n): WorkerInterface<TInput, TResult> {\n\treturn createWorker<TInput, NodeThread, TResult>({\n\t\tpool: {\n\t\t\tcreate: () => spawnThread(options.script, options.workerData),\n\t\t\tdestroy: (thread) => thread.worker.terminate().then(() => {}),\n\t\t\tvalidate: (thread) => thread.alive && thread.worker.threadId > 0,\n\t\t\tmax: options.concurrency,\n\t\t},\n\t\thandler: (input, thread, execution) => {\n\t\t\tif (!options.input(input)) {\n\t\t\t\treturn Promise.reject(new Error('input did not satisfy input guard'))\n\t\t\t}\n\t\t\treturn dispatch(thread, input, execution, options.result)\n\t\t},\n\t\tconcurrency: options.concurrency,\n\t\tretries: options.retries,\n\t\ttimeout: options.timeout,\n\t\tstore: options.store,\n\t})\n}\n"],"mappings":"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"}