@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.
- package/README.md +110 -0
- package/dist/index.js +19 -0
- package/dist/index10.js +186 -0
- package/dist/index11.js +52 -0
- package/dist/index12.js +66 -0
- package/dist/index13.js +27 -0
- package/dist/index14.js +63 -0
- package/dist/index15.js +50 -0
- package/dist/index16.js +46 -0
- package/dist/index17.js +42 -0
- package/dist/index18.js +61 -0
- package/dist/index19.js +56 -0
- package/dist/index2.js +10 -0
- package/dist/index20.js +59 -0
- package/dist/index3.js +120 -0
- package/dist/index4.js +40 -0
- package/dist/index5.js +481 -0
- package/dist/index6.js +23 -0
- package/dist/index7.js +10 -0
- package/dist/index8.js +37 -0
- package/dist/index9.js +22 -0
- package/package.json +68 -0
- package/src/contract/listSchema.ts +211 -0
- package/src/contract/msgpackCodec.ts +9 -0
- package/src/contract/msgpackrCodec.ts +16 -0
- package/src/contract/mz.ts +48 -0
- package/src/contract/sharedMemory.ts +909 -0
- package/src/contract/types.ts +123 -0
- package/src/index.ts +114 -0
- package/src/log.ts +69 -0
- package/src/observable.ts +151 -0
- package/src/pool/errors.ts +31 -0
- package/src/pool/memory.ts +68 -0
- package/src/pool/workerClient.ts +181 -0
- package/src/pool/workerPool.ts +438 -0
- package/src/reactive.ts +161 -0
- package/src/service.ts +283 -0
- package/src/shared/sharedWorkerClient.ts +142 -0
- package/src/shared/sharedWorkerHost.ts +104 -0
- package/src/task.ts +99 -0
- package/src/testing/inProcessWorker.ts +127 -0
- package/src/worker/defineWorker.ts +135 -0
- package/src/worker/registry.ts +42 -0
- package/src/worker/workerBootstrap.ts +56 -0
package/src/reactive.ts
ADDED
|
@@ -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
|
+
}
|