@b9g/platform 0.1.12 → 0.1.14-beta.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/index.js CHANGED
@@ -1,21 +1,13 @@
1
1
  /// <reference types="./index.d.ts" />
2
- import {
3
- __require
4
- } from "../chunk-P57PW2II.js";
5
-
6
2
  // src/index.ts
7
- import * as Path from "path";
8
- import { existsSync } from "fs";
9
- import { fileURLToPath } from "url";
10
- import { CustomCacheStorage } from "@b9g/cache";
11
- import { MemoryCache } from "@b9g/cache/memory";
12
3
  import { getLogger } from "@logtape/logtape";
4
+ import { CustomLoggerStorage } from "./runtime.js";
5
+ import { validateConfig, ConfigValidationError } from "./config.js";
13
6
  import {
14
- ServiceWorkerGlobals,
15
- ShovelServiceWorkerRegistration,
16
- CustomLoggerStorage
7
+ CustomDatabaseStorage,
8
+ createDatabaseFactory
17
9
  } from "./runtime.js";
18
- var logger = getLogger(["platform"]);
10
+ var logger = getLogger(["shovel", "platform"]);
19
11
  function detectRuntime() {
20
12
  if (typeof Bun !== "undefined" || process.versions?.bun) {
21
13
  return "bun";
@@ -66,27 +58,20 @@ function resolvePlatform(options) {
66
58
  async function createPlatform(platformName, options = {}) {
67
59
  switch (platformName) {
68
60
  case "node": {
69
- const modulePath = import.meta.resolve("@b9g/platform-node");
70
- const NodePlatform = await import(modulePath).then((m) => m.default);
61
+ const { default: NodePlatform } = await import("@b9g/platform-node");
71
62
  return new NodePlatform(options);
72
63
  }
73
64
  case "bun": {
74
- const modulePath = import.meta.resolve("@b9g/platform-bun");
75
- const BunPlatform = await import(modulePath).then((m) => m.default);
65
+ const { default: BunPlatform } = await import("@b9g/platform-bun");
76
66
  return new BunPlatform(options);
77
67
  }
78
- case "cloudflare":
79
- case "cloudflare-workers":
80
- case "cf": {
81
- const modulePath = import.meta.resolve("@b9g/platform-cloudflare");
82
- const CloudflarePlatform = await import(modulePath).then(
83
- (m) => m.default
84
- );
68
+ case "cloudflare": {
69
+ const { default: CloudflarePlatform } = await import("@b9g/platform-cloudflare");
85
70
  return new CloudflarePlatform(options);
86
71
  }
87
72
  default:
88
73
  throw new Error(
89
- `Unknown platform: ${platformName}. Available platforms: node, bun, cloudflare`
74
+ `Unknown platform: ${platformName}. Valid platforms: node, bun, cloudflare`
90
75
  );
91
76
  }
92
77
  }
@@ -96,15 +81,21 @@ var BasePlatform = class {
96
81
  this.config = config;
97
82
  }
98
83
  /**
99
- * Create cache storage
100
- * Returns empty CacheStorage - applications create caches on-demand via caches.open()
84
+ * Dispose of platform resources
85
+ * Subclasses should override to clean up worker pools, connections, etc.
101
86
  */
102
- async createCaches() {
103
- return new CustomCacheStorage(
104
- (name) => new MemoryCache(name)
105
- );
87
+ async dispose() {
106
88
  }
107
89
  };
90
+ function mergeConfigWithDefaults(defaults, userConfig) {
91
+ const user = userConfig ?? {};
92
+ const allNames = /* @__PURE__ */ new Set([...Object.keys(defaults), ...Object.keys(user)]);
93
+ const merged = {};
94
+ for (const name of allNames) {
95
+ merged[name] = { ...defaults[name], ...user[name] };
96
+ }
97
+ return merged;
98
+ }
108
99
  var DefaultPlatformRegistry = class {
109
100
  #platforms;
110
101
  constructor() {
@@ -161,124 +152,6 @@ async function getPlatformAsync(name) {
161
152
  }
162
153
  return platform;
163
154
  }
164
- var SingleThreadedRuntime = class {
165
- #registration;
166
- #scope;
167
- #ready;
168
- #entrypoint;
169
- constructor(options) {
170
- this.#ready = false;
171
- this.#registration = new ShovelServiceWorkerRegistration();
172
- this.#scope = new ServiceWorkerGlobals({
173
- registration: this.#registration,
174
- caches: options.caches,
175
- directories: options.directories,
176
- loggers: options.loggers
177
- });
178
- logger.info("SingleThreadedRuntime created");
179
- }
180
- /**
181
- * Initialize the runtime (install ServiceWorker globals)
182
- */
183
- async init() {
184
- this.#scope.install();
185
- logger.info("SingleThreadedRuntime initialized - globals installed");
186
- }
187
- /**
188
- * Load (or reload) a ServiceWorker entrypoint
189
- * @param entrypoint - Path to the entrypoint file (content-hashed filename)
190
- */
191
- async load(entrypoint) {
192
- const isReload = this.#entrypoint !== void 0;
193
- if (isReload) {
194
- logger.info("Reloading ServiceWorker", {
195
- oldEntrypoint: this.#entrypoint,
196
- newEntrypoint: entrypoint
197
- });
198
- this.#registration._serviceWorker._setState("parsed");
199
- } else {
200
- logger.info("Loading ServiceWorker entrypoint", { entrypoint });
201
- }
202
- this.#entrypoint = entrypoint;
203
- this.#ready = false;
204
- await import(entrypoint);
205
- await this.#registration.install();
206
- await this.#registration.activate();
207
- this.#ready = true;
208
- logger.info("ServiceWorker loaded and activated", { entrypoint });
209
- }
210
- /**
211
- * Handle an HTTP request
212
- * This is the key method - direct call, no postMessage!
213
- */
214
- async handleRequest(request) {
215
- if (!this.#ready) {
216
- throw new Error(
217
- "SingleThreadedRuntime not ready - ServiceWorker not loaded"
218
- );
219
- }
220
- return this.#registration.handleRequest(request);
221
- }
222
- /**
223
- * Graceful shutdown
224
- */
225
- async terminate() {
226
- this.#ready = false;
227
- logger.info("SingleThreadedRuntime terminated");
228
- }
229
- /**
230
- * Get the number of workers (always 1 for single-threaded)
231
- */
232
- get workerCount() {
233
- return 1;
234
- }
235
- /**
236
- * Check if ready to handle requests
237
- */
238
- get ready() {
239
- return this.#ready;
240
- }
241
- };
242
- function resolveWorkerScript(entrypoint) {
243
- if (entrypoint) {
244
- const entryDir = Path.dirname(entrypoint);
245
- const bundledWorker = Path.join(entryDir, "worker.js");
246
- try {
247
- if (typeof Bun !== "undefined") {
248
- const file = Bun.file(bundledWorker);
249
- if (file.size > 0) {
250
- logger.info("Using bundled worker", { bundledWorker });
251
- return bundledWorker;
252
- }
253
- } else if (typeof __require !== "undefined") {
254
- if (existsSync(bundledWorker)) {
255
- logger.info("Using bundled worker", { bundledWorker });
256
- return bundledWorker;
257
- }
258
- }
259
- } catch (err) {
260
- if (err.code !== "ENOENT") {
261
- throw err;
262
- }
263
- }
264
- }
265
- try {
266
- const workerURL = import.meta.resolve("@b9g/platform/worker.js");
267
- let workerScript;
268
- if (workerURL.startsWith("file://")) {
269
- workerScript = fileURLToPath(workerURL);
270
- } else {
271
- workerScript = workerURL;
272
- }
273
- logger.info("Using worker entry script", { workerScript });
274
- return workerScript;
275
- } catch (error) {
276
- const bundledPath = entrypoint ? Path.join(Path.dirname(entrypoint), "worker.js") : "worker.js";
277
- throw new Error(
278
- `Could not resolve worker.js. Checked bundled path: ${bundledPath} and package: @b9g/platform/worker.js. Error: ${error instanceof Error ? error.message : String(error)}`
279
- );
280
- }
281
- }
282
155
  async function createWebWorker(workerScript) {
283
156
  if (typeof Worker !== "undefined") {
284
157
  return new Worker(workerScript, { type: "module" });
@@ -327,22 +200,21 @@ var ServiceWorkerPool = class {
327
200
  #currentWorker;
328
201
  #requestID;
329
202
  #pendingRequests;
330
- #pendingWorkerInit;
203
+ #pendingWorkerReady;
331
204
  #options;
332
205
  #appEntrypoint;
333
206
  #cacheStorage;
334
- // CustomCacheStorage for cache coordination
335
- #config;
336
- // ShovelConfig from config.ts
337
- constructor(options = {}, appEntrypoint, cacheStorage, config) {
207
+ // Waiters for when workers become available (used during reload)
208
+ #workerAvailableWaiters;
209
+ constructor(options = {}, appEntrypoint, cacheStorage) {
338
210
  this.#workers = [];
339
211
  this.#currentWorker = 0;
340
212
  this.#requestID = 0;
341
213
  this.#pendingRequests = /* @__PURE__ */ new Map();
342
- this.#pendingWorkerInit = /* @__PURE__ */ new Map();
214
+ this.#pendingWorkerReady = /* @__PURE__ */ new Map();
215
+ this.#workerAvailableWaiters = [];
343
216
  this.#appEntrypoint = appEntrypoint;
344
217
  this.#cacheStorage = cacheStorage;
345
- this.#config = config || {};
346
218
  this.#options = {
347
219
  workerCount: 1,
348
220
  requestTimeout: 3e4,
@@ -353,27 +225,35 @@ var ServiceWorkerPool = class {
353
225
  * Initialize workers (must be called after construction)
354
226
  */
355
227
  async init() {
356
- await this.#initWorkers();
357
- }
358
- async #initWorkers() {
228
+ const promises = [];
359
229
  for (let i = 0; i < this.#options.workerCount; i++) {
360
- await this.#createWorker();
230
+ promises.push(this.#createWorker(this.#appEntrypoint));
361
231
  }
232
+ await Promise.all(promises);
362
233
  }
363
- async #createWorker() {
364
- const workerScript = resolveWorkerScript(this.#appEntrypoint);
365
- const worker = await createWebWorker(workerScript);
366
- let rejectWorkerReady;
367
- let workerReadyTimeoutId;
368
- const workerReadyPromise = new Promise((resolve, reject) => {
369
- rejectWorkerReady = reject;
370
- workerReadyTimeoutId = setTimeout(() => {
371
- reject(new Error("Worker failed to send ready signal within 30000ms"));
234
+ /**
235
+ * Create a worker from the unified bundle
236
+ * The bundle self-initializes and sends "ready" when done
237
+ */
238
+ async #createWorker(entrypoint) {
239
+ const worker = this.#options.createWorker ? await this.#options.createWorker(entrypoint) : await createWebWorker(entrypoint);
240
+ const readyPromise = new Promise((resolve, reject) => {
241
+ const timeoutId = setTimeout(() => {
242
+ this.#pendingWorkerReady.delete(worker);
243
+ reject(
244
+ new Error(
245
+ `Worker failed to become ready within 30000ms (${entrypoint})`
246
+ )
247
+ );
372
248
  }, 3e4);
373
- this.#pendingWorkerInit.set(worker, {
374
- workerReady: () => {
375
- clearTimeout(workerReadyTimeoutId);
249
+ this.#pendingWorkerReady.set(worker, {
250
+ resolve: () => {
251
+ clearTimeout(timeoutId);
376
252
  resolve();
253
+ },
254
+ reject: (error) => {
255
+ clearTimeout(timeoutId);
256
+ reject(error);
377
257
  }
378
258
  });
379
259
  });
@@ -382,74 +262,49 @@ var ServiceWorkerPool = class {
382
262
  });
383
263
  worker.addEventListener("error", (event) => {
384
264
  const errorMessage = event.message || event.error?.message || "Unknown worker error";
385
- const error = new Error(`Worker failed to start: ${errorMessage}`);
265
+ const error = new Error(`Worker error: ${errorMessage}`);
386
266
  logger.error("Worker error: {error}", {
387
267
  error: event.error || errorMessage,
388
268
  filename: event.filename,
389
269
  lineno: event.lineno,
390
270
  colno: event.colno
391
271
  });
392
- clearTimeout(workerReadyTimeoutId);
393
- rejectWorkerReady(error);
272
+ const pending = this.#pendingWorkerReady.get(worker);
273
+ if (pending) {
274
+ this.#pendingWorkerReady.delete(worker);
275
+ pending.reject(error);
276
+ }
394
277
  });
278
+ logger.debug("Waiting for worker ready signal", { entrypoint });
279
+ await readyPromise;
280
+ this.#pendingWorkerReady.delete(worker);
281
+ await new Promise((resolve) => setTimeout(resolve, 0));
395
282
  this.#workers.push(worker);
396
- logger.info("Waiting for worker-ready signal");
397
- await workerReadyPromise;
398
- logger.info("Received worker-ready signal");
399
- const initializedPromise = new Promise((resolve, reject) => {
400
- const timeoutId = setTimeout(() => {
401
- reject(
402
- new Error("Worker failed to send initialized signal within 30000ms")
403
- );
404
- }, 3e4);
405
- const pending = this.#pendingWorkerInit.get(worker) || {};
406
- pending.initialized = () => {
407
- clearTimeout(timeoutId);
408
- resolve();
409
- };
410
- this.#pendingWorkerInit.set(worker, pending);
411
- });
412
- if (!this.#appEntrypoint) {
413
- throw new Error(
414
- "ServiceWorkerPool requires an entrypoint to derive baseDir"
415
- );
283
+ logger.debug("Worker ready", { entrypoint });
284
+ const waiters = this.#workerAvailableWaiters;
285
+ this.#workerAvailableWaiters = [];
286
+ for (const waiter of waiters) {
287
+ waiter.resolve();
416
288
  }
417
- const baseDir = Path.dirname(this.#appEntrypoint);
418
- const initMessage = {
419
- type: "init",
420
- config: this.#config,
421
- baseDir
422
- };
423
- logger.info("Sending init message", { config: this.#config, baseDir });
424
- worker.postMessage(initMessage);
425
- logger.info("Sent init message, waiting for initialized response");
426
- await initializedPromise;
427
- logger.info("Received initialized response");
428
- this.#pendingWorkerInit.delete(worker);
429
289
  return worker;
430
290
  }
431
291
  #handleWorkerMessage(worker, message) {
432
292
  logger.debug("Worker message received", { type: message.type });
433
- const pending = this.#pendingWorkerInit.get(worker);
434
- if (message.type === "worker-ready" && pending?.workerReady) {
435
- pending.workerReady();
436
- } else if (message.type === "initialized" && pending?.initialized) {
437
- pending.initialized();
438
- return;
439
- }
440
293
  switch (message.type) {
294
+ case "ready": {
295
+ const pending = this.#pendingWorkerReady.get(worker);
296
+ if (pending) {
297
+ pending.resolve();
298
+ }
299
+ logger.debug("ServiceWorker ready");
300
+ break;
301
+ }
441
302
  case "response":
442
303
  this.#handleResponse(message);
443
304
  break;
444
305
  case "error":
445
306
  this.#handleError(message);
446
307
  break;
447
- case "ready":
448
- case "worker-ready":
449
- this.#handleReady(message);
450
- break;
451
- case "initialized":
452
- break;
453
308
  default:
454
309
  if (message.type?.startsWith("cache:")) {
455
310
  logger.debug("Cache message received", { type: message.type });
@@ -497,23 +352,38 @@ var ServiceWorkerPool = class {
497
352
  pending.reject(new Error(message.error));
498
353
  this.#pendingRequests.delete(message.requestID);
499
354
  }
500
- } else {
501
- logger.error("Worker error: {error}", { error: message.error });
502
- }
503
- }
504
- #handleReady(message) {
505
- if (message.type === "ready") {
506
- logger.info("ServiceWorker ready", { entrypoint: message.entrypoint });
507
- } else if (message.type === "worker-ready") {
508
- logger.info("Worker initialized", {});
509
355
  }
510
356
  }
511
357
  /**
512
358
  * Handle HTTP request using round-robin worker selection
513
359
  */
514
360
  async handleRequest(request) {
361
+ if (this.#workers.length === 0) {
362
+ logger.debug("No workers available, waiting for worker to be ready");
363
+ await new Promise((resolve, reject) => {
364
+ const waiter = { resolve, reject };
365
+ this.#workerAvailableWaiters.push(waiter);
366
+ const timeoutId = setTimeout(() => {
367
+ const index = this.#workerAvailableWaiters.indexOf(waiter);
368
+ if (index !== -1) {
369
+ this.#workerAvailableWaiters.splice(index, 1);
370
+ reject(new Error("Timeout waiting for worker to become available"));
371
+ }
372
+ }, this.#options.requestTimeout);
373
+ const originalResolve = waiter.resolve;
374
+ const originalReject = waiter.reject;
375
+ waiter.resolve = () => {
376
+ clearTimeout(timeoutId);
377
+ originalResolve();
378
+ };
379
+ waiter.reject = (error) => {
380
+ clearTimeout(timeoutId);
381
+ originalReject(error);
382
+ };
383
+ });
384
+ }
515
385
  const worker = this.#workers[this.#currentWorker];
516
- logger.info("Dispatching to worker", {
386
+ logger.debug("Dispatching to worker", {
517
387
  workerIndex: this.#currentWorker + 1,
518
388
  totalWorkers: this.#workers.length
519
389
  });
@@ -530,9 +400,6 @@ var ServiceWorkerPool = class {
530
400
  this.#sendRequest(worker, request, requestID).catch(reject);
531
401
  });
532
402
  }
533
- /**
534
- * Send request to worker (async helper to avoid async promise executor)
535
- */
536
403
  async #sendRequest(worker, request, requestID) {
537
404
  let body = null;
538
405
  if (request.body) {
@@ -555,75 +422,89 @@ var ServiceWorkerPool = class {
555
422
  }
556
423
  }
557
424
  /**
558
- * Reload ServiceWorker with new entrypoint (hot reload)
559
- * The entrypoint path contains a content hash for cache busting
425
+ * Gracefully shutdown a worker by closing all resources first
560
426
  */
561
- async reloadWorkers(entrypoint) {
562
- logger.info("Reloading ServiceWorker", { entrypoint });
563
- this.#appEntrypoint = entrypoint;
564
- const loadPromises = this.#workers.map((worker) => {
565
- return new Promise((resolve, reject) => {
566
- let timeoutId;
567
- const cleanup = () => {
568
- worker.removeEventListener("message", handleReady);
569
- worker.removeEventListener("error", handleError);
570
- if (timeoutId) {
571
- clearTimeout(timeoutId);
572
- }
573
- };
574
- const handleReady = (event) => {
575
- const message = event.data || event;
576
- if (message.type === "ready" && message.entrypoint === entrypoint) {
577
- cleanup();
427
+ async #gracefulShutdown(worker, timeout = 5e3) {
428
+ return new Promise((resolve) => {
429
+ let resolved = false;
430
+ const onMessage = (event) => {
431
+ const message = event.data || event;
432
+ if (message?.type === "shutdown-complete") {
433
+ if (!resolved) {
434
+ resolved = true;
435
+ worker.removeEventListener("message", onMessage);
578
436
  resolve();
579
- } else if (message.type === "error") {
580
- cleanup();
581
- reject(
582
- new Error(
583
- `Worker failed to load ServiceWorker: ${message.error}`
584
- )
585
- );
586
437
  }
587
- };
588
- const handleError = (error) => {
589
- cleanup();
590
- const errorMsg = error?.error?.message || error?.message || JSON.stringify(error);
591
- reject(new Error(`Worker failed to load ServiceWorker: ${errorMsg}`));
592
- };
593
- timeoutId = setTimeout(() => {
594
- cleanup();
595
- reject(
596
- new Error(
597
- `Worker failed to load ServiceWorker within 30000ms (entrypoint ${entrypoint})`
598
- )
599
- );
600
- }, 3e4);
601
- logger.info("Sending load message", {
602
- entrypoint
603
- });
604
- worker.addEventListener("message", handleReady);
605
- worker.addEventListener("error", handleError);
606
- const loadMessage = {
607
- type: "load",
608
- entrypoint
609
- };
610
- logger.debug("[WorkerPool] Sending load message", {
611
- entrypoint
612
- });
613
- worker.postMessage(loadMessage);
614
- });
438
+ }
439
+ };
440
+ worker.addEventListener("message", onMessage);
441
+ worker.postMessage({ type: "shutdown" });
442
+ setTimeout(() => {
443
+ if (!resolved) {
444
+ resolved = true;
445
+ worker.removeEventListener("message", onMessage);
446
+ logger.warn("Worker shutdown timed out, forcing termination");
447
+ resolve();
448
+ }
449
+ }, timeout);
615
450
  });
616
- await Promise.all(loadPromises);
617
- logger.info("All workers reloaded", { entrypoint });
451
+ }
452
+ /**
453
+ * Reload workers with new entrypoint (hot reload)
454
+ *
455
+ * With unified builds, hot reload means:
456
+ * 1. Gracefully shutdown existing workers (close databases, etc.)
457
+ * 2. Terminate workers after resources are closed
458
+ * 3. Create new workers with the new bundle
459
+ */
460
+ async reloadWorkers(entrypoint) {
461
+ logger.debug("Reloading workers", { entrypoint });
462
+ this.#appEntrypoint = entrypoint;
463
+ const shutdownPromises = this.#workers.map(
464
+ (worker) => this.#gracefulShutdown(worker)
465
+ );
466
+ await Promise.allSettled(shutdownPromises);
467
+ const terminatePromises = this.#workers.map((worker) => worker.terminate());
468
+ await Promise.allSettled(terminatePromises);
469
+ this.#workers = [];
470
+ this.#currentWorker = 0;
471
+ try {
472
+ const createPromises = [];
473
+ for (let i = 0; i < this.#options.workerCount; i++) {
474
+ createPromises.push(this.#createWorker(entrypoint));
475
+ }
476
+ await Promise.all(createPromises);
477
+ logger.debug("All workers reloaded", { entrypoint });
478
+ } catch (error) {
479
+ const waiters = this.#workerAvailableWaiters;
480
+ this.#workerAvailableWaiters = [];
481
+ const reloadError = error instanceof Error ? error : new Error("Worker creation failed during reload");
482
+ for (const waiter of waiters) {
483
+ waiter.reject(reloadError);
484
+ }
485
+ throw error;
486
+ }
618
487
  }
619
488
  /**
620
489
  * Graceful shutdown of all workers
621
490
  */
622
491
  async terminate() {
492
+ const shutdownPromises = this.#workers.map(
493
+ (worker) => this.#gracefulShutdown(worker)
494
+ );
495
+ await Promise.allSettled(shutdownPromises);
623
496
  const terminatePromises = this.#workers.map((worker) => worker.terminate());
624
497
  await Promise.allSettled(terminatePromises);
625
498
  this.#workers = [];
499
+ this.#currentWorker = 0;
626
500
  this.#pendingRequests.clear();
501
+ this.#pendingWorkerReady.clear();
502
+ const waiters = this.#workerAvailableWaiters;
503
+ this.#workerAvailableWaiters = [];
504
+ const terminateError = new Error("Worker pool terminated");
505
+ for (const waiter of waiters) {
506
+ waiter.reject(terminateError);
507
+ }
627
508
  }
628
509
  /**
629
510
  * Get the number of active workers
@@ -640,15 +521,19 @@ var ServiceWorkerPool = class {
640
521
  };
641
522
  export {
642
523
  BasePlatform,
524
+ ConfigValidationError,
525
+ CustomDatabaseStorage,
643
526
  CustomLoggerStorage,
644
527
  ServiceWorkerPool,
645
- SingleThreadedRuntime,
528
+ createDatabaseFactory,
646
529
  createPlatform,
647
530
  detectDeploymentPlatform,
648
531
  detectDevelopmentPlatform,
649
532
  detectRuntime,
650
533
  getPlatform,
651
534
  getPlatformAsync,
535
+ mergeConfigWithDefaults,
652
536
  platformRegistry,
653
- resolvePlatform
537
+ resolvePlatform,
538
+ validateConfig
654
539
  };