@atolljs/core 0.1.0

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.
Files changed (44) hide show
  1. package/README.md +110 -0
  2. package/dist/index.js +19 -0
  3. package/dist/index10.js +186 -0
  4. package/dist/index11.js +52 -0
  5. package/dist/index12.js +66 -0
  6. package/dist/index13.js +27 -0
  7. package/dist/index14.js +63 -0
  8. package/dist/index15.js +50 -0
  9. package/dist/index16.js +46 -0
  10. package/dist/index17.js +42 -0
  11. package/dist/index18.js +61 -0
  12. package/dist/index19.js +56 -0
  13. package/dist/index2.js +10 -0
  14. package/dist/index20.js +59 -0
  15. package/dist/index3.js +120 -0
  16. package/dist/index4.js +40 -0
  17. package/dist/index5.js +481 -0
  18. package/dist/index6.js +23 -0
  19. package/dist/index7.js +10 -0
  20. package/dist/index8.js +37 -0
  21. package/dist/index9.js +22 -0
  22. package/package.json +68 -0
  23. package/src/contract/listSchema.ts +211 -0
  24. package/src/contract/msgpackCodec.ts +9 -0
  25. package/src/contract/msgpackrCodec.ts +16 -0
  26. package/src/contract/mz.ts +48 -0
  27. package/src/contract/sharedMemory.ts +909 -0
  28. package/src/contract/types.ts +123 -0
  29. package/src/index.ts +114 -0
  30. package/src/log.ts +69 -0
  31. package/src/observable.ts +151 -0
  32. package/src/pool/errors.ts +31 -0
  33. package/src/pool/memory.ts +68 -0
  34. package/src/pool/workerClient.ts +181 -0
  35. package/src/pool/workerPool.ts +438 -0
  36. package/src/reactive.ts +161 -0
  37. package/src/service.ts +283 -0
  38. package/src/shared/sharedWorkerClient.ts +142 -0
  39. package/src/shared/sharedWorkerHost.ts +104 -0
  40. package/src/task.ts +99 -0
  41. package/src/testing/inProcessWorker.ts +127 -0
  42. package/src/worker/defineWorker.ts +135 -0
  43. package/src/worker/registry.ts +42 -0
  44. package/src/worker/workerBootstrap.ts +56 -0
package/src/task.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { scoped } from './log';
2
+ import type { ObservableValue } from './observable';
3
+
4
+ const taskLog = scoped('task');
5
+
6
+ /**
7
+ * Point-in-time state of an {@link AsyncTask}. Replaced wholesale on every
8
+ * transition so `get()` stays stable for `useSyncExternalStore`-style
9
+ * adapters.
10
+ */
11
+ export interface TaskSnapshot<R> {
12
+ /** Result of the last completed run; kept while a newer run is in flight. */
13
+ data: R | null;
14
+ pending: boolean;
15
+ /** True once any run has completed (success or failure). */
16
+ settled: boolean;
17
+ /** Wall-clock duration of the last completed run. */
18
+ elapsedMs: number | null;
19
+ error: unknown;
20
+ }
21
+
22
+ /**
23
+ * An async function turned into a subscribable task: `run` starts an
24
+ * invocation, and concurrent runs are latest-wins — a stale result is
25
+ * discarded rather than applied. Framework bindings subscribe to the
26
+ * snapshot; callers trigger `run`.
27
+ */
28
+ export interface AsyncTask<A, R> extends ObservableValue<TaskSnapshot<R>> {
29
+ run(input: A): void;
30
+ /** No-op if a run is pending or has already settled — for init-style tasks. */
31
+ runOnce(input: A): void;
32
+ }
33
+
34
+ export function defineTask<A, R>(fn: (input: A) => Promise<R>): AsyncTask<A, R> {
35
+ let reqId = 0;
36
+ let snapshot: TaskSnapshot<R> = { data: null, pending: false, settled: false, elapsedMs: null, error: null };
37
+ const subscribers = new Set<(s: TaskSnapshot<R>) => void>();
38
+ const emit = (next: TaskSnapshot<R>) => {
39
+ snapshot = next;
40
+ for (const cb of subscribers) cb(next);
41
+ };
42
+
43
+ const task: AsyncTask<A, R> = {
44
+ get: () => snapshot,
45
+ subscribe(onChange) {
46
+ subscribers.add(onChange);
47
+ return () => {
48
+ subscribers.delete(onChange);
49
+ };
50
+ },
51
+ run(input: A) {
52
+ const id = ++reqId;
53
+ const t0 = performance.now();
54
+ emit({ ...snapshot, pending: true, error: null });
55
+ void fn(input).then(
56
+ (data) => {
57
+ if (id === reqId) emit({ data, pending: false, settled: true, elapsedMs: performance.now() - t0, error: null });
58
+ },
59
+ (error) => {
60
+ if (id === reqId) emit({ ...snapshot, pending: false, settled: true, error });
61
+ taskLog.error('task run failed', error);
62
+ }
63
+ );
64
+ },
65
+ runOnce(input: A) {
66
+ if (snapshot.pending || snapshot.settled) return;
67
+ task.run(input);
68
+ },
69
+ };
70
+ return task;
71
+ }
72
+
73
+ /** Structural check: run/runOnce triggers plus the observable contract. */
74
+ export function isAsyncTask(x: unknown): x is AsyncTask<any, any> {
75
+ return (
76
+ typeof x === 'object' &&
77
+ x !== null &&
78
+ typeof (x as AsyncTask<any, any>).run === 'function' &&
79
+ typeof (x as AsyncTask<any, any>).runOnce === 'function' &&
80
+ typeof (x as AsyncTask<any, any>).get === 'function' &&
81
+ typeof (x as AsyncTask<any, any>).subscribe === 'function'
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Accepts an {@link AsyncTask} or a plain async function and always returns
87
+ * a task — framework bindings (useTask, taskState, …) take either, so a
88
+ * worker client's method drops straight in:
89
+ *
90
+ * const seed = useTask(initIncidents); // async fn → defineTask
91
+ * const page = useTask(incidents.queryIncidents); // client method → task
92
+ */
93
+ export function toTask<R>(source: AsyncTask<void, R> | (() => Promise<R>)): AsyncTask<void, R>;
94
+ export function toTask<A, R>(source: AsyncTask<A, R> | ((input: A) => Promise<R>)): AsyncTask<A, R>;
95
+ export function toTask<A, R>(
96
+ source: AsyncTask<A, R> | ((input: A) => Promise<R>),
97
+ ): AsyncTask<A, R> {
98
+ return isAsyncTask(source) ? source : defineTask(source);
99
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Test double for the Worker global that runs the real worker protocol
3
+ * in-process: INIT_MEMORY binds every defined shared-memory contract to the
4
+ * pool's buffer, EXECUTE_TASK dispatches through the real TaskRegistry.
5
+ * Everything except the OS thread boundary is real.
6
+ *
7
+ * Register worker-entry modules via `InProcessWorker.handlerModules` —
8
+ * importing them performs their TaskRegistry.register side effects, exactly
9
+ * what a bundled worker entry does at boot.
10
+ */
11
+ import { bindSharedMemories } from '../contract/sharedMemory';
12
+ import { TaskRegistry } from '../worker/registry';
13
+
14
+ export class InProcessWorker {
15
+ static created: InProcessWorker[] = [];
16
+ static handlerModules: Array<() => Promise<unknown>> = [];
17
+
18
+ public executed: any[] = [];
19
+ public terminated = false;
20
+ private listeners = {
21
+ message: new Set<(event: { data: any }) => void>(),
22
+ error: new Set<(event: any) => void>(),
23
+ };
24
+ private ready: Promise<unknown> | null = null;
25
+ /** Module-load failure — a crashed worker never executes another task. */
26
+ private dead = false;
27
+
28
+ constructor(public url: URL, public options?: any) {
29
+ InProcessWorker.created.push(this);
30
+ }
31
+
32
+ postMessage(data: any) {
33
+ queueMicrotask(() => void this.handle(data));
34
+ }
35
+
36
+ /** Imports the registered handler modules — what a real worker's entry does at boot. */
37
+ private boot(): Promise<unknown> {
38
+ return (async () => {
39
+ // workerBootstrap assigns self.onmessage at module load — provide a
40
+ // minimal stand-in so the real worker entry can be imported in-process.
41
+ (globalThis as { self?: unknown }).self ??= {
42
+ postMessage() {},
43
+ onmessage: null,
44
+ };
45
+ for (const load of InProcessWorker.handlerModules) await load();
46
+ })();
47
+ }
48
+
49
+ /** Module-load failure = a real worker's 'error' event. */
50
+ private crashOnBoot(err: unknown): void {
51
+ // A real worker whose entry module fails to load fires 'error' and never
52
+ // answers a task — mirror that so pools reject in-flight calls (and
53
+ // respawn) instead of hanging on a ready that never resolves.
54
+ this.dead = true;
55
+ const event = {
56
+ type: 'error',
57
+ message: `worker entry failed to load: ${(err as Error)?.message ?? String(err)}`,
58
+ error: err instanceof Error ? err : new Error(String(err)),
59
+ };
60
+ for (const l of this.listeners.error) l(event);
61
+ }
62
+
63
+ private async handle(data: any) {
64
+ if (data.type === 'INIT') {
65
+ // Message-only pool — the entry still loads; just no shared buffer.
66
+ this.ready ??= this.boot();
67
+ try {
68
+ await this.ready;
69
+ } catch (err) {
70
+ this.crashOnBoot(err);
71
+ }
72
+ return;
73
+ }
74
+ if (data.type === 'INIT_MEMORY') {
75
+ this.ready ??= this.boot().then(() =>
76
+ bindSharedMemories((data.memory as WebAssembly.Memory).buffer as unknown as SharedArrayBuffer),
77
+ );
78
+ try {
79
+ await this.ready;
80
+ } catch (err) {
81
+ this.crashOnBoot(err);
82
+ }
83
+ return;
84
+ }
85
+ if (data.type === 'EXECUTE_TASK') {
86
+ if (this.dead) return;
87
+ try {
88
+ await this.ready;
89
+ } catch {
90
+ // Boot failed in the meantime — a crashed worker drops the task
91
+ // (the INIT handler already fired 'error').
92
+ return;
93
+ }
94
+ if (this.dead) return;
95
+ this.executed.push(data);
96
+ try {
97
+ const result = await TaskRegistry.execute(data.taskId, ...(data.args || []));
98
+ this.emit({ messageId: data.messageId, success: true, result });
99
+ } catch (err: any) {
100
+ this.emit({ messageId: data.messageId, success: false, error: err.message || String(err) });
101
+ }
102
+ }
103
+ }
104
+
105
+ private emit(msg: any) {
106
+ for (const l of this.listeners.message) l({ data: msg });
107
+ }
108
+
109
+ /** Simulates a worker crash: fires 'error' listeners like a real worker's error event. */
110
+ crash(message = 'simulated crash') {
111
+ const event = { type: 'error', message, error: new Error(message) };
112
+ for (const l of this.listeners.error) l(event);
113
+ }
114
+
115
+ addEventListener(type: string, l: (event: any) => void) {
116
+ if (type === 'error') this.listeners.error.add(l);
117
+ else this.listeners.message.add(l);
118
+ }
119
+ removeEventListener(type: string, l: (event: any) => void) {
120
+ if (type === 'error') this.listeners.error.delete(l);
121
+ else this.listeners.message.delete(l);
122
+ }
123
+ terminate() { this.terminated = true; }
124
+ }
125
+
126
+ /** Flushes microtasks + the waitAsync observer loop after a shared write. */
127
+ export const flushObservers = () => new Promise((r) => setTimeout(r, 5));
@@ -0,0 +1,135 @@
1
+ import './workerBootstrap'; // side-effect: wires self.onmessage (guarded for main-thread import)
2
+ import { TaskRegistry } from './registry';
3
+ import { scoped } from '../log';
4
+ import type { ServiceMethod } from '../service';
5
+ import type { SharedMemory, SharedSpec } from '../contract/sharedMemory';
6
+
7
+ const workerLog = scoped('worker');
8
+
9
+ /**
10
+ * One worker-exposed method: a plain function (siblings reachable via `this`)
11
+ * or a {@link ServiceMethod} unit (`{ def, run }` — schemas enforced on the
12
+ * wire).
13
+ */
14
+ export type WorkerMethod = ((...args: any[]) => any) | ServiceMethod<any[], any>;
15
+ export type WorkerMethodMap = Record<string, WorkerMethod>;
16
+ export type WorkerServiceMap = Record<string, WorkerMethodMap>;
17
+
18
+ type ReservedKey = (typeof RESERVED_CLIENT_KEYS)[number];
19
+
20
+ /** Turns any reserved keys in M into `never`-typed members — a compile error. */
21
+ type NoReserved<M> = [Extract<keyof M, ReservedKey>] extends [never]
22
+ ? M
23
+ : M & { [K in Extract<keyof M, ReservedKey>]: never };
24
+
25
+ export interface DefineWorkerConfig<
26
+ S extends SharedSpec,
27
+ M extends WorkerMethodMap,
28
+ Svc extends WorkerServiceMap,
29
+ > {
30
+ /**
31
+ * Optional — carried in the type so connectWorker can check the main-side
32
+ * contract matches. Binding is global via workerBootstrap.
33
+ */
34
+ sharedMemory?: SharedMemory<S>;
35
+ /** Flat methods → taskId = method name. */
36
+ methods?: NoReserved<M>;
37
+ /** Namespaced methods → taskId = `${service}.${method}`. */
38
+ services?: NoReserved<Svc>;
39
+ }
40
+
41
+ export interface WorkerDefinition<
42
+ S extends SharedSpec = SharedSpec,
43
+ M extends WorkerMethodMap = {},
44
+ Svc extends WorkerServiceMap = {},
45
+ > {
46
+ readonly methods: M;
47
+ readonly services: Svc;
48
+ readonly sharedMemory?: SharedMemory<S>;
49
+ }
50
+
51
+ /**
52
+ * Client-surface keys a worker method must not claim — a method named
53
+ * `pool` or `then` would collide with {@link connectWorker}'s API (or make
54
+ * the client look like a thenable).
55
+ */
56
+ export const RESERVED_CLIENT_KEYS = ['start', 'terminate', 'pool', 'sharedMemory', 'then', 'with'] as const;
57
+
58
+ const isUnit = (x: unknown): x is ServiceMethod<any[], any> =>
59
+ typeof x === 'object' && x !== null && 'run' in x;
60
+
61
+ const RESERVED = new Set<string>(RESERVED_CLIENT_KEYS);
62
+
63
+ /**
64
+ * The worker-side half of the tRPC-style pair: the worker script owns the
65
+ * runtime (importing this module wires `self.onmessage` via workerBootstrap)
66
+ * and the method list — the main thread imports only `typeof` the returned
67
+ * definition and drives it through a {@link connectWorker} Proxy client.
68
+ *
69
+ * // incidents.worker.ts — the whole worker entry
70
+ * export const incidentsWorker = defineWorker({
71
+ * sharedMemory: incidentsMemory,
72
+ * methods: { seedIncidents, queryIncidents, computeMetrics },
73
+ * });
74
+ * export type IncidentsWorker = typeof incidentsWorker;
75
+ *
76
+ * Flat methods register under their own name; `services: { svc: { m } }`
77
+ * registers `svc.m`. Plain functions are invoked with `this` bound to the
78
+ * methods object (sibling calls); ServiceMethod units contribute their
79
+ * `def` schemas (and a `def.taskId` override) and run bound to the unit.
80
+ *
81
+ * BOOTSTRAP ORDERING (Node): the `@atolljs/node/shim` import must
82
+ * evaluate before this module — it binds `self = parentPort`, which
83
+ * workerBootstrap needs at import time. Keep `import '@atolljs/node/shim'`
84
+ * first in the worker entry.
85
+ */
86
+ export function defineWorker<
87
+ S extends SharedSpec,
88
+ M extends WorkerMethodMap = {},
89
+ Svc extends WorkerServiceMap = {},
90
+ >(config: DefineWorkerConfig<S, M, Svc>): WorkerDefinition<S, M, Svc> {
91
+ const methods = (config.methods ?? {}) as M;
92
+ const services = (config.services ?? {}) as Svc;
93
+
94
+ let count = 0;
95
+ const register = (taskId: string, name: string, method: WorkerMethod, thisArg: object) => {
96
+ if (RESERVED.has(name)) {
97
+ throw new Error(`defineWorker: "${name}" is reserved for the client API — rename the method`);
98
+ }
99
+ if (isUnit(method)) {
100
+ const { def } = method;
101
+ TaskRegistry.register(
102
+ {
103
+ taskId: def.taskId ?? taskId,
104
+ ...(def.dataType ? { dataType: def.dataType } : {}),
105
+ ...(def.argsSchema ? { argsSchema: def.argsSchema } : {}),
106
+ ...(def.resultSchema ? { resultSchema: def.resultSchema } : {}),
107
+ },
108
+ method.run.bind(method),
109
+ );
110
+ } else {
111
+ TaskRegistry.register({ taskId }, method.bind(thisArg));
112
+ }
113
+ count++;
114
+ };
115
+
116
+ for (const [name, method] of Object.entries(methods)) {
117
+ register(name, name, method, methods);
118
+ }
119
+ for (const [service, serviceMethods] of Object.entries(services)) {
120
+ if (RESERVED.has(service)) {
121
+ throw new Error(`defineWorker: service name "${service}" is reserved for the client API — rename it`);
122
+ }
123
+ for (const [name, method] of Object.entries(serviceMethods)) {
124
+ register(`${service}.${name}`, name, method, serviceMethods);
125
+ }
126
+ }
127
+
128
+ workerLog.info(`worker defined — ${count} method(s)`);
129
+
130
+ const definition: WorkerDefinition<S, M, Svc> = { methods, services };
131
+ if (config.sharedMemory) {
132
+ (definition as { sharedMemory?: SharedMemory<S> }).sharedMemory = config.sharedMemory;
133
+ }
134
+ return definition;
135
+ }
@@ -0,0 +1,42 @@
1
+ import { TaskContract } from '../contract/types';
2
+ import { scoped } from '../log';
3
+
4
+ const regLog = scoped('registry');
5
+
6
+ export type TaskHandler<Args extends any[] = any[], Result = any> =
7
+ (...args: Args) => Result | Promise<Result>;
8
+
9
+ interface RegisteredTask {
10
+ contract: TaskContract;
11
+ handler: TaskHandler;
12
+ }
13
+
14
+ export class TaskRegistry {
15
+ private static tasks: Map<string, RegisteredTask> = new Map();
16
+
17
+ public static register<Args extends any[], Result>(
18
+ contract: TaskContract<Args, Result>,
19
+ handler: TaskHandler<Args, Result>
20
+ ): void {
21
+ this.tasks.set(contract.taskId, { contract, handler });
22
+ regLog.debug(`registered "${contract.taskId}"`, {
23
+ args: contract.argsSchema ? 'validated' : 'unvalidated',
24
+ result: contract.resultSchema ? 'validated' : 'unvalidated',
25
+ });
26
+ }
27
+
28
+ public static getContract(taskId: string): TaskContract | undefined {
29
+ return this.tasks.get(taskId)?.contract;
30
+ }
31
+
32
+ public static async execute(taskId: string, ...args: any[]): Promise<any> {
33
+ const task = this.tasks.get(taskId);
34
+ if (!task) {
35
+ throw new Error(`Task handler not found for id: ${taskId}`);
36
+ }
37
+ const { contract, handler } = task;
38
+ const validatedArgs = contract.argsSchema ? contract.argsSchema.parse(args) : args;
39
+ const result = await handler(...validatedArgs);
40
+ return contract.resultSchema ? contract.resultSchema.parse(result) : result;
41
+ }
42
+ }
@@ -0,0 +1,56 @@
1
+ import { TaskRegistry } from './registry';
2
+ import { bindSharedMemories, getDefinedSharedMemoryCount } from '../contract/sharedMemory';
3
+ import { fmtBytes, scoped } from '../log';
4
+
5
+ const workerLog = scoped('worker');
6
+
7
+ let memoryInitialized = false;
8
+
9
+ if (typeof self === 'undefined') {
10
+ // Imported outside a worker context (e.g. a Nest entry that shares the
11
+ // atoll-nestjs barrel with the worker entry) — nothing to wire.
12
+ workerLog.warn('workerBootstrap: no worker global found — skipping message wiring');
13
+ } else {
14
+ self.onmessage = async (event) => {
15
+ const data = event.data;
16
+
17
+ if (data.type === 'INIT') {
18
+ memoryInitialized = true;
19
+ if (getDefinedSharedMemoryCount() > 0) {
20
+ workerLog.warn(
21
+ 'pool sent INIT without memory, but shared-memory contracts are defined — field access will throw until bound',
22
+ );
23
+ }
24
+ workerLog.info('initialized (message-only pool — no shared memory)');
25
+ return;
26
+ }
27
+
28
+ if (data.type === 'INIT_MEMORY') {
29
+ const memory = data.memory as WebAssembly.Memory;
30
+ bindSharedMemories(memory.buffer as unknown as SharedArrayBuffer);
31
+ memoryInitialized = true;
32
+ workerLog.info(`shared memory initialized (${fmtBytes(memory.buffer.byteLength)})`);
33
+ return;
34
+ }
35
+
36
+ if (data.type === 'EXECUTE_TASK') {
37
+ const { messageId, taskId, args } = data;
38
+
39
+ if (!memoryInitialized) {
40
+ workerLog.warn(`rejected ${taskId} — shared memory not initialized`);
41
+ self.postMessage({ messageId, success: false, error: 'Shared memory not initialized in worker.' });
42
+ return;
43
+ }
44
+
45
+ const t0 = performance.now();
46
+ try {
47
+ const result = await TaskRegistry.execute(taskId, ...(args || []));
48
+ workerLog.debug(`task ${taskId} → ${(performance.now() - t0).toFixed(1)}ms`);
49
+ self.postMessage({ messageId, success: true, result });
50
+ } catch (err: any) {
51
+ workerLog.warn(`task ${taskId} failed: ${err.message || String(err)}`);
52
+ self.postMessage({ messageId, success: false, error: err.message || String(err) });
53
+ }
54
+ }
55
+ };
56
+ }