@ecopages/core 0.2.0-beta.3 → 0.2.0-beta.5
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 +2 -2
- package/src/adapters/abstract/application-adapter.d.ts +46 -0
- package/src/adapters/abstract/application-adapter.js +70 -0
- package/src/adapters/abstract/ws-pattern-matcher.d.ts +60 -0
- package/src/adapters/abstract/ws-pattern-matcher.js +61 -0
- package/src/adapters/bun/create-app.d.ts +3 -0
- package/src/adapters/bun/create-app.js +8 -1
- package/src/adapters/bun/hmr-manager.d.ts +2 -1
- package/src/adapters/bun/hmr-manager.js +2 -2
- package/src/adapters/bun/server-adapter.d.ts +61 -5
- package/src/adapters/bun/server-adapter.js +201 -6
- package/src/adapters/node/create-app.d.ts +3 -0
- package/src/adapters/node/create-app.js +7 -0
- package/src/adapters/node/node-hmr-manager.d.ts +2 -1
- package/src/adapters/node/node-hmr-manager.js +2 -2
- package/src/adapters/node/server-adapter.d.ts +25 -2
- package/src/adapters/node/server-adapter.js +60 -13
- package/src/adapters/shared/bun-user-websocket-lifecycle.d.ts +22 -0
- package/src/adapters/shared/bun-user-websocket-lifecycle.js +102 -0
- package/src/adapters/shared/node-http-websocket-upgrades.d.ts +21 -0
- package/src/adapters/shared/node-http-websocket-upgrades.js +125 -0
- package/src/adapters/shared/shared-hmr-manager.d.ts +1 -0
- package/src/adapters/shared/shared-hmr-manager.js +22 -3
- package/src/adapters/shared/websocket-lifecycle.d.ts +3 -0
- package/src/adapters/shared/websocket-lifecycle.js +19 -0
- package/src/build/runtime-build-output-normalizer.js +9 -1
- 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
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
297
|
+
const hmrPreflight = (req, socket, head) => {
|
|
260
298
|
const url = new URL(req.url ?? "/", this.runtimeOrigin);
|
|
261
|
-
if (url.pathname
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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;
|