@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { SharedAccess, SharedMemory, SharedSpec } from './sharedMemory';
|
|
2
|
+
|
|
3
|
+
export type ViewType = 'Int32' | 'Float64' | 'BigInt64' | 'Uint8';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal validation contract — compatible with zod schemas (`z.object(...)`)
|
|
7
|
+
* and any validator exposing `parse`.
|
|
8
|
+
*/
|
|
9
|
+
export interface Schema<T> {
|
|
10
|
+
parse(value: unknown): T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Logger = (...args: unknown[]) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Flattens a computed object type into its concrete shape so IDE hovers show
|
|
17
|
+
* `{ id: number; site: string }` instead of the mapped-type machinery that
|
|
18
|
+
* produced it. Applied to synthetic types (e.g. list records); user-named
|
|
19
|
+
* types keep their names since the name is the better display.
|
|
20
|
+
*/
|
|
21
|
+
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
22
|
+
|
|
23
|
+
export interface MemoryConfig {
|
|
24
|
+
initialPages?: number; // 1 page = 64KB
|
|
25
|
+
maximumPages?: number; // default 16384 (1GB)
|
|
26
|
+
growthFactor?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkerPoolConfig<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap> {
|
|
30
|
+
/** Worker entry as a URL — e.g. `new URL('./worker.ts', import.meta.url)`. */
|
|
31
|
+
workerUrl?: URL;
|
|
32
|
+
/**
|
|
33
|
+
* Bundler-detectable worker factory. Most bundlers only emit a worker chunk
|
|
34
|
+
* when the entry appears inline, so prefer this form:
|
|
35
|
+
* `createWorker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })`.
|
|
36
|
+
* One of `workerUrl` / `createWorker` is required.
|
|
37
|
+
*/
|
|
38
|
+
createWorker?: () => Worker;
|
|
39
|
+
/**
|
|
40
|
+
* The contract the pool binds and hands to each worker via INIT_MEMORY.
|
|
41
|
+
* Omit for message-only workers — the SharedArrayBuffer/isolation check is
|
|
42
|
+
* skipped and workers receive a plain `{ type: 'INIT' }` handshake.
|
|
43
|
+
*/
|
|
44
|
+
sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
|
|
45
|
+
/** Worker count — a number, or 'auto' (the default) for navigator.hardwareConcurrency ?? 4. */
|
|
46
|
+
poolSize?: number | 'auto';
|
|
47
|
+
memory?: MemoryConfig;
|
|
48
|
+
/** Max in-flight tasks per worker (default 1); excess tasks queue FIFO. */
|
|
49
|
+
concurrency?: number;
|
|
50
|
+
/** Max queued tasks (default Infinity); a full queue rejects with PoolQueueFullError. */
|
|
51
|
+
maxQueue?: number;
|
|
52
|
+
/** Default true — a worker 'error' rejects its in-flight tasks and spawns a replacement. */
|
|
53
|
+
respawn?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Default per-task timeout in ms (per-call `options.timeout` overrides).
|
|
56
|
+
* The timer starts at enqueue, so it bounds queue-wait + execution together.
|
|
57
|
+
*/
|
|
58
|
+
taskTimeout?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Named task contracts. Keys become first-class pool methods:
|
|
61
|
+
* `tasks: { queryIncidents: QueryIncidents }` → `pool.queryIncidents(q)`.
|
|
62
|
+
*/
|
|
63
|
+
tasks?: T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SharedWorkerConfig<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap> {
|
|
67
|
+
/** SharedWorker entry as a URL — e.g. `new URL('./worker.ts', import.meta.url)`. */
|
|
68
|
+
workerUrl?: URL;
|
|
69
|
+
/**
|
|
70
|
+
* Bundler-detectable factory — `createWorker: () => new SharedWorker(new URL('./worker.ts', import.meta.url), { type: 'module' })`.
|
|
71
|
+
* One of `workerUrl` / `createWorker` / `port` is required.
|
|
72
|
+
*/
|
|
73
|
+
createWorker?: () => SharedWorker;
|
|
74
|
+
/** A pre-opened MessagePort — tests, or hosts that forward a port another way. */
|
|
75
|
+
port?: MessagePort;
|
|
76
|
+
sharedMemory: SharedMemory<S> & Prettify<SharedAccess<S>>;
|
|
77
|
+
/**
|
|
78
|
+
* Buffer sizing hint sent on connect — the first connected client wins;
|
|
79
|
+
* the shared worker allocates the buffer once and shares it with everyone.
|
|
80
|
+
*/
|
|
81
|
+
memory?: MemoryConfig;
|
|
82
|
+
/**
|
|
83
|
+
* Named task contracts. Keys become first-class client methods:
|
|
84
|
+
* `tasks: { queryIncidents: QueryIncidents }` → `client.queryIncidents(q)`.
|
|
85
|
+
*/
|
|
86
|
+
tasks?: T;
|
|
87
|
+
/** Connect handshake timeout in ms (default 10000). */
|
|
88
|
+
connectTimeoutMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TaskContract<Args extends any[] = any[], Result = any> {
|
|
92
|
+
taskId: string;
|
|
93
|
+
dataType?: ViewType; // element datatype of the shared-memory region this task operates on (omit for structured fields)
|
|
94
|
+
argsSchema?: Schema<Args>; // validates args as they cross the thread boundary
|
|
95
|
+
resultSchema?: Schema<Result>; // validates the handler's return value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A map of named task contracts — keys become first-class WorkerPool methods. */
|
|
99
|
+
export type TaskMap = Record<string, TaskContract<any[], any>>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The method surface a WorkerPool exposes for a task map: each contract
|
|
103
|
+
* becomes a function with its declared args returning `Promise<Result>`.
|
|
104
|
+
*/
|
|
105
|
+
export type PoolTasks<T extends TaskMap> = {
|
|
106
|
+
[K in keyof T]: T[K] extends TaskContract<infer Args, infer Result>
|
|
107
|
+
? (...args: Args) => Promise<Result>
|
|
108
|
+
: never;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export interface TaskMessage {
|
|
112
|
+
taskId: string;
|
|
113
|
+
args?: any[];
|
|
114
|
+
memoryOffset?: number;
|
|
115
|
+
byteLength?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TaskResult {
|
|
119
|
+
taskId: string;
|
|
120
|
+
success: boolean;
|
|
121
|
+
result?: any;
|
|
122
|
+
error?: string;
|
|
123
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/* ── Contract — shared memory declarations (both threads) ───────────────── */
|
|
2
|
+
|
|
3
|
+
export { defineSharedMemory, field, getDefinedSharedMemoryCount, jsonCodec, registerConnectorFactory } from './contract/sharedMemory';
|
|
4
|
+
export { mz } from './contract/mz';
|
|
5
|
+
export type {
|
|
6
|
+
Codec,
|
|
7
|
+
Connector,
|
|
8
|
+
ConnectorContext,
|
|
9
|
+
ConnectorFactory,
|
|
10
|
+
FieldDescriptor,
|
|
11
|
+
FieldGroup,
|
|
12
|
+
FieldSchema,
|
|
13
|
+
FixedArrayDescriptor,
|
|
14
|
+
FixedObjectDescriptor,
|
|
15
|
+
FlatKey,
|
|
16
|
+
InferField,
|
|
17
|
+
PathConnector,
|
|
18
|
+
SpecPath,
|
|
19
|
+
SpecSchemas,
|
|
20
|
+
SharedAccess,
|
|
21
|
+
SharedMemory,
|
|
22
|
+
SharedMemoryOptions,
|
|
23
|
+
SharedSpec,
|
|
24
|
+
ScalarKind,
|
|
25
|
+
ListConnector,
|
|
26
|
+
ListDescriptor,
|
|
27
|
+
ListFieldSpec,
|
|
28
|
+
ListFields,
|
|
29
|
+
ListMember,
|
|
30
|
+
ListRecord,
|
|
31
|
+
ListSpec,
|
|
32
|
+
} from './contract/sharedMemory';
|
|
33
|
+
export { msgpackCodec } from './contract/msgpackCodec';
|
|
34
|
+
export { msgpackrCodec } from './contract/msgpackrCodec';
|
|
35
|
+
export { listSchema } from './contract/listSchema';
|
|
36
|
+
export type { ListZodShape } from './contract/listSchema';
|
|
37
|
+
export type {
|
|
38
|
+
MemoryConfig,
|
|
39
|
+
PoolTasks,
|
|
40
|
+
Prettify,
|
|
41
|
+
Schema,
|
|
42
|
+
Logger,
|
|
43
|
+
TaskContract,
|
|
44
|
+
TaskMap,
|
|
45
|
+
TaskMessage,
|
|
46
|
+
TaskResult,
|
|
47
|
+
SharedWorkerConfig,
|
|
48
|
+
ViewType,
|
|
49
|
+
WorkerPoolConfig,
|
|
50
|
+
} from './contract/types';
|
|
51
|
+
|
|
52
|
+
/* ── Pool — main-thread runtime ─────────────────────────────────────────── */
|
|
53
|
+
|
|
54
|
+
export { MemoryManager } from './pool/memory';
|
|
55
|
+
export { WorkerPool } from './pool/workerPool';
|
|
56
|
+
export type { PoolStats, RunOptions } from './pool/workerPool';
|
|
57
|
+
export {
|
|
58
|
+
PoolQueueFullError,
|
|
59
|
+
TaskAbortedError,
|
|
60
|
+
TaskTimeoutError,
|
|
61
|
+
WorkerCrashedError,
|
|
62
|
+
} from './pool/errors';
|
|
63
|
+
export { connectWorker, workerClient } from './pool/workerClient';
|
|
64
|
+
export type {
|
|
65
|
+
ClientMethods,
|
|
66
|
+
ConnectWorkerConfig,
|
|
67
|
+
WorkerClient,
|
|
68
|
+
WorkerMethods,
|
|
69
|
+
} from './pool/workerClient';
|
|
70
|
+
|
|
71
|
+
/* ── SharedWorker — one worker (and one buffer) across tabs/iframes ──────── */
|
|
72
|
+
|
|
73
|
+
export { connectSharedWorker } from './shared/sharedWorkerClient';
|
|
74
|
+
export type { SharedWorkerClient } from './shared/sharedWorkerClient';
|
|
75
|
+
export { sharedWorkerHost } from './shared/sharedWorkerHost';
|
|
76
|
+
|
|
77
|
+
/* ── Service — framework-neutral RPC contracts shared by both threads ────── */
|
|
78
|
+
|
|
79
|
+
export { createClient, defineService, implementService, rpc, serviceMethod } from './service';
|
|
80
|
+
export type {
|
|
81
|
+
RpcMethodDef,
|
|
82
|
+
RpcMethodInput,
|
|
83
|
+
RpcMethodInputMap,
|
|
84
|
+
RpcMethodMap,
|
|
85
|
+
ServiceClient,
|
|
86
|
+
ServiceContract,
|
|
87
|
+
ServiceHandlers,
|
|
88
|
+
ServiceImpl,
|
|
89
|
+
ServiceMethod,
|
|
90
|
+
TaskRunner,
|
|
91
|
+
} from './service';
|
|
92
|
+
|
|
93
|
+
/* ── Worker — worker-side runtime ───────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
export { TaskRegistry } from './worker/registry';
|
|
96
|
+
export { defineWorker, RESERVED_CLIENT_KEYS } from './worker/defineWorker';
|
|
97
|
+
export type {
|
|
98
|
+
DefineWorkerConfig,
|
|
99
|
+
WorkerDefinition,
|
|
100
|
+
WorkerMethod,
|
|
101
|
+
WorkerMethodMap,
|
|
102
|
+
WorkerServiceMap,
|
|
103
|
+
} from './worker/defineWorker';
|
|
104
|
+
|
|
105
|
+
/* ── Shared runtime utilities ───────────────────────────────────────────── */
|
|
106
|
+
|
|
107
|
+
export { reactive, shallowEqual, watch } from './reactive';
|
|
108
|
+
export type { EqualityFn, ReactiveConnector, SliceOptions } from './reactive';
|
|
109
|
+
export { observe } from './observable';
|
|
110
|
+
export type { ObservableValue } from './observable';
|
|
111
|
+
export { defineTask, isAsyncTask, toTask } from './task';
|
|
112
|
+
export type { AsyncTask, TaskSnapshot } from './task';
|
|
113
|
+
export { getLogLevel, log, scoped, setLogLevel, setLogSink } from './log';
|
|
114
|
+
export type { LogEntry, LogLevel, LogSink } from './log';
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped, leveled logging for the SDK. Default level is `info` — lifecycle
|
|
3
|
+
* events (pool spawn, memory bind, seed phases) are always visible; `debug`
|
|
4
|
+
* adds per-task dispatch/completion and `trace` adds per-message detail.
|
|
5
|
+
*
|
|
6
|
+
* import { setLogLevel } from '…/index';
|
|
7
|
+
* setLogLevel('debug'); // or 'trace', 'warn', 'off'
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off';
|
|
11
|
+
|
|
12
|
+
const LEVELS: Record<LogLevel, number> = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, off: 5 };
|
|
13
|
+
|
|
14
|
+
export interface LogEntry {
|
|
15
|
+
level: LogLevel;
|
|
16
|
+
scope: string;
|
|
17
|
+
message: string;
|
|
18
|
+
data?: unknown;
|
|
19
|
+
/** Which side of the boundary emitted this entry. */
|
|
20
|
+
thread: 'main' | 'worker';
|
|
21
|
+
/** performance.now() at emission — useful for sequencing across threads. */
|
|
22
|
+
at: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LogSink = (entry: LogEntry) => void;
|
|
26
|
+
|
|
27
|
+
const THREAD: LogEntry['thread'] =
|
|
28
|
+
typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope ? 'worker' : 'main';
|
|
29
|
+
|
|
30
|
+
const consoleSink: LogSink = ({ level, scope, message, data, thread, at }) => {
|
|
31
|
+
const prefix = `[${(at / 1000).toFixed(2)}s ${thread}:${scope}]`;
|
|
32
|
+
const fn =
|
|
33
|
+
level === 'error' ? console.error
|
|
34
|
+
: level === 'warn' ? console.warn
|
|
35
|
+
: level === 'info' ? console.info
|
|
36
|
+
: console.debug;
|
|
37
|
+
if (data !== undefined) fn(`${prefix} ${message}`, data);
|
|
38
|
+
else fn(`${prefix} ${message}`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let minLevel: LogLevel = 'info';
|
|
42
|
+
let sink: LogSink = consoleSink;
|
|
43
|
+
|
|
44
|
+
export const setLogLevel = (level: LogLevel): void => { minLevel = level; };
|
|
45
|
+
export const getLogLevel = (): LogLevel => minLevel;
|
|
46
|
+
/** Replace the console sink (e.g. collect entries for a UI panel); null restores it. */
|
|
47
|
+
export const setLogSink = (custom: LogSink | null): void => { sink = custom ?? consoleSink; };
|
|
48
|
+
|
|
49
|
+
export function log(level: Exclude<LogLevel, 'off'>, scope: string, message: string, data?: unknown): void {
|
|
50
|
+
if (LEVELS[level] < LEVELS[minLevel]) return;
|
|
51
|
+
sink({ level, scope, message, data, thread: THREAD, at: performance.now() });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A logger bound to a subsystem scope: `poolLog.info('spawned')`. */
|
|
55
|
+
export function scoped(scope: string) {
|
|
56
|
+
return {
|
|
57
|
+
trace: (message: string, data?: unknown) => log('trace', scope, message, data),
|
|
58
|
+
debug: (message: string, data?: unknown) => log('debug', scope, message, data),
|
|
59
|
+
info: (message: string, data?: unknown) => log('info', scope, message, data),
|
|
60
|
+
warn: (message: string, data?: unknown) => log('warn', scope, message, data),
|
|
61
|
+
error: (message: string, data?: unknown) => log('error', scope, message, data),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compact byte formatting for log output. */
|
|
66
|
+
export const fmtBytes = (bytes: number): string =>
|
|
67
|
+
bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)}MB`
|
|
68
|
+
: bytes >= 1024 ? `${(bytes / 1024).toFixed(1)}KB`
|
|
69
|
+
: `${bytes}B`;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Connector, PathConnector, SharedMemory, SharedSpec, SpecPath } from './contract/sharedMemory';
|
|
2
|
+
import { watch, type SliceOptions } from './reactive';
|
|
3
|
+
import { scoped } from './log';
|
|
4
|
+
|
|
5
|
+
const obsLog = scoped('observe');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A minimal subscribable snapshot — `get()` plus `subscribe()`. This is the
|
|
9
|
+
* interface every framework binding adapts (useSyncExternalStore, refs,
|
|
10
|
+
* signals, runes). `get()` must return a stable value between notifications.
|
|
11
|
+
*/
|
|
12
|
+
export interface ObservableValue<T> {
|
|
13
|
+
get(): T;
|
|
14
|
+
subscribe(onChange: (value: T) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type FieldValue<C> = C extends Connector<infer T> ? T : never;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Observe one field of a shared memory contract as a subscribable snapshot.
|
|
21
|
+
*
|
|
22
|
+
* Safe to call before the contract is bound — `get()` returns `undefined`
|
|
23
|
+
* until binding, and a subscriber registered early activates its watch when
|
|
24
|
+
* `bind()` lands (useful for SSR, where renders happen without a buffer).
|
|
25
|
+
* The underlying `watch` starts on the first subscriber and stops when the
|
|
26
|
+
* last one leaves.
|
|
27
|
+
*/
|
|
28
|
+
export function observe<S extends SharedSpec, K extends SpecPath<S>>(
|
|
29
|
+
memory: SharedMemory<S>,
|
|
30
|
+
key: K
|
|
31
|
+
): ObservableValue<FieldValue<PathConnector<S, K>> | undefined>;
|
|
32
|
+
/**
|
|
33
|
+
* Slice form: `observe(memory, 'metrics', m => m.critical)` — the observable
|
|
34
|
+
* emits only when the selected slice changes. Slice equality defaults to
|
|
35
|
+
* `Object.is`; pass `{ equals }` to define what counts as a change —
|
|
36
|
+
* `shallowEqual` for object slices, or any `(prev, next) => boolean`.
|
|
37
|
+
*/
|
|
38
|
+
export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
39
|
+
memory: SharedMemory<S>,
|
|
40
|
+
key: K,
|
|
41
|
+
select: (value: FieldValue<PathConnector<S, K>>) => Sel,
|
|
42
|
+
options?: SliceOptions<Sel>
|
|
43
|
+
): ObservableValue<Sel | undefined>;
|
|
44
|
+
export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
45
|
+
memory: SharedMemory<S>,
|
|
46
|
+
key: K,
|
|
47
|
+
select?: (value: FieldValue<PathConnector<S, K>>) => Sel,
|
|
48
|
+
options?: SliceOptions<Sel>
|
|
49
|
+
): ObservableValue<unknown> {
|
|
50
|
+
type T = FieldValue<PathConnector<S, K>>;
|
|
51
|
+
type Out = T | Sel;
|
|
52
|
+
const selectFn = (select ?? ((v: T) => v)) as (value: T) => Out;
|
|
53
|
+
const equals = (options?.equals ?? Object.is) as (a: Out, b: Out) => boolean;
|
|
54
|
+
let current: Out | undefined;
|
|
55
|
+
let lastVersion = -1;
|
|
56
|
+
let unwatch: (() => void) | null = null;
|
|
57
|
+
let pendingBind: (() => void) | null = null;
|
|
58
|
+
let rebindUn: (() => void) | null = null;
|
|
59
|
+
let watched: Connector<T> | null = null;
|
|
60
|
+
const subscribers = new Set<(value: Out | undefined) => void>();
|
|
61
|
+
|
|
62
|
+
const activate = () => {
|
|
63
|
+
if (unwatch || pendingBind) return;
|
|
64
|
+
let connector: Connector<T>;
|
|
65
|
+
try {
|
|
66
|
+
connector = memory.connector(String(key)) as Connector<T>;
|
|
67
|
+
} catch {
|
|
68
|
+
// Not bound on this thread yet — activate when it is.
|
|
69
|
+
pendingBind = memory.onBound(() => {
|
|
70
|
+
pendingBind = null;
|
|
71
|
+
activate();
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
unwatch = watch(
|
|
76
|
+
connector,
|
|
77
|
+
selectFn as (value: T) => Sel,
|
|
78
|
+
(value) => {
|
|
79
|
+
// Same gate as get(): get() may already have stored an equal-valued
|
|
80
|
+
// snapshot — keep its reference and skip the emit.
|
|
81
|
+
if (current === undefined || !equals(current as Out, value)) {
|
|
82
|
+
current = value;
|
|
83
|
+
for (const cb of subscribers) cb(value);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
options
|
|
87
|
+
);
|
|
88
|
+
watched = connector;
|
|
89
|
+
// bind() can REBIND this contract to a different buffer — in-process
|
|
90
|
+
// harnesses run every worker's bindSharedMemories in one module graph,
|
|
91
|
+
// so a later pool's spawn rebinds this contract too. The watch above
|
|
92
|
+
// pins the old connector (old buffer); re-watch when it swaps. onBound
|
|
93
|
+
// fires synchronously when already bound — the identity check makes
|
|
94
|
+
// that first call a no-op.
|
|
95
|
+
rebindUn ??= memory.onBound(() => {
|
|
96
|
+
let next: Connector<T>;
|
|
97
|
+
try {
|
|
98
|
+
next = memory.connector(String(key)) as Connector<T>;
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (next === watched) return;
|
|
103
|
+
unwatch?.();
|
|
104
|
+
unwatch = null;
|
|
105
|
+
watched = null;
|
|
106
|
+
activate();
|
|
107
|
+
});
|
|
108
|
+
obsLog.debug(`observing "${String(key)}" (offset ${connector.byteOffset})`);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const deactivate = () => {
|
|
112
|
+
unwatch?.();
|
|
113
|
+
unwatch = null;
|
|
114
|
+
watched = null;
|
|
115
|
+
pendingBind?.();
|
|
116
|
+
pendingBind = null;
|
|
117
|
+
rebindUn?.();
|
|
118
|
+
rebindUn = null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
get() {
|
|
123
|
+
// Re-read only when the shared version counter moved — decoding a
|
|
124
|
+
// structured field on every snapshot check would be wasted work.
|
|
125
|
+
if (unwatch || memory.bound) {
|
|
126
|
+
try {
|
|
127
|
+
const connector = memory.connector(String(key)) as Connector<T>;
|
|
128
|
+
const version = connector._version ? Atomics.load(connector._version.view, connector._version.index) : 0;
|
|
129
|
+
if (version !== lastVersion) {
|
|
130
|
+
lastVersion = version;
|
|
131
|
+
// Keep the snapshot reference stable between emissions — the
|
|
132
|
+
// comparator decides what counts as a change here too.
|
|
133
|
+
const next = selectFn(connector.read());
|
|
134
|
+
if (current === undefined || !equals(current as Out, next)) current = next;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Racing an unbind/rebind — keep the last known value.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return current;
|
|
141
|
+
},
|
|
142
|
+
subscribe(onChange) {
|
|
143
|
+
subscribers.add(onChange);
|
|
144
|
+
activate();
|
|
145
|
+
return () => {
|
|
146
|
+
subscribers.delete(onChange);
|
|
147
|
+
if (subscribers.size === 0) deactivate();
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Task queue is at `maxQueue` — dispatch rejected without ever running. */
|
|
2
|
+
export class PoolQueueFullError extends Error {
|
|
3
|
+
constructor(message = 'WorkerPool task queue is full') {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'PoolQueueFullError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The task's AbortSignal fired — never dispatched, or reply discarded. */
|
|
10
|
+
export class TaskAbortedError extends Error {
|
|
11
|
+
constructor(message = 'task aborted') {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'AbortError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The task exceeded its `timeout` / pool `taskTimeout` budget. */
|
|
18
|
+
export class TaskTimeoutError extends Error {
|
|
19
|
+
constructor(message = 'task timed out') {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'TaskTimeoutError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The worker errored/exited, or the pool was terminated mid-flight. */
|
|
26
|
+
export class WorkerCrashedError extends Error {
|
|
27
|
+
constructor(message = 'worker crashed') {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'WorkerCrashedError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { MemoryConfig, ViewType } from '../contract/types';
|
|
2
|
+
|
|
3
|
+
export class MemoryManager {
|
|
4
|
+
public memory: WebAssembly.Memory;
|
|
5
|
+
private maxPages: number;
|
|
6
|
+
|
|
7
|
+
constructor(config: MemoryConfig = {}) {
|
|
8
|
+
const initialPages = config.initialPages ?? 16; // 1MB default
|
|
9
|
+
this.maxPages = config.maximumPages ?? 16384; // 1GB default limit
|
|
10
|
+
|
|
11
|
+
// Initialize WebAssembly.Memory with shared: true for SharedArrayBuffer support
|
|
12
|
+
this.memory = new WebAssembly.Memory({
|
|
13
|
+
initial: initialPages,
|
|
14
|
+
maximum: this.maxPages,
|
|
15
|
+
shared: true,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Automatically checks if memory needs to grow to accommodate new bytes,
|
|
21
|
+
* and expands via memory.grow() if necessary. Thread-safe.
|
|
22
|
+
*/
|
|
23
|
+
public ensureCapacity(requiredBytes: number): void {
|
|
24
|
+
const currentByteLength = this.memory.buffer.byteLength;
|
|
25
|
+
if (currentByteLength >= requiredBytes) return;
|
|
26
|
+
|
|
27
|
+
const bytesNeeded = requiredBytes - currentByteLength;
|
|
28
|
+
const pagesNeeded = Math.ceil(bytesNeeded / 65536);
|
|
29
|
+
const currentPages = this.memory.buffer.byteLength / 65536;
|
|
30
|
+
|
|
31
|
+
if (currentPages + pagesNeeded > this.maxPages) {
|
|
32
|
+
throw new Error(`Cannot grow memory: exceeds maximum page limit of ${this.maxPages}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.memory.grow(pagesNeeded);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a typed view over the shared memory buffer at a specific byte offset.
|
|
40
|
+
*/
|
|
41
|
+
public getView<T extends Int32Array | Float64Array | BigInt64Array | Uint8Array>(
|
|
42
|
+
type: ViewType,
|
|
43
|
+
byteOffset: number = 0,
|
|
44
|
+
length?: number
|
|
45
|
+
): T {
|
|
46
|
+
const buffer = this.memory.buffer;
|
|
47
|
+
|
|
48
|
+
switch (type) {
|
|
49
|
+
case 'Int32':
|
|
50
|
+
return new Int32Array(buffer, byteOffset, length) as T;
|
|
51
|
+
case 'Float64':
|
|
52
|
+
return new Float64Array(buffer, byteOffset, length) as T;
|
|
53
|
+
case 'BigInt64':
|
|
54
|
+
return new BigInt64Array(buffer, byteOffset, length) as T;
|
|
55
|
+
case 'Uint8':
|
|
56
|
+
return new Uint8Array(buffer, byteOffset, length) as T;
|
|
57
|
+
default:
|
|
58
|
+
throw new Error(`Unsupported view type: ${type}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Exposes the raw SharedArrayBuffer
|
|
64
|
+
*/
|
|
65
|
+
public getBuffer(): SharedArrayBuffer {
|
|
66
|
+
return this.memory.buffer as unknown as SharedArrayBuffer;
|
|
67
|
+
}
|
|
68
|
+
}
|