@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
@@ -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 = 4e3 }) {
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 (isDeclaredAppPackageImport(specifier, rootDir)) {
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;