@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
@@ -0,0 +1,161 @@
1
+ import { createEffect, createMemo, createRoot, createSignal } from 'solid-js';
2
+ import { Connector } from './contract/sharedMemory';
3
+ import { scoped } from './log';
4
+
5
+ const reactiveLog = scoped('reactive');
6
+
7
+ /** Emission control for slices — return `true` when `next` should be treated
8
+ * as unchanged (no emit). Defaults to `Object.is`. */
9
+ export type EqualityFn<T> = (prev: T, next: T) => boolean;
10
+
11
+ export interface SliceOptions<S> {
12
+ /** Distinct comparator for the selected slice: `true` = unchanged. */
13
+ equals?: EqualityFn<S>;
14
+ }
15
+
16
+ /** Reference-equality object comparator — emits when any own property value
17
+ * changes. Handy for object slices: `{ equals: shallowEqual }`. */
18
+ export function shallowEqual<T extends object>(prev: T | undefined, next: T | undefined): boolean {
19
+ if (prev === next) return true;
20
+ if (prev == null || next == null) return false;
21
+ const a = Object.keys(prev) as (keyof T)[];
22
+ const b = Object.keys(next) as (keyof T)[];
23
+ return a.length === b.length && a.every((k) => Object.is(prev[k], next[k]));
24
+ }
25
+
26
+ /**
27
+ * A reactive view over a shared memory connector. `get()` is signal-tracked,
28
+ * so Solid effects and memos re-run when the field changes. `set()` writes
29
+ * through the connector and notifies subscribers on this thread.
30
+ */
31
+ export interface ReactiveConnector<T> {
32
+ get(): T;
33
+ set(value: T): void;
34
+ peek(): T;
35
+ /**
36
+ * Start observing writes made by the other thread (via the shared version
37
+ * counter). Returns a function to stop observing. Local writes already
38
+ * notify through `set()`, so this is only needed for remote changes.
39
+ */
40
+ observeRemote(): () => void;
41
+ }
42
+
43
+ export function reactive<T>(connector: Connector<T>): ReactiveConnector<T> {
44
+ const [value, setValue] = createSignal<T>(connector.read(), { equals: false });
45
+ const bump = () => setValue(() => connector.read());
46
+
47
+ return {
48
+ get: value,
49
+ peek: () => connector.read(),
50
+ set: (v) => {
51
+ connector.write(v);
52
+ bump();
53
+ },
54
+ observeRemote: () => {
55
+ const version = connector._version;
56
+ if (!version) {
57
+ throw new Error('This connector does not support remote observation.');
58
+ }
59
+ let stopped = false;
60
+ let last = Atomics.load(version.view, version.index);
61
+
62
+ if (typeof Atomics.waitAsync === 'function') {
63
+ reactiveLog.debug(`observing remote writes (offset ${connector.byteOffset}, waitAsync)`);
64
+ void (async () => {
65
+ while (!stopped) {
66
+ const result = Atomics.waitAsync(version.view, version.index, last);
67
+ if (result.async) {
68
+ await result.value;
69
+ }
70
+ last = Atomics.load(version.view, version.index);
71
+ if (!stopped) {
72
+ reactiveLog.trace(`remote write observed (offset ${connector.byteOffset})`);
73
+ bump();
74
+ }
75
+ }
76
+ reactiveLog.debug(`stopped observing (offset ${connector.byteOffset})`);
77
+ })();
78
+ return () => { stopped = true; };
79
+ }
80
+
81
+ // Fallback for environments without Atomics.waitAsync
82
+ reactiveLog.warn(`Atomics.waitAsync unavailable — observing via 50ms poll (offset ${connector.byteOffset})`);
83
+ const timer = setInterval(() => {
84
+ const current = Atomics.load(version.view, version.index);
85
+ if (current !== last) {
86
+ last = current;
87
+ bump();
88
+ }
89
+ }, 50);
90
+ return () => {
91
+ stopped = true;
92
+ clearInterval(timer);
93
+ };
94
+ },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Observe a shared-memory field and invoke `onChange` with its value on
100
+ * every write — local or remote (both bump the shared version counter).
101
+ * Fires once immediately with the current value unless the field has never
102
+ * been written (structured fields read `undefined` until then).
103
+ *
104
+ * Pass a selector to watch a slice: `watch(conn, m => m.critical, cb)` —
105
+ * the selected value is memoized, so the callback only runs when the slice
106
+ * itself changes, not on every field write. Slice equality defaults to
107
+ * `Object.is`; pass `options.equals` to decide what emits — e.g.
108
+ * `{ equals: shallowEqual }` for object slices, or a custom comparator like
109
+ * `(a, b) => Math.abs(a - b) < 0.01` to mute noise.
110
+ *
111
+ * Returns an unsubscribe function. Requires the contract to be bound on
112
+ * this thread (the pool binds it on construction).
113
+ */
114
+ export function watch<T>(connector: Connector<T>, onChange: (value: T) => void): () => void;
115
+ export function watch<T, S>(
116
+ connector: Connector<T>,
117
+ select: (value: T) => S,
118
+ onChange: (slice: S, previous: S | undefined) => void,
119
+ options?: SliceOptions<S>
120
+ ): () => void;
121
+ export function watch<T, S = T>(
122
+ connector: Connector<T>,
123
+ selOrCb: ((value: T) => S) | ((slice: S, previous: S | undefined) => void),
124
+ onChange?: (slice: S, previous: S | undefined) => void,
125
+ options?: SliceOptions<S>
126
+ ): () => void {
127
+ const select = onChange ? (selOrCb as (value: T) => S) : ((v: T) => v as unknown as S);
128
+ const cb = (onChange ?? selOrCb) as (slice: S, previous: S | undefined) => void;
129
+
130
+ const sig = reactive(connector);
131
+ const stopRemote = sig.observeRemote();
132
+ const dispose = createRoot((d) => {
133
+ const slice = createMemo(
134
+ () => {
135
+ const v = sig.get() as T | null | undefined;
136
+ return v == null ? undefined : select(v);
137
+ },
138
+ undefined,
139
+ options?.equals
140
+ ? {
141
+ // The memo starts at `undefined` — guard so user comparators only
142
+ // ever see real slice values.
143
+ equals: (a: S | undefined, b: S | undefined) =>
144
+ a === undefined || b === undefined ? a === b : options.equals!(a, b),
145
+ }
146
+ : undefined
147
+ );
148
+ createEffect((prev: S | undefined) => {
149
+ const s = slice();
150
+ if (s !== undefined) cb(s, prev);
151
+ return s;
152
+ });
153
+ return d;
154
+ });
155
+ reactiveLog.debug(`watching connector at offset ${connector.byteOffset}`);
156
+ return () => {
157
+ reactiveLog.debug(`watch stopped (offset ${connector.byteOffset})`);
158
+ stopRemote();
159
+ dispose();
160
+ };
161
+ }
package/src/service.ts ADDED
@@ -0,0 +1,283 @@
1
+ // Framework-neutral RPC layer. A service contract is declared ONCE and
2
+ // imported by both threads: the worker binds handlers via implementService
3
+ // (higher-order functions, no decorators), and the main thread dispatches
4
+ // through createClient or by passing service.tasks straight into a
5
+ // WorkerPool/SharedWorker config. Method names and wire ids can never drift
6
+ // because every surface derives from the same object.
7
+ import type { Schema, TaskContract, ViewType } from './contract/types';
8
+ import { scoped } from './log';
9
+ import { TaskRegistry } from './worker/registry';
10
+
11
+ const svcLog = scoped('service');
12
+
13
+ /**
14
+ * One RPC method's declaration. Identical fields to {@link TaskContract}
15
+ * except `taskId` is optional — when omitted the wire id is derived as
16
+ * `${serviceName}.${methodName}` so it can't drift between threads.
17
+ *
18
+ * Most methods need no wrapper — write the object inline and defineService
19
+ * infers Args/Result from the schemas:
20
+ *
21
+ * seedIncidents: { resultSchema: z.number() }, // → () => number
22
+ * queryIncidents: { argsSchema, resultSchema }, // → (q) => Result
23
+ *
24
+ * Use {@link rpc} only to declare types a method's schemas can't carry —
25
+ * e.g. typed args with no validation: `rpc<[QueryArgs], QueryResult>()`.
26
+ */
27
+ export interface RpcMethodDef<Args extends any[] = any[], Result = any> {
28
+ /** Explicit wire id — only needed to interop with pre-existing taskIds. */
29
+ taskId?: string;
30
+ /** Element datatype of the shared-memory region this method operates on. */
31
+ dataType?: ViewType;
32
+ /** Validates args as they cross the thread boundary. */
33
+ argsSchema?: Schema<Args>;
34
+ /** Validates the handler's return value. */
35
+ resultSchema?: Schema<Result>;
36
+ }
37
+
38
+ /**
39
+ * Explicit type carrier for method declarations — an identity function.
40
+ * Needed only when schemas are absent or don't express the full signature;
41
+ * schema-typed methods infer automatically inside defineService.
42
+ */
43
+ export function rpc<Args extends any[] = any[], Result = any>(
44
+ def: RpcMethodDef<Args, Result> = {},
45
+ ): RpcMethodDef<Args, Result> {
46
+ return def;
47
+ }
48
+
49
+ /**
50
+ * One method's whole unit — the pattern for one-file-per-task modules. The
51
+ * file's top-level export satisfies this interface: `def` is the wire
52
+ * declaration {@link defineService} composes, `run` is the worker-side
53
+ * implementation {@link implementService} registers. Both entry points
54
+ * accept the unit directly and unwrap the part they need:
55
+ *
56
+ * // service/seedIncidents.ts — one file, the whole stack
57
+ * export const seedIncidents = {
58
+ * def: { resultSchema: z.number() },
59
+ * run() { …; return ms; },
60
+ * } satisfies ServiceMethod<[], number>;
61
+ */
62
+ export interface ServiceMethod<Args extends any[] = any[], Result = any> {
63
+ /** The wire declaration fed into {@link defineService}. */
64
+ def: RpcMethodDef<Args, Result>;
65
+ /** The worker-side implementation fed into {@link implementService}. */
66
+ run(...args: Args): Result | Promise<Result>;
67
+ }
68
+
69
+ /** The args a method def declares — argsSchema's tuple, else an rpc<A,R>() declaration's type args, else []. */
70
+ type DefArgs<D> =
71
+ D extends { argsSchema: Schema<infer A extends any[]> }
72
+ ? A
73
+ : D extends { resultSchema: Schema<any> }
74
+ ? []
75
+ : D extends RpcMethodDef<infer A extends any[], any>
76
+ ? A
77
+ : [];
78
+
79
+ /** The result a method def declares — resultSchema's output, else an rpc<A,R>() declaration's type args, else unknown. */
80
+ type DefResult<D> =
81
+ D extends { resultSchema: Schema<infer R> }
82
+ ? R
83
+ : D extends RpcMethodDef<any[], infer R>
84
+ ? R
85
+ : unknown;
86
+
87
+ /**
88
+ * Builds a {@link ServiceMethod} unit — the factory form of
89
+ * `{ def, run } satisfies ServiceMethod<…>`. The def's schemas (or an
90
+ * `rpc<A,R>()` declaration's type args) supply the signature, so `run`
91
+ * gets contextual parameter types with nothing to annotate:
92
+ *
93
+ * export const queryIncidents = serviceMethod({
94
+ * def: { argsSchema: z.tuple([queryArgsSchema]), resultSchema: queryResultSchema },
95
+ * run(q) { … }, // q: QueryArgs — inferred
96
+ * });
97
+ *
98
+ * The unit's exact type is returned unchanged, so extra fields (config,
99
+ * counters) ride along for implementService's `this`-binding — `run`'s
100
+ * `this` is the unit; annotate it to type extra fields:
101
+ * `run(this: { prefix: string }, name) { … }`.
102
+ */
103
+ export function serviceMethod<
104
+ D extends RpcMethodDef,
105
+ U extends { def: D },
106
+ >(
107
+ unit: U & {
108
+ run: (...args: DefArgs<D>) => DefResult<D> | Promise<DefResult<D>>;
109
+ },
110
+ ): U {
111
+ return unit;
112
+ }
113
+
114
+ /** A service method entry: a bare RpcMethodDef or a full {@link ServiceMethod} unit. */
115
+ export type RpcMethodInput = RpcMethodDef<any[], any> | ServiceMethod<any[], any>;
116
+ export type RpcMethodMap = Record<string, RpcMethodDef<any[], any>>;
117
+ export type RpcMethodInputMap = Record<string, RpcMethodInput>;
118
+
119
+ /**
120
+ * Resolves a method declaration to its TaskContract type. Schemas win when
121
+ * present (argsSchema+resultSchema → full signature; resultSchema alone →
122
+ * a no-arg method); an rpc<A,R>() declaration's type args carry the rest.
123
+ */
124
+ type MethodContract<D> =
125
+ D extends { def: infer Def }
126
+ ? MethodContract<Def> // ServiceMethod unit — unwrap the def
127
+ : D extends { argsSchema: Schema<infer A extends any[]> }
128
+ ? D extends { resultSchema: Schema<infer R> }
129
+ ? TaskContract<A, R>
130
+ : TaskContract<A, any>
131
+ : D extends { resultSchema: Schema<infer R> }
132
+ ? TaskContract<[], R>
133
+ : D extends RpcMethodDef<infer A extends any[], infer R>
134
+ ? TaskContract<A, R>
135
+ : TaskContract<any[], any>;
136
+
137
+ type MethodsToContracts<M extends RpcMethodInputMap> = {
138
+ [K in keyof M]: MethodContract<M[K]>;
139
+ };
140
+
141
+ /**
142
+ * A named bundle of RPC method contracts shared by both threads. `tasks` is
143
+ * a fully-typed {@link TaskMap} — hand it to `WorkerPool`/`SharedWorker`
144
+ * config or iterate it for registration.
145
+ */
146
+ export interface ServiceContract<M extends RpcMethodInputMap = RpcMethodInputMap> {
147
+ readonly name: string;
148
+ readonly tasks: MethodsToContracts<M>;
149
+ }
150
+
151
+ /**
152
+ * Declares a worker service's RPC surface. The same object is imported by
153
+ * the worker entry (for `implementService`) and the main thread (for
154
+ * `createClient` / pool config):
155
+ *
156
+ * export const incidentsService = defineService('incidents', {
157
+ * seedIncidents: { resultSchema: z.number() },
158
+ * queryIncidents: {
159
+ * argsSchema: z.tuple([queryArgsSchema]),
160
+ * resultSchema: queryResultSchema,
161
+ * },
162
+ * });
163
+ */
164
+ export function defineService<M extends RpcMethodInputMap>(
165
+ name: string,
166
+ methods: M,
167
+ ): ServiceContract<M> {
168
+ const tasks = {} as Record<string, TaskContract>;
169
+ for (const [method, input] of Object.entries(methods)) {
170
+ const def = 'def' in input ? input.def : input;
171
+ tasks[method] = {
172
+ taskId: def.taskId ?? `${name}.${method}`,
173
+ ...(def.dataType ? { dataType: def.dataType } : {}),
174
+ ...(def.argsSchema ? { argsSchema: def.argsSchema } : {}),
175
+ ...(def.resultSchema ? { resultSchema: def.resultSchema } : {}),
176
+ };
177
+ }
178
+ return { name, tasks: tasks as MethodsToContracts<M> };
179
+ }
180
+
181
+ type ServiceHandlerFn<A extends any[], R> = (...args: A) => R | Promise<R>;
182
+
183
+ /** The handler signatures a service implementation must provide. */
184
+ export type ServiceHandlers<M extends RpcMethodInputMap> = {
185
+ [K in keyof M]: MethodContract<M[K]> extends TaskContract<infer A, infer R>
186
+ ? ServiceHandlerFn<A, R> | { run: ServiceHandlerFn<A, R> }
187
+ : never;
188
+ };
189
+
190
+ /**
191
+ * A service contract bound to handler implementations — the worker-side
192
+ * "service" that owns the RPC methods. Registration happens eagerly inside
193
+ * {@link implementService}; the returned handle is for composition/testing.
194
+ */
195
+ export interface ServiceImpl<M extends RpcMethodInputMap = RpcMethodInputMap> {
196
+ readonly service: ServiceContract<M>;
197
+ readonly handlers: ServiceHandlers<M>;
198
+ }
199
+
200
+ /**
201
+ * Binds a service contract to handler functions and registers every method
202
+ * with the {@link TaskRegistry} — the worker-side half of the contract:
203
+ *
204
+ * export const incidentsImpl = implementService(incidentsService, {
205
+ * seedIncidents: () => { …; return ms; },
206
+ * queryIncidents: (q) => { …; return page; },
207
+ * });
208
+ *
209
+ * Missing methods throw at bind time (a silent omission would surface as a
210
+ * runtime "handler not found" on the far thread). Handlers are invoked with
211
+ * `this` bound to the handlers object, so methods may call siblings via
212
+ * `this`. When `handlers` is a class instance (e.g. a DI-resolved provider
213
+ * whose methods match the service surface), binding preserves its `this`.
214
+ */
215
+ export function implementService<M extends RpcMethodInputMap>(
216
+ service: ServiceContract<M>,
217
+ handlers: ServiceHandlers<M>,
218
+ ): ServiceImpl<M> {
219
+ const missing = Object.keys(service.tasks).filter((k) => {
220
+ const h = handlers[k] as unknown;
221
+ return typeof h !== 'function' && typeof (h as { run?: unknown })?.run !== 'function';
222
+ });
223
+ if (missing.length > 0) {
224
+ throw new Error(
225
+ `implementService("${service.name}"): missing handlers for ${missing.join(', ')}`,
226
+ );
227
+ }
228
+ for (const [method, contract] of Object.entries(service.tasks)) {
229
+ const h = handlers[method] as ServiceHandlerFn<any[], any> | { run: ServiceHandlerFn<any[], any> };
230
+ // Function form binds to the handlers object (sibling calls, DI
231
+ // instances); unit form binds `run` to the unit itself.
232
+ TaskRegistry.register(
233
+ contract,
234
+ typeof h === 'function' ? h.bind(handlers) : h.run.bind(h),
235
+ );
236
+ }
237
+ svcLog.info(
238
+ `service "${service.name}" implemented — ${Object.keys(service.tasks).join(', ')}`,
239
+ );
240
+ return { service, handlers };
241
+ }
242
+
243
+ /** Anything that can dispatch a TaskContract — WorkerPool, SharedWorkerClient. */
244
+ export interface TaskRunner {
245
+ runTask<Args extends any[], Result>(
246
+ contract: TaskContract<Args, Result>,
247
+ ...args: Args
248
+ ): Promise<Result>;
249
+ /**
250
+ * Options-aware dispatch (abort/timeout). When present, `workerClient`
251
+ * `client.with(options)` calls route through it; otherwise they fall back
252
+ * to runTask and the options are ignored.
253
+ */
254
+ dispatch?<Args extends any[], Result>(
255
+ contract: TaskContract<Args, Result>,
256
+ args: Args,
257
+ options?: { signal?: AbortSignal; timeout?: number },
258
+ ): Promise<Result>;
259
+ }
260
+
261
+ /** The callable RPC surface a service client exposes. */
262
+ export type ServiceClient<M extends RpcMethodMap> = {
263
+ [K in keyof M]: MethodContract<M[K]> extends TaskContract<infer A, infer R>
264
+ ? (...args: A) => Promise<R>
265
+ : never;
266
+ };
267
+
268
+ /**
269
+ * The main-thread half of a service contract: a typed proxy that turns
270
+ * `client.seedIncidents()` into `runner.runTask(contract)`. Works over any
271
+ * {@link TaskRunner} — a WorkerPool, a SharedWorkerClient, or a test stub.
272
+ */
273
+ export function createClient<M extends RpcMethodMap>(
274
+ service: ServiceContract<M>,
275
+ runner: TaskRunner,
276
+ ): ServiceClient<M> {
277
+ const client = {} as Record<string, unknown>;
278
+ for (const [method, contract] of Object.entries(service.tasks)) {
279
+ client[method] = (...args: unknown[]) =>
280
+ runner.runTask(contract as TaskContract<any[], any>, ...(args as any[]));
281
+ }
282
+ return client as ServiceClient<M>;
283
+ }
@@ -0,0 +1,142 @@
1
+ import { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
2
+ import { PoolTasks, TaskContract, TaskMap, TaskResult, SharedWorkerConfig } from '../contract/types';
3
+ import { scoped } from '../log';
4
+
5
+ const clientLog = scoped('shared-client');
6
+ const taskLog = scoped('task');
7
+
8
+ class SharedWorkerClientImpl<S extends SharedSpec = SharedSpec> {
9
+ public readonly sharedMemory: SharedMemory<S> & SharedAccess<S>;
10
+ private readonly port: MessagePort;
11
+
12
+ constructor(
13
+ port: MessagePort,
14
+ public readonly clientIndex: number,
15
+ config: SharedWorkerConfig<S, TaskMap>
16
+ ) {
17
+ this.port = port;
18
+ this.sharedMemory = config.sharedMemory;
19
+
20
+ // Install first-class task methods, same convention as WorkerPool.
21
+ const tasks = config.tasks ?? {};
22
+ for (const [name, contract] of Object.entries(tasks)) {
23
+ if (name in this) {
24
+ clientLog.warn(`task "${name}" collides with a SharedWorkerClient member — not installed`);
25
+ continue;
26
+ }
27
+ (this as Record<string, unknown>)[name] = (...args: unknown[]) =>
28
+ this.runTask(contract as TaskContract<any[], any>, ...(args as any[]));
29
+ }
30
+ if (Object.keys(tasks).length > 0) {
31
+ clientLog.info(`task methods: ${Object.keys(tasks).join(', ')}`);
32
+ }
33
+ }
34
+
35
+ /** Dispatches a task to the shared worker; the reply comes back on this client's port. */
36
+ public runTask<Args extends any[], Result>(contract: TaskContract<Args, Result>, ...args: Args): Promise<Result> {
37
+ return new Promise((resolve, reject) => {
38
+ const messageId = Math.random().toString(36).substring(2);
39
+ const t0 = performance.now();
40
+ taskLog.debug(`→ ${contract.taskId}`, { args });
41
+
42
+ const handleMessage = (event: MessageEvent) => {
43
+ const data = event.data as TaskResult & { messageId?: string };
44
+ if (data.messageId === messageId) {
45
+ this.port.removeEventListener('message', handleMessage);
46
+ const ms = (performance.now() - t0).toFixed(1);
47
+ if (data.success) {
48
+ taskLog.debug(`← ${contract.taskId} ok`, { ms: +ms });
49
+ resolve(data.result);
50
+ } else {
51
+ taskLog.warn(`← ${contract.taskId} failed: ${data.error}`, { ms: +ms });
52
+ reject(new Error(data.error));
53
+ }
54
+ }
55
+ };
56
+
57
+ this.port.addEventListener('message', handleMessage);
58
+ this.port.postMessage({ type: 'EXECUTE_TASK', messageId, taskId: contract.taskId, args });
59
+ });
60
+ }
61
+
62
+ /** Detaches this client. The shared worker (and its buffer) stay alive for other clients. */
63
+ public disconnect(): void {
64
+ try {
65
+ this.port.postMessage({ type: 'SHARED_DISCONNECT' });
66
+ } finally {
67
+ this.port.close();
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A SharedWorkerClient with first-class task methods baked in from
74
+ * `config.tasks` — same shape as `WorkerPool`.
75
+ */
76
+ export type SharedWorkerClient<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap> =
77
+ SharedWorkerClientImpl<S> & PoolTasks<T>;
78
+
79
+ /**
80
+ * Connects this page to a `SharedWorker`. The worker owns the SharedArrayBuffer
81
+ * and shares it with every connecting client — so every page/tab/iframe bound
82
+ * to the same contract reads and writes the same memory. State propagation
83
+ * needs no messaging: writes bump shared version counters via Atomics, which
84
+ * `observe`/`watch` subscribers in every context are already parked on.
85
+ * Ports carry only the connect handshake and task dispatch.
86
+ *
87
+ * ```ts
88
+ * const worker = await connectSharedWorker({
89
+ * createWorker: () => new SharedWorker(new URL('./incidents.sharedWorker.ts', import.meta.url), { type: 'module' }),
90
+ * sharedMemory: incidentsMemory,
91
+ * tasks: { initIncidents: InitIncidents, queryIncidents: QueryIncidents },
92
+ * });
93
+ * ```
94
+ */
95
+ export async function connectSharedWorker<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap>(
96
+ config: SharedWorkerConfig<S, T>
97
+ ): Promise<SharedWorkerClient<S, T>> {
98
+ if (typeof SharedArrayBuffer === 'undefined' || (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated)) {
99
+ throw new Error(
100
+ 'connectSharedWorker requires SharedArrayBuffer in a cross-origin isolated context — serve the page with COOP/COEP headers.'
101
+ );
102
+ }
103
+ if (!config.port && !config.workerUrl && !config.createWorker) {
104
+ throw new Error('connectSharedWorker requires `workerUrl`, `createWorker`, or `port` in its config.');
105
+ }
106
+
107
+ const port =
108
+ config.port ??
109
+ (config.createWorker
110
+ ? config.createWorker().port
111
+ : new SharedWorker(config.workerUrl!, { type: 'module' }).port);
112
+ port.start();
113
+
114
+ const client = await new Promise<SharedWorkerClient<S, T>>((resolve, reject) => {
115
+ const timeout = setTimeout(
116
+ () => reject(new Error('connectSharedWorker: timed out waiting for the shared worker — is the host module calling sharedWorkerHost()?')),
117
+ config.connectTimeoutMs ?? 10_000
118
+ );
119
+
120
+ const handleMessage = (event: MessageEvent) => {
121
+ const data = event.data;
122
+ if (data?.type === 'SHARED_MEMORY') {
123
+ port.removeEventListener('message', handleMessage);
124
+ clearTimeout(timeout);
125
+ config.sharedMemory.bind(data.buffer as SharedArrayBuffer);
126
+ clientLog.info(`connected as client #${data.clientIndex}`, {
127
+ bytes: data.buffer.byteLength,
128
+ });
129
+ resolve(new SharedWorkerClientImpl<S>(port, data.clientIndex, config) as SharedWorkerClient<S, T>);
130
+ }
131
+ };
132
+
133
+ port.addEventListener('message', handleMessage);
134
+ port.postMessage({
135
+ type: 'SHARED_CONNECT',
136
+ memoryBytes: config.sharedMemory.totalBytes,
137
+ memory: config.memory,
138
+ });
139
+ });
140
+
141
+ return client;
142
+ }
@@ -0,0 +1,104 @@
1
+ import { bindSharedMemories } from '../contract/sharedMemory';
2
+ import { MemoryManager } from '../pool/memory';
3
+ import { TaskRegistry } from '../worker/registry';
4
+ import { fmtBytes, scoped } from '../log';
5
+ import type { MemoryConfig } from '../contract/types';
6
+
7
+ const hostLog = scoped('shared-worker');
8
+
9
+ // Minimal shape — lib.dom doesn't model SharedWorkerGlobalScope reliably.
10
+ interface ConnectEvent {
11
+ ports: readonly MessagePort[];
12
+ }
13
+ interface SharedWorkerScope {
14
+ onconnect: ((event: ConnectEvent) => void) | null;
15
+ }
16
+
17
+ let memory: WebAssembly.Memory | null = null;
18
+ let clientCount = 0;
19
+
20
+ /**
21
+ * Lazily creates the shared buffer on first connect. Unlike WorkerPool — where
22
+ * the page allocates the buffer and pushes it to workers — the SharedWorker
23
+ * owns the memory so every connecting page/tab binds to the SAME buffer.
24
+ */
25
+ function ensureMemory(memoryBytes: number, config?: MemoryConfig): WebAssembly.Memory {
26
+ if (memory) {
27
+ if (memory.buffer.byteLength < memoryBytes) {
28
+ hostLog.warn(
29
+ `client requested ${fmtBytes(memoryBytes)} but buffer is ${fmtBytes(memory.buffer.byteLength)} — first client's capacity wins`
30
+ );
31
+ }
32
+ return memory;
33
+ }
34
+ const manager = new MemoryManager(config);
35
+ manager.ensureCapacity(memoryBytes);
36
+ memory = manager.memory;
37
+ bindSharedMemories(manager.getBuffer());
38
+ hostLog.info(`shared buffer allocated (${fmtBytes(memory.buffer.byteLength)})`);
39
+ return memory;
40
+ }
41
+
42
+ /**
43
+ * Attaches the shared-worker protocol to one client port. Exported separately
44
+ * so tests can drive it with a `MessageChannel` without a real SharedWorker.
45
+ */
46
+ export function attachSharedPort(port: MessagePort): void {
47
+ const index = ++clientCount;
48
+ hostLog.info(`client #${index} connected`);
49
+
50
+ port.onmessage = async (event: MessageEvent) => {
51
+ const data = event.data;
52
+
53
+ if (data?.type === 'SHARED_CONNECT') {
54
+ const mem = ensureMemory(data.memoryBytes ?? 0, data.memory);
55
+ port.postMessage({ type: 'SHARED_MEMORY', buffer: mem.buffer, clientIndex: index });
56
+ return;
57
+ }
58
+
59
+ if (data?.type === 'EXECUTE_TASK') {
60
+ const { messageId, taskId, args } = data;
61
+ if (!memory) {
62
+ port.postMessage({ messageId, success: false, error: 'Shared memory not initialized in worker.' });
63
+ return;
64
+ }
65
+ const t0 = performance.now();
66
+ try {
67
+ const result = await TaskRegistry.execute(taskId, ...(args || []));
68
+ hostLog.debug(`task ${taskId} → ${(performance.now() - t0).toFixed(1)}ms (client #${index})`);
69
+ port.postMessage({ messageId, success: true, result });
70
+ } catch (err: any) {
71
+ hostLog.warn(`task ${taskId} failed: ${err.message || String(err)}`);
72
+ port.postMessage({ messageId, success: false, error: err.message || String(err) });
73
+ }
74
+ return;
75
+ }
76
+
77
+ if (data?.type === 'SHARED_DISCONNECT') {
78
+ port.close();
79
+ hostLog.info(`client #${index} disconnected`);
80
+ }
81
+ };
82
+ port.start();
83
+ }
84
+
85
+ /**
86
+ * SharedWorker-side entry point — call once at the top of the shared worker
87
+ * module, after registering task handlers:
88
+ *
89
+ * ```ts
90
+ * import { sharedWorkerHost, TaskRegistry } from '@atolljs/core';
91
+ * import './tasks'; // TaskRegistry.register(...) calls
92
+ * sharedWorkerHost();
93
+ * ```
94
+ *
95
+ * Each page/tab/iframe that opens this SharedWorker gets a MessagePort here;
96
+ * all of them bind the same contract to the same SharedArrayBuffer. Ports are
97
+ * the control plane (handshake + task dispatch) — state itself never crosses
98
+ * them; clients observe shared fields directly via Atomics version counters.
99
+ */
100
+ export function sharedWorkerHost(): void {
101
+ (self as unknown as SharedWorkerScope).onconnect = (event) => {
102
+ attachSharedPort(event.ports[0]);
103
+ };
104
+ }