@aethervtt/shard-platform 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +16 -0
- package/dist/workers.d.ts +60 -0
- package/dist/workers.js +183 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
import type { Workers } from './workers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Everything the engine needs from its host. The engine only talks to this interface;
|
|
5
|
+
* hosts (browser, Tauri, headless) provide an implementation at startup.
|
|
6
|
+
*/
|
|
7
|
+
export interface Platform {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly fs: PlatformFileSystem;
|
|
10
|
+
readonly storage: KeyValueStorage;
|
|
11
|
+
readonly clock: Clock;
|
|
12
|
+
readonly log: Logger;
|
|
13
|
+
/** Where sound goes: Web Audio in a browser or webview. Absent when headless (the audio plugin records voices instead). */
|
|
14
|
+
readonly audio?: AudioBackend;
|
|
15
|
+
/**
|
|
16
|
+
* Background threads: web workers in a browser or webview, `worker_threads` in Node. Made on
|
|
17
|
+
* first use. Absent on hosts without threads; `workersOf` falls back to running jobs inline.
|
|
18
|
+
*/
|
|
19
|
+
readonly workers?: Workers;
|
|
20
|
+
}
|
|
21
|
+
export * from './workers.js';
|
|
22
|
+
export type MouseButton = 'left' | 'middle' | 'right' | 'back' | 'forward';
|
|
23
|
+
/**
|
|
24
|
+
* Raw input as the platform sees it. The input plugin drains these once per frame. `action` events
|
|
25
|
+
* are synthetic (injected by tests or agents) and go through the same stream so they're recorded
|
|
26
|
+
* and replayed like everything else.
|
|
27
|
+
*/
|
|
28
|
+
export type RawInputEvent = {
|
|
29
|
+
type: 'key';
|
|
30
|
+
code: string;
|
|
31
|
+
pressed: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
type: 'mouse-button';
|
|
34
|
+
button: MouseButton;
|
|
35
|
+
pressed: boolean;
|
|
36
|
+
} | {
|
|
37
|
+
type: 'mouse-move';
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
dx: number;
|
|
41
|
+
dy: number;
|
|
42
|
+
} | {
|
|
43
|
+
type: 'wheel';
|
|
44
|
+
dx: number;
|
|
45
|
+
dy: number;
|
|
46
|
+
} | {
|
|
47
|
+
type: 'touch';
|
|
48
|
+
id: number;
|
|
49
|
+
phase: 'start' | 'move' | 'end';
|
|
50
|
+
x: number;
|
|
51
|
+
y: number;
|
|
52
|
+
} | {
|
|
53
|
+
type: 'gamepad';
|
|
54
|
+
index: number;
|
|
55
|
+
connected: boolean;
|
|
56
|
+
buttons: number[];
|
|
57
|
+
axes: number[];
|
|
58
|
+
} | {
|
|
59
|
+
type: 'focus';
|
|
60
|
+
focused: boolean;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'action';
|
|
63
|
+
name: string;
|
|
64
|
+
pressed: boolean;
|
|
65
|
+
value?: number;
|
|
66
|
+
}
|
|
67
|
+
/** Typed characters, key repeats included; "\b" is a backspace. Text fields read these. */
|
|
68
|
+
| {
|
|
69
|
+
type: 'text';
|
|
70
|
+
text: string;
|
|
71
|
+
};
|
|
72
|
+
/** Where raw input comes from: DOM listeners in a browser or webview, nothing when headless. */
|
|
73
|
+
export interface InputSource {
|
|
74
|
+
/** Appends events since the last drain (and polls devices like gamepads). */
|
|
75
|
+
drain(out: RawInputEvent[]): void;
|
|
76
|
+
dispose(): void;
|
|
77
|
+
}
|
|
78
|
+
export type FileChangeKind = 'create' | 'modify' | 'remove';
|
|
79
|
+
export interface FileChangeEvent {
|
|
80
|
+
readonly kind: FileChangeKind;
|
|
81
|
+
readonly path: string;
|
|
82
|
+
}
|
|
83
|
+
export interface PlatformFileSystem {
|
|
84
|
+
/** False on hosts that can only read (for example a static web build). */
|
|
85
|
+
readonly writable: boolean;
|
|
86
|
+
readText(path: string): Promise<string>;
|
|
87
|
+
readBytes(path: string): Promise<Uint8Array>;
|
|
88
|
+
writeText(path: string, data: string): Promise<void>;
|
|
89
|
+
writeBytes(path: string, data: Uint8Array): Promise<void>;
|
|
90
|
+
exists(path: string): Promise<boolean>;
|
|
91
|
+
/** Present only on hosts that can watch files. Returns an unsubscribe function. */
|
|
92
|
+
watch?(path: string, onChange: (event: FileChangeEvent) => void): Promise<() => void>;
|
|
93
|
+
/** Entries directly inside a directory; empty if it doesn't exist. Absent on hosts that can't list. */
|
|
94
|
+
list?(dir: string): Promise<DirEntry[]>;
|
|
95
|
+
/** Size and modification time, or undefined if the file doesn't exist. */
|
|
96
|
+
stat?(path: string): Promise<FileStat | undefined>;
|
|
97
|
+
/** Moves a file, creating the target's directory. Writable hosts only. */
|
|
98
|
+
move?(from: string, to: string): Promise<void>;
|
|
99
|
+
/** Deletes a file if it exists. Writable hosts only. */
|
|
100
|
+
remove?(path: string): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
export interface DirEntry {
|
|
103
|
+
/** Name within the directory (not a path). */
|
|
104
|
+
readonly name: string;
|
|
105
|
+
readonly kind: 'file' | 'dir';
|
|
106
|
+
}
|
|
107
|
+
export interface FileStat {
|
|
108
|
+
readonly size: number;
|
|
109
|
+
/** Modification time in milliseconds. */
|
|
110
|
+
readonly mtime: number;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The player's own data: saved games and settings, kept per user rather than in the project.
|
|
114
|
+
* Keys are paths (`saves/slot1.json`, `settings.json`). Node and Tauri write files under a user
|
|
115
|
+
* data folder, browsers use IndexedDB, and headless tests use `createMemoryStorage`.
|
|
116
|
+
*/
|
|
117
|
+
export interface KeyValueStorage {
|
|
118
|
+
/** The stored bytes, or undefined if nothing is stored under the key. */
|
|
119
|
+
read(key: string): Promise<Uint8Array | undefined>;
|
|
120
|
+
write(key: string, data: Uint8Array): Promise<void>;
|
|
121
|
+
/** Keys that start with `prefix`, sorted. */
|
|
122
|
+
list(prefix: string): Promise<string[]>;
|
|
123
|
+
/** Removes the key if it exists. */
|
|
124
|
+
delete(key: string): Promise<void>;
|
|
125
|
+
}
|
|
126
|
+
/** Storage that lives as long as the process: headless runs and tests. Writes are copied. */
|
|
127
|
+
export declare function createMemoryStorage(): KeyValueStorage;
|
|
128
|
+
export interface Clock {
|
|
129
|
+
/** Monotonic time in milliseconds. */
|
|
130
|
+
now(): number;
|
|
131
|
+
}
|
|
132
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
133
|
+
export interface Logger {
|
|
134
|
+
log(level: LogLevel, message: string, data?: Record<string, unknown>): void;
|
|
135
|
+
}
|
|
136
|
+
/** Encoded sound the backend decodes (or streams) itself. An imported `AudioClip` is one. */
|
|
137
|
+
export interface AudioClipSource {
|
|
138
|
+
/** Asset guid, for logs. */
|
|
139
|
+
readonly id: string;
|
|
140
|
+
/** The file as imported (WAV, Ogg, MP3, FLAC). A new array means new contents. */
|
|
141
|
+
readonly bytes: Uint8Array;
|
|
142
|
+
/** `wav`, `vorbis`, `opus`, `mp3`, or `flac`. */
|
|
143
|
+
readonly codec: string;
|
|
144
|
+
/** Seconds. */
|
|
145
|
+
readonly duration: number;
|
|
146
|
+
/** Play through a media element instead of decoding it all up front (music). */
|
|
147
|
+
readonly stream: boolean;
|
|
148
|
+
/** Scale decoded samples so the peak is 1. */
|
|
149
|
+
readonly normalize: boolean;
|
|
150
|
+
/** Loop region in seconds; `loopEnd` 0 means the end of the clip. */
|
|
151
|
+
readonly loopStart: number;
|
|
152
|
+
readonly loopEnd: number;
|
|
153
|
+
}
|
|
154
|
+
export type AudioPanningModel = 'hrtf' | 'equal-power';
|
|
155
|
+
export type AudioDistanceModel = 'inverse' | 'linear' | 'exponential';
|
|
156
|
+
/** How a spatial voice pans and fades with distance: the parameters of a Web Audio PannerNode. */
|
|
157
|
+
export interface AudioSpatialDesc {
|
|
158
|
+
readonly panning: AudioPanningModel;
|
|
159
|
+
readonly distanceModel: AudioDistanceModel;
|
|
160
|
+
readonly refDistance: number;
|
|
161
|
+
readonly maxDistance: number;
|
|
162
|
+
readonly rolloffFactor: number;
|
|
163
|
+
}
|
|
164
|
+
/** What changes while a voice plays. */
|
|
165
|
+
export interface AudioVoiceParams {
|
|
166
|
+
/** Linear gain before the bus and distance: volume × occlusion. */
|
|
167
|
+
gain: number;
|
|
168
|
+
/** Playback rate (pitch × Doppler). */
|
|
169
|
+
pitch: number;
|
|
170
|
+
/** World position of a spatial voice (ignored for non-spatial ones). */
|
|
171
|
+
x: number;
|
|
172
|
+
y: number;
|
|
173
|
+
z: number;
|
|
174
|
+
}
|
|
175
|
+
export interface AudioVoiceDesc extends AudioVoiceParams {
|
|
176
|
+
readonly clip: AudioClipSource;
|
|
177
|
+
/** Bus name; the backend mixes it at the gain last given to `setBus`. */
|
|
178
|
+
readonly bus: string;
|
|
179
|
+
readonly loop: boolean;
|
|
180
|
+
/** Where playback starts, in seconds of clip time. */
|
|
181
|
+
readonly offset: number;
|
|
182
|
+
/** Null for non-spatial voices (UI, music). */
|
|
183
|
+
readonly spatial: AudioSpatialDesc | null;
|
|
184
|
+
}
|
|
185
|
+
export type AudioContextState = 'running' | 'suspended' | 'closed' | 'headless';
|
|
186
|
+
/**
|
|
187
|
+
* Plays voices the audio plugin decides on. The plugin owns timing, voice limits, and virtual
|
|
188
|
+
* voices; a backend only makes the sound (or, headless, records it).
|
|
189
|
+
*/
|
|
190
|
+
export interface AudioBackend {
|
|
191
|
+
/** `web`, `headless`, ... */
|
|
192
|
+
readonly kind: string;
|
|
193
|
+
/** Browsers keep the context suspended until a user gesture; voices wait until then. */
|
|
194
|
+
readonly state: AudioContextState;
|
|
195
|
+
/** Returns an id for `update` and `stop`. */
|
|
196
|
+
play(voice: AudioVoiceDesc): number;
|
|
197
|
+
update(voice: number, params: AudioVoiceParams): void;
|
|
198
|
+
/** Stops a voice, fading out over `fade` seconds (default: at once). */
|
|
199
|
+
stop(voice: number, fade?: number): void;
|
|
200
|
+
/** The listener's world matrix (affine 3x4, row by row, as in core/GlobalTransform). */
|
|
201
|
+
setListener(matrix: ArrayLike<number>): void;
|
|
202
|
+
/** A bus's final gain: its volume, mute, ducking, and parents multiplied. */
|
|
203
|
+
setBus(name: string, gain: number): void;
|
|
204
|
+
/** Called with decode failures and other problems the plugin should log. */
|
|
205
|
+
onError?: (error: ShardError) => void;
|
|
206
|
+
dispose?(): void;
|
|
207
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from "./workers.js";
|
|
2
|
+
function createMemoryStorage() {
|
|
3
|
+
const data = /* @__PURE__ */ new Map();
|
|
4
|
+
return {
|
|
5
|
+
read: async (key) => {
|
|
6
|
+
const bytes = data.get(key);
|
|
7
|
+
return bytes?.slice();
|
|
8
|
+
},
|
|
9
|
+
write: async (key, bytes) => void data.set(key, bytes.slice()),
|
|
10
|
+
list: async (prefix) => [...data.keys()].filter((k) => k.startsWith(prefix)).sort(),
|
|
11
|
+
delete: async (key) => void data.delete(key)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
createMemoryStorage
|
|
16
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Platform } from './index.js';
|
|
2
|
+
export interface WorkerRunOptions {
|
|
3
|
+
/** Buffers that move to the worker instead of being copied. The caller can't use them after. */
|
|
4
|
+
readonly transfer?: readonly ArrayBuffer[];
|
|
5
|
+
/** `high` jobs start before every queued `normal` one. Default `normal`. */
|
|
6
|
+
readonly priority?: 'high' | 'normal';
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A pool of background threads that run functions exported by ES modules. Results never depend on
|
|
10
|
+
* which worker ran a job; when order matters, callers apply results in the order they asked.
|
|
11
|
+
*/
|
|
12
|
+
export interface Workers {
|
|
13
|
+
/** Worker threads. 0 means jobs run inline on the calling thread. */
|
|
14
|
+
readonly size: number;
|
|
15
|
+
/**
|
|
16
|
+
* Runs `fn` from the module at `module` (a URL, such as
|
|
17
|
+
* `new URL('./worker.js', import.meta.url).href`) with `args`, and resolves with its result.
|
|
18
|
+
* Worker modules are plain JavaScript: they load in a worker without a bundler. Typed arrays
|
|
19
|
+
* in the result (the result itself or its own properties) move back instead of being copied.
|
|
20
|
+
* Rejects with `platform/worker-crashed` if the worker dies (the pool replaces it), or with the
|
|
21
|
+
* error the function threw.
|
|
22
|
+
*/
|
|
23
|
+
run<T>(module: string, fn: string, args: readonly unknown[], options?: WorkerRunOptions): Promise<T>;
|
|
24
|
+
/** Stops every worker. Jobs still running or queued reject. */
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
/** What a host gives the pool for one worker thread. */
|
|
28
|
+
export interface WorkerThread {
|
|
29
|
+
post(message: unknown, transfer: readonly ArrayBuffer[]): void;
|
|
30
|
+
/** Lets the host exit while this worker is idle (Node); optional. */
|
|
31
|
+
idle?(idle: boolean): void;
|
|
32
|
+
terminate(): void;
|
|
33
|
+
}
|
|
34
|
+
export interface WorkerThreadEvents {
|
|
35
|
+
message(message: unknown): void;
|
|
36
|
+
/** The worker died: an uncaught error, or it exited. */
|
|
37
|
+
exit(reason: string): void;
|
|
38
|
+
}
|
|
39
|
+
export interface WorkerPoolOptions {
|
|
40
|
+
size: number;
|
|
41
|
+
/** Starts one worker running `workerHostSource`. */
|
|
42
|
+
spawn(events: WorkerThreadEvents): WorkerThread;
|
|
43
|
+
}
|
|
44
|
+
/** The shared queue: a worker per slot, one job per worker at a time, FIFO with a priority lane. */
|
|
45
|
+
export declare function createWorkerPool(options: WorkerPoolOptions): Workers;
|
|
46
|
+
/**
|
|
47
|
+
* A pool of size 0: jobs run inline on the calling thread, each after an `await`. Hosts without
|
|
48
|
+
* threads and tests that turn workers off use it, and everything that uses a pool works the same.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createInlineWorkers(): Workers;
|
|
51
|
+
/** The platform's pool, or a shared inline one on hosts without threads. */
|
|
52
|
+
export declare function workersOf(platform: Pick<Platform, 'workers'> | undefined): Workers;
|
|
53
|
+
/** How many workers a host with `cores` logical cores gets: one per core but one, 1 to 8. */
|
|
54
|
+
export declare function defaultWorkerCount(cores: number): number;
|
|
55
|
+
/**
|
|
56
|
+
* The script every worker runs. It imports job modules on first use (cached), calls the export,
|
|
57
|
+
* and posts the result back, moving typed arrays in it. `node` reads `worker_threads`; `web`
|
|
58
|
+
* uses the worker global.
|
|
59
|
+
*/
|
|
60
|
+
export declare function workerHostSource(host: 'node' | 'web'): string;
|
package/dist/workers.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
function createWorkerPool(options) {
|
|
3
|
+
const slots = [];
|
|
4
|
+
const high = [];
|
|
5
|
+
const normal = [];
|
|
6
|
+
let nextId = 1;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const start = (slot) => {
|
|
9
|
+
const events = {
|
|
10
|
+
message: (message) => {
|
|
11
|
+
const reply = message;
|
|
12
|
+
const job = slot.job;
|
|
13
|
+
if (!job || job.id !== reply.id) return;
|
|
14
|
+
slot.job = void 0;
|
|
15
|
+
if (reply.ok) job.resolve(reply.result);
|
|
16
|
+
else job.reject(toError(reply.error));
|
|
17
|
+
pump();
|
|
18
|
+
},
|
|
19
|
+
exit: (reason) => {
|
|
20
|
+
if (slot.thread !== thread) return;
|
|
21
|
+
slot.thread = void 0;
|
|
22
|
+
const job = slot.job;
|
|
23
|
+
slot.job = void 0;
|
|
24
|
+
job?.reject(
|
|
25
|
+
new ShardError(
|
|
26
|
+
"platform/worker-crashed",
|
|
27
|
+
`A worker died running "${job.fn}": ${reason}`,
|
|
28
|
+
{
|
|
29
|
+
hint: "The pool started a replacement; the job can be retried.",
|
|
30
|
+
path: job.module
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
if (!disposed) pump();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const thread = options.spawn(events);
|
|
38
|
+
slot.thread = thread;
|
|
39
|
+
return thread;
|
|
40
|
+
};
|
|
41
|
+
const pump = () => {
|
|
42
|
+
for (const slot of slots) {
|
|
43
|
+
if (slot.job) continue;
|
|
44
|
+
const job = high.shift() ?? normal.shift();
|
|
45
|
+
if (!job) {
|
|
46
|
+
slot.thread?.idle?.(true);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const thread = slot.thread ?? start(slot);
|
|
50
|
+
slot.job = job;
|
|
51
|
+
thread.idle?.(false);
|
|
52
|
+
const { id, module, fn, args } = job;
|
|
53
|
+
try {
|
|
54
|
+
thread.post({ id, module, fn, args }, job.transfer);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
slot.job = void 0;
|
|
57
|
+
job.reject(err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
for (let i = 0; i < options.size; i++) slots.push({ thread: void 0, job: void 0 });
|
|
62
|
+
return {
|
|
63
|
+
size: options.size,
|
|
64
|
+
run: (module, fn, args, opts = {}) => new Promise((resolve, reject) => {
|
|
65
|
+
if (disposed) {
|
|
66
|
+
reject(new ShardError("platform/workers-disposed", "The worker pool was disposed"));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const job = {
|
|
70
|
+
id: nextId++,
|
|
71
|
+
module,
|
|
72
|
+
fn,
|
|
73
|
+
args,
|
|
74
|
+
transfer: opts.transfer ?? [],
|
|
75
|
+
resolve,
|
|
76
|
+
reject
|
|
77
|
+
};
|
|
78
|
+
(opts.priority === "high" ? high : normal).push(job);
|
|
79
|
+
pump();
|
|
80
|
+
}),
|
|
81
|
+
dispose: () => {
|
|
82
|
+
disposed = true;
|
|
83
|
+
const stopped = new ShardError("platform/workers-disposed", "The worker pool was disposed");
|
|
84
|
+
for (const job of [...high.splice(0), ...normal.splice(0)]) job.reject(stopped);
|
|
85
|
+
for (const slot of slots) {
|
|
86
|
+
slot.job?.reject(stopped);
|
|
87
|
+
slot.job = void 0;
|
|
88
|
+
slot.thread?.terminate();
|
|
89
|
+
slot.thread = void 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function toError(e) {
|
|
95
|
+
return new ShardError(e.code ?? "platform/worker-job-failed", e.message, {
|
|
96
|
+
hint: e.hint,
|
|
97
|
+
path: e.path
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function createInlineWorkers() {
|
|
101
|
+
const modules = /* @__PURE__ */ new Map();
|
|
102
|
+
let disposed = false;
|
|
103
|
+
return {
|
|
104
|
+
size: 0,
|
|
105
|
+
run: async (module, fn, args) => {
|
|
106
|
+
if (disposed)
|
|
107
|
+
throw new ShardError("platform/workers-disposed", "The worker pool was disposed");
|
|
108
|
+
let mod = modules.get(module);
|
|
109
|
+
if (!mod) {
|
|
110
|
+
mod = import(
|
|
111
|
+
/* @vite-ignore */
|
|
112
|
+
module
|
|
113
|
+
);
|
|
114
|
+
modules.set(module, mod);
|
|
115
|
+
}
|
|
116
|
+
const f = (await mod)[fn];
|
|
117
|
+
if (typeof f !== "function") {
|
|
118
|
+
throw new ShardError("platform/worker-no-export", `"${module}" has no export "${fn}"`, {
|
|
119
|
+
hint: "Worker functions are named exports of the module passed to run()."
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return await f(...args);
|
|
123
|
+
},
|
|
124
|
+
dispose: () => {
|
|
125
|
+
disposed = true;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
let inline;
|
|
130
|
+
function workersOf(platform) {
|
|
131
|
+
if (platform?.workers) return platform.workers;
|
|
132
|
+
inline ??= createInlineWorkers();
|
|
133
|
+
return inline;
|
|
134
|
+
}
|
|
135
|
+
function defaultWorkerCount(cores) {
|
|
136
|
+
return Math.max(1, Math.min(8, Math.floor(cores) - 1));
|
|
137
|
+
}
|
|
138
|
+
function workerHostSource(host) {
|
|
139
|
+
const body = `
|
|
140
|
+
const modules = new Map()
|
|
141
|
+
const load = (url) => {
|
|
142
|
+
let m = modules.get(url)
|
|
143
|
+
if (!m) { m = import(url); modules.set(url, m) }
|
|
144
|
+
return m
|
|
145
|
+
}
|
|
146
|
+
const transfers = (value) => {
|
|
147
|
+
const out = []
|
|
148
|
+
const add = (x) => {
|
|
149
|
+
if (ArrayBuffer.isView(x) && x.buffer instanceof ArrayBuffer && !out.includes(x.buffer)) out.push(x.buffer)
|
|
150
|
+
}
|
|
151
|
+
add(value)
|
|
152
|
+
if (value && typeof value === 'object' && !ArrayBuffer.isView(value)) for (const k of Object.keys(value)) add(value[k])
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
const handle = async (msg, post) => {
|
|
156
|
+
const { id, module, fn, args } = msg
|
|
157
|
+
try {
|
|
158
|
+
const mod = await load(module)
|
|
159
|
+
const f = mod[fn]
|
|
160
|
+
if (typeof f !== 'function') {
|
|
161
|
+
throw Object.assign(new Error('"' + module + '" has no export "' + fn + '"'), { code: 'platform/worker-no-export' })
|
|
162
|
+
}
|
|
163
|
+
const result = await f(...args)
|
|
164
|
+
post({ id, ok: true, result }, transfers(result))
|
|
165
|
+
} catch (e) {
|
|
166
|
+
post({ id, ok: false, error: { code: e && e.code, message: String((e && e.message) || e), hint: e && e.hint, path: e && e.path } }, [])
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
return host === "node" ? `${body}
|
|
171
|
+
const { parentPort } = require('node:worker_threads')
|
|
172
|
+
parentPort.on('message', (m) => handle(m, (r, t) => parentPort.postMessage(r, t)))
|
|
173
|
+
` : `${body}
|
|
174
|
+
self.onmessage = (e) => handle(e.data, (r, t) => self.postMessage(r, t))
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
createInlineWorkers,
|
|
179
|
+
createWorkerPool,
|
|
180
|
+
defaultWorkerCount,
|
|
181
|
+
workerHostSource,
|
|
182
|
+
workersOf
|
|
183
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aethervtt/shard-platform",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@aethervtt/shard-core": "0.0.1"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/aethervtt/shard.git",
|
|
24
|
+
"directory": "packages/platform"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"shard": {
|
|
31
|
+
"buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
|
|
32
|
+
}
|
|
33
|
+
}
|