@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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-beta.3",
3
+ "version": "0.2.0-beta.5",
4
4
  "description": "Core package for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -17,7 +17,7 @@
17
17
  "directory": "packages/core"
18
18
  },
19
19
  "dependencies": {
20
- "@ecopages/file-system": "0.2.0-beta.3",
20
+ "@ecopages/file-system": "0.2.0-beta.5",
21
21
  "@ecopages/logger": "^0.2.3",
22
22
  "@ecopages/scripts-injector": "^0.1.5",
23
23
  "@oxc-project/runtime": "0.134.0",
@@ -9,6 +9,7 @@
9
9
  import type { SourceModuleLoader } from '../../services/module-loading/module-loading-types.js';
10
10
  import type { EcoPagesAppConfig } from '../../types/internal-types.js';
11
11
  import type { ApiHandler, ApiHandlerContext, ErrorHandler, Middleware, RouteOptions, StaticRoute, ViewLoader } from '../../types/public-types.js';
12
+ import type { EcopagesWebSocketHandler } from '../../types/public-types.js';
12
13
  import { type ReturnParseCliArgs } from '../../utils/parse-cli-args.js';
13
14
  /**
14
15
  * Runtime bootstrap options layered on top of the app config.
@@ -72,6 +73,11 @@ export declare abstract class AbstractApplicationAdapter<TOptions extends Applic
72
73
  protected apiHandlers: ApiHandler[];
73
74
  protected staticRoutes: StaticRoute[];
74
75
  protected errorHandler?: ErrorHandler;
76
+ /**
77
+ * App-level WebSocket handlers keyed by URL path pattern (e.g. '/ws/chat/:id').
78
+ * Both Bun and Node adapters read this map to register upgrade routes.
79
+ */
80
+ protected websocketHandlers: Map<string, EcopagesWebSocketHandler<any, any>>;
75
81
  constructor(options: TOptions);
76
82
  private clearDistFolder;
77
83
  /**
@@ -160,6 +166,46 @@ export declare abstract class AbstractApplicationAdapter<TOptions extends Applic
160
166
  * Get all registered static routes
161
167
  */
162
168
  getStaticRoutes(): StaticRoute[];
169
+ /**
170
+ * Register a WebSocket handler for the given path pattern.
171
+ *
172
+ * The runtime adapter handles the HTTP→WebSocket upgrade for this path
173
+ * and routes lifecycle events to `handler`.
174
+ *
175
+ * Supports dynamic segments via `:param` syntax. The handler receives
176
+ * typed `params` and `search` fields, and a typed `context` produced by
177
+ * the optional `context()` factory.
178
+ *
179
+ * One pattern registration matches infinite path variations. For example,
180
+ * `app.websocket('/ws/chat/:roomId', handler)` matches `/ws/chat/abc`,
181
+ * `/ws/chat/xyz`, etc. Each connection receives its own `params.roomId`.
182
+ *
183
+ * Works across both Bun and Node runtimes — no runtime-specific imports needed.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * app.websocket<ChatContext, { roomId: string }>('/ws/chat/:roomId', {
188
+ * async context({ params, search }) {
189
+ * return { username: search.username ?? 'anonymous', roomId: params.roomId };
190
+ * },
191
+ * onConnect(socket) {
192
+ * socket.send(`Welcome to room ${socket.context.roomId}`);
193
+ * },
194
+ * onMessage(socket, message) {
195
+ * if (message.kind === 'text') {
196
+ * socket.send(message.text);
197
+ * }
198
+ * },
199
+ * });
200
+ * ```
201
+ */
202
+ websocket<TContext = unknown, TParams extends Record<string, string> = Record<string, string>>(path: string, handler: EcopagesWebSocketHandler<TContext, TParams>): this;
203
+ /**
204
+ * Get the registered WebSocket handlers map.
205
+ *
206
+ * @returns The map of WebSocket route patterns to handlers
207
+ */
208
+ getWebsocketHandlers(): Map<string, EcopagesWebSocketHandler<any, any>>;
163
209
  /**
164
210
  * Register a global error handler for all routes.
165
211
  * Useful for logging, monitoring integration, and custom error formatting.
@@ -15,6 +15,11 @@ class AbstractApplicationAdapter {
15
15
  apiHandlers = [];
16
16
  staticRoutes = [];
17
17
  errorHandler;
18
+ /**
19
+ * App-level WebSocket handlers keyed by URL path pattern (e.g. '/ws/chat/:id').
20
+ * Both Bun and Node adapters read this map to register upgrade routes.
21
+ */
22
+ websocketHandlers = /* @__PURE__ */ new Map();
18
23
  constructor(options) {
19
24
  this.appConfig = options.appConfig;
20
25
  this.serverOptions = options.serverOptions || {};
@@ -93,6 +98,71 @@ class AbstractApplicationAdapter {
93
98
  getStaticRoutes() {
94
99
  return this.staticRoutes;
95
100
  }
101
+ /**
102
+ * Register a WebSocket handler for the given path pattern.
103
+ *
104
+ * The runtime adapter handles the HTTP→WebSocket upgrade for this path
105
+ * and routes lifecycle events to `handler`.
106
+ *
107
+ * Supports dynamic segments via `:param` syntax. The handler receives
108
+ * typed `params` and `search` fields, and a typed `context` produced by
109
+ * the optional `context()` factory.
110
+ *
111
+ * One pattern registration matches infinite path variations. For example,
112
+ * `app.websocket('/ws/chat/:roomId', handler)` matches `/ws/chat/abc`,
113
+ * `/ws/chat/xyz`, etc. Each connection receives its own `params.roomId`.
114
+ *
115
+ * Works across both Bun and Node runtimes — no runtime-specific imports needed.
116
+ *
117
+ * @example
118
+ * ```typescript
119
+ * app.websocket<ChatContext, { roomId: string }>('/ws/chat/:roomId', {
120
+ * async context({ params, search }) {
121
+ * return { username: search.username ?? 'anonymous', roomId: params.roomId };
122
+ * },
123
+ * onConnect(socket) {
124
+ * socket.send(`Welcome to room ${socket.context.roomId}`);
125
+ * },
126
+ * onMessage(socket, message) {
127
+ * if (message.kind === 'text') {
128
+ * socket.send(message.text);
129
+ * }
130
+ * },
131
+ * });
132
+ * ```
133
+ */
134
+ websocket(path, handler) {
135
+ invariant(
136
+ typeof path === "string" && path.startsWith("/"),
137
+ `app.websocket(): path must be a string starting with "/", got "${path}".`
138
+ );
139
+ const segments = path.split("/").filter(Boolean);
140
+ const paramNames = /* @__PURE__ */ new Set();
141
+ for (const segment of segments) {
142
+ if (segment.startsWith(":")) {
143
+ const paramName = segment.slice(1);
144
+ invariant(
145
+ paramName.length > 0,
146
+ `app.websocket(): invalid pattern "${path}" \u2014 empty param name in segment ":${paramName}".`
147
+ );
148
+ invariant(
149
+ !paramNames.has(paramName),
150
+ `app.websocket(): invalid pattern "${path}" \u2014 duplicate param name ":${paramName}".`
151
+ );
152
+ paramNames.add(paramName);
153
+ }
154
+ }
155
+ this.websocketHandlers.set(path, handler);
156
+ return this;
157
+ }
158
+ /**
159
+ * Get the registered WebSocket handlers map.
160
+ *
161
+ * @returns The map of WebSocket route patterns to handlers
162
+ */
163
+ getWebsocketHandlers() {
164
+ return this.websocketHandlers;
165
+ }
96
166
  /**
97
167
  * Register a global error handler for all routes.
98
168
  * Useful for logging, monitoring integration, and custom error formatting.
@@ -0,0 +1,60 @@
1
+ import type { EcopagesWebSocketHandler } from '../../types/public-types.js';
2
+ /**
3
+ * Pattern matcher for WebSocket route paths.
4
+ *
5
+ * Supports dynamic segments via `:param` syntax. Matches are exact on literal
6
+ * segments and capture dynamic segments into a params object.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * matchWebSocketPath('/ws/chat/:id', '/ws/chat/abc123')
11
+ * // Returns: { id: 'abc123' }
12
+ *
13
+ * matchWebSocketPath('/ws/chat/:id', '/ws/chat/abc123/messages')
14
+ * // Returns: null (length mismatch)
15
+ *
16
+ * matchWebSocketPath('/ws/chat', '/ws/chat')
17
+ * // Returns: {} (no params)
18
+ * ```
19
+ *
20
+ * @remarks
21
+ * This is a minimal segment-based matcher. It does not support wildcards,
22
+ * optional segments, or catch-all patterns. Those are future concerns.
23
+ *
24
+ * @param pattern - The registered route pattern (e.g. '/ws/chat/:id')
25
+ * @param pathname - The actual request pathname (e.g. '/ws/chat/abc123')
26
+ * @returns A params object if the pattern matches, or null if it does not
27
+ */
28
+ export declare function matchWebSocketPath(pattern: string, pathname: string): Record<string, string> | null;
29
+ /**
30
+ * Represents a matched WebSocket route.
31
+ */
32
+ export type WebSocketRouteMatch = {
33
+ handler: EcopagesWebSocketHandler<any, any>;
34
+ params: Record<string, string>;
35
+ kind: string;
36
+ };
37
+ /**
38
+ * Scores a pattern for specificity ordering.
39
+ *
40
+ * Higher scores win. Rules, in order:
41
+ * 1. More literal segments beats more dynamic segments.
42
+ * 2. Fewer dynamic segments beats more dynamic segments.
43
+ * 3. Longer route (more segments) beats shorter route.
44
+ *
45
+ * @param pattern - The registered route pattern
46
+ * @returns A numeric specificity score
47
+ */
48
+ export declare function scoreWebSocketPattern(pattern: string): number;
49
+ /**
50
+ * Find the registered WebSocket handler that best matches a pathname.
51
+ *
52
+ * Iterates through the provided handlers map, computes each pattern's
53
+ * specificity score, and returns the highest-scoring match. Ties resolve
54
+ * to the earliest-registered pattern.
55
+ *
56
+ * @param handlers - The map of registered WebSocket handlers
57
+ * @param pathname - The actual request pathname
58
+ * @returns The best match, or null if no pattern matches
59
+ */
60
+ export declare function findWebSocketRoute(handlers: Map<string, EcopagesWebSocketHandler<any, any>>, pathname: string): WebSocketRouteMatch | null;
@@ -0,0 +1,61 @@
1
+ function matchWebSocketPath(pattern, pathname) {
2
+ const patternSegments = pattern.split("/").filter(Boolean);
3
+ const pathSegments = pathname.split("/").filter(Boolean);
4
+ if (patternSegments.length !== pathSegments.length) {
5
+ return null;
6
+ }
7
+ const params = {};
8
+ for (let i = 0; i < patternSegments.length; i++) {
9
+ const patternSegment = patternSegments[i];
10
+ const pathSegment = pathSegments[i];
11
+ if (!patternSegment || !pathSegment) {
12
+ return null;
13
+ }
14
+ if (patternSegment.startsWith(":")) {
15
+ const paramName = patternSegment.slice(1);
16
+ if (!paramName) {
17
+ return null;
18
+ }
19
+ params[paramName] = pathSegment;
20
+ } else if (patternSegment !== pathSegment) {
21
+ return null;
22
+ }
23
+ }
24
+ return params;
25
+ }
26
+ function scoreWebSocketPattern(pattern) {
27
+ const segments = pattern.split("/").filter(Boolean);
28
+ let literals = 0;
29
+ let dynamics = 0;
30
+ for (const segment of segments) {
31
+ if (segment.startsWith(":")) {
32
+ dynamics += 1;
33
+ } else {
34
+ literals += 1;
35
+ }
36
+ }
37
+ return literals * 100 - dynamics * 10 + segments.length;
38
+ }
39
+ function findWebSocketRoute(handlers, pathname) {
40
+ let best = null;
41
+ let index = 0;
42
+ for (const [pattern, handler] of handlers) {
43
+ const params = matchWebSocketPath(pattern, pathname);
44
+ if (params === null) {
45
+ index += 1;
46
+ continue;
47
+ }
48
+ const score = scoreWebSocketPattern(pattern);
49
+ const match = { handler, params, kind: pattern };
50
+ if (best === null || score > best.score || score === best.score && index < best.index) {
51
+ best = { score, match, index };
52
+ }
53
+ index += 1;
54
+ }
55
+ return best?.match ?? null;
56
+ }
57
+ export {
58
+ findWebSocketRoute,
59
+ matchWebSocketPath,
60
+ scoreWebSocketPattern
61
+ };
@@ -38,6 +38,9 @@ export declare class BunEcopagesApp<WebSocketData = undefined> extends SharedApp
38
38
  previewHost: StaticPreviewHost;
39
39
  });
40
40
  fetch(request: Request): Promise<Response>;
41
+ attachWebSocketUpgrades(httpServer: import('node:http').Server, options?: {
42
+ passthroughUnmatched?: boolean;
43
+ }): Promise<void>;
41
44
  /**
42
45
  * Complete the initialization of the server adapter by processing dynamic routes
43
46
  * @param server The Bun server instance
@@ -22,6 +22,12 @@ class BunEcopagesApp extends SharedApplicationAdapter {
22
22
  await this.serverAdapter.completeInitialization(this.server);
23
23
  return this.serverAdapter.handleRequest(request);
24
24
  }
25
+ async attachWebSocketUpgrades(httpServer, options) {
26
+ if (!this.serverAdapter) {
27
+ this.serverAdapter = await this.initializeServerAdapter();
28
+ }
29
+ this.serverAdapter.attachUserWebSocketUpgrades(httpServer, options);
30
+ }
25
31
  /**
26
32
  * Complete the initialization of the server adapter by processing dynamic routes
27
33
  * @param server The Bun server instance
@@ -50,12 +56,13 @@ class BunEcopagesApp extends SharedApplicationAdapter {
50
56
  preferredHostname: binding.preferredHostname,
51
57
  composedUrl: binding.runtimeOrigin
52
58
  });
53
- return await createBunServerAdapter({
59
+ return createBunServerAdapter({
54
60
  runtimeOrigin: binding.runtimeOrigin,
55
61
  appConfig: this.appConfig,
56
62
  apiHandlers: this.apiHandlers,
57
63
  staticRoutes: this.staticRoutes,
58
64
  errorHandler: this.errorHandler,
65
+ websocketHandlers: this.websocketHandlers.size > 0 ? this.websocketHandlers : void 0,
59
66
  options: { watch: binding.watch },
60
67
  serveOptions: binding.serveOptions
61
68
  });
@@ -7,6 +7,7 @@ type BunSocketHandler = WebSocketHandler<unknown>;
7
7
  export interface HmrManagerParams {
8
8
  appConfig: EcoPagesAppConfig;
9
9
  bridge: ClientBridge;
10
+ registrationTimeoutMs?: number;
10
11
  }
11
12
  /**
12
13
  * Bun development HMR manager.
@@ -26,7 +27,7 @@ export declare class HmrManager extends SharedHmrManager {
26
27
  * generation to `SharedHmrManager`. The Bun subclass only supplies the
27
28
  * transport-specific dependency graph policy and websocket hook surface.
28
29
  */
29
- constructor({ appConfig, bridge }: HmrManagerParams);
30
+ constructor({ appConfig, bridge, registrationTimeoutMs }: HmrManagerParams);
30
31
  /**
31
32
  * Reuses the shared in-memory dependency graph when possible and otherwise
32
33
  * creates the Bun-compatible default graph implementation.
@@ -13,8 +13,8 @@ class HmrManager extends SharedHmrManager {
13
13
  * generation to `SharedHmrManager`. The Bun subclass only supplies the
14
14
  * transport-specific dependency graph policy and websocket hook surface.
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 possible and otherwise
@@ -1,6 +1,7 @@
1
+ import type { Server as NodeHttpServer } from 'node:http';
1
2
  import type { Server, WebSocketHandler } from 'bun';
2
3
  import type { EcoPagesAppConfig } from '../../types/internal-types.js';
3
- import type { ApiHandler, ErrorHandler, StaticRoute } from '../../types/public-types.js';
4
+ import type { ApiHandler, ErrorHandler, StaticRoute, EcopagesWebSocketHandler } from '../../types/public-types.js';
4
5
  import { SharedServerAdapter } from '../shared/server-adapter.js';
5
6
  import type { ServerAdapterResult } from '../abstract/server-adapter.js';
6
7
  import type { StaticPreviewHost } from '../shared/static-preview-host.js';
@@ -32,6 +33,7 @@ export interface BunServerAdapterParams {
32
33
  apiHandlers?: ApiHandler<string, Request, BunServerInstance>[];
33
34
  staticRoutes?: StaticRoute[];
34
35
  errorHandler?: ErrorHandler;
36
+ websocketHandlers?: Map<string, EcopagesWebSocketHandler<any, any>>;
35
37
  options?: {
36
38
  watch?: boolean;
37
39
  };
@@ -48,6 +50,9 @@ export interface BunServerAdapterResult extends ServerAdapterResult {
48
50
  }) => Promise<void>;
49
51
  completeInitialization: (server?: BunServerInstance | null) => Promise<void>;
50
52
  handleRequest: (request: Request) => Promise<Response>;
53
+ attachUserWebSocketUpgrades: (server: NodeHttpServer, options?: {
54
+ passthroughUnmatched?: boolean;
55
+ }) => void;
51
56
  }
52
57
  /**
53
58
  * Bun transport adapter that wires shared Ecopages request handling onto a live
@@ -72,6 +77,47 @@ export declare class BunServerAdapter extends SharedServerAdapter<BunServerAdapt
72
77
  private fullyInitialized;
73
78
  serverInstance: BunServerInstance | null;
74
79
  private readonly previewHost;
80
+ /**
81
+ * Reference to the application-level WebSocket handlers map.
82
+ *
83
+ * @remarks
84
+ * This is a reference to the map owned by `AbstractApplicationAdapter`,
85
+ * passed in via the constructor. The Bun adapter reads from it to wire
86
+ * WebSocket upgrades for user-registered patterns.
87
+ */
88
+ protected websocketHandlers: Map<string, EcopagesWebSocketHandler<any, any>>;
89
+ /**
90
+ * Adapts a Bun ServerWebSocket to the public EcopagesSocket interface.
91
+ *
92
+ * @remarks
93
+ * This is the single source of truth for the Bun→public type adaptation.
94
+ * Both the HMR and production branches use this helper to ensure consistency.
95
+ *
96
+ * @param ws - The raw Bun ServerWebSocket
97
+ * @param kind - The registered route pattern
98
+ * @param params - Dynamic path parameters
99
+ * @param search - Query string parameters
100
+ * @param context - The resolved per-connection context
101
+ * @returns An EcopagesSocket view
102
+ */
103
+ private adaptBunWebSocket;
104
+ /**
105
+ * Resolves the per-connection context for a Bun user connection.
106
+ *
107
+ * @remarks
108
+ * `context()` is invoked exactly once per accepted connection. Its
109
+ * resolved value is shared by all subsequent lifecycle hooks. If
110
+ * `context()` throws, the connection is closed immediately with code
111
+ * 1011 (server error) and the error is logged.
112
+ *
113
+ * @param request - The original upgrade request (best-effort reconstructed)
114
+ * @param handler - The registered handler
115
+ * @param kind - The registered route pattern
116
+ * @param params - Dynamic path parameters
117
+ * @param search - Query string parameters
118
+ * @returns The resolved per-connection context
119
+ */
120
+ private resolveBunContext;
75
121
  /**
76
122
  * Creates a Bun server adapter with already-resolved runtime collaborators.
77
123
  *
@@ -81,11 +127,17 @@ export declare class BunServerAdapter extends SharedServerAdapter<BunServerAdapt
81
127
  * is constructed, those collaborators are mandatory because the adapter cannot
82
128
  * initialize Bun HMR or preview flows without them.
83
129
  */
84
- constructor({ appConfig, runtimeOrigin, serveOptions, apiHandlers, staticRoutes, errorHandler, options, hmrManager, bridge, previewHost, }: BunServerAdapterParams & {
130
+ constructor({ appConfig, runtimeOrigin, serveOptions, apiHandlers, staticRoutes, errorHandler, websocketHandlers, options, hmrManager, bridge, previewHost, }: BunServerAdapterParams & {
85
131
  hmrManager: HmrManager;
86
132
  bridge: ClientBridge;
87
133
  previewHost: StaticPreviewHost;
88
134
  });
135
+ /**
136
+ * Wires user WebSocket routes onto a Node HTTP server used by host integrations.
137
+ */
138
+ attachUserWebSocketUpgrades(server: NodeHttpServer, options?: {
139
+ passthroughUnmatched?: boolean;
140
+ }): void;
89
141
  /**
90
142
  * Returns whether adapter-level HTML responses still need HMR runtime injection.
91
143
  *
@@ -134,9 +186,13 @@ export declare class BunServerAdapter extends SharedServerAdapter<BunServerAdapt
134
186
  * Builds the `Bun.serve()` options for the current adapter state.
135
187
  *
136
188
  * @remarks
137
- * The HMR-enabled variant wraps the base fetch handler so one Bun server can
138
- * serve normal requests, accept HMR websocket upgrades, and expose the HMR
139
- * runtime asset without splitting responsibility across separate listeners.
189
+ * When HMR is enabled the websocket dispatcher merges HMR and user handlers,
190
+ * routing by the `kind` field embedded in socket data at upgrade time.
191
+ * This prevents user-registered websocket handlers from being silently overwritten
192
+ * by the HMR handler in development mode.
193
+ *
194
+ * User WebSocket paths are intercepted in the fetch handler via the pattern matcher.
195
+ * The upgrade happens implicitly — no manual GET route registration needed.
140
196
  */
141
197
  getServerOptions({ enableHmr }?: {
142
198
  enableHmr?: boolean | undefined;