@b9g/platform-node 0.1.13 → 0.1.14-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/platform-node",
3
- "version": "0.1.13",
3
+ "version": "0.1.14-beta.1",
4
4
  "description": "Node.js platform adapter for Shovel with hot reloading and ESBuild integration",
5
5
  "keywords": [
6
6
  "shovel",
@@ -14,7 +14,7 @@
14
14
  "@b9g/cache": "^0.2.0-beta.0",
15
15
  "@b9g/http-errors": "^0.2.0-beta.0",
16
16
  "@b9g/node-webworker": "^0.2.0-beta.1",
17
- "@b9g/platform": "^0.1.13",
17
+ "@b9g/platform": "^0.1.14-beta.4",
18
18
  "@logtape/logtape": "^1.2.0"
19
19
  },
20
20
  "devDependencies": {
@@ -37,6 +37,14 @@
37
37
  "./index.js": {
38
38
  "types": "./src/index.d.ts",
39
39
  "import": "./src/index.js"
40
+ },
41
+ "./platform": {
42
+ "types": "./src/platform.d.ts",
43
+ "import": "./src/platform.js"
44
+ },
45
+ "./platform.js": {
46
+ "types": "./src/platform.d.ts",
47
+ "import": "./src/platform.js"
40
48
  }
41
49
  }
42
50
  }
package/src/index.d.ts CHANGED
@@ -3,11 +3,9 @@
3
3
  *
4
4
  * Provides hot reloading, ESBuild integration, and optimized caching for Node.js environments.
5
5
  */
6
- import { BasePlatform, type PlatformConfig, type PlatformDefaults, type Handler, type Server, type ServerOptions, type ServiceWorkerOptions, type ServiceWorkerInstance, type EntryWrapperOptions, type PlatformESBuildConfig, ServiceWorkerPool, CustomLoggerStorage, CustomDatabaseStorage } from "@b9g/platform";
6
+ import { type PlatformDefaults, type Handler, type Server, type ServerOptions, type PlatformESBuildConfig, type EntryPoints, ServiceWorkerPool } from "@b9g/platform";
7
7
  import { type ShovelConfig } from "@b9g/platform/runtime";
8
- import { CustomCacheStorage } from "@b9g/cache";
9
- import { CustomDirectoryStorage } from "@b9g/filesystem";
10
- export interface NodePlatformOptions extends PlatformConfig {
8
+ export interface NodePlatformOptions {
11
9
  /** Port for development server (default: 3000) */
12
10
  port?: number;
13
11
  /** Host for development server (default: localhost) */
@@ -20,74 +18,103 @@ export interface NodePlatformOptions extends PlatformConfig {
20
18
  config?: ShovelConfig;
21
19
  }
22
20
  /**
23
- * Node.js platform implementation
24
- * ServiceWorker entrypoint loader for Node.js with ESBuild VM system
21
+ * Node.js ServiceWorkerContainer implementation
22
+ * Manages ServiceWorker registrations backed by worker threads
25
23
  */
26
- export declare class NodePlatform extends BasePlatform {
24
+ export declare class NodeServiceWorkerContainer extends EventTarget implements ServiceWorkerContainer {
27
25
  #private;
28
- readonly name: string;
29
- constructor(options?: NodePlatformOptions);
26
+ readonly controller: ServiceWorker | null;
27
+ oncontrollerchange: ((ev: Event) => unknown) | null;
28
+ onmessage: ((ev: MessageEvent) => unknown) | null;
29
+ onmessageerror: ((ev: MessageEvent) => unknown) | null;
30
+ constructor(platform: NodePlatform);
30
31
  /**
31
- * Get options for testing
32
+ * Register a ServiceWorker script
33
+ * Spawns worker threads and runs lifecycle
32
34
  */
33
- get options(): {
34
- port: number;
35
- host: string;
36
- cwd: string;
37
- workers: number;
38
- config?: ShovelConfig;
39
- };
35
+ register(scriptURL: string | URL, options?: RegistrationOptions): Promise<ServiceWorkerRegistration>;
40
36
  /**
41
- * Get/set worker pool for testing
37
+ * Get registration for scope
42
38
  */
43
- get workerPool(): ServiceWorkerPool | undefined;
44
- set workerPool(pool: ServiceWorkerPool | undefined);
39
+ getRegistration(scope?: string): Promise<ServiceWorkerRegistration | undefined>;
45
40
  /**
46
- * THE MAIN JOB - Load and run a ServiceWorker-style entrypoint in Node.js
47
- * Uses Worker threads with coordinated cache storage for isolation and standards compliance
41
+ * Get all registrations
48
42
  */
49
- loadServiceWorker(entrypoint: string, options?: ServiceWorkerOptions): Promise<ServiceWorkerInstance>;
43
+ getRegistrations(): Promise<readonly ServiceWorkerRegistration[]>;
50
44
  /**
51
- * Create cache storage using config from shovel.json
52
- * Used for testing - production uses the generated config module
53
- * Merges with runtime defaults (actual class references) for fallback behavior
45
+ * Start receiving messages (no-op in server context)
54
46
  */
55
- createCaches(): Promise<CustomCacheStorage>;
47
+ startMessages(): void;
56
48
  /**
57
- * Create directory storage using config from shovel.json
58
- * Used for testing - production uses the generated config module
59
- * Merges with runtime defaults (actual class references) for fallback behavior
49
+ * Ready promise - resolves when a registration is active
60
50
  */
61
- createDirectories(): Promise<CustomDirectoryStorage>;
51
+ get ready(): Promise<ServiceWorkerRegistration>;
62
52
  /**
63
- * Create logger storage using config from shovel.json
53
+ * Internal: Get worker pool for request handling
64
54
  */
65
- createLoggers(): Promise<CustomLoggerStorage>;
55
+ get pool(): ServiceWorkerPool | undefined;
66
56
  /**
67
- * Create database storage from declarative config in shovel.json
57
+ * Internal: Terminate workers and dispose cache storage
68
58
  */
69
- createDatabases(configOverride?: NodePlatformOptions["config"]): CustomDatabaseStorage | undefined;
59
+ terminate(): Promise<void>;
60
+ /**
61
+ * Internal: Reload workers (for hot reload)
62
+ */
63
+ reloadWorkers(entrypoint: string): Promise<void>;
64
+ }
65
+ /**
66
+ * Node.js platform implementation
67
+ * ServiceWorker entrypoint loader for Node.js with ESBuild VM system
68
+ */
69
+ export declare class NodePlatform {
70
+ #private;
71
+ readonly name: string;
72
+ readonly serviceWorker: NodeServiceWorkerContainer;
73
+ constructor(options?: NodePlatformOptions);
74
+ /**
75
+ * Create a worker instance for the pool
76
+ * Can be overridden for testing
77
+ */
78
+ createWorker(entrypoint: string): Promise<Worker>;
79
+ /**
80
+ * Start the HTTP server, routing requests to ServiceWorker
81
+ */
82
+ listen(): Promise<Server>;
83
+ /**
84
+ * Close the server and terminate workers
85
+ */
86
+ close(): Promise<void>;
87
+ /**
88
+ * Get options for testing
89
+ */
90
+ get options(): {
91
+ port: number;
92
+ host: string;
93
+ cwd: string;
94
+ workers: number;
95
+ config?: ShovelConfig;
96
+ };
70
97
  /**
71
- * SUPPORTING UTILITY - Create HTTP server for Node.js
98
+ * Create HTTP server for Node.js
72
99
  */
73
100
  createServer(handler: Handler, options?: ServerOptions): Server;
74
101
  /**
75
102
  * Reload workers for hot reloading (called by CLI)
103
+ * @deprecated Use serviceWorker.reloadWorkers() instead
76
104
  * @param entrypoint - Path to the new entrypoint (hashed filename)
77
105
  */
78
106
  reloadWorkers(entrypoint: string): Promise<void>;
79
107
  /**
80
- * Get virtual entry wrapper for Node.js
108
+ * Get entry points for bundling.
81
109
  *
82
- * @param entryPath - Absolute path to user's entrypoint file
83
- * @param options - Entry wrapper options
84
- * @param options.type - "production" (default) or "worker"
110
+ * Development mode:
111
+ * - worker.js: Single worker with message loop (develop command acts as supervisor)
85
112
  *
86
- * Returns:
87
- * - "production": Server entry that loads ServiceWorkerPool
88
- * - "worker": Worker entry that sets up runtime and message loop
113
+ * Production mode:
114
+ * - index.js: Supervisor that spawns workers and owns the HTTP server
115
+ * - worker.js: Worker that handles requests via message loop
89
116
  */
90
- getEntryWrapper(entryPath: string, options?: EntryWrapperOptions): string;
117
+ getEntryPoints(userEntryPath: string, mode: "development" | "production"): EntryPoints;
91
118
  /**
92
119
  * Get Node.js-specific esbuild configuration
93
120
  *
package/src/index.js CHANGED
@@ -1,500 +1,190 @@
1
1
  /// <reference types="./index.d.ts" />
2
2
  // src/index.ts
3
+ import * as HTTP from "node:http";
3
4
  import { builtinModules } from "node:module";
4
5
  import { tmpdir } from "node:os";
6
+ import * as Path from "node:path";
7
+ import { getLogger } from "@logtape/logtape";
8
+ import { CustomCacheStorage } from "@b9g/cache";
9
+ import { InternalServerError, isHTTPError } from "@b9g/http-errors";
5
10
  import {
6
- BasePlatform,
7
- ServiceWorkerPool,
8
- SingleThreadedRuntime,
9
- CustomLoggerStorage,
10
- CustomDatabaseStorage,
11
- createDatabaseFactory
11
+ ServiceWorkerPool
12
12
  } from "@b9g/platform";
13
13
  import {
14
- createCacheFactory,
15
- createDirectoryFactory
14
+ ShovelServiceWorkerRegistration,
15
+ kServiceWorker,
16
+ createCacheFactory
16
17
  } from "@b9g/platform/runtime";
17
- import { CustomCacheStorage } from "@b9g/cache";
18
18
  import { MemoryCache } from "@b9g/cache/memory";
19
- import { CustomDirectoryStorage } from "@b9g/filesystem";
20
- import { NodeFSDirectory } from "@b9g/filesystem/node-fs";
21
- import { InternalServerError, isHTTPError } from "@b9g/http-errors";
22
- import * as HTTP from "http";
23
- import * as Path from "path";
24
- import { getLogger } from "@logtape/logtape";
25
- import { MemoryCache as MemoryCache2 } from "@b9g/cache/memory";
26
- var entryTemplate = `// Node.js Production Server Entry
27
- import {tmpdir} from "os"; // For [tmpdir] config expressions
28
- import * as HTTP from "http";
29
- import {parentPort} from "worker_threads";
30
- import {Worker} from "@b9g/node-webworker";
31
- import {getLogger} from "@logtape/logtape";
32
- import {configureLogging} from "@b9g/platform/runtime";
33
- import {config} from "shovel:config"; // Virtual module - resolved at build time
34
- import Platform from "@b9g/platform-node";
35
-
36
- // Configure logging before anything else
37
- await configureLogging(config.logging);
38
-
39
- const logger = getLogger(["shovel", "platform"]);
40
-
41
- // Configuration from shovel:config
42
- const PORT = config.port;
43
- const HOST = config.host;
44
- const WORKERS = config.workers;
45
-
46
- // Use explicit marker instead of isMainThread
47
- // This handles the edge case where Shovel's build output is embedded in another worker
48
- const isShovelWorker = process.env.SHOVEL_SPAWNED_WORKER === "1";
49
-
50
- if (WORKERS === 1) {
51
- // Single worker mode: worker owns server (same as Bun)
52
- // No reusePort needed since there's only one listener
53
- if (isShovelWorker) {
54
- // Worker thread: runs BOTH server AND ServiceWorker
55
- const platform = new Platform({port: PORT, host: HOST, workers: 1});
56
-
57
- // Track resources for shutdown - these get assigned during startup
58
- let server;
59
- let serviceWorker;
60
-
61
- // Register shutdown handler BEFORE async startup to prevent race condition
62
- // where SIGINT arrives during startup and the shutdown message is dropped
63
- self.onmessage = async (event) => {
64
- if (event.data.type === "shutdown") {
65
- logger.info("Worker shutting down");
66
- if (server) await server.close();
67
- if (serviceWorker) await serviceWorker.dispose();
68
- await platform.dispose();
69
- postMessage({type: "shutdown-complete"});
70
- }
71
- };
72
-
73
- const userCodePath = new URL("./server.js", import.meta.url).pathname;
74
- serviceWorker = await platform.loadServiceWorker(userCodePath);
75
-
76
- server = platform.createServer(serviceWorker.handleRequest, {
77
- port: PORT,
78
- host: HOST,
79
- });
80
- await server.listen();
81
-
82
- // Signal ready to main thread
83
- postMessage({type: "ready"});
84
- logger.info("Worker started with server", {port: PORT});
85
- } else {
86
- // Main thread: supervisor only - spawn single worker
87
- logger.info("Starting production server (single worker)", {port: PORT});
88
-
89
- // Port availability check - fail fast if port is in use
90
- const checkPort = () => new Promise((resolve, reject) => {
91
- const testServer = HTTP.createServer();
92
- testServer.once("error", reject);
93
- testServer.listen(PORT, HOST, () => {
94
- testServer.close(() => resolve(undefined));
95
- });
96
- });
97
- try {
98
- await checkPort();
99
- } catch (err) {
100
- logger.error("Port unavailable", {port: PORT, host: HOST, error: err});
101
- process.exit(1);
102
- }
103
-
104
- let shuttingDown = false;
105
-
106
- const worker = new Worker(new URL(import.meta.url), {
107
- env: {SHOVEL_SPAWNED_WORKER: "1"},
108
- });
109
-
110
- worker.onmessage = (event) => {
111
- if (event.data.type === "ready") {
112
- logger.info("Worker ready", {port: PORT});
113
- } else if (event.data.type === "shutdown-complete") {
114
- logger.info("Worker shutdown complete");
115
- process.exit(0);
116
- }
117
- };
118
-
119
- worker.onerror = (event) => {
120
- logger.error("Worker error", {error: event.error});
121
- };
122
-
123
- // If a worker crashes, fail fast - let process supervisor handle restarts
124
- worker.addEventListener("close", (event) => {
125
- if (shuttingDown) return;
126
- if (event.code === 0) return; // Clean exit
127
- logger.error("Worker crashed", {exitCode: event.code});
128
- process.exit(1);
129
- });
130
-
131
- // Graceful shutdown
132
- const shutdown = () => {
133
- shuttingDown = true;
134
- logger.info("Shutting down");
135
- worker.postMessage({type: "shutdown"});
136
- };
137
-
138
- process.on("SIGINT", shutdown);
139
- process.on("SIGTERM", shutdown);
140
- }
141
- } else {
142
- // Multi-worker mode: main thread owns server (no reusePort in Node.js)
143
- // Workers handle ServiceWorker code via postMessage
144
- if (isShovelWorker) {
145
- // Worker: ServiceWorker only, respond via message loop
146
- // This path uses the workerEntryTemplate, not this template
147
- throw new Error("Multi-worker mode uses workerEntryTemplate, not entryTemplate");
148
- } else {
149
- // Main thread: HTTP server + dispatch to worker pool
150
- const platform = new Platform({port: PORT, host: HOST, workers: WORKERS});
151
- const userCodePath = new URL("./server.js", import.meta.url).pathname;
152
-
153
- logger.info("Starting production server (multi-worker)", {workers: WORKERS, port: PORT});
154
-
155
- // Load ServiceWorker with worker pool
156
- const serviceWorker = await platform.loadServiceWorker(userCodePath, {
157
- workerCount: WORKERS,
158
- });
159
-
160
- // Create HTTP server
161
- const server = platform.createServer(serviceWorker.handleRequest, {
162
- port: PORT,
163
- host: HOST,
164
- });
165
-
166
- await server.listen();
167
- logger.info("Server running", {url: \`http://\${HOST}:\${PORT}\`, workers: WORKERS});
168
-
169
- // Graceful shutdown
170
- const shutdown = async () => {
171
- logger.info("Shutting down server");
172
- await serviceWorker.dispose();
173
- await platform.dispose();
174
- await server.close();
175
- logger.info("Server stopped");
176
- process.exit(0);
177
- };
178
-
179
- process.on("SIGINT", shutdown);
180
- process.on("SIGTERM", shutdown);
181
- }
182
- }
183
- `;
184
- var workerEntryTemplate = `// Worker Entry for ServiceWorkerPool
185
- // This file sets up the ServiceWorker runtime and message loop
186
- import {tmpdir} from "os"; // For [tmpdir] config expressions
187
- import {config} from "shovel:config";
188
- import {initWorkerRuntime, startWorkerMessageLoop, configureLogging} from "@b9g/platform/runtime";
189
-
190
- // Configure logging before anything else
191
- await configureLogging(config.logging);
192
-
193
- // Initialize the worker runtime (installs ServiceWorker globals)
194
- // Platform defaults and paths are already resolved at build time
195
- const {registration, databases} = await initWorkerRuntime({config});
196
-
197
- // Import user code (registers event handlers via addEventListener)
198
- // Must use dynamic import to ensure globals are installed first
199
- await import("__USER_ENTRY__");
200
-
201
- // Run ServiceWorker lifecycle
202
- await registration.install();
203
- await registration.activate();
204
-
205
- // Start the message loop (handles request/response messages from main thread)
206
- // Pass databases for graceful shutdown (close connections before termination)
207
- startWorkerMessageLoop({registration, databases});
208
- `;
209
19
  var logger = getLogger(["shovel", "platform"]);
210
- var NodePlatform = class extends BasePlatform {
211
- name;
212
- #options;
213
- #workerPool;
214
- #singleThreadedRuntime;
20
+ var NodeServiceWorkerContainer = class extends EventTarget {
21
+ #platform;
22
+ #pool;
215
23
  #cacheStorage;
216
- #directoryStorage;
217
- #databaseStorage;
218
- constructor(options = {}) {
219
- super(options);
220
- this.name = "node";
221
- const cwd = options.cwd || process.cwd();
222
- this.#options = {
223
- port: options.port ?? 3e3,
224
- host: options.host ?? "localhost",
225
- workers: options.workers ?? 1,
226
- cwd,
227
- config: options.config
228
- };
229
- }
230
- /**
231
- * Get options for testing
232
- */
233
- get options() {
234
- return this.#options;
235
- }
236
- /**
237
- * Get/set worker pool for testing
238
- */
239
- get workerPool() {
240
- return this.#workerPool;
241
- }
242
- set workerPool(pool) {
243
- this.#workerPool = pool;
244
- }
245
- /**
246
- * THE MAIN JOB - Load and run a ServiceWorker-style entrypoint in Node.js
247
- * Uses Worker threads with coordinated cache storage for isolation and standards compliance
248
- */
249
- async loadServiceWorker(entrypoint, options = {}) {
250
- const workerCount = options.workerCount ?? this.#options.workers;
251
- if (workerCount === 1 && !options.hotReload) {
252
- return this.#loadServiceWorkerDirect(entrypoint, options);
253
- }
254
- return this.#loadServiceWorkerWithPool(entrypoint, options, workerCount);
255
- }
256
- /**
257
- * Load ServiceWorker directly in main thread (single-threaded mode)
258
- * No postMessage overhead - maximum performance for production
259
- */
260
- async #loadServiceWorkerDirect(entrypoint, _options) {
261
- const entryPath = Path.resolve(this.#options.cwd, entrypoint);
262
- let config = this.#options.config;
263
- const configPath = Path.join(Path.dirname(entryPath), "config.js");
264
- try {
265
- const configModule = await import(configPath);
266
- config = configModule.config ?? config;
267
- } catch (err) {
268
- logger.debug`Using platform config (no config.js): ${err}`;
269
- }
270
- if (!this.#cacheStorage) {
271
- const runtimeCacheDefaults = {
272
- default: { impl: MemoryCache }
273
- };
274
- const userCaches = config?.caches ?? {};
275
- const cacheConfigs = {};
276
- const allCacheNames = /* @__PURE__ */ new Set([
277
- ...Object.keys(runtimeCacheDefaults),
278
- ...Object.keys(userCaches)
279
- ]);
280
- for (const name of allCacheNames) {
281
- cacheConfigs[name] = {
282
- ...runtimeCacheDefaults[name],
283
- ...userCaches[name]
284
- };
285
- }
286
- this.#cacheStorage = new CustomCacheStorage(
287
- createCacheFactory({ configs: cacheConfigs })
288
- );
289
- }
290
- if (!this.#directoryStorage) {
291
- const runtimeDirDefaults = {
292
- server: { impl: NodeFSDirectory },
293
- public: { impl: NodeFSDirectory },
294
- tmp: { impl: NodeFSDirectory }
295
- };
296
- const userDirs = config?.directories ?? {};
297
- const dirConfigs = {};
298
- const allDirNames = /* @__PURE__ */ new Set([
299
- ...Object.keys(runtimeDirDefaults),
300
- ...Object.keys(userDirs)
301
- ]);
302
- for (const name of allDirNames) {
303
- dirConfigs[name] = { ...runtimeDirDefaults[name], ...userDirs[name] };
304
- }
305
- this.#directoryStorage = new CustomDirectoryStorage(
306
- createDirectoryFactory(dirConfigs)
307
- );
308
- }
309
- if (!this.#databaseStorage) {
310
- this.#databaseStorage = this.createDatabases(config);
311
- }
312
- if (this.#singleThreadedRuntime) {
313
- await this.#singleThreadedRuntime.terminate();
314
- }
315
- if (this.#workerPool) {
316
- await this.#workerPool.terminate();
317
- this.#workerPool = void 0;
318
- }
319
- logger.info("Creating single-threaded ServiceWorker runtime", { entryPath });
320
- this.#singleThreadedRuntime = new SingleThreadedRuntime({
321
- caches: this.#cacheStorage,
322
- directories: this.#directoryStorage,
323
- databases: this.#databaseStorage,
324
- loggers: new CustomLoggerStorage((cats) => getLogger(cats))
24
+ #registration;
25
+ #readyPromise;
26
+ #readyResolve;
27
+ // Standard ServiceWorkerContainer properties
28
+ controller;
29
+ oncontrollerchange;
30
+ onmessage;
31
+ onmessageerror;
32
+ constructor(platform) {
33
+ super();
34
+ this.#platform = platform;
35
+ this.#readyPromise = new Promise((resolve2) => {
36
+ this.#readyResolve = resolve2;
325
37
  });
326
- await this.#singleThreadedRuntime.init();
327
- await this.#singleThreadedRuntime.load(entryPath);
328
- const runtime = this.#singleThreadedRuntime;
329
- const platform = this;
330
- const instance = {
331
- runtime,
332
- handleRequest: async (request) => {
333
- if (!platform.#singleThreadedRuntime) {
334
- throw new Error("SingleThreadedRuntime not initialized");
335
- }
336
- return platform.#singleThreadedRuntime.handleRequest(request);
337
- },
338
- install: async () => {
339
- logger.info("ServiceWorker installed", { method: "single_threaded" });
340
- },
341
- activate: async () => {
342
- logger.info("ServiceWorker activated", { method: "single_threaded" });
343
- },
344
- get ready() {
345
- return runtime?.ready ?? false;
346
- },
347
- dispose: async () => {
348
- if (platform.#singleThreadedRuntime) {
349
- await platform.#singleThreadedRuntime.terminate();
350
- platform.#singleThreadedRuntime = void 0;
351
- }
352
- logger.info("ServiceWorker disposed", {});
353
- }
354
- };
355
- logger.info("ServiceWorker loaded", {
356
- features: ["single_threaded", "no_postmessage_overhead"]
357
- });
358
- return instance;
38
+ this.controller = null;
39
+ this.oncontrollerchange = null;
40
+ this.onmessage = null;
41
+ this.onmessageerror = null;
359
42
  }
360
43
  /**
361
- * Load ServiceWorker using worker pool (multi-threaded mode or dev mode)
44
+ * Register a ServiceWorker script
45
+ * Spawns worker threads and runs lifecycle
362
46
  */
363
- async #loadServiceWorkerWithPool(entrypoint, _options, workerCount) {
364
- const entryPath = Path.resolve(this.#options.cwd, entrypoint);
365
- let config = this.#options.config;
47
+ async register(scriptURL, options) {
48
+ const urlStr = typeof scriptURL === "string" ? scriptURL : scriptURL.toString();
49
+ const scope = options?.scope ?? "/";
50
+ let entryPath;
51
+ if (urlStr.startsWith("file://")) {
52
+ entryPath = new URL(urlStr).pathname;
53
+ } else {
54
+ entryPath = Path.resolve(this.#platform.options.cwd, urlStr);
55
+ }
56
+ let config = this.#platform.options.config;
366
57
  const configPath = Path.join(Path.dirname(entryPath), "config.js");
367
58
  try {
368
59
  const configModule = await import(configPath);
369
60
  config = configModule.config ?? config;
370
- } catch (err) {
371
- logger.debug`Using platform config (no config.js): ${err}`;
61
+ } catch (error) {
62
+ logger.debug`Using platform config (no config.js found): ${error}`;
372
63
  }
373
- if (!this.#cacheStorage) {
64
+ if (!this.#cacheStorage && config?.caches) {
374
65
  this.#cacheStorage = new CustomCacheStorage(
375
- createCacheFactory({
376
- configs: config?.caches ?? {}
377
- })
66
+ createCacheFactory({ configs: config.caches })
378
67
  );
379
68
  }
380
- if (this.#singleThreadedRuntime) {
381
- await this.#singleThreadedRuntime.terminate();
382
- this.#singleThreadedRuntime = void 0;
69
+ if (this.#pool) {
70
+ await this.#pool.terminate();
383
71
  }
384
- if (this.#workerPool) {
385
- await this.#workerPool.terminate();
386
- }
387
- logger.info("Creating ServiceWorker pool", {
388
- entryPath,
389
- workerCount
390
- });
391
- this.#workerPool = new ServiceWorkerPool(
72
+ this.#pool = new ServiceWorkerPool(
392
73
  {
393
- workerCount,
394
- requestTimeout: 3e4,
395
- cwd: this.#options.cwd
74
+ workerCount: this.#platform.options.workers,
75
+ createWorker: (entrypoint) => this.#platform.createWorker(entrypoint)
396
76
  },
397
77
  entryPath,
398
78
  this.#cacheStorage
399
79
  );
400
- await this.#workerPool.init();
401
- const workerPool = this.#workerPool;
402
- const platform = this;
403
- const instance = {
404
- runtime: workerPool,
405
- handleRequest: async (request) => {
406
- if (!platform.#workerPool) {
407
- throw new Error("ServiceWorkerPool not initialized");
408
- }
409
- return platform.#workerPool.handleRequest(request);
410
- },
411
- install: async () => {
412
- logger.info("ServiceWorker installed", {
413
- method: "worker_threads"
414
- });
415
- },
416
- activate: async () => {
417
- logger.info("ServiceWorker activated", {
418
- method: "worker_threads"
419
- });
420
- },
421
- get ready() {
422
- return workerPool?.ready ?? false;
423
- },
424
- dispose: async () => {
425
- if (platform.#workerPool) {
426
- await platform.#workerPool.terminate();
427
- platform.#workerPool = void 0;
428
- }
429
- logger.info("ServiceWorker disposed", {});
430
- }
431
- };
432
- logger.info("ServiceWorker loaded", {
433
- features: ["worker_threads", "coordinated_caches"]
434
- });
435
- return instance;
80
+ await this.#pool.init();
81
+ this.#registration = new ShovelServiceWorkerRegistration(scope, urlStr);
82
+ this.#registration[kServiceWorker]._setState("activated");
83
+ this.#readyResolve?.(this.#registration);
84
+ return this.#registration;
436
85
  }
437
86
  /**
438
- * Create cache storage using config from shovel.json
439
- * Used for testing - production uses the generated config module
440
- * Merges with runtime defaults (actual class references) for fallback behavior
87
+ * Get registration for scope
441
88
  */
442
- async createCaches() {
443
- const runtimeDefaults = {
444
- default: { impl: MemoryCache }
445
- };
446
- const userCaches = this.#options.config?.caches ?? {};
447
- const configs = {};
448
- const allNames = /* @__PURE__ */ new Set([
449
- ...Object.keys(runtimeDefaults),
450
- ...Object.keys(userCaches)
451
- ]);
452
- for (const name of allNames) {
453
- configs[name] = { ...runtimeDefaults[name], ...userCaches[name] };
89
+ async getRegistration(scope) {
90
+ if (scope === void 0 || scope === "/" || scope === this.#registration?.scope) {
91
+ return this.#registration;
454
92
  }
455
- return new CustomCacheStorage(createCacheFactory({ configs }));
93
+ return void 0;
94
+ }
95
+ /**
96
+ * Get all registrations
97
+ */
98
+ async getRegistrations() {
99
+ return this.#registration ? [this.#registration] : [];
456
100
  }
457
101
  /**
458
- * Create directory storage using config from shovel.json
459
- * Used for testing - production uses the generated config module
460
- * Merges with runtime defaults (actual class references) for fallback behavior
102
+ * Start receiving messages (no-op in server context)
461
103
  */
462
- async createDirectories() {
463
- const runtimeDefaults = {
464
- server: { impl: NodeFSDirectory, path: this.#options.cwd },
465
- public: { impl: NodeFSDirectory, path: this.#options.cwd },
466
- tmp: { impl: NodeFSDirectory, path: tmpdir() }
104
+ startMessages() {
105
+ }
106
+ /**
107
+ * Ready promise - resolves when a registration is active
108
+ */
109
+ get ready() {
110
+ return this.#readyPromise;
111
+ }
112
+ /**
113
+ * Internal: Get worker pool for request handling
114
+ */
115
+ get pool() {
116
+ return this.#pool;
117
+ }
118
+ /**
119
+ * Internal: Terminate workers and dispose cache storage
120
+ */
121
+ async terminate() {
122
+ await this.#pool?.terminate();
123
+ this.#pool = void 0;
124
+ await this.#cacheStorage?.dispose();
125
+ this.#cacheStorage = void 0;
126
+ }
127
+ /**
128
+ * Internal: Reload workers (for hot reload)
129
+ */
130
+ async reloadWorkers(entrypoint) {
131
+ await this.#pool?.reloadWorkers(entrypoint);
132
+ }
133
+ };
134
+ var NodePlatform = class {
135
+ name;
136
+ serviceWorker;
137
+ #options;
138
+ #server;
139
+ constructor(options = {}) {
140
+ this.name = "node";
141
+ const cwd = options.cwd || process.cwd();
142
+ this.#options = {
143
+ port: options.port ?? 3e3,
144
+ host: options.host ?? "localhost",
145
+ workers: options.workers ?? 1,
146
+ cwd,
147
+ config: options.config
467
148
  };
468
- const userDirs = this.#options.config?.directories ?? {};
469
- const configs = {};
470
- const allNames = /* @__PURE__ */ new Set([
471
- ...Object.keys(runtimeDefaults),
472
- ...Object.keys(userDirs)
473
- ]);
474
- for (const name of allNames) {
475
- configs[name] = { ...runtimeDefaults[name], ...userDirs[name] };
476
- }
477
- return new CustomDirectoryStorage(createDirectoryFactory(configs));
149
+ this.serviceWorker = new NodeServiceWorkerContainer(this);
478
150
  }
479
151
  /**
480
- * Create logger storage using config from shovel.json
152
+ * Create a worker instance for the pool
153
+ * Can be overridden for testing
481
154
  */
482
- async createLoggers() {
483
- return new CustomLoggerStorage((categories) => getLogger(categories));
155
+ async createWorker(entrypoint) {
156
+ const { Worker: NodeWebWorker } = await import("@b9g/node-webworker");
157
+ return new NodeWebWorker(entrypoint);
484
158
  }
485
159
  /**
486
- * Create database storage from declarative config in shovel.json
160
+ * Start the HTTP server, routing requests to ServiceWorker
487
161
  */
488
- createDatabases(configOverride) {
489
- const config = configOverride ?? this.#options.config;
490
- if (config?.databases && Object.keys(config.databases).length > 0) {
491
- const factory = createDatabaseFactory(config.databases);
492
- return new CustomDatabaseStorage(factory);
162
+ async listen() {
163
+ const pool = this.serviceWorker.pool;
164
+ if (!pool) {
165
+ throw new Error(
166
+ "No ServiceWorker registered. Call serviceWorker.register() first."
167
+ );
493
168
  }
494
- return void 0;
169
+ this.#server = this.createServer((request) => pool.handleRequest(request));
170
+ await this.#server.listen();
171
+ return this.#server;
172
+ }
173
+ /**
174
+ * Close the server and terminate workers
175
+ */
176
+ async close() {
177
+ await this.#server?.close();
178
+ await this.serviceWorker.terminate();
179
+ }
180
+ /**
181
+ * Get options for testing
182
+ */
183
+ get options() {
184
+ return this.#options;
495
185
  }
496
186
  /**
497
- * SUPPORTING UTILITY - Create HTTP server for Node.js
187
+ * Create HTTP server for Node.js
498
188
  */
499
189
  createServer(handler, options = {}) {
500
190
  const port = options.port ?? this.#options.port;
@@ -531,8 +221,15 @@ var NodePlatform = class extends BasePlatform {
531
221
  }
532
222
  } catch (error) {
533
223
  const err = error instanceof Error ? error : new Error(String(error));
534
- logger.error("Request error: {error}", { error: err });
535
224
  const httpError = isHTTPError(error) ? error : new InternalServerError(err.message, { cause: err });
225
+ if (httpError.status >= 500) {
226
+ logger.error("Request error: {error}", { error: err });
227
+ } else {
228
+ logger.warn("Request error: {status} {error}", {
229
+ status: httpError.status,
230
+ error: err
231
+ });
232
+ }
536
233
  const isDev = import.meta.env?.MODE !== "production";
537
234
  const response = httpError.toResponse(isDev);
538
235
  res.statusCode = response.status;
@@ -584,31 +281,89 @@ var NodePlatform = class extends BasePlatform {
584
281
  }
585
282
  /**
586
283
  * Reload workers for hot reloading (called by CLI)
284
+ * @deprecated Use serviceWorker.reloadWorkers() instead
587
285
  * @param entrypoint - Path to the new entrypoint (hashed filename)
588
286
  */
589
287
  async reloadWorkers(entrypoint) {
590
- if (this.#workerPool) {
591
- await this.#workerPool.reloadWorkers(entrypoint);
592
- } else if (this.#singleThreadedRuntime) {
593
- await this.#singleThreadedRuntime.load(entrypoint);
594
- }
288
+ await this.serviceWorker.reloadWorkers(entrypoint);
595
289
  }
596
290
  /**
597
- * Get virtual entry wrapper for Node.js
291
+ * Get entry points for bundling.
598
292
  *
599
- * @param entryPath - Absolute path to user's entrypoint file
600
- * @param options - Entry wrapper options
601
- * @param options.type - "production" (default) or "worker"
293
+ * Development mode:
294
+ * - worker.js: Single worker with message loop (develop command acts as supervisor)
602
295
  *
603
- * Returns:
604
- * - "production": Server entry that loads ServiceWorkerPool
605
- * - "worker": Worker entry that sets up runtime and message loop
296
+ * Production mode:
297
+ * - index.js: Supervisor that spawns workers and owns the HTTP server
298
+ * - worker.js: Worker that handles requests via message loop
606
299
  */
607
- getEntryWrapper(entryPath, options) {
608
- if (options?.type === "worker") {
609
- return workerEntryTemplate.replace("__USER_ENTRY__", entryPath);
300
+ getEntryPoints(userEntryPath, mode) {
301
+ const workerCode = `// Node.js Worker
302
+ import {parentPort} from "node:worker_threads";
303
+ import {configureLogging, initWorkerRuntime, runLifecycle, startWorkerMessageLoop} from "@b9g/platform/runtime";
304
+ import {config} from "shovel:config";
305
+
306
+ await configureLogging(config.logging);
307
+
308
+ // Initialize worker runtime (installs ServiceWorker globals)
309
+ const {registration, databases} = await initWorkerRuntime({config});
310
+
311
+ // Import user code (registers event handlers)
312
+ await import("${userEntryPath}");
313
+
314
+ // Run ServiceWorker lifecycle (stage from config.lifecycle if present)
315
+ await runLifecycle(registration, config.lifecycle?.stage);
316
+
317
+ // Start message loop for request handling, or signal ready and exit in lifecycle-only mode
318
+ if (config.lifecycle) {
319
+ parentPort?.postMessage({type: "ready"});
320
+ // Clean shutdown after lifecycle
321
+ if (databases) await databases.closeAll();
322
+ process.exit(0);
323
+ } else {
324
+ startWorkerMessageLoop({registration, databases});
325
+ }
326
+ `;
327
+ if (mode === "development") {
328
+ return { worker: workerCode };
610
329
  }
611
- return entryTemplate;
330
+ const supervisorCode = `// Node.js Production Supervisor
331
+ import {Worker} from "@b9g/node-webworker";
332
+ import {getLogger} from "@logtape/logtape";
333
+ import {configureLogging} from "@b9g/platform/runtime";
334
+ import NodePlatform from "@b9g/platform-node";
335
+ import {config} from "shovel:config";
336
+
337
+ await configureLogging(config.logging);
338
+ const logger = getLogger(["shovel", "platform"]);
339
+
340
+ logger.info("Starting production server", {port: config.port, workers: config.workers});
341
+
342
+ // Initialize platform and register ServiceWorker
343
+ // Override createWorker to use the imported Worker class (avoids require() issues with ESM)
344
+ const platform = new NodePlatform({port: config.port, host: config.host, workers: config.workers});
345
+ platform.createWorker = (entrypoint) => new Worker(entrypoint);
346
+ await platform.serviceWorker.register(new URL("./worker.js", import.meta.url).href);
347
+ await platform.serviceWorker.ready;
348
+
349
+ // Start HTTP server
350
+ await platform.listen();
351
+
352
+ logger.info("Server started", {port: config.port, host: config.host, workers: config.workers});
353
+
354
+ // Graceful shutdown
355
+ const handleShutdown = async () => {
356
+ logger.info("Shutting down");
357
+ await platform.close();
358
+ process.exit(0);
359
+ };
360
+ process.on("SIGINT", handleShutdown);
361
+ process.on("SIGTERM", handleShutdown);
362
+ `;
363
+ return {
364
+ supervisor: supervisorCode,
365
+ worker: workerCode
366
+ };
612
367
  }
613
368
  /**
614
369
  * Get Node.js-specific esbuild configuration
@@ -663,22 +418,8 @@ var NodePlatform = class extends BasePlatform {
663
418
  * Dispose of platform resources
664
419
  */
665
420
  async dispose() {
666
- if (this.#singleThreadedRuntime) {
667
- await this.#singleThreadedRuntime.terminate();
668
- this.#singleThreadedRuntime = void 0;
669
- }
670
- if (this.#workerPool) {
671
- await this.#workerPool.terminate();
672
- this.#workerPool = void 0;
673
- }
674
- if (this.#cacheStorage) {
675
- await this.#cacheStorage.dispose();
676
- this.#cacheStorage = void 0;
677
- }
678
- if (this.#databaseStorage) {
679
- await this.#databaseStorage.closeAll();
680
- this.#databaseStorage = void 0;
681
- }
421
+ await this.close();
422
+ await this.serviceWorker.terminate();
682
423
  }
683
424
  // =========================================================================
684
425
  // Config Expression Method Overrides
@@ -692,7 +433,8 @@ var NodePlatform = class extends BasePlatform {
692
433
  };
693
434
  var src_default = NodePlatform;
694
435
  export {
695
- MemoryCache2 as DefaultCache,
436
+ MemoryCache as DefaultCache,
696
437
  NodePlatform,
438
+ NodeServiceWorkerContainer,
697
439
  src_default as default
698
440
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Node.js Platform Module
3
+ *
4
+ * Build-time and dev-time functions for Node.js.
5
+ * Runtime functions are in ./runtime.ts
6
+ */
7
+ import type { EntryPoints, ESBuildConfig, PlatformDefaults, DevServerOptions, DevServer } from "@b9g/platform/module";
8
+ export declare const name = "node";
9
+ /**
10
+ * Get entry points for bundling.
11
+ *
12
+ * Development mode:
13
+ * - worker.js: Single worker with message loop (develop command acts as supervisor)
14
+ *
15
+ * Production mode:
16
+ * - supervisor.js: Spawns workers and owns the HTTP server
17
+ * - worker.js: Handles requests via message loop
18
+ */
19
+ export declare function getEntryPoints(userEntryPath: string, mode: "development" | "production"): EntryPoints;
20
+ /**
21
+ * Get ESBuild configuration for Node.js.
22
+ *
23
+ * Note: Node.js doesn't support import.meta.env natively, so we alias it
24
+ * to process.env for compatibility with code that uses Vite-style env access.
25
+ */
26
+ export declare function getESBuildConfig(): ESBuildConfig;
27
+ /**
28
+ * Get platform defaults for config generation.
29
+ *
30
+ * Provides default directories (server, public, tmp) that work
31
+ * out of the box for Node.js deployments.
32
+ */
33
+ export declare function getDefaults(): PlatformDefaults;
34
+ /**
35
+ * Create a dev server using ServiceWorkerPool.
36
+ *
37
+ * Dynamically imports the platform class to keep heavy dependencies
38
+ * out of production bundles.
39
+ */
40
+ export declare function createDevServer(options: DevServerOptions): Promise<DevServer>;
@@ -0,0 +1,146 @@
1
+ /// <reference types="./platform.d.ts" />
2
+ // src/platform.ts
3
+ import { builtinModules } from "node:module";
4
+ import { getLogger } from "@logtape/logtape";
5
+ var logger = getLogger(["shovel", "platform"]);
6
+ var name = "node";
7
+ function getEntryPoints(userEntryPath, mode) {
8
+ const safePath = JSON.stringify(userEntryPath);
9
+ const workerCode = `// Node.js Worker
10
+ import {parentPort} from "node:worker_threads";
11
+ import {configureLogging, initWorkerRuntime, runLifecycle, startWorkerMessageLoop} from "@b9g/platform/runtime";
12
+ import {config} from "shovel:config";
13
+
14
+ await configureLogging(config.logging);
15
+
16
+ // Initialize worker runtime (installs ServiceWorker globals)
17
+ const {registration, databases} = await initWorkerRuntime({config});
18
+
19
+ // Import user code (registers event handlers)
20
+ await import(${safePath});
21
+
22
+ // Run ServiceWorker lifecycle (stage from config.lifecycle if present)
23
+ await runLifecycle(registration, config.lifecycle?.stage);
24
+
25
+ // Start message loop for request handling, or signal ready and exit in lifecycle-only mode
26
+ if (config.lifecycle) {
27
+ parentPort?.postMessage({type: "ready"});
28
+ // Clean shutdown after lifecycle
29
+ if (databases) await databases.closeAll();
30
+ process.exit(0);
31
+ } else {
32
+ startWorkerMessageLoop({registration, databases});
33
+ }
34
+ `;
35
+ if (mode === "development") {
36
+ return { worker: workerCode };
37
+ }
38
+ const supervisorCode = `// Node.js Production Supervisor
39
+ import {Worker} from "@b9g/node-webworker";
40
+ import {getLogger} from "@logtape/logtape";
41
+ import {configureLogging} from "@b9g/platform/runtime";
42
+ import NodePlatform from "@b9g/platform-node";
43
+ import {config} from "shovel:config";
44
+
45
+ await configureLogging(config.logging);
46
+ const logger = getLogger(["shovel", "platform"]);
47
+
48
+ logger.info("Starting production server", {port: config.port, workers: config.workers});
49
+
50
+ // Initialize platform and register ServiceWorker
51
+ // Override createWorker to use the imported Worker class (avoids require() issues with ESM)
52
+ const platform = new NodePlatform({port: config.port, host: config.host, workers: config.workers});
53
+ platform.createWorker = (entrypoint) => new Worker(entrypoint);
54
+ await platform.serviceWorker.register(new URL("./worker.js", import.meta.url).href);
55
+ await platform.serviceWorker.ready;
56
+
57
+ // Start HTTP server
58
+ await platform.listen();
59
+
60
+ logger.info("Server started", {port: config.port, host: config.host, workers: config.workers});
61
+
62
+ // Graceful shutdown
63
+ const handleShutdown = async () => {
64
+ logger.info("Shutting down");
65
+ await platform.close();
66
+ process.exit(0);
67
+ };
68
+ process.on("SIGINT", handleShutdown);
69
+ process.on("SIGTERM", handleShutdown);
70
+ `;
71
+ return {
72
+ supervisor: supervisorCode,
73
+ worker: workerCode
74
+ };
75
+ }
76
+ function getESBuildConfig() {
77
+ return {
78
+ platform: "node",
79
+ external: ["node:*", ...builtinModules],
80
+ define: {
81
+ // Node.js doesn't support import.meta.env, alias to process.env
82
+ "import.meta.env": "process.env"
83
+ }
84
+ };
85
+ }
86
+ function getDefaults() {
87
+ return {
88
+ caches: {
89
+ default: {
90
+ module: "@b9g/cache/memory",
91
+ export: "MemoryCache"
92
+ }
93
+ },
94
+ directories: {
95
+ server: {
96
+ module: "@b9g/filesystem/node-fs",
97
+ export: "NodeFSDirectory",
98
+ path: "[outdir]/server"
99
+ },
100
+ public: {
101
+ module: "@b9g/filesystem/node-fs",
102
+ export: "NodeFSDirectory",
103
+ path: "[outdir]/public"
104
+ },
105
+ tmp: {
106
+ module: "@b9g/filesystem/node-fs",
107
+ export: "NodeFSDirectory",
108
+ path: "[tmpdir]"
109
+ }
110
+ }
111
+ };
112
+ }
113
+ async function createDevServer(options) {
114
+ const { port, host, workerPath, workers = 1 } = options;
115
+ logger.info("Starting Node.js dev server", { workerPath, workers });
116
+ const { default: NodePlatform } = await import("./index.js");
117
+ const platform = new NodePlatform({
118
+ port,
119
+ host,
120
+ workers
121
+ });
122
+ await platform.serviceWorker.register(workerPath);
123
+ await platform.serviceWorker.ready;
124
+ await platform.listen();
125
+ logger.info("Node.js dev server ready");
126
+ const url = `http://${host}:${port}`;
127
+ return {
128
+ url,
129
+ async reload(newWorkerPath) {
130
+ logger.info("Reloading workers", { workerPath: newWorkerPath });
131
+ await platform.serviceWorker.reloadWorkers(newWorkerPath);
132
+ logger.info("Workers reloaded");
133
+ },
134
+ async close() {
135
+ logger.info("Stopping Node.js dev server");
136
+ await platform.dispose();
137
+ }
138
+ };
139
+ }
140
+ export {
141
+ createDevServer,
142
+ getDefaults,
143
+ getESBuildConfig,
144
+ getEntryPoints,
145
+ name
146
+ };