spooder 5.1.11 → 6.0.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 +447 -59
- package/bun.lock +6 -6
- package/package.json +1 -1
- package/src/api.ts +535 -53
- package/src/cli.ts +201 -52
- package/src/config.ts +10 -5
- package/src/dispatch.ts +3 -0
package/src/api.ts
CHANGED
|
@@ -7,93 +7,506 @@ import { Blob } from 'node:buffer';
|
|
|
7
7
|
import { ColorInput } from 'bun';
|
|
8
8
|
import packageJson from '../package.json' with { type: 'json' };
|
|
9
9
|
|
|
10
|
+
// region exit codes
|
|
11
|
+
export const EXIT_CODE = {
|
|
12
|
+
SUCCESS: 0,
|
|
13
|
+
GENERAL_ERROR: 1,
|
|
14
|
+
|
|
15
|
+
// 3-125 are free for application errors
|
|
16
|
+
SPOODER_AUTO_UPDATE: 42
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const EXIT_CODE_NAMES = Object.fromEntries(
|
|
20
|
+
Object.entries(EXIT_CODE).map(([key, value]) => [value, key])
|
|
21
|
+
);
|
|
22
|
+
// endregion
|
|
23
|
+
|
|
10
24
|
// region api forwarding
|
|
11
25
|
export * from './api_db';
|
|
12
26
|
// endregion
|
|
13
27
|
|
|
14
28
|
// region workers
|
|
15
29
|
type WorkerMessageData = Record<string, any>;
|
|
16
|
-
type
|
|
17
|
-
|
|
30
|
+
type WorkerMessage = {
|
|
31
|
+
id: string;
|
|
32
|
+
peer: string;
|
|
33
|
+
data?: WorkerMessageData;
|
|
34
|
+
uuid: string;
|
|
35
|
+
response_to?: string;
|
|
18
36
|
};
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
const RESPONSE_TIMEOUT_MS = 5000;
|
|
39
|
+
|
|
40
|
+
export interface WorkerPool {
|
|
41
|
+
id: string;
|
|
42
|
+
send(peer: string, id: string, data?: WorkerMessageData, expect_response?: false): void;
|
|
43
|
+
send(peer: string, id: string, data: WorkerMessageData | undefined, expect_response: true): Promise<WorkerMessage>;
|
|
44
|
+
broadcast: (id: string, data?: WorkerMessageData) => void;
|
|
45
|
+
respond: (message: WorkerMessage, data?: WorkerMessageData) => void;
|
|
46
|
+
on: (event: string, callback: (message: WorkerMessage) => Promise<void> | void) => void;
|
|
47
|
+
once: (event: string, callback: (message: WorkerMessage) => Promise<void> | void) => void;
|
|
24
48
|
off: (event: string) => void;
|
|
25
49
|
}
|
|
26
50
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
throw new ErrorWithMetadata('invalid worker message type', { message });
|
|
30
|
-
|
|
31
|
-
if (typeof message.id !== 'string')
|
|
32
|
-
throw new Error('missing worker message .id');
|
|
33
|
-
}
|
|
51
|
+
export const WORKER_EXIT_NO_RESTART = 42;
|
|
52
|
+
const log_worker = log_create_logger('worker_pool', 'spooder');
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
type AutoRestartConfig = {
|
|
55
|
+
backoff_max?: number;
|
|
56
|
+
backoff_grace?: number;
|
|
57
|
+
max_attempts?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type WorkerPoolOptions = {
|
|
61
|
+
id?: string;
|
|
62
|
+
worker: string | string[];
|
|
63
|
+
size?: number;
|
|
64
|
+
auto_restart?: boolean | AutoRestartConfig;
|
|
65
|
+
response_timeout?: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type WorkerState = {
|
|
69
|
+
worker: Worker;
|
|
70
|
+
worker_id?: string;
|
|
71
|
+
restart_delay: number;
|
|
72
|
+
restart_attempts: number;
|
|
73
|
+
restart_success_timer: Timer | null;
|
|
74
|
+
worker_path: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export async function worker_pool(options: WorkerPoolOptions): Promise<WorkerPool> {
|
|
78
|
+
const pipe_workers = new BiMap<string, Worker>();
|
|
79
|
+
const worker_promises = new WeakMap<Worker, (value: void | PromiseLike<void>) => void>();
|
|
80
|
+
|
|
81
|
+
const peer_id = options.id ?? 'main';
|
|
82
|
+
const response_timeout = options.response_timeout ?? RESPONSE_TIMEOUT_MS;
|
|
83
|
+
|
|
84
|
+
const auto_restart_enabled = options.auto_restart !== undefined && options.auto_restart !== false;
|
|
85
|
+
const auto_restart_config = typeof options.auto_restart === 'object' ? options.auto_restart : {};
|
|
86
|
+
const backoff_max = auto_restart_config.backoff_max ?? 5 * 60 * 1000; // 5 min
|
|
87
|
+
const backoff_grace = auto_restart_config.backoff_grace ?? 30000; // 30 seconds
|
|
88
|
+
const max_attempts = auto_restart_config.max_attempts ?? 5;
|
|
89
|
+
|
|
90
|
+
const worker_states = new WeakMap<Worker, WorkerState>();
|
|
91
|
+
|
|
92
|
+
const worker_paths: string[] = options.size !== undefined
|
|
93
|
+
? Array(options.size).fill(options.worker)
|
|
94
|
+
: Array.isArray(options.worker) ? options.worker : [options.worker];
|
|
95
|
+
|
|
96
|
+
log_worker(`created worker pool {${peer_id}}`);
|
|
97
|
+
|
|
98
|
+
const callbacks = new Map<string, (data: WorkerMessage) => Promise<void> | void>();
|
|
99
|
+
const pending_responses = new Map<string, { resolve: (message: WorkerMessage) => void, reject: (error: Error) => void, timeout: Timer | undefined }>();
|
|
100
|
+
|
|
101
|
+
async function restart_worker(worker: Worker) {
|
|
102
|
+
if (!auto_restart_enabled)
|
|
103
|
+
return;
|
|
104
|
+
|
|
105
|
+
const state = worker_states.get(worker);
|
|
106
|
+
if (!state)
|
|
107
|
+
return;
|
|
108
|
+
|
|
109
|
+
if (state.restart_success_timer) {
|
|
110
|
+
clearTimeout(state.restart_success_timer);
|
|
111
|
+
state.restart_success_timer = null;
|
|
53
112
|
}
|
|
113
|
+
|
|
114
|
+
if (max_attempts !== -1 && state.restart_attempts >= max_attempts) {
|
|
115
|
+
log_worker(`worker {${state.worker_id ?? 'unknown'}} maximum restart attempts ({${max_attempts}}) reached, stopping auto-restart`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
state.restart_attempts++;
|
|
120
|
+
const current_delay = Math.min(state.restart_delay, backoff_max);
|
|
121
|
+
const max_attempt_str = max_attempts === -1 ? '∞' : max_attempts;
|
|
122
|
+
|
|
123
|
+
log_worker(`restarting worker {${state.worker_id ?? 'unknown'}} in {${current_delay}ms} (attempt {${state.restart_attempts}}/{${max_attempt_str}}, delay capped at {${backoff_max}ms})`);
|
|
124
|
+
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
const new_worker = new Worker(state.worker_path);
|
|
127
|
+
|
|
128
|
+
state.worker = new_worker;
|
|
129
|
+
state.restart_delay = Math.min(state.restart_delay * 2, backoff_max);
|
|
130
|
+
|
|
131
|
+
state.restart_success_timer = setTimeout(() => {
|
|
132
|
+
state.restart_delay = 100;
|
|
133
|
+
state.restart_attempts = 0;
|
|
134
|
+
state.restart_success_timer = null;
|
|
135
|
+
}, backoff_grace);
|
|
136
|
+
|
|
137
|
+
worker_states.delete(worker);
|
|
138
|
+
worker_states.set(new_worker, state);
|
|
139
|
+
|
|
140
|
+
setup_worker_listeners(new_worker);
|
|
141
|
+
}, current_delay);
|
|
54
142
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
worker.
|
|
143
|
+
|
|
144
|
+
function setup_worker_listeners(worker: Worker) {
|
|
145
|
+
worker.addEventListener('message', (event) => {
|
|
146
|
+
handle_worker_message(worker, event);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
worker.addEventListener('close', (event: CloseEvent) => {
|
|
150
|
+
const worker_id = pipe_workers.getByValue(worker);
|
|
151
|
+
const exit_code = event.code;
|
|
152
|
+
log_worker(`worker {${worker_id ?? 'unknown'}} closed, exit code {${exit_code}}`);
|
|
153
|
+
|
|
154
|
+
if (worker_id)
|
|
155
|
+
pipe_workers.deleteByKey(worker_id);
|
|
156
|
+
|
|
157
|
+
if (auto_restart_enabled && exit_code !== WORKER_EXIT_NO_RESTART)
|
|
158
|
+
restart_worker(worker);
|
|
159
|
+
else if (exit_code === WORKER_EXIT_NO_RESTART)
|
|
160
|
+
log_worker(`worker {${worker_id ?? 'unknown'}} exited with {WORKER_EXIT_NO_RESTART}, skipping auto-restart`);
|
|
161
|
+
});
|
|
62
162
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
163
|
+
|
|
164
|
+
function handle_worker_message(worker: Worker, event: MessageEvent) {
|
|
165
|
+
const message = event.data as WorkerMessage;
|
|
166
|
+
|
|
167
|
+
if (message.id === '__register__') {
|
|
168
|
+
const worker_id = message.data?.worker_id;
|
|
169
|
+
if (worker_id === undefined) {
|
|
170
|
+
log_error('cannot register worker without ID');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (pipe_workers.hasKey(worker_id)) {
|
|
175
|
+
log_error(`worker ID {${worker_id}} already in-use`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pipe_workers.set(message.data?.worker_id, worker);
|
|
180
|
+
|
|
181
|
+
const state = worker_states.get(worker);
|
|
182
|
+
if (state)
|
|
183
|
+
state.worker_id = worker_id;
|
|
184
|
+
|
|
185
|
+
worker_promises.get(worker)?.();
|
|
186
|
+
worker_promises.delete(worker);
|
|
187
|
+
} else if (message.peer === '__broadcast__') {
|
|
188
|
+
const worker_id = pipe_workers.getByValue(worker);
|
|
189
|
+
if (worker_id === undefined)
|
|
190
|
+
return;
|
|
191
|
+
|
|
192
|
+
message.peer = worker_id;
|
|
193
|
+
callbacks.get(message.id)?.(message);
|
|
194
|
+
|
|
195
|
+
for (const target_worker of pipe_workers.values()) {
|
|
196
|
+
if (target_worker === worker)
|
|
197
|
+
continue;
|
|
198
|
+
|
|
199
|
+
target_worker.postMessage(message);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const target_peer = message.peer;
|
|
203
|
+
const target_worker = pipe_workers.getByKey(target_peer);
|
|
204
|
+
const worker_id = pipe_workers.getByValue(worker);
|
|
205
|
+
|
|
206
|
+
if (worker_id === undefined)
|
|
207
|
+
return;
|
|
208
|
+
|
|
209
|
+
message.peer = worker_id;
|
|
210
|
+
|
|
211
|
+
if (target_peer === peer_id) {
|
|
212
|
+
if (message.response_to && pending_responses.has(message.response_to)) {
|
|
213
|
+
const pending = pending_responses.get(message.response_to)!;
|
|
214
|
+
if (pending.timeout)
|
|
215
|
+
clearTimeout(pending.timeout);
|
|
216
|
+
|
|
217
|
+
pending_responses.delete(message.response_to);
|
|
218
|
+
pending.resolve(message);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
callbacks.get(message.id)?.(message);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
target_worker?.postMessage(message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const pool: WorkerPool = {
|
|
230
|
+
id: peer_id,
|
|
231
|
+
|
|
232
|
+
send(peer: string, id: string, data?: WorkerMessageData, expect_response?: boolean): any {
|
|
233
|
+
const message: WorkerMessage = { id, peer: peer_id, data, uuid: Bun.randomUUIDv7() };
|
|
234
|
+
|
|
235
|
+
if (expect_response) {
|
|
236
|
+
return new Promise<WorkerMessage>((resolve, reject) => {
|
|
237
|
+
let timeout: Timer | undefined;
|
|
238
|
+
|
|
239
|
+
if (response_timeout !== -1) {
|
|
240
|
+
timeout = setTimeout(() => {
|
|
241
|
+
pending_responses.delete(message.uuid);
|
|
242
|
+
reject(new Error(`Response timeout after ${response_timeout}ms`));
|
|
243
|
+
}, response_timeout);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
pending_responses.set(message.uuid, { resolve, reject, timeout });
|
|
247
|
+
|
|
248
|
+
const target_worker = pipe_workers.getByKey(peer);
|
|
249
|
+
target_worker?.postMessage(message);
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
const target_worker = pipe_workers.getByKey(peer);
|
|
253
|
+
target_worker?.postMessage(message);
|
|
254
|
+
}
|
|
67
255
|
},
|
|
68
|
-
|
|
69
|
-
|
|
256
|
+
|
|
257
|
+
broadcast: (id: string, data?: WorkerMessageData) => {
|
|
258
|
+
const message: WorkerMessage = { peer: peer_id, id, data, uuid: Bun.randomUUIDv7() };
|
|
259
|
+
|
|
260
|
+
for (const target_worker of pipe_workers.values())
|
|
261
|
+
target_worker.postMessage(message);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
respond: (message: WorkerMessage, data?: WorkerMessageData) => {
|
|
265
|
+
const response: WorkerMessage = {
|
|
266
|
+
id: message.id,
|
|
267
|
+
peer: peer_id,
|
|
268
|
+
data,
|
|
269
|
+
uuid: Bun.randomUUIDv7(),
|
|
270
|
+
response_to: message.uuid
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const target_worker = pipe_workers.getByKey(message.peer);
|
|
274
|
+
target_worker?.postMessage(response);
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
on: (event: string, callback: (data: WorkerMessage) => Promise<void> | void) => {
|
|
70
278
|
callbacks.set(event, callback);
|
|
71
279
|
},
|
|
72
|
-
|
|
280
|
+
|
|
73
281
|
off: (event: string) => {
|
|
74
282
|
callbacks.delete(event);
|
|
75
283
|
},
|
|
76
|
-
|
|
77
|
-
once: (event: string, callback: (data:
|
|
78
|
-
callbacks.set(event, async (data:
|
|
284
|
+
|
|
285
|
+
once: (event: string, callback: (data: WorkerMessage) => Promise<void> | void) => {
|
|
286
|
+
callbacks.set(event, async (data: WorkerMessage) => {
|
|
79
287
|
await callback(data);
|
|
80
288
|
callbacks.delete(event);
|
|
81
289
|
});
|
|
82
290
|
}
|
|
83
291
|
};
|
|
292
|
+
|
|
293
|
+
const promises = [];
|
|
294
|
+
for (const path of worker_paths) {
|
|
295
|
+
const worker = new Worker(path);
|
|
296
|
+
|
|
297
|
+
if (auto_restart_enabled) {
|
|
298
|
+
worker_states.set(worker, {
|
|
299
|
+
worker,
|
|
300
|
+
restart_delay: 100,
|
|
301
|
+
restart_attempts: 0,
|
|
302
|
+
restart_success_timer: null,
|
|
303
|
+
worker_path: path
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
setup_worker_listeners(worker);
|
|
308
|
+
|
|
309
|
+
promises.push(new Promise<void>(resolve => {
|
|
310
|
+
worker_promises.set(worker, resolve);
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await Promise.all(promises);
|
|
315
|
+
|
|
316
|
+
return pool;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function worker_connect(peer_id?: string, response_timeout?: number): WorkerPool {
|
|
320
|
+
const listeners = new Map<string, (message: WorkerMessage) => Promise<void> | void>();
|
|
321
|
+
const pending_responses = new Map<string, { resolve: (message: WorkerMessage) => void, reject: (error: Error) => void, timeout: Timer | undefined }>();
|
|
322
|
+
|
|
323
|
+
if (peer_id === undefined) {
|
|
324
|
+
// normally we would increment 'worker1', 'worker2' etc but we
|
|
325
|
+
// have no simple way of keeping track of global worker count.
|
|
326
|
+
|
|
327
|
+
// in normal circumstances, users should provide an ID via the
|
|
328
|
+
// parameter, this is just a sensible fallback.
|
|
329
|
+
peer_id = 'worker-' + Bun.randomUUIDv7();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
response_timeout = response_timeout ?? RESPONSE_TIMEOUT_MS;
|
|
333
|
+
|
|
334
|
+
log_worker(`worker {${peer_id}} connected to pool`);
|
|
335
|
+
|
|
336
|
+
const worker = globalThis as unknown as Worker;
|
|
337
|
+
worker.onmessage = event => {
|
|
338
|
+
const message = event.data as WorkerMessage;
|
|
339
|
+
|
|
340
|
+
if (message.response_to && pending_responses.has(message.response_to)) {
|
|
341
|
+
const pending = pending_responses.get(message.response_to)!;
|
|
342
|
+
if (pending.timeout)
|
|
343
|
+
clearTimeout(pending.timeout);
|
|
344
|
+
|
|
345
|
+
pending_responses.delete(message.response_to);
|
|
346
|
+
pending.resolve(message);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
listeners.get(message.id)?.(message);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
worker.postMessage({
|
|
354
|
+
id: '__register__',
|
|
355
|
+
data: {
|
|
356
|
+
worker_id: peer_id
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
id: peer_id,
|
|
362
|
+
|
|
363
|
+
send(peer: string, id: string, data?: WorkerMessageData, expect_response?: boolean): any {
|
|
364
|
+
const message: WorkerMessage = { id, peer, data, uuid: Bun.randomUUIDv7() };
|
|
365
|
+
|
|
366
|
+
if (expect_response) {
|
|
367
|
+
return new Promise<WorkerMessage>((resolve, reject) => {
|
|
368
|
+
let timeout: Timer | undefined;
|
|
369
|
+
|
|
370
|
+
if (response_timeout !== -1) {
|
|
371
|
+
timeout = setTimeout(() => {
|
|
372
|
+
pending_responses.delete(message.uuid);
|
|
373
|
+
reject(new Error(`Response timeout after ${response_timeout}ms`));
|
|
374
|
+
}, response_timeout);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
pending_responses.set(message.uuid, { resolve, reject, timeout });
|
|
378
|
+
worker.postMessage(message);
|
|
379
|
+
});
|
|
380
|
+
} else {
|
|
381
|
+
worker.postMessage(message);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
broadcast: (id: string, data?: WorkerMessageData) => {
|
|
386
|
+
const message: WorkerMessage = { peer: '__broadcast__', id, data, uuid: Bun.randomUUIDv7() };
|
|
387
|
+
worker.postMessage(message);
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
respond: (message: WorkerMessage, data?: WorkerMessageData) => {
|
|
391
|
+
const response: WorkerMessage = {
|
|
392
|
+
id: message.id,
|
|
393
|
+
peer: message.peer,
|
|
394
|
+
data,
|
|
395
|
+
uuid: Bun.randomUUIDv7(),
|
|
396
|
+
response_to: message.uuid
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
worker.postMessage(response);
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
on: (event: string, callback: (message: WorkerMessage) => Promise<void> | void) => {
|
|
403
|
+
listeners.set(event, callback);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
off: (event: string) => {
|
|
407
|
+
listeners.delete(event);
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
once: (event: string, callback: (message: WorkerMessage) => Promise<void> | void) => {
|
|
411
|
+
listeners.set(event, async (message: WorkerMessage) => {
|
|
412
|
+
await callback(message);
|
|
413
|
+
listeners.delete(event);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
};
|
|
84
417
|
}
|
|
418
|
+
|
|
85
419
|
// endregion
|
|
86
420
|
|
|
87
421
|
// region utility
|
|
422
|
+
export class BiMap<K, V> {
|
|
423
|
+
private ktv = new Map<K, V>();
|
|
424
|
+
private vtk = new Map<V, K>();
|
|
425
|
+
|
|
426
|
+
set(key: K, value: V): void {
|
|
427
|
+
const old_val = this.ktv.get(key);
|
|
428
|
+
if (old_val !== undefined)
|
|
429
|
+
this.vtk.delete(old_val);
|
|
430
|
+
|
|
431
|
+
const old_key = this.vtk.get(value);
|
|
432
|
+
if (old_key !== undefined)
|
|
433
|
+
this.ktv.delete(old_key);
|
|
434
|
+
|
|
435
|
+
this.ktv.set(key, value);
|
|
436
|
+
this.vtk.set(value, key);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
getByKey(key: K): V | undefined {
|
|
440
|
+
return this.ktv.get(key);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
getByValue(value: V): K | undefined {
|
|
444
|
+
return this.vtk.get(value);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
hasKey(key: K): boolean {
|
|
448
|
+
return this.ktv.has(key);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
hasValue(value: V): boolean {
|
|
452
|
+
return this.vtk.has(value);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
deleteByKey(key: K): boolean {
|
|
456
|
+
const value = this.ktv.get(key);
|
|
457
|
+
if (value === undefined)
|
|
458
|
+
return false;
|
|
459
|
+
|
|
460
|
+
this.ktv.delete(key);
|
|
461
|
+
this.vtk.delete(value);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
deleteByValue(value: V): boolean {
|
|
466
|
+
const key = this.vtk.get(value);
|
|
467
|
+
if (key === undefined)
|
|
468
|
+
return false;
|
|
469
|
+
|
|
470
|
+
this.vtk.delete(value);
|
|
471
|
+
this.ktv.delete(key);
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
clear(): void {
|
|
476
|
+
this.ktv.clear();
|
|
477
|
+
this.vtk.clear();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
get size(): number {
|
|
481
|
+
return this.ktv.size;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
entries(): IterableIterator<[K, V]> {
|
|
485
|
+
return this.ktv.entries();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
keys(): IterableIterator<K> {
|
|
489
|
+
return this.ktv.keys();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
values(): IterableIterator<V> {
|
|
493
|
+
return this.ktv.values();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
[Symbol.iterator](): IterableIterator<[K, V]> {
|
|
497
|
+
return this.ktv.entries();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
88
501
|
const FILESIZE_UNITS = ['bytes', 'kb', 'mb', 'gb', 'tb'];
|
|
89
502
|
|
|
90
|
-
function filesize(bytes: number): string {
|
|
503
|
+
export function filesize(bytes: number): string {
|
|
91
504
|
if (bytes === 0)
|
|
92
505
|
return '0 bytes';
|
|
93
|
-
|
|
506
|
+
|
|
94
507
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
95
508
|
const size = bytes / Math.pow(1024, i);
|
|
96
|
-
|
|
509
|
+
|
|
97
510
|
return `${size.toFixed(i === 0 ? 0 : 1)} ${FILESIZE_UNITS[i]}`;
|
|
98
511
|
}
|
|
99
512
|
// endregion
|
|
@@ -121,6 +534,71 @@ export const log_error = log_create_logger('error', 'red');
|
|
|
121
534
|
|
|
122
535
|
// endregion
|
|
123
536
|
|
|
537
|
+
// region spooder ipc
|
|
538
|
+
export const IPC_OP = {
|
|
539
|
+
CMSG_TRIGGER_UPDATE: -1,
|
|
540
|
+
SMSG_UPDATE_READY: -2,
|
|
541
|
+
CMSG_REGISTER_LISTENER: -3,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// internal targets should always use __X__ as this format is
|
|
545
|
+
// reserved; userland instances cannot be named this way
|
|
546
|
+
export const IPC_TARGET = {
|
|
547
|
+
SPOODER: '__spooder__',
|
|
548
|
+
BROADCAST: '__broadcast__'
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
type IPC_Callback = (data: IPC_Message) => void;
|
|
552
|
+
type IPC_Message = {
|
|
553
|
+
op: number;
|
|
554
|
+
peer: string;
|
|
555
|
+
data?: object
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
let ipc_fail_announced = false;
|
|
559
|
+
let ipc_listener_attached = false;
|
|
560
|
+
|
|
561
|
+
const ipc_listeners = new Map<number, Set<IPC_Callback>>();
|
|
562
|
+
|
|
563
|
+
function ipc_on_message(payload: IPC_Message) {
|
|
564
|
+
const listeners = ipc_listeners.get(payload.op);
|
|
565
|
+
if (!listeners)
|
|
566
|
+
return;
|
|
567
|
+
|
|
568
|
+
for (const callback of listeners)
|
|
569
|
+
callback(payload);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function ipc_send(peer: string, op: number, data?: object) {
|
|
573
|
+
if (!process.send) {
|
|
574
|
+
if (!ipc_fail_announced) {
|
|
575
|
+
log_spooder(`{ipc_send} failed, process not spawned with ipc channel`);
|
|
576
|
+
caution('ipc_send failed', { e: 'process not spawned with ipc channel' });
|
|
577
|
+
ipc_fail_announced = true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
process.send({ peer, op, data });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function ipc_register(op: number, callback: IPC_Callback) {
|
|
587
|
+
if (!ipc_listener_attached) {
|
|
588
|
+
process.on('message', ipc_on_message);
|
|
589
|
+
ipc_listener_attached = true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const listeners = ipc_listeners.get(op);
|
|
593
|
+
if (listeners)
|
|
594
|
+
listeners.add(callback);
|
|
595
|
+
else
|
|
596
|
+
ipc_listeners.set(op, new Set([callback]));
|
|
597
|
+
|
|
598
|
+
ipc_send(IPC_TARGET.SPOODER, IPC_OP.CMSG_REGISTER_LISTENER, { op });
|
|
599
|
+
}
|
|
600
|
+
// endregion
|
|
601
|
+
|
|
124
602
|
// region cache
|
|
125
603
|
type CacheOptions = {
|
|
126
604
|
ttl?: number;
|
|
@@ -128,6 +606,7 @@ type CacheOptions = {
|
|
|
128
606
|
use_etags?: boolean;
|
|
129
607
|
headers?: Record<string, string>,
|
|
130
608
|
use_canary_reporting?: boolean;
|
|
609
|
+
enabled?: boolean;
|
|
131
610
|
};
|
|
132
611
|
|
|
133
612
|
type CacheEntry = {
|
|
@@ -154,6 +633,7 @@ export function cache_http(options?: CacheOptions) {
|
|
|
154
633
|
const use_etags = options?.use_etags ?? true;
|
|
155
634
|
const cache_headers = options?.headers ?? {};
|
|
156
635
|
const canary_report = options?.use_canary_reporting ?? false;
|
|
636
|
+
const enabled = options?.enabled ?? true;
|
|
157
637
|
|
|
158
638
|
const entries = new Map<string, CacheEntry>();
|
|
159
639
|
let total_cache_size = 0;
|
|
@@ -267,7 +747,8 @@ export function cache_http(options?: CacheOptions) {
|
|
|
267
747
|
cached_ts: now_ts
|
|
268
748
|
};
|
|
269
749
|
|
|
270
|
-
|
|
750
|
+
if (enabled)
|
|
751
|
+
store_cache_entry(file_path, entry, now_ts);
|
|
271
752
|
}
|
|
272
753
|
|
|
273
754
|
return build_response(entry, req, 200);
|
|
@@ -290,7 +771,8 @@ export function cache_http(options?: CacheOptions) {
|
|
|
290
771
|
cached_ts: now_ts
|
|
291
772
|
};
|
|
292
773
|
|
|
293
|
-
|
|
774
|
+
if (enabled)
|
|
775
|
+
store_cache_entry(cache_key, entry, now_ts);
|
|
294
776
|
}
|
|
295
777
|
|
|
296
778
|
return build_response(entry, req, status_code);
|