@b9g/platform-node 0.1.13 → 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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/index.d.ts +87 -29
  3. package/src/index.js +254 -451
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.0",
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.0",
18
18
  "@logtape/logtape": "^1.2.0"
19
19
  },
20
20
  "devDependencies": {
package/src/index.d.ts CHANGED
@@ -3,10 +3,10 @@
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";
7
- import { type ShovelConfig } from "@b9g/platform/runtime";
8
6
  import { CustomCacheStorage } from "@b9g/cache";
9
7
  import { CustomDirectoryStorage } from "@b9g/filesystem";
8
+ import { BasePlatform, type PlatformConfig, type PlatformDefaults, type Handler, type Server, type ServerOptions, type PlatformESBuildConfig, type ProductionEntryPoints, ServiceWorkerPool, CustomLoggerStorage, CustomDatabaseStorage } from "@b9g/platform";
9
+ import { type ShovelConfig } from "@b9g/platform/runtime";
10
10
  export interface NodePlatformOptions extends PlatformConfig {
11
11
  /** Port for development server (default: 3000) */
12
12
  port?: number;
@@ -19,6 +19,51 @@ export interface NodePlatformOptions extends PlatformConfig {
19
19
  /** Shovel configuration (caches, directories, etc.) */
20
20
  config?: ShovelConfig;
21
21
  }
22
+ /**
23
+ * Node.js ServiceWorkerContainer implementation
24
+ * Manages ServiceWorker registrations backed by worker threads
25
+ */
26
+ export declare class NodeServiceWorkerContainer extends EventTarget implements ServiceWorkerContainer {
27
+ #private;
28
+ readonly controller: ServiceWorker | null;
29
+ oncontrollerchange: ((ev: Event) => unknown) | null;
30
+ onmessage: ((ev: MessageEvent) => unknown) | null;
31
+ onmessageerror: ((ev: MessageEvent) => unknown) | null;
32
+ constructor(platform: NodePlatform);
33
+ /**
34
+ * Register a ServiceWorker script
35
+ * Spawns worker threads and runs lifecycle
36
+ */
37
+ register(scriptURL: string | URL, options?: RegistrationOptions): Promise<ServiceWorkerRegistration>;
38
+ /**
39
+ * Get registration for scope
40
+ */
41
+ getRegistration(scope?: string): Promise<ServiceWorkerRegistration | undefined>;
42
+ /**
43
+ * Get all registrations
44
+ */
45
+ getRegistrations(): Promise<readonly ServiceWorkerRegistration[]>;
46
+ /**
47
+ * Start receiving messages (no-op in server context)
48
+ */
49
+ startMessages(): void;
50
+ /**
51
+ * Ready promise - resolves when a registration is active
52
+ */
53
+ get ready(): Promise<ServiceWorkerRegistration>;
54
+ /**
55
+ * Internal: Get worker pool for request handling
56
+ */
57
+ get pool(): ServiceWorkerPool | undefined;
58
+ /**
59
+ * Internal: Terminate workers and dispose cache storage
60
+ */
61
+ terminate(): Promise<void>;
62
+ /**
63
+ * Internal: Reload workers (for hot reload)
64
+ */
65
+ reloadWorkers(entrypoint: string): Promise<void>;
66
+ }
22
67
  /**
23
68
  * Node.js platform implementation
24
69
  * ServiceWorker entrypoint loader for Node.js with ESBuild VM system
@@ -26,7 +71,21 @@ export interface NodePlatformOptions extends PlatformConfig {
26
71
  export declare class NodePlatform extends BasePlatform {
27
72
  #private;
28
73
  readonly name: string;
74
+ readonly serviceWorker: NodeServiceWorkerContainer;
29
75
  constructor(options?: NodePlatformOptions);
76
+ /**
77
+ * Create a worker instance for the pool
78
+ * Can be overridden for testing
79
+ */
80
+ createWorker(entrypoint: string): Promise<Worker>;
81
+ /**
82
+ * Start the HTTP server, routing requests to ServiceWorker
83
+ */
84
+ listen(): Promise<Server>;
85
+ /**
86
+ * Close the server and terminate workers
87
+ */
88
+ close(): Promise<void>;
30
89
  /**
31
90
  * Get options for testing
32
91
  */
@@ -38,33 +97,35 @@ export declare class NodePlatform extends BasePlatform {
38
97
  config?: ShovelConfig;
39
98
  };
40
99
  /**
41
- * Get/set worker pool for testing
42
- */
43
- get workerPool(): ServiceWorkerPool | undefined;
44
- set workerPool(pool: ServiceWorkerPool | undefined);
45
- /**
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
48
- */
49
- loadServiceWorker(entrypoint: string, options?: ServiceWorkerOptions): Promise<ServiceWorkerInstance>;
50
- /**
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
100
+ * Create cache storage for Node.js
101
+ *
102
+ * Default: MemoryCache (in-process LRU cache).
103
+ * Override via shovel.json caches config.
104
+ * Note: Used for dev/testing - production uses generated config module.
54
105
  */
55
106
  createCaches(): Promise<CustomCacheStorage>;
56
107
  /**
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
108
+ * Create directory storage for Node.js
109
+ *
110
+ * Defaults:
111
+ * - server: NodeFSDirectory at cwd (app files)
112
+ * - public: NodeFSDirectory at cwd (static assets)
113
+ * - tmp: NodeFSDirectory at OS temp dir
114
+ *
115
+ * Override via shovel.json directories config.
60
116
  */
61
117
  createDirectories(): Promise<CustomDirectoryStorage>;
62
118
  /**
63
- * Create logger storage using config from shovel.json
119
+ * Create logger storage for Node.js
120
+ *
121
+ * Uses LogTape for structured logging.
64
122
  */
65
123
  createLoggers(): Promise<CustomLoggerStorage>;
66
124
  /**
67
- * Create database storage from declarative config in shovel.json
125
+ * Create database storage for Node.js
126
+ *
127
+ * Returns undefined if no databases configured in shovel.json.
128
+ * Supports SQLite via better-sqlite3.
68
129
  */
69
130
  createDatabases(configOverride?: NodePlatformOptions["config"]): CustomDatabaseStorage | undefined;
70
131
  /**
@@ -73,21 +134,18 @@ export declare class NodePlatform extends BasePlatform {
73
134
  createServer(handler: Handler, options?: ServerOptions): Server;
74
135
  /**
75
136
  * Reload workers for hot reloading (called by CLI)
137
+ * @deprecated Use serviceWorker.reloadWorkers() instead
76
138
  * @param entrypoint - Path to the new entrypoint (hashed filename)
77
139
  */
78
140
  reloadWorkers(entrypoint: string): Promise<void>;
79
141
  /**
80
- * Get virtual entry wrapper for Node.js
81
- *
82
- * @param entryPath - Absolute path to user's entrypoint file
83
- * @param options - Entry wrapper options
84
- * @param options.type - "production" (default) or "worker"
142
+ * Get production entry points for bundling.
85
143
  *
86
- * Returns:
87
- * - "production": Server entry that loads ServiceWorkerPool
88
- * - "worker": Worker entry that sets up runtime and message loop
144
+ * Node.js produces two files:
145
+ * - index.js: Supervisor that spawns workers and owns the HTTP server
146
+ * - worker.js: Worker that handles requests via message loop
89
147
  */
90
- getEntryWrapper(entryPath: string, options?: EntryWrapperOptions): string;
148
+ getProductionEntryPoints(userEntryPath: string): ProductionEntryPoints;
91
149
  /**
92
150
  * Get Node.js-specific esbuild configuration
93
151
  *
package/src/index.js CHANGED
@@ -1,220 +1,151 @@
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 { MemoryCache } from "@b9g/cache/memory";
10
+ import { CustomDirectoryStorage } from "@b9g/filesystem";
11
+ import { NodeFSDirectory } from "@b9g/filesystem/node-fs";
12
+ import { InternalServerError, isHTTPError } from "@b9g/http-errors";
5
13
  import {
6
14
  BasePlatform,
7
15
  ServiceWorkerPool,
8
- SingleThreadedRuntime,
9
16
  CustomLoggerStorage,
10
17
  CustomDatabaseStorage,
11
- createDatabaseFactory
18
+ createDatabaseFactory,
19
+ mergeConfigWithDefaults
12
20
  } from "@b9g/platform";
13
21
  import {
22
+ ShovelServiceWorkerRegistration,
23
+ kServiceWorker,
14
24
  createCacheFactory,
15
25
  createDirectoryFactory
16
26
  } from "@b9g/platform/runtime";
17
- import { CustomCacheStorage } from "@b9g/cache";
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
27
  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
28
  var logger = getLogger(["shovel", "platform"]);
29
+ var NodeServiceWorkerContainer = class extends EventTarget {
30
+ #platform;
31
+ #pool;
32
+ #cacheStorage;
33
+ #registration;
34
+ #readyPromise;
35
+ #readyResolve;
36
+ // Standard ServiceWorkerContainer properties
37
+ controller;
38
+ oncontrollerchange;
39
+ onmessage;
40
+ onmessageerror;
41
+ constructor(platform) {
42
+ super();
43
+ this.#platform = platform;
44
+ this.#readyPromise = new Promise((resolve2) => {
45
+ this.#readyResolve = resolve2;
46
+ });
47
+ this.controller = null;
48
+ this.oncontrollerchange = null;
49
+ this.onmessage = null;
50
+ this.onmessageerror = null;
51
+ }
52
+ /**
53
+ * Register a ServiceWorker script
54
+ * Spawns worker threads and runs lifecycle
55
+ */
56
+ async register(scriptURL, options) {
57
+ const urlStr = typeof scriptURL === "string" ? scriptURL : scriptURL.toString();
58
+ const scope = options?.scope ?? "/";
59
+ let entryPath;
60
+ if (urlStr.startsWith("file://")) {
61
+ entryPath = new URL(urlStr).pathname;
62
+ } else {
63
+ entryPath = Path.resolve(this.#platform.options.cwd, urlStr);
64
+ }
65
+ let config = this.#platform.options.config;
66
+ const configPath = Path.join(Path.dirname(entryPath), "config.js");
67
+ try {
68
+ const configModule = await import(configPath);
69
+ config = configModule.config ?? config;
70
+ } catch (error) {
71
+ logger.debug`Using platform config (no config.js found): ${error}`;
72
+ }
73
+ if (!this.#cacheStorage && config?.caches) {
74
+ this.#cacheStorage = new CustomCacheStorage(
75
+ createCacheFactory({ configs: config.caches })
76
+ );
77
+ }
78
+ if (this.#pool) {
79
+ await this.#pool.terminate();
80
+ }
81
+ this.#pool = new ServiceWorkerPool(
82
+ {
83
+ workerCount: this.#platform.options.workers,
84
+ createWorker: (entrypoint) => this.#platform.createWorker(entrypoint)
85
+ },
86
+ entryPath,
87
+ this.#cacheStorage
88
+ );
89
+ await this.#pool.init();
90
+ this.#registration = new ShovelServiceWorkerRegistration(scope, urlStr);
91
+ this.#registration[kServiceWorker]._setState("activated");
92
+ this.#readyResolve?.(this.#registration);
93
+ return this.#registration;
94
+ }
95
+ /**
96
+ * Get registration for scope
97
+ */
98
+ async getRegistration(scope) {
99
+ if (scope === void 0 || scope === "/" || scope === this.#registration?.scope) {
100
+ return this.#registration;
101
+ }
102
+ return void 0;
103
+ }
104
+ /**
105
+ * Get all registrations
106
+ */
107
+ async getRegistrations() {
108
+ return this.#registration ? [this.#registration] : [];
109
+ }
110
+ /**
111
+ * Start receiving messages (no-op in server context)
112
+ */
113
+ startMessages() {
114
+ }
115
+ /**
116
+ * Ready promise - resolves when a registration is active
117
+ */
118
+ get ready() {
119
+ return this.#readyPromise;
120
+ }
121
+ /**
122
+ * Internal: Get worker pool for request handling
123
+ */
124
+ get pool() {
125
+ return this.#pool;
126
+ }
127
+ /**
128
+ * Internal: Terminate workers and dispose cache storage
129
+ */
130
+ async terminate() {
131
+ await this.#pool?.terminate();
132
+ this.#pool = void 0;
133
+ await this.#cacheStorage?.dispose();
134
+ this.#cacheStorage = void 0;
135
+ }
136
+ /**
137
+ * Internal: Reload workers (for hot reload)
138
+ */
139
+ async reloadWorkers(entrypoint) {
140
+ await this.#pool?.reloadWorkers(entrypoint);
141
+ }
142
+ };
210
143
  var NodePlatform = class extends BasePlatform {
211
144
  name;
145
+ serviceWorker;
212
146
  #options;
213
- #workerPool;
214
- #singleThreadedRuntime;
215
- #cacheStorage;
216
- #directoryStorage;
217
147
  #databaseStorage;
148
+ #server;
218
149
  constructor(options = {}) {
219
150
  super(options);
220
151
  this.name = "node";
@@ -226,264 +157,93 @@ var NodePlatform = class extends BasePlatform {
226
157
  cwd,
227
158
  config: options.config
228
159
  };
160
+ this.serviceWorker = new NodeServiceWorkerContainer(this);
229
161
  }
230
162
  /**
231
- * Get options for testing
163
+ * Create a worker instance for the pool
164
+ * Can be overridden for testing
232
165
  */
233
- get options() {
234
- return this.#options;
166
+ async createWorker(entrypoint) {
167
+ const { Worker: NodeWebWorker } = await import("@b9g/node-webworker");
168
+ return new NodeWebWorker(entrypoint);
235
169
  }
236
170
  /**
237
- * Get/set worker pool for testing
171
+ * Start the HTTP server, routing requests to ServiceWorker
238
172
  */
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);
173
+ async listen() {
174
+ const pool = this.serviceWorker.pool;
175
+ if (!pool) {
176
+ throw new Error(
177
+ "No ServiceWorker registered. Call serviceWorker.register() first."
178
+ );
253
179
  }
254
- return this.#loadServiceWorkerWithPool(entrypoint, options, workerCount);
180
+ this.#server = this.createServer((request) => pool.handleRequest(request));
181
+ await this.#server.listen();
182
+ return this.#server;
255
183
  }
256
184
  /**
257
- * Load ServiceWorker directly in main thread (single-threaded mode)
258
- * No postMessage overhead - maximum performance for production
185
+ * Close the server and terminate workers
259
186
  */
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))
325
- });
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;
187
+ async close() {
188
+ await this.#server?.close();
189
+ await this.serviceWorker.terminate();
359
190
  }
360
191
  /**
361
- * Load ServiceWorker using worker pool (multi-threaded mode or dev mode)
192
+ * Get options for testing
362
193
  */
363
- async #loadServiceWorkerWithPool(entrypoint, _options, workerCount) {
364
- const entryPath = Path.resolve(this.#options.cwd, entrypoint);
365
- let config = this.#options.config;
366
- const configPath = Path.join(Path.dirname(entryPath), "config.js");
367
- try {
368
- const configModule = await import(configPath);
369
- config = configModule.config ?? config;
370
- } catch (err) {
371
- logger.debug`Using platform config (no config.js): ${err}`;
372
- }
373
- if (!this.#cacheStorage) {
374
- this.#cacheStorage = new CustomCacheStorage(
375
- createCacheFactory({
376
- configs: config?.caches ?? {}
377
- })
378
- );
379
- }
380
- if (this.#singleThreadedRuntime) {
381
- await this.#singleThreadedRuntime.terminate();
382
- this.#singleThreadedRuntime = void 0;
383
- }
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(
392
- {
393
- workerCount,
394
- requestTimeout: 3e4,
395
- cwd: this.#options.cwd
396
- },
397
- entryPath,
398
- this.#cacheStorage
399
- );
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;
194
+ get options() {
195
+ return this.#options;
436
196
  }
437
197
  /**
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
198
+ * Create cache storage for Node.js
199
+ *
200
+ * Default: MemoryCache (in-process LRU cache).
201
+ * Override via shovel.json caches config.
202
+ * Note: Used for dev/testing - production uses generated config module.
441
203
  */
442
204
  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] };
454
- }
205
+ const defaults = { default: { impl: MemoryCache } };
206
+ const configs = mergeConfigWithDefaults(
207
+ defaults,
208
+ this.#options.config?.caches
209
+ );
455
210
  return new CustomCacheStorage(createCacheFactory({ configs }));
456
211
  }
457
212
  /**
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
213
+ * Create directory storage for Node.js
214
+ *
215
+ * Defaults:
216
+ * - server: NodeFSDirectory at cwd (app files)
217
+ * - public: NodeFSDirectory at cwd (static assets)
218
+ * - tmp: NodeFSDirectory at OS temp dir
219
+ *
220
+ * Override via shovel.json directories config.
461
221
  */
462
222
  async createDirectories() {
463
- const runtimeDefaults = {
223
+ const defaults = {
464
224
  server: { impl: NodeFSDirectory, path: this.#options.cwd },
465
225
  public: { impl: NodeFSDirectory, path: this.#options.cwd },
466
226
  tmp: { impl: NodeFSDirectory, path: tmpdir() }
467
227
  };
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
- }
228
+ const configs = mergeConfigWithDefaults(
229
+ defaults,
230
+ this.#options.config?.directories
231
+ );
477
232
  return new CustomDirectoryStorage(createDirectoryFactory(configs));
478
233
  }
479
234
  /**
480
- * Create logger storage using config from shovel.json
235
+ * Create logger storage for Node.js
236
+ *
237
+ * Uses LogTape for structured logging.
481
238
  */
482
239
  async createLoggers() {
483
240
  return new CustomLoggerStorage((categories) => getLogger(categories));
484
241
  }
485
242
  /**
486
- * Create database storage from declarative config in shovel.json
243
+ * Create database storage for Node.js
244
+ *
245
+ * Returns undefined if no databases configured in shovel.json.
246
+ * Supports SQLite via better-sqlite3.
487
247
  */
488
248
  createDatabases(configOverride) {
489
249
  const config = configOverride ?? this.#options.config;
@@ -584,31 +344,83 @@ var NodePlatform = class extends BasePlatform {
584
344
  }
585
345
  /**
586
346
  * Reload workers for hot reloading (called by CLI)
347
+ * @deprecated Use serviceWorker.reloadWorkers() instead
587
348
  * @param entrypoint - Path to the new entrypoint (hashed filename)
588
349
  */
589
350
  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
- }
351
+ await this.serviceWorker.reloadWorkers(entrypoint);
595
352
  }
596
353
  /**
597
- * Get virtual entry wrapper for Node.js
354
+ * Get production entry points for bundling.
598
355
  *
599
- * @param entryPath - Absolute path to user's entrypoint file
600
- * @param options - Entry wrapper options
601
- * @param options.type - "production" (default) or "worker"
602
- *
603
- * Returns:
604
- * - "production": Server entry that loads ServiceWorkerPool
605
- * - "worker": Worker entry that sets up runtime and message loop
356
+ * Node.js produces two files:
357
+ * - index.js: Supervisor that spawns workers and owns the HTTP server
358
+ * - worker.js: Worker that handles requests via message loop
606
359
  */
607
- getEntryWrapper(entryPath, options) {
608
- if (options?.type === "worker") {
609
- return workerEntryTemplate.replace("__USER_ENTRY__", entryPath);
610
- }
611
- return entryTemplate;
360
+ getProductionEntryPoints(userEntryPath) {
361
+ const supervisorCode = `// Node.js Production Supervisor
362
+ import {Worker} from "@b9g/node-webworker";
363
+ import {getLogger} from "@logtape/logtape";
364
+ import {configureLogging} from "@b9g/platform/runtime";
365
+ import NodePlatform from "@b9g/platform-node";
366
+ import {config} from "shovel:config";
367
+
368
+ await configureLogging(config.logging);
369
+ const logger = getLogger(["shovel", "platform"]);
370
+
371
+ logger.info("Starting production server", {port: config.port, workers: config.workers});
372
+
373
+ // Initialize platform and register ServiceWorker
374
+ // Override createWorker to use the imported Worker class (avoids require() issues with ESM)
375
+ const platform = new NodePlatform({port: config.port, host: config.host, workers: config.workers});
376
+ platform.createWorker = (entrypoint) => new Worker(entrypoint);
377
+ await platform.serviceWorker.register(new URL("./worker.js", import.meta.url).href);
378
+ await platform.serviceWorker.ready;
379
+
380
+ // Start HTTP server
381
+ await platform.listen();
382
+
383
+ logger.info("Server started", {port: config.port, host: config.host, workers: config.workers});
384
+
385
+ // Graceful shutdown
386
+ const handleShutdown = async () => {
387
+ logger.info("Shutting down");
388
+ await platform.close();
389
+ process.exit(0);
390
+ };
391
+ process.on("SIGINT", handleShutdown);
392
+ process.on("SIGTERM", handleShutdown);
393
+ `;
394
+ const workerCode = `// Node.js Production Worker
395
+ import {parentPort} from "node:worker_threads";
396
+ import {configureLogging, initWorkerRuntime, runLifecycle, startWorkerMessageLoop} from "@b9g/platform/runtime";
397
+ import {config} from "shovel:config";
398
+
399
+ await configureLogging(config.logging);
400
+
401
+ // Initialize worker runtime (installs ServiceWorker globals)
402
+ const {registration, databases} = await initWorkerRuntime({config});
403
+
404
+ // Import user code (registers event handlers)
405
+ await import("${userEntryPath}");
406
+
407
+ // Run ServiceWorker lifecycle (stage from config.lifecycle if present)
408
+ await runLifecycle(registration, config.lifecycle?.stage);
409
+
410
+ // Start message loop for request handling, or signal ready and exit in lifecycle-only mode
411
+ if (config.lifecycle) {
412
+ parentPort?.postMessage({type: "ready"});
413
+ // Clean shutdown after lifecycle
414
+ if (databases) await databases.closeAll();
415
+ process.exit(0);
416
+ } else {
417
+ startWorkerMessageLoop({registration, databases});
418
+ }
419
+ `;
420
+ return {
421
+ index: supervisorCode,
422
+ worker: workerCode
423
+ };
612
424
  }
613
425
  /**
614
426
  * Get Node.js-specific esbuild configuration
@@ -663,18 +475,8 @@ var NodePlatform = class extends BasePlatform {
663
475
  * Dispose of platform resources
664
476
  */
665
477
  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
- }
478
+ await this.close();
479
+ await this.serviceWorker.terminate();
678
480
  if (this.#databaseStorage) {
679
481
  await this.#databaseStorage.closeAll();
680
482
  this.#databaseStorage = void 0;
@@ -694,5 +496,6 @@ var src_default = NodePlatform;
694
496
  export {
695
497
  MemoryCache2 as DefaultCache,
696
498
  NodePlatform,
499
+ NodeServiceWorkerContainer,
697
500
  src_default as default
698
501
  };