@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.
- 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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { WebSocketServer } from "ws";
|
|
2
|
+
import { appLogger } from "../../global/app-logger.js";
|
|
3
|
+
import { findWebSocketRoute } from "../abstract/ws-pattern-matcher.js";
|
|
4
|
+
import { invokeWebSocketHandlerHook, toWebSocketCloseInfo } from "./websocket-lifecycle.js";
|
|
5
|
+
const attachedUpgradeServers = /* @__PURE__ */ new WeakSet();
|
|
6
|
+
function adaptNodeWebSocket(ws, kind, params, search, context) {
|
|
7
|
+
return {
|
|
8
|
+
kind,
|
|
9
|
+
params,
|
|
10
|
+
search,
|
|
11
|
+
context,
|
|
12
|
+
send: (message) => {
|
|
13
|
+
if (typeof message === "string") {
|
|
14
|
+
ws.send(message);
|
|
15
|
+
} else if (message instanceof Blob) {
|
|
16
|
+
void message.arrayBuffer().then((buf) => ws.send(new Uint8Array(buf)));
|
|
17
|
+
} else if (message instanceof ArrayBuffer) {
|
|
18
|
+
ws.send(new Uint8Array(message));
|
|
19
|
+
} else if (ArrayBuffer.isView(message)) {
|
|
20
|
+
ws.send(new Uint8Array(message.buffer, message.byteOffset, message.byteLength));
|
|
21
|
+
} else {
|
|
22
|
+
ws.send(message);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
sendStream: async (stream) => {
|
|
26
|
+
const reader = stream.getReader();
|
|
27
|
+
try {
|
|
28
|
+
while (true) {
|
|
29
|
+
const { value, done } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
if (value) ws.send(value);
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
reader.releaseLock();
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
close: (code, reason) => ws.close(code, reason)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function toUpgradeRequest(runtimeOrigin, req) {
|
|
41
|
+
const upgradeUrl = new URL(req.url ?? "/", runtimeOrigin);
|
|
42
|
+
return new Request(upgradeUrl, {
|
|
43
|
+
method: req.method,
|
|
44
|
+
headers: req.headers
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function resolveNodeContext(request, handler, kind, params, search) {
|
|
48
|
+
if (!handler.context) {
|
|
49
|
+
return void 0;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return await handler.context({ request, kind, params, search });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
appLogger.error(`[WS:${kind}] context() failed; closing connection.`, error);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function setupNodeWebSocketConnection(ws, wsMatch, req, runtimeOrigin, search) {
|
|
59
|
+
const handler = wsMatch.handler;
|
|
60
|
+
const baseRequest = toUpgradeRequest(runtimeOrigin, req);
|
|
61
|
+
let context;
|
|
62
|
+
try {
|
|
63
|
+
context = await resolveNodeContext(baseRequest, handler, wsMatch.kind, wsMatch.params, search);
|
|
64
|
+
} catch {
|
|
65
|
+
ws.close(1011, "context initialization failed");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const socket = adaptNodeWebSocket(ws, wsMatch.kind, wsMatch.params, search, context);
|
|
69
|
+
try {
|
|
70
|
+
await handler.onConnect?.(socket);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
appLogger.error(`[WS:${wsMatch.kind}] onConnect failed:`, error);
|
|
73
|
+
ws.close(1011, "onConnect failed");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
ws.on("message", (msg, isBinary) => {
|
|
77
|
+
const message = isBinary ? {
|
|
78
|
+
kind: "binary",
|
|
79
|
+
data: msg instanceof ArrayBuffer ? new Uint8Array(msg) : Array.isArray(msg) ? new Uint8Array(Buffer.concat(msg)) : new Uint8Array(msg)
|
|
80
|
+
} : { kind: "text", text: msg.toString() };
|
|
81
|
+
invokeWebSocketHandlerHook(wsMatch.kind, "onMessage", handler.onMessage?.(socket, message));
|
|
82
|
+
});
|
|
83
|
+
ws.on("close", (code, reason) => {
|
|
84
|
+
const event = toWebSocketCloseInfo(code, reason.toString());
|
|
85
|
+
invokeWebSocketHandlerHook(wsMatch.kind, "onClose", handler.onClose?.(socket, event));
|
|
86
|
+
});
|
|
87
|
+
ws.on("error", (err) => {
|
|
88
|
+
appLogger.error(`[WS:${wsMatch.kind}] error:`, err);
|
|
89
|
+
invokeWebSocketHandlerHook(wsMatch.kind, "onError", handler.onError?.(socket, err));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function attachNodeHttpWebSocketUpgrades(server, options) {
|
|
93
|
+
if (options.websocketHandlers.size === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (attachedUpgradeServers.has(server)) {
|
|
97
|
+
appLogger.warn("[WS] WebSocket upgrades already attached to this HTTP server; skipping duplicate attach.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
attachedUpgradeServers.add(server);
|
|
101
|
+
const userWss = new WebSocketServer({ noServer: true });
|
|
102
|
+
server.on("upgrade", (req, socket, head) => {
|
|
103
|
+
if (options.preflight?.(req, socket, head)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const url = new URL(req.url ?? "/", options.runtimeOrigin);
|
|
107
|
+
const wsMatch = findWebSocketRoute(options.websocketHandlers, url.pathname);
|
|
108
|
+
if (!wsMatch) {
|
|
109
|
+
if (!options.passthroughUnmatched) {
|
|
110
|
+
socket.destroy();
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
userWss.handleUpgrade(req, socket, head, (ws) => {
|
|
115
|
+
const search = Object.fromEntries(url.searchParams.entries());
|
|
116
|
+
void setupNodeWebSocketConnection(ws, wsMatch, req, options.runtimeOrigin, search).catch((error) => {
|
|
117
|
+
appLogger.error(`[WS:${wsMatch.kind}] unexpected error:`, error);
|
|
118
|
+
ws.close(1011, "internal error");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
export {
|
|
124
|
+
attachNodeHttpWebSocketUpgrades
|
|
125
|
+
};
|
|
@@ -10,6 +10,7 @@ import type { ServerModuleTranspiler } from '../../services/module-loading/serve
|
|
|
10
10
|
type HandleFileChangeOptions = {
|
|
11
11
|
broadcast?: boolean;
|
|
12
12
|
};
|
|
13
|
+
export declare function resolveHmrRegistrationTimeoutMs(explicitTimeoutMs?: number): number;
|
|
13
14
|
type SharedHmrManagerParams = {
|
|
14
15
|
appConfig: EcoPagesAppConfig;
|
|
15
16
|
bridge: IClientBridge;
|
|
@@ -16,6 +16,24 @@ import {
|
|
|
16
16
|
setAppEntrypointDependencyGraph
|
|
17
17
|
} from "../../services/runtime-state/entrypoint-dependency-graph.service.js";
|
|
18
18
|
import { resolveInternalExecutionDir, resolveInternalWorkDir } from "../../utils/resolve-work-dir.js";
|
|
19
|
+
const DEFAULT_HMR_REGISTRATION_TIMEOUT_MS = 4e3;
|
|
20
|
+
const DEVELOPMENT_HMR_REGISTRATION_TIMEOUT_MS = 15e3;
|
|
21
|
+
function resolveHmrRegistrationTimeoutMs(explicitTimeoutMs) {
|
|
22
|
+
if (explicitTimeoutMs !== void 0) {
|
|
23
|
+
return explicitTimeoutMs;
|
|
24
|
+
}
|
|
25
|
+
const envTimeoutMs = process.env.ECOPAGES_HMR_REGISTRATION_TIMEOUT_MS;
|
|
26
|
+
if (envTimeoutMs !== void 0 && envTimeoutMs !== "") {
|
|
27
|
+
const parsedTimeoutMs = Number(envTimeoutMs);
|
|
28
|
+
if (Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0) {
|
|
29
|
+
return parsedTimeoutMs;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (process.env.NODE_ENV === "development") {
|
|
33
|
+
return DEVELOPMENT_HMR_REGISTRATION_TIMEOUT_MS;
|
|
34
|
+
}
|
|
35
|
+
return DEFAULT_HMR_REGISTRATION_TIMEOUT_MS;
|
|
36
|
+
}
|
|
19
37
|
class SharedHmrManager {
|
|
20
38
|
appConfig;
|
|
21
39
|
bridge;
|
|
@@ -30,7 +48,7 @@ class SharedHmrManager {
|
|
|
30
48
|
browserBundleService;
|
|
31
49
|
entrypointDependencyGraph;
|
|
32
50
|
serverModuleTranspiler;
|
|
33
|
-
constructor({ appConfig, bridge, registrationTimeoutMs
|
|
51
|
+
constructor({ appConfig, bridge, registrationTimeoutMs }) {
|
|
34
52
|
this.appConfig = appConfig;
|
|
35
53
|
this.bridge = bridge;
|
|
36
54
|
this.distDir = path.join(resolveInternalWorkDir(this.appConfig), RESOLVED_ASSETS_DIR, "_hmr");
|
|
@@ -40,7 +58,7 @@ class SharedHmrManager {
|
|
|
40
58
|
entrypointRegistrations: this.entrypointRegistrations,
|
|
41
59
|
watchedFiles: this.watchedFiles,
|
|
42
60
|
clearFailedRegistration: (entrypointPath) => this.clearFailedEntrypointRegistration(entrypointPath),
|
|
43
|
-
registrationTimeoutMs
|
|
61
|
+
registrationTimeoutMs: resolveHmrRegistrationTimeoutMs(registrationTimeoutMs)
|
|
44
62
|
});
|
|
45
63
|
this.browserBundleService = new BrowserBundleService(appConfig);
|
|
46
64
|
this.entrypointDependencyGraph = this.createEntrypointDependencyGraph(
|
|
@@ -237,5 +255,6 @@ class SharedHmrManager {
|
|
|
237
255
|
}
|
|
238
256
|
}
|
|
239
257
|
export {
|
|
240
|
-
SharedHmrManager
|
|
258
|
+
SharedHmrManager,
|
|
259
|
+
resolveHmrRegistrationTimeoutMs
|
|
241
260
|
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { WebSocketCloseInfo } from '../../types/public-types.js';
|
|
2
|
+
export declare function toWebSocketCloseInfo(code: number, reason: string): WebSocketCloseInfo;
|
|
3
|
+
export declare function invokeWebSocketHandlerHook(kind: string, hookName: string, result: void | Promise<void>): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { appLogger } from "../../global/app-logger.js";
|
|
2
|
+
function toWebSocketCloseInfo(code, reason) {
|
|
3
|
+
return {
|
|
4
|
+
code,
|
|
5
|
+
reason,
|
|
6
|
+
wasClean: code === 1e3 || code === 1001
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function invokeWebSocketHandlerHook(kind, hookName, result) {
|
|
10
|
+
if (result instanceof Promise) {
|
|
11
|
+
result.catch((error) => {
|
|
12
|
+
appLogger.error(`[WS:${kind}] ${hookName} failed:`, error);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
invokeWebSocketHandlerHook,
|
|
18
|
+
toWebSocketCloseInfo
|
|
19
|
+
};
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
const corePackageRequire = createRequire(new URL("../../package.json", import.meta.url));
|
|
6
6
|
const appDeclaredPackageCache = /* @__PURE__ */ new Map();
|
|
7
|
+
const CORE_RUNTIME_BARE_SPECIFIER_PACKAGES = /* @__PURE__ */ new Set(["ws"]);
|
|
7
8
|
function tryResolveRuntimeImport(specifier, resolver) {
|
|
8
9
|
try {
|
|
9
10
|
return resolver.resolve(specifier);
|
|
@@ -49,11 +50,18 @@ function getDeclaredAppPackages(rootDir) {
|
|
|
49
50
|
function isDeclaredAppPackageImport(specifier, rootDir) {
|
|
50
51
|
return getDeclaredAppPackages(rootDir).has(getPackageNameFromSpecifier(specifier));
|
|
51
52
|
}
|
|
53
|
+
function isDeclaredInResolutionChain(specifier, rootDir) {
|
|
54
|
+
const packageName = getPackageNameFromSpecifier(specifier);
|
|
55
|
+
if (CORE_RUNTIME_BARE_SPECIFIER_PACKAGES.has(packageName)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return isDeclaredAppPackageImport(specifier, rootDir);
|
|
59
|
+
}
|
|
52
60
|
function rewriteRuntimeImportSpecifier(specifier, quote, rootDir) {
|
|
53
61
|
if (!isBareRuntimeImport(specifier)) {
|
|
54
62
|
return void 0;
|
|
55
63
|
}
|
|
56
|
-
if (
|
|
64
|
+
if (isDeclaredInResolutionChain(specifier, rootDir)) {
|
|
57
65
|
return void 0;
|
|
58
66
|
}
|
|
59
67
|
const resolvedPath = tryResolveRuntimeImport(specifier, corePackageRequire);
|
|
@@ -14,6 +14,82 @@ import type { EntrypointDependencyGraph } from '../services/runtime-state/entryp
|
|
|
14
14
|
export type { EcoPagesAppConfig } from './internal-types.js';
|
|
15
15
|
export type { EcoPageComponent } from '../eco/eco.types.js';
|
|
16
16
|
export type { ProcessedAsset } from '../services/assets/asset-processing-service/assets.types.js';
|
|
17
|
+
/**
|
|
18
|
+
* Runtime-agnostic incoming WebSocket frame.
|
|
19
|
+
*
|
|
20
|
+
* Both Bun and Node `ws` deliver text and binary frames; this discriminated
|
|
21
|
+
* union lets handlers deal with both without runtime-specific casts.
|
|
22
|
+
*/
|
|
23
|
+
export type IncomingWebSocketMessage = {
|
|
24
|
+
readonly kind: 'text';
|
|
25
|
+
readonly text: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'binary';
|
|
28
|
+
readonly data: Uint8Array;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Runtime-agnostic outgoing WebSocket payload.
|
|
32
|
+
*
|
|
33
|
+
* Strings are sent as text frames. Anything binary-shaped is sent as binary
|
|
34
|
+
* frames. `Blob` is included for cross-runtime parity and is normalized to a
|
|
35
|
+
* binary frame by each adapter.
|
|
36
|
+
*/
|
|
37
|
+
export type OutgoingWebSocketMessage = string | Uint8Array | ArrayBuffer | ArrayBufferView | Blob;
|
|
38
|
+
/**
|
|
39
|
+
* Close event delivered to `onClose` after a connection terminates.
|
|
40
|
+
*/
|
|
41
|
+
export interface WebSocketCloseInfo {
|
|
42
|
+
readonly code: number;
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
readonly wasClean: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Input passed to a WebSocket handler's `context()` factory.
|
|
48
|
+
*
|
|
49
|
+
* Adapters call `context()` exactly once per accepted upgrade. The returned
|
|
50
|
+
* value becomes `socket.context` and is shared by all lifecycle hooks.
|
|
51
|
+
*/
|
|
52
|
+
export interface WebSocketContextFactoryInput<TParams extends Record<string, string> = Record<string, string>> {
|
|
53
|
+
readonly request: Request;
|
|
54
|
+
readonly kind: string;
|
|
55
|
+
readonly params: TParams;
|
|
56
|
+
readonly search: Readonly<Record<string, string>>;
|
|
57
|
+
readonly locals?: RequestLocals;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Runtime-agnostic WebSocket socket view exposed to handlers.
|
|
61
|
+
*
|
|
62
|
+
* Framework-owned fields:
|
|
63
|
+
* - `kind`: the registered route pattern (e.g. '/ws/chat/:roomId')
|
|
64
|
+
* - `params`: dynamic segments captured at match time
|
|
65
|
+
* - `search`: query string, always string-typed
|
|
66
|
+
*
|
|
67
|
+
* App-owned field:
|
|
68
|
+
* - `context`: returned by the handler's `context()` factory
|
|
69
|
+
*/
|
|
70
|
+
export interface EcopagesSocket<TContext = unknown, TParams extends Record<string, string> = Record<string, string>> {
|
|
71
|
+
readonly kind: string;
|
|
72
|
+
readonly params: TParams;
|
|
73
|
+
readonly search: Readonly<Record<string, string>>;
|
|
74
|
+
readonly context: TContext;
|
|
75
|
+
send(message: OutgoingWebSocketMessage): void;
|
|
76
|
+
sendStream(stream: ReadableStream<Uint8Array>): Promise<void>;
|
|
77
|
+
close(code?: number, reason?: string): void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Connection-lifecycle handler object.
|
|
81
|
+
*
|
|
82
|
+
* All hooks are optional. `context()` is the one-time initializer that
|
|
83
|
+
* produces the per-connection state shared by the lifecycle hooks. Returning
|
|
84
|
+
* a rejected promise from `context()` aborts the upgrade.
|
|
85
|
+
*/
|
|
86
|
+
export interface EcopagesWebSocketHandler<TContext = unknown, TParams extends Record<string, string> = Record<string, string>> {
|
|
87
|
+
context?(input: WebSocketContextFactoryInput<TParams>): TContext | Promise<TContext>;
|
|
88
|
+
onConnect?(socket: EcopagesSocket<TContext, TParams>): void | Promise<void>;
|
|
89
|
+
onMessage?(socket: EcopagesSocket<TContext, TParams>, message: IncomingWebSocketMessage): void | Promise<void>;
|
|
90
|
+
onClose?(socket: EcopagesSocket<TContext, TParams>, event: WebSocketCloseInfo): void | Promise<void>;
|
|
91
|
+
onError?(socket: EcopagesSocket<TContext, TParams>, error: unknown): void | Promise<void>;
|
|
92
|
+
}
|
|
17
93
|
import type { StandardSchema, StandardSchemaResult, StandardSchemaSuccessResult, StandardSchemaFailureResult, StandardSchemaIssue, InferOutput } from '../services/validation/standard-schema.types.js';
|
|
18
94
|
export type { StandardSchema, StandardSchemaResult, StandardSchemaSuccessResult, StandardSchemaFailureResult, StandardSchemaIssue, InferOutput, ForeignChildRuntime, };
|
|
19
95
|
export type InteractionEventsString = ScriptsInjectorInteractionEventsString;
|