@ecopages/core 0.2.0-beta.4 → 0.2.0-beta.6

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 (27) hide show
  1. package/package.json +2 -2
  2. package/src/adapters/abstract/application-adapter.d.ts +46 -0
  3. package/src/adapters/abstract/application-adapter.js +70 -0
  4. package/src/adapters/abstract/ws-pattern-matcher.d.ts +60 -0
  5. package/src/adapters/abstract/ws-pattern-matcher.js +61 -0
  6. package/src/adapters/bun/create-app.d.ts +3 -0
  7. package/src/adapters/bun/create-app.js +8 -1
  8. package/src/adapters/bun/hmr-manager.d.ts +2 -1
  9. package/src/adapters/bun/hmr-manager.js +2 -2
  10. package/src/adapters/bun/server-adapter.d.ts +61 -5
  11. package/src/adapters/bun/server-adapter.js +201 -6
  12. package/src/adapters/node/create-app.d.ts +3 -0
  13. package/src/adapters/node/create-app.js +7 -0
  14. package/src/adapters/node/node-hmr-manager.d.ts +2 -1
  15. package/src/adapters/node/node-hmr-manager.js +2 -2
  16. package/src/adapters/node/server-adapter.d.ts +25 -2
  17. package/src/adapters/node/server-adapter.js +60 -13
  18. package/src/adapters/shared/bun-user-websocket-lifecycle.d.ts +22 -0
  19. package/src/adapters/shared/bun-user-websocket-lifecycle.js +102 -0
  20. package/src/adapters/shared/node-http-websocket-upgrades.d.ts +21 -0
  21. package/src/adapters/shared/node-http-websocket-upgrades.js +125 -0
  22. package/src/adapters/shared/shared-hmr-manager.d.ts +1 -0
  23. package/src/adapters/shared/shared-hmr-manager.js +22 -3
  24. package/src/adapters/shared/websocket-lifecycle.d.ts +3 -0
  25. package/src/adapters/shared/websocket-lifecycle.js +19 -0
  26. package/src/build/runtime-build-output-normalizer.js +9 -1
  27. package/src/types/public-types.d.ts +76 -0
@@ -4,6 +4,7 @@ import { RESOLVED_ASSETS_DIR } from "../../config/constants.js";
4
4
  import { appLogger } from "../../global/app-logger.js";
5
5
  import { HttpError } from "../../errors/http-error.js";
6
6
  import { createRequire } from "../../utils/locals-utils.js";
7
+ import { findWebSocketRoute } from "../abstract/ws-pattern-matcher.js";
7
8
  import { fileSystem } from "@ecopages/file-system";
8
9
  import { getAppBrowserBuildPlugins, setupAppRuntimePlugins } from "../../build/build-adapter.js";
9
10
  import { installAppRuntimeBuildExecutor } from "../../build/runtime-build-executor.js";
@@ -17,6 +18,8 @@ import {
17
18
  isHtmlResponse,
18
19
  shouldInjectHmrHtmlResponse
19
20
  } from "../shared/hmr-html-response.js";
21
+ import { attachNodeHttpWebSocketUpgrades } from "../shared/node-http-websocket-upgrades.js";
22
+ import { createBunUserWebSocketLifecycle } from "../shared/bun-user-websocket-lifecycle.js";
20
23
  import { resolveServeRuntimeOrigin } from "../shared/runtime-app-bootstrap.js";
21
24
  import { ClientBridge } from "./client-bridge.js";
22
25
  import { HmrManager } from "./hmr-manager.js";
@@ -30,6 +33,90 @@ class BunServerAdapter extends SharedServerAdapter {
30
33
  initializationPromise = null;
31
34
  fullyInitialized = false;
32
35
  previewHost;
36
+ /**
37
+ * Reference to the application-level WebSocket handlers map.
38
+ *
39
+ * @remarks
40
+ * This is a reference to the map owned by `AbstractApplicationAdapter`,
41
+ * passed in via the constructor. The Bun adapter reads from it to wire
42
+ * WebSocket upgrades for user-registered patterns.
43
+ */
44
+ websocketHandlers = /* @__PURE__ */ new Map();
45
+ /**
46
+ * Adapts a Bun ServerWebSocket to the public EcopagesSocket interface.
47
+ *
48
+ * @remarks
49
+ * This is the single source of truth for the Bun→public type adaptation.
50
+ * Both the HMR and production branches use this helper to ensure consistency.
51
+ *
52
+ * @param ws - The raw Bun ServerWebSocket
53
+ * @param kind - The registered route pattern
54
+ * @param params - Dynamic path parameters
55
+ * @param search - Query string parameters
56
+ * @param context - The resolved per-connection context
57
+ * @returns An EcopagesSocket view
58
+ */
59
+ adaptBunWebSocket(ws, kind, params, search, context) {
60
+ return {
61
+ kind,
62
+ params,
63
+ search,
64
+ context,
65
+ send: (message) => {
66
+ if (typeof message === "string") {
67
+ ws.send(message);
68
+ } else if (message instanceof Blob) {
69
+ void message.arrayBuffer().then((buf) => ws.send(new Uint8Array(buf)));
70
+ } else if (message instanceof ArrayBuffer) {
71
+ ws.send(new Uint8Array(message));
72
+ } else if (ArrayBuffer.isView(message)) {
73
+ ws.send(new Uint8Array(message.buffer, message.byteOffset, message.byteLength));
74
+ } else {
75
+ ws.send(message);
76
+ }
77
+ },
78
+ sendStream: async (stream) => {
79
+ const reader = stream.getReader();
80
+ try {
81
+ while (true) {
82
+ const { value, done } = await reader.read();
83
+ if (done) break;
84
+ if (value) ws.send(value);
85
+ }
86
+ } finally {
87
+ reader.releaseLock();
88
+ }
89
+ },
90
+ close: (code, reason) => ws.close(code, reason)
91
+ };
92
+ }
93
+ /**
94
+ * Resolves the per-connection context for a Bun user connection.
95
+ *
96
+ * @remarks
97
+ * `context()` is invoked exactly once per accepted connection. Its
98
+ * resolved value is shared by all subsequent lifecycle hooks. If
99
+ * `context()` throws, the connection is closed immediately with code
100
+ * 1011 (server error) and the error is logged.
101
+ *
102
+ * @param request - The original upgrade request (best-effort reconstructed)
103
+ * @param handler - The registered handler
104
+ * @param kind - The registered route pattern
105
+ * @param params - Dynamic path parameters
106
+ * @param search - Query string parameters
107
+ * @returns The resolved per-connection context
108
+ */
109
+ async resolveBunContext(request, handler, kind, params, search) {
110
+ if (!handler.context) {
111
+ return void 0;
112
+ }
113
+ try {
114
+ return await handler.context({ request, kind, params, search });
115
+ } catch (error) {
116
+ appLogger.error(`[WS:${kind}] context() failed; closing connection.`, error);
117
+ throw error;
118
+ }
119
+ }
33
120
  /**
34
121
  * Creates a Bun server adapter with already-resolved runtime collaborators.
35
122
  *
@@ -46,6 +133,7 @@ class BunServerAdapter extends SharedServerAdapter {
46
133
  apiHandlers,
47
134
  staticRoutes,
48
135
  errorHandler,
136
+ websocketHandlers,
49
137
  options,
50
138
  hmrManager,
51
139
  bridge,
@@ -58,6 +146,19 @@ class BunServerAdapter extends SharedServerAdapter {
58
146
  this.bridge = bridge;
59
147
  this.hmrManager = hmrManager;
60
148
  this.previewHost = previewHost;
149
+ if (websocketHandlers) {
150
+ this.websocketHandlers = websocketHandlers;
151
+ }
152
+ }
153
+ /**
154
+ * Wires user WebSocket routes onto a Node HTTP server used by host integrations.
155
+ */
156
+ attachUserWebSocketUpgrades(server, options) {
157
+ attachNodeHttpWebSocketUpgrades(server, {
158
+ runtimeOrigin: this.runtimeOrigin,
159
+ websocketHandlers: this.websocketHandlers,
160
+ passthroughUnmatched: options?.passthroughUnmatched
161
+ });
61
162
  }
62
163
  /**
63
164
  * Returns whether adapter-level HTML responses still need HMR runtime injection.
@@ -181,9 +282,13 @@ class BunServerAdapter extends SharedServerAdapter {
181
282
  * Builds the `Bun.serve()` options for the current adapter state.
182
283
  *
183
284
  * @remarks
184
- * The HMR-enabled variant wraps the base fetch handler so one Bun server can
185
- * serve normal requests, accept HMR websocket upgrades, and expose the HMR
186
- * runtime asset without splitting responsibility across separate listeners.
285
+ * When HMR is enabled the websocket dispatcher merges HMR and user handlers,
286
+ * routing by the `kind` field embedded in socket data at upgrade time.
287
+ * This prevents user-registered websocket handlers from being silently overwritten
288
+ * by the HMR handler in development mode.
289
+ *
290
+ * User WebSocket paths are intercepted in the fetch handler via the pattern matcher.
291
+ * The upgrade happens implicitly — no manual GET route registration needed.
187
292
  */
188
293
  getServerOptions({ enableHmr = false } = {}) {
189
294
  appLogger.debug(`[BunServerAdapter] getServerOptions called with enableHmr: ${enableHmr}`);
@@ -192,14 +297,54 @@ class BunServerAdapter extends SharedServerAdapter {
192
297
  const originalFetch = serverOptions.fetch;
193
298
  const hmrHandler = this.hmrManager.getWebSocketHandler();
194
299
  const hmrManager = this.hmrManager;
300
+ const matchRoute = (pathname) => findWebSocketRoute(this.websocketHandlers, pathname);
301
+ const userLifecycle = createBunUserWebSocketLifecycle({
302
+ runtimeOrigin: this.runtimeOrigin,
303
+ userHandlers: this.websocketHandlers,
304
+ resolveContext: this.resolveBunContext.bind(this),
305
+ adaptSocket: (ws, kind, params, search, context) => this.adaptBunWebSocket(ws, kind, params, search, context)
306
+ });
195
307
  serverOptions.development = true;
196
- serverOptions.websocket = hmrHandler;
308
+ serverOptions.websocket = {
309
+ open(ws) {
310
+ const kind = ws.data?.kind ?? "__hmr__";
311
+ if (kind === "__hmr__") {
312
+ hmrHandler.open?.(ws);
313
+ return;
314
+ }
315
+ userLifecycle.open(ws);
316
+ },
317
+ message(ws, msg) {
318
+ const kind = ws.data?.kind ?? "__hmr__";
319
+ if (kind === "__hmr__") {
320
+ hmrHandler.message?.(ws, msg);
321
+ return;
322
+ }
323
+ userLifecycle.message(ws, msg);
324
+ },
325
+ close(ws, code, reason) {
326
+ const kind = ws.data?.kind ?? "__hmr__";
327
+ if (kind === "__hmr__") {
328
+ hmrHandler.close?.(ws, code, reason);
329
+ return;
330
+ }
331
+ userLifecycle.close(ws, code, reason);
332
+ },
333
+ error(ws, error) {
334
+ const kind = ws.data?.kind ?? "__hmr__";
335
+ if (kind === "__hmr__") {
336
+ appLogger.error("[HMR] WebSocket error:", error);
337
+ return;
338
+ }
339
+ userLifecycle.error(ws, error);
340
+ }
341
+ };
197
342
  serverOptions.fetch = async function(request, _server) {
198
343
  const url = new URL(request.url);
199
344
  appLogger.debug(`[HMR] Request: ${url.pathname}`);
200
345
  if (url.pathname === "/_hmr") {
201
346
  const success = this.upgrade(request, {
202
- data: void 0
347
+ data: { kind: "__hmr__", params: {}, search: {} }
203
348
  });
204
349
  if (success) return;
205
350
  return new Response("WebSocket upgrade failed", { status: 400 });
@@ -210,6 +355,55 @@ class BunServerAdapter extends SharedServerAdapter {
210
355
  headers: { "Content-Type": "application/javascript" }
211
356
  });
212
357
  }
358
+ const wsMatch = matchRoute(url.pathname);
359
+ if (wsMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
360
+ const search = Object.fromEntries(url.searchParams.entries());
361
+ const success = this.upgrade(request, {
362
+ data: {
363
+ kind: wsMatch.kind,
364
+ params: wsMatch.params,
365
+ search,
366
+ upgradeUrl: request.url
367
+ }
368
+ });
369
+ if (success) return;
370
+ return new Response("WebSocket upgrade failed", { status: 400 });
371
+ }
372
+ let response;
373
+ if (originalFetch) {
374
+ const res = await originalFetch.call(this, request, this);
375
+ response = res instanceof Response ? res : new Response("Not Found", { status: 404 });
376
+ } else {
377
+ response = new Response("Not Found", { status: 404 });
378
+ }
379
+ return response;
380
+ };
381
+ } else if (this.websocketHandlers.size > 0) {
382
+ const matchRoute = (pathname) => findWebSocketRoute(this.websocketHandlers, pathname);
383
+ const userLifecycle = createBunUserWebSocketLifecycle({
384
+ runtimeOrigin: this.runtimeOrigin,
385
+ userHandlers: this.websocketHandlers,
386
+ resolveContext: this.resolveBunContext.bind(this),
387
+ adaptSocket: (ws, kind, params, search, context) => this.adaptBunWebSocket(ws, kind, params, search, context)
388
+ });
389
+ serverOptions.websocket = userLifecycle;
390
+ const originalFetch = serverOptions.fetch;
391
+ serverOptions.fetch = async function(request, _server) {
392
+ const url = new URL(request.url);
393
+ const wsMatch = matchRoute(url.pathname);
394
+ if (wsMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
395
+ const search = Object.fromEntries(url.searchParams.entries());
396
+ const success = this.upgrade(request, {
397
+ data: {
398
+ kind: wsMatch.kind,
399
+ params: wsMatch.params,
400
+ search,
401
+ upgradeUrl: request.url
402
+ }
403
+ });
404
+ if (success) return;
405
+ return new Response("WebSocket upgrade failed", { status: 400 });
406
+ }
213
407
  let response;
214
408
  if (originalFetch) {
215
409
  const res = await originalFetch.call(this, request, this);
@@ -380,7 +574,8 @@ class BunServerAdapter extends SharedServerAdapter {
380
574
  getServerOptions: this.getServerOptions.bind(this),
381
575
  buildStatic: this.buildStatic.bind(this),
382
576
  completeInitialization: this.completeInitialization.bind(this),
383
- handleRequest: this.handleRequest.bind(this)
577
+ handleRequest: this.handleRequest.bind(this),
578
+ attachUserWebSocketUpgrades: this.attachUserWebSocketUpgrades.bind(this)
384
579
  };
385
580
  }
386
581
  /**
@@ -19,6 +19,9 @@ export declare class NodeEcopagesApp extends SharedApplicationAdapter<EcopagesAp
19
19
  protected initializeServerAdapter(): Promise<NodeServerAdapterResult>;
20
20
  start(): Promise<NodeServerInstance | void>;
21
21
  fetch(request: Request): Promise<Response>;
22
+ attachWebSocketUpgrades(httpServer: import('node:http').Server, options?: {
23
+ passthroughUnmatched?: boolean;
24
+ }): Promise<void>;
22
25
  }
23
26
  export declare function createNodeApp(options: EcopagesAppOptions): Promise<NodeEcopagesApp>;
24
27
  export declare function createApp(options: EcopagesAppOptions): Promise<NodeEcopagesApp>;
@@ -36,6 +36,7 @@ class NodeEcopagesApp extends SharedApplicationAdapter {
36
36
  apiHandlers: this.apiHandlers,
37
37
  staticRoutes: this.staticRoutes,
38
38
  errorHandler: this.errorHandler,
39
+ websocketHandlers: this.websocketHandlers.size > 0 ? this.websocketHandlers : void 0,
39
40
  options: { watch: binding.watch },
40
41
  serveOptions: binding.serveOptions
41
42
  });
@@ -76,6 +77,12 @@ class NodeEcopagesApp extends SharedApplicationAdapter {
76
77
  }
77
78
  return this.serverAdapter.handleRequest(request);
78
79
  }
80
+ async attachWebSocketUpgrades(httpServer, options) {
81
+ if (!this.serverAdapter) {
82
+ this.serverAdapter = await this.initializeServerAdapter();
83
+ }
84
+ this.serverAdapter.attachUserWebSocketUpgrades(httpServer, options);
85
+ }
79
86
  }
80
87
  async function createNodeApp(options) {
81
88
  return new NodeEcopagesApp(options, {
@@ -4,6 +4,7 @@ import { SharedHmrManager } from '../shared/shared-hmr-manager.js';
4
4
  export interface NodeHmrManagerParams {
5
5
  appConfig: EcoPagesAppConfig;
6
6
  bridge: IClientBridge;
7
+ registrationTimeoutMs?: number;
7
8
  }
8
9
  /**
9
10
  * Node development HMR manager.
@@ -28,7 +29,7 @@ export declare class NodeHmrManager extends SharedHmrManager {
28
29
  * actually differs: dependency-graph storage, missing-file tolerance, and how
29
30
  * runtime bundle failures disable HMR.
30
31
  */
31
- constructor({ appConfig, bridge }: NodeHmrManagerParams);
32
+ constructor({ appConfig, bridge, registrationTimeoutMs }: NodeHmrManagerParams);
32
33
  /**
33
34
  * Reuses the shared in-memory dependency graph when the app already has one and
34
35
  * otherwise creates the default Node development graph.
@@ -13,8 +13,8 @@ class NodeHmrManager extends SharedHmrManager {
13
13
  * actually differs: dependency-graph storage, missing-file tolerance, and how
14
14
  * runtime bundle failures disable HMR.
15
15
  */
16
- constructor({ appConfig, bridge }) {
17
- super({ appConfig, bridge });
16
+ constructor({ appConfig, bridge, registrationTimeoutMs }) {
17
+ super({ appConfig, bridge, registrationTimeoutMs });
18
18
  }
19
19
  /**
20
20
  * Reuses the shared in-memory dependency graph when the app already has one and
@@ -1,6 +1,6 @@
1
1
  import { type Server as NodeHttpServer } from 'node:http';
2
2
  import type { EcoPagesAppConfig } from '../../types/internal-types.js';
3
- import type { ApiHandler, ErrorHandler, StaticRoute } from '../../types/public-types.js';
3
+ import type { ApiHandler, ErrorHandler, StaticRoute, EcopagesWebSocketHandler } from '../../types/public-types.js';
4
4
  import { SharedServerAdapter } from '../shared/server-adapter.js';
5
5
  import type { ServerAdapterResult } from '../abstract/server-adapter.js';
6
6
  import { NodeHttpRequestBridge } from './http-request-bridge.js';
@@ -19,6 +19,7 @@ export interface NodeServerAdapterParams {
19
19
  apiHandlers?: ApiHandler[];
20
20
  staticRoutes?: StaticRoute[];
21
21
  errorHandler?: ErrorHandler;
22
+ websocketHandlers?: Map<string, EcopagesWebSocketHandler<any, any>>;
22
23
  options?: {
23
24
  watch?: boolean;
24
25
  };
@@ -29,6 +30,9 @@ export interface NodeServerAdapterParams {
29
30
  export interface NodeServerAdapterResult extends ServerAdapterResult {
30
31
  completeInitialization: (server: NodeServerInstance) => Promise<void>;
31
32
  handleRequest: (request: Request) => Promise<Response>;
33
+ attachUserWebSocketUpgrades: (server: NodeServerInstance, options?: {
34
+ passthroughUnmatched?: boolean;
35
+ }) => void;
32
36
  }
33
37
  /**
34
38
  * Node.js HTTP server adapter for the Ecopages runtime.
@@ -61,6 +65,25 @@ export declare class NodeServerAdapter extends SharedServerAdapter<NodeServerAda
61
65
  private readonly previewHost;
62
66
  private readonly requestBridge;
63
67
  private readonly devRuntimeFactory;
68
+ /**
69
+ * Reference to the application-level WebSocket handlers map.
70
+ *
71
+ * @remarks
72
+ * This is a reference to the map owned by `AbstractApplicationAdapter`,
73
+ * passed in via the constructor. The Node adapter reads from it to wire
74
+ * WebSocket upgrades for user-registered patterns.
75
+ */
76
+ protected websocketHandlers: Map<string, EcopagesWebSocketHandler<any, any>>;
77
+ /**
78
+ * Wires user WebSocket routes onto a foreign Node HTTP server.
79
+ *
80
+ * Host integrations such as the Vite plugin call this so `app.websocket()`
81
+ * handlers work while HTTP is still served by the host dev server.
82
+ */
83
+ attachUserWebSocketUpgrades(server: NodeServerInstance, options?: {
84
+ passthroughUnmatched?: boolean;
85
+ }): void;
86
+ private wireUserWebSocketUpgrades;
64
87
  private shouldInjectHmrScript;
65
88
  private isHtmlResponse;
66
89
  private maybeInjectHmrScript;
@@ -123,7 +146,7 @@ export declare class NodeServerAdapter extends SharedServerAdapter<NodeServerAda
123
146
  * - Shared watcher bootstrapping listens for route-level file changes and
124
147
  * refreshes the router and response handlers when pages are added or removed.
125
148
  *
126
- * WebSocket upgrade requests that do not target `/_hmr` are rejected with an
149
+ * WebSocket upgrade requests that do not match a known path are rejected with an
127
150
  * immediate socket destroy to prevent unhandled upgrade leaks.
128
151
  */
129
152
  completeInitialization(server: NodeServerInstance): Promise<void>;
@@ -8,6 +8,9 @@ import { appLogger } from "../../global/app-logger.js";
8
8
  import { NodeClientBridge } from "./node-client-bridge.js";
9
9
  import { NodeHmrManager } from "./node-hmr-manager.js";
10
10
  import { ProjectWatcher } from "../../watchers/project-watcher.js";
11
+ import {
12
+ attachNodeHttpWebSocketUpgrades
13
+ } from "../shared/node-http-websocket-upgrades.js";
11
14
  import { StaticSiteGenerator } from "../../static-site-generator/static-site-generator.js";
12
15
  import { SharedServerAdapter } from "../shared/server-adapter.js";
13
16
  import { ServerStaticBuilder } from "../shared/server-static-builder.js";
@@ -32,6 +35,36 @@ class NodeServerAdapter extends SharedServerAdapter {
32
35
  previewHost;
33
36
  requestBridge;
34
37
  devRuntimeFactory;
38
+ /**
39
+ * Reference to the application-level WebSocket handlers map.
40
+ *
41
+ * @remarks
42
+ * This is a reference to the map owned by `AbstractApplicationAdapter`,
43
+ * passed in via the constructor. The Node adapter reads from it to wire
44
+ * WebSocket upgrades for user-registered patterns.
45
+ */
46
+ websocketHandlers = /* @__PURE__ */ new Map();
47
+ /**
48
+ * Wires user WebSocket routes onto a foreign Node HTTP server.
49
+ *
50
+ * Host integrations such as the Vite plugin call this so `app.websocket()`
51
+ * handlers work while HTTP is still served by the host dev server.
52
+ */
53
+ attachUserWebSocketUpgrades(server, options) {
54
+ attachNodeHttpWebSocketUpgrades(server, {
55
+ runtimeOrigin: this.runtimeOrigin,
56
+ websocketHandlers: this.websocketHandlers,
57
+ passthroughUnmatched: options?.passthroughUnmatched
58
+ });
59
+ }
60
+ wireUserWebSocketUpgrades(server, preflight) {
61
+ attachNodeHttpWebSocketUpgrades(server, {
62
+ runtimeOrigin: this.runtimeOrigin,
63
+ websocketHandlers: this.websocketHandlers,
64
+ passthroughUnmatched: false,
65
+ preflight
66
+ });
67
+ }
35
68
  shouldInjectHmrScript() {
36
69
  return shouldInjectHmrHtmlResponse(this.options?.watch === true, this.hmrManager ?? void 0);
37
70
  }
@@ -52,6 +85,9 @@ class NodeServerAdapter extends SharedServerAdapter {
52
85
  this.previewHost = options.previewHost;
53
86
  this.requestBridge = options.requestBridge;
54
87
  this.devRuntimeFactory = options.devRuntimeFactory;
88
+ if (options.websocketHandlers) {
89
+ this.websocketHandlers = options.websocketHandlers;
90
+ }
55
91
  }
56
92
  /**
57
93
  * Prepares the adapter for use.
@@ -200,7 +236,8 @@ class NodeServerAdapter extends SharedServerAdapter {
200
236
  getServerOptions: this.getServerOptions.bind(this),
201
237
  buildStatic: this.buildStatic.bind(this),
202
238
  completeInitialization: this.completeInitialization.bind(this),
203
- handleRequest: this.handleRequest.bind(this)
239
+ handleRequest: this.handleRequest.bind(this),
240
+ attachUserWebSocketUpgrades: this.attachUserWebSocketUpgrades.bind(this)
204
241
  };
205
242
  }
206
243
  /**
@@ -244,11 +281,12 @@ class NodeServerAdapter extends SharedServerAdapter {
244
281
  * - Shared watcher bootstrapping listens for route-level file changes and
245
282
  * refreshes the router and response handlers when pages are added or removed.
246
283
  *
247
- * WebSocket upgrade requests that do not target `/_hmr` are rejected with an
284
+ * WebSocket upgrade requests that do not match a known path are rejected with an
248
285
  * immediate socket destroy to prevent unhandled upgrade leaks.
249
286
  */
250
287
  async completeInitialization(server) {
251
288
  this.serverInstance = server;
289
+ const hasUserWs = this.websocketHandlers.size > 0;
252
290
  if (this.options?.watch) {
253
291
  const devRuntime = this.devRuntimeFactory.create({ appConfig: this.appConfig });
254
292
  const wss = devRuntime.websocketServer;
@@ -256,18 +294,25 @@ class NodeServerAdapter extends SharedServerAdapter {
256
294
  this.hmrManager = devRuntime.hmrManager;
257
295
  this.hmrManager.setEnabled(true);
258
296
  await this.hmrManager.buildRuntime();
259
- server.on("upgrade", (req, socket, head) => {
297
+ const hmrPreflight = (req, socket, head) => {
260
298
  const url = new URL(req.url ?? "/", this.runtimeOrigin);
261
- if (url.pathname === "/_hmr") {
262
- wss.handleUpgrade(req, socket, head, (ws) => {
263
- this.bridge.subscribe(ws);
264
- ws.on("close", () => this.bridge.unsubscribe(ws));
265
- ws.on("error", (err) => appLogger.error("[HMR] WebSocket error:", err));
266
- });
267
- } else {
268
- socket.destroy();
269
- }
270
- });
299
+ if (url.pathname !== "/_hmr") return false;
300
+ wss.handleUpgrade(req, socket, head, (ws) => {
301
+ this.bridge.subscribe(ws);
302
+ ws.on("close", () => this.bridge.unsubscribe(ws));
303
+ ws.on("error", (err) => appLogger.error("[HMR] WebSocket error:", err));
304
+ });
305
+ return true;
306
+ };
307
+ if (hasUserWs) {
308
+ this.wireUserWebSocketUpgrades(server, hmrPreflight);
309
+ } else {
310
+ server.on("upgrade", (req, socket, head) => {
311
+ if (!hmrPreflight(req, socket, head)) {
312
+ socket.destroy();
313
+ }
314
+ });
315
+ }
271
316
  const browserBuildPlugins = getAppBrowserBuildPlugins(this.appConfig);
272
317
  this.hmrManager.setPlugins(browserBuildPlugins);
273
318
  for (const integration of this.appConfig.integrations) {
@@ -284,6 +329,8 @@ class NodeServerAdapter extends SharedServerAdapter {
284
329
  bridge: this.bridge
285
330
  });
286
331
  await watcher.createWatcherSubscription();
332
+ } else if (hasUserWs) {
333
+ this.wireUserWebSocketUpgrades(server);
287
334
  }
288
335
  appLogger.debug("Node server adapter initialization completed", {
289
336
  apiHandlers: this.apiHandlers.length,
@@ -0,0 +1,22 @@
1
+ import type { ServerWebSocket } from 'bun';
2
+ import type { EcopagesSocket, EcopagesWebSocketHandler } from '../../types/public-types.js';
3
+ export type BunUserWebSocketData = {
4
+ kind: string;
5
+ params: Record<string, string>;
6
+ search: Record<string, string>;
7
+ upgradeUrl?: string;
8
+ context?: unknown;
9
+ [key: string]: unknown;
10
+ };
11
+ export type BunUserWebSocketLifecycleDeps<TWsData extends BunUserWebSocketData> = {
12
+ runtimeOrigin: string;
13
+ userHandlers: Map<string, EcopagesWebSocketHandler<any, any>>;
14
+ resolveContext: <TContext, TParams extends Record<string, string>>(request: Request, handler: EcopagesWebSocketHandler<TContext, TParams>, kind: string, params: TParams, search: Record<string, string>) => Promise<TContext>;
15
+ adaptSocket: <TContext, TParams extends Record<string, string>>(ws: ServerWebSocket<TWsData>, kind: string, params: TParams, search: Record<string, string>, context: TContext) => EcopagesSocket<TContext, TParams>;
16
+ };
17
+ export declare function createBunUserWebSocketLifecycle<TWsData extends BunUserWebSocketData>(deps: BunUserWebSocketLifecycleDeps<TWsData>): {
18
+ open(ws: ServerWebSocket<TWsData>): void;
19
+ message(ws: ServerWebSocket<TWsData>, msg: string | Buffer): void;
20
+ close(ws: ServerWebSocket<TWsData>, code: number, reason: string): void;
21
+ error(ws: ServerWebSocket<TWsData>, error: Error): void;
22
+ };
@@ -0,0 +1,102 @@
1
+ import { appLogger } from "../../global/app-logger.js";
2
+ import { invokeWebSocketHandlerHook, toWebSocketCloseInfo } from "./websocket-lifecycle.js";
3
+ function toUpgradeRequest(ws, runtimeOrigin) {
4
+ const upgradeUrl = ws.data?.upgradeUrl;
5
+ if (upgradeUrl) {
6
+ return new Request(upgradeUrl);
7
+ }
8
+ return new Request(runtimeOrigin);
9
+ }
10
+ async function runUserWebSocketOpen(ws, kind, deps) {
11
+ const handler = deps.userHandlers.get(kind);
12
+ if (!handler) {
13
+ return;
14
+ }
15
+ const params = ws.data?.params ?? {};
16
+ const search = ws.data?.search ?? {};
17
+ const request = toUpgradeRequest(ws, deps.runtimeOrigin);
18
+ let context;
19
+ try {
20
+ context = await deps.resolveContext(request, handler, kind, params, search);
21
+ } catch {
22
+ ws.close(1011, "context initialization failed");
23
+ return;
24
+ }
25
+ ws.data.context = context;
26
+ const socket = deps.adaptSocket(ws, kind, params, search, context);
27
+ try {
28
+ await handler.onConnect?.(socket);
29
+ } catch (error) {
30
+ appLogger.error(`[WS:${kind}] onConnect failed:`, error);
31
+ ws.close(1011, "onConnect failed");
32
+ }
33
+ }
34
+ function createBunUserWebSocketLifecycle(deps) {
35
+ return {
36
+ open(ws) {
37
+ const kind = ws.data?.kind;
38
+ if (!kind) {
39
+ return;
40
+ }
41
+ void runUserWebSocketOpen(ws, kind, deps).catch((error) => {
42
+ appLogger.error(`[WS:${kind}] open failed:`, error);
43
+ ws.close(1011, "internal error");
44
+ });
45
+ },
46
+ message(ws, msg) {
47
+ const kind = ws.data?.kind;
48
+ if (!kind) {
49
+ return;
50
+ }
51
+ const handler = deps.userHandlers.get(kind);
52
+ if (!handler) {
53
+ return;
54
+ }
55
+ const context = ws.data?.context;
56
+ if (context === void 0 && handler.context) {
57
+ appLogger.warn(`[WS:${kind}] message received before context resolved; dropping.`);
58
+ return;
59
+ }
60
+ const params = ws.data?.params ?? {};
61
+ const search = ws.data?.search ?? {};
62
+ const socket = deps.adaptSocket(ws, kind, params, search, context);
63
+ const message = typeof msg === "string" ? { kind: "text", text: msg } : { kind: "binary", data: new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength) };
64
+ invokeWebSocketHandlerHook(kind, "onMessage", handler.onMessage?.(socket, message));
65
+ },
66
+ close(ws, code, reason) {
67
+ const kind = ws.data?.kind;
68
+ if (!kind) {
69
+ return;
70
+ }
71
+ const handler = deps.userHandlers.get(kind);
72
+ if (!handler) {
73
+ return;
74
+ }
75
+ const context = ws.data?.context;
76
+ const params = ws.data?.params ?? {};
77
+ const search = ws.data?.search ?? {};
78
+ const socket = deps.adaptSocket(ws, kind, params, search, context);
79
+ const event = toWebSocketCloseInfo(code, reason);
80
+ invokeWebSocketHandlerHook(kind, "onClose", handler.onClose?.(socket, event));
81
+ },
82
+ error(ws, error) {
83
+ const kind = ws.data?.kind;
84
+ if (!kind) {
85
+ return;
86
+ }
87
+ const handler = deps.userHandlers.get(kind);
88
+ if (!handler) {
89
+ return;
90
+ }
91
+ appLogger.error(`[WS:${kind}] error:`, error);
92
+ const context = ws.data?.context;
93
+ const params = ws.data?.params ?? {};
94
+ const search = ws.data?.search ?? {};
95
+ const socket = deps.adaptSocket(ws, kind, params, search, context);
96
+ invokeWebSocketHandlerHook(kind, "onError", handler.onError?.(socket, error));
97
+ }
98
+ };
99
+ }
100
+ export {
101
+ createBunUserWebSocketLifecycle
102
+ };
@@ -0,0 +1,21 @@
1
+ import type { Duplex } from 'node:stream';
2
+ import type { Server as NodeHttpServer, IncomingMessage } from 'node:http';
3
+ import type { EcopagesWebSocketHandler } from '../../types/public-types.js';
4
+ export type NodeHttpWebSocketUpgradePreflight = (req: IncomingMessage, socket: Duplex, head: Buffer) => boolean;
5
+ export type AttachNodeHttpWebSocketUpgradesOptions = {
6
+ runtimeOrigin: string;
7
+ websocketHandlers: Map<string, EcopagesWebSocketHandler<any, any>>;
8
+ /**
9
+ * When true, unmatched upgrade requests are left for other listeners (e.g.
10
+ * Vite HMR). When false, unmatched upgrades are destroyed immediately.
11
+ */
12
+ passthroughUnmatched?: boolean;
13
+ preflight?: NodeHttpWebSocketUpgradePreflight;
14
+ };
15
+ /**
16
+ * Wires Ecopages user WebSocket routes onto a Node HTTP server's `upgrade` event.
17
+ *
18
+ * Used by the Node adapter in standalone mode and by host integrations such as
19
+ * the Vite plugin that embed Ecopages behind a foreign HTTP server.
20
+ */
21
+ export declare function attachNodeHttpWebSocketUpgrades(server: NodeHttpServer, options: AttachNodeHttpWebSocketUpgradesOptions): void;