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/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 WorkerEventPipeOptions = {
17
- use_canary_reporting?: boolean;
30
+ type WorkerMessage = {
31
+ id: string;
32
+ peer: string;
33
+ data?: WorkerMessageData;
34
+ uuid: string;
35
+ response_to?: string;
18
36
  };
19
37
 
20
- export interface WorkerEventPipe {
21
- send: (id: string, data?: object) => void;
22
- on: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => void;
23
- once: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => void;
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
- function worker_validate_message(message: any) {
28
- if (typeof message !== 'object' || message === null)
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
- const log_worker = log_create_logger('worker', 'spooder');
36
- export function worker_event_pipe(worker: Worker, options?: WorkerEventPipeOptions): WorkerEventPipe {
37
- const use_canary_reporting = options?.use_canary_reporting ?? false;
38
- const callbacks = new Map<string, (data: Record<string, any>) => Promise<void> | void>();
39
-
40
- function handle_message(event: MessageEvent) {
41
- try {
42
- const message = JSON.parse(event.data);
43
- worker_validate_message(message);
44
-
45
- const callback = callbacks.get(message.id);
46
- if (callback !== undefined)
47
- callback(message.data ?? {});
48
- } catch (e) {
49
- log_error(`exception in worker: ${(e as Error).message}`);
50
-
51
- if (use_canary_reporting)
52
- caution('worker: exception handling payload', { exception: e });
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
- if (Bun.isMainThread) {
57
- log_worker(`event pipe connected {main thread} ⇄ {worker}`);
58
- worker.addEventListener('message', handle_message);
59
- } else {
60
- log_worker(`event pipe connected {worker} ⇄ {main thread}`);
61
- worker.onmessage = handle_message;
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
- return {
65
- send: (id: string, data: object = {}) => {
66
- worker.postMessage(JSON.stringify({ id, data }));
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
- on: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => {
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: WorkerMessageData) => Promise<void> | void) => {
78
- callbacks.set(event, async (data: WorkerMessageData) => {
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
- store_cache_entry(file_path, entry, now_ts);
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
- store_cache_entry(cache_key, entry, now_ts);
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);