diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 181e591..75fe152 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -100,7 +100,8 @@ jobs: - name: Upload macOS artifacts uses: actions/upload-artifact@v6 with: - name: opencodex-macos-${{ matrix.arch }} + # 外层 Artifact 名称携带完整提交哈希,内部 DMG 文件名保持不变。 + name: opencodex-macos-${{ matrix.arch }}-${{ github.sha }} path: release/*.dmg if-no-files-found: error @@ -136,7 +137,8 @@ jobs: - name: Upload Windows artifact uses: actions/upload-artifact@v6 with: - name: opencodex-windows + # 外层 Artifact 名称携带完整提交哈希,内部 EXE 文件名保持不变。 + name: opencodex-windows-${{ github.sha }} path: release/*.exe if-no-files-found: error diff --git a/gateway/runtime/http/static-assets.cjs b/gateway/runtime/http/static-assets.cjs index 52c5ce5..4a53623 100644 --- a/gateway/runtime/http/static-assets.cjs +++ b/gateway/runtime/http/static-assets.cjs @@ -65,6 +65,7 @@ const RUNTIME_COMPATIBILITY_SCRIPT_PATH = "/opencodex/runtime-compatibility.js"; const RUNTIME_COMPATIBILITY_STYLE_PATH = "/opencodex/runtime-compatibility.css"; const OPENCODEX_SIDEBAR_PREVIEW_PATH = "/codex-sidebar-preview.js"; const OPENCODEX_OFFSCREEN_ANIMATION_GUARD_PATH = "/codex-offscreen-animation-guard.js"; +const CODEX_APP_HOST_MESSAGE_CODEC_PATH = "/codex-app-host-message-codec.js"; const CODEX_BRIDGE_POLYFILL_PATH = "/codex-bridge-polyfill.js"; const CODEX_REMOTE_FILE_ACTIONS_PATH = "/codex-remote-file-actions.js"; const CODEX_WORKSPACE_ROOT_PICKER_CSS_PATH = "/codex-workspace-root-picker.css"; @@ -278,6 +279,7 @@ const WEB_SHELL_STATIC_FILES = new Map([ [OPENCODEX_TOKEN_USAGE_CAPABILITY_PATH, path.join(INTERNAL_PROVIDER_DIR, "codex-token-usage-capability.js")], [OPENCODEX_WINDOW_CONTROLS_OVERLAY_CSS_PATH, path.join(WEB_SHELL_DIR, "codex-window-controls-overlay.css")], [OPENCODEX_WINDOW_CONTROLS_OVERLAY_PATH, path.join(INTERNAL_PROVIDER_DIR, "codex-window-controls-overlay.js")], + [CODEX_APP_HOST_MESSAGE_CODEC_PATH, path.join(WEB_SHELL_DIR, "codex-app-host-message-codec.js")], [OPENCODEX_SIDEBAR_PREVIEW_PATH, path.join(INTERNAL_PROVIDER_DIR, "codex-sidebar-preview.js")], [ OPENCODEX_OFFSCREEN_ANIMATION_GUARD_PATH, @@ -690,6 +692,7 @@ function createStaticAssetService({ CODEX_SMART_SCHEDULING_SUMMARY_PATH, OPENCODEX_TOKEN_USAGE_CAPABILITY_PATH, OPENCODEX_WINDOW_CONTROLS_OVERLAY_PATH, + CODEX_APP_HOST_MESSAGE_CODEC_PATH, CODEX_BRIDGE_POLYFILL_PATH, CODEX_REMOTE_FILE_ACTIONS_PATH, CODEX_WORKSPACE_ROOT_PICKER_PATH, @@ -944,6 +947,7 @@ function createStaticAssetService({ ``, ``, ``, + ``, ``, ``, ``, diff --git a/gateway/runtime/ipc/official-runtime.cjs b/gateway/runtime/ipc/official-runtime.cjs index 96b1c96..efc8853 100644 --- a/gateway/runtime/ipc/official-runtime.cjs +++ b/gateway/runtime/ipc/official-runtime.cjs @@ -2200,9 +2200,20 @@ async function connectOfficialAppHostPort(port, context = {}) { return true; } +function deliverOfficialAppHostMessage(event, onMessage, close) { + // renderer transport 只把 null 作为 peer close;undefined 是合法的结构化值。 + const data = event ? event.data : undefined; + if (data === null) { + close("official_closed"); + return false; + } + onMessage(data); + return true; +} + /** * 在 gateway 进程里创建一条“浏览器 MessagePort <-> 官方 MessagePort”的透明中继。 - * 这里不解析 app-host RPC 的 JSON 内容,只保证字符串帧和关闭信号按顺序穿过边界。 + * 这里不解析 AppHost RPC 内容,只保证结构化值和关闭信号按顺序穿过边界。 */ function createOfficialAppHostRelay(options = {}) { const { clientId = "", onClose, onError, onMessage, portId = "", remoteAddress = "" } = options; @@ -2213,6 +2224,7 @@ function createOfficialAppHostRelay(options = {}) { // port1 交给官方 IPC listener;port2 留在 gateway,用来和浏览器 WebSocket 互转消息。 const { port1, port2 } = new electron.MessageChannelMain(); let closed = false; + let nullCloseScheduled = false; function close(reason = "closed") { if (closed) return; @@ -2229,29 +2241,19 @@ function createOfficialAppHostRelay(options = {}) { } port2.on("message", (event) => { - // Electron MessageEvent.data 可能挂在原型 getter 上,必须直接读取,不能用 hasOwnProperty 判断。 - const data = event ? event.data : undefined; - if (data == null) { - // app-host 约定 null 表示端口关闭,收到后要同步释放两端资源。 - close("official_closed"); - return; - } - if (typeof data !== "string") { - diagnosticWarn("official-app-host", "non_string_message_from_official", { - clientId: shortId(clientId), - payloadType: typeof data, - portId: shortId(portId), - }); - return; - } try { - onMessage && onMessage(data); + deliverOfficialAppHostMessage(event, (data) => onMessage && onMessage(data), close); } catch (error) { diagnosticWarn("official-app-host", "forward_to_browser_failed", { clientId: shortId(clientId), error: error instanceof Error ? error.message : String(error), portId: shortId(portId), }); + try { + onError && onError(error); + } catch {} + // 编码或下行失败后继续保留 session 只会让双方永久等待,立即关闭以便页面重连恢复。 + close("forward_to_browser_failed"); } }); port2.on("close", () => close("official_port_closed")); @@ -2285,9 +2287,13 @@ function createOfficialAppHostRelay(options = {}) { postMessage(data) { if (closed) return false; try { - // 浏览器侧也用 null 作为关闭信号;其它 payload 必须保持官方 RPC 字符串原样。 + // main-process transport 在发送 nullish 后释放 relay,先交给官方 listener 消费 terminal 帧。 port2.postMessage(data); - if (data == null) close("browser_closed"); + if (data == null && !nullCloseScheduled) { + nullCloseScheduled = true; + const scheduleClose = typeof setImmediate === "function" ? setImmediate : (callback) => setTimeout(callback, 0); + scheduleClose(() => close("browser_closed")); + } return true; } catch (error) { diagnosticWarn("official-app-host", "forward_to_official_failed", { @@ -2563,6 +2569,7 @@ module.exports = { startOfficialRuntime, webConfigScript, __test: { + deliverOfficialAppHostMessage, compactOfficialAppCatalogPayload, configureOfficialWebContentsListenerBudget, fileManagerPathFromSpawn, diff --git a/gateway/runtime/ipc/ws-hub.cjs b/gateway/runtime/ipc/ws-hub.cjs index d6023f8..6ccb0b5 100644 --- a/gateway/runtime/ipc/ws-hub.cjs +++ b/gateway/runtime/ipc/ws-hub.cjs @@ -5,6 +5,7 @@ try { } catch {} const { diagnosticLog, diagnosticWarn, shortId } = require("../core/diagnostics.cjs"); const { DEBUG_LOGS } = require("../core/config.cjs"); +const appHostMessageCodec = require("../../../web-shell/codex-app-host-message-codec.js"); // 下面这些阈值只服务于 OPENCODEX_DEBUG_WS=1 的链路排障;默认运行不会采样慢 WS 发送。 const WS_LARGE_MESSAGE_BYTES = Number(process.env.OPENCODEX_WS_LARGE_LOG_BYTES || 256 * 1024); @@ -33,6 +34,16 @@ const WS_IPC_MAX_IN_FLIGHT = Math.max(32, Number(process.env.OPENCODEX_WS_IPC_MA const ROUTE_ID_SCAN_MAX_NODES = 128; const BROADCAST_DEDUPE_MAX_ENTRIES_PER_SOCKET = 16; const BROADCAST_DEDUPE_MAX_WINDOW_MS = 60_000; +let nextAppHostRelayGeneration = 0; + +function webSocketServerOptions({ perMessageDeflate, maxPayloadBytes = WS_MAX_PAYLOAD_BYTES } = {}) { + return { + noServer: true, + perMessageDeflate, + // 64 MiB 是 WebSocket 入站帧边界,不是 AppHost codec 的消息限制。 + maxPayload: Math.max(1024 * 1024, Number(maxPayloadBytes) || WS_MAX_PAYLOAD_BYTES), + }; +} function byteLength(value) { // WebSocket bufferedAmount 用字节衡量;日志里也统一按 UTF-8 字节估算,方便对齐网络层现象。 @@ -124,11 +135,7 @@ function createWsHub( const perMessageDeflate = wsCompressionOptions(); const effectiveMaxBufferedBytes = Math.max(1024, Number(maxBufferedBytes) || WS_MAX_BUFFERED_BYTES); - const wss = new WebSocketServer({ - noServer: true, - perMessageDeflate, - maxPayload: Math.max(1024 * 1024, Number(maxPayloadBytes) || WS_MAX_PAYLOAD_BYTES), - }); + const wss = new WebSocketServer(webSocketServerOptions({ perMessageDeflate, maxPayloadBytes })); if (WS_DEBUG_ENABLED) { // 压缩配置只在排障模式打印;压缩本身始终按上面的配置生效。 diagnosticLog("ws-hub", "compression_configured", { @@ -199,7 +206,7 @@ function createWsHub( startedAtMs: Date.now(), timer: null, }; - // 聚合窗口结束后只打一条 summary,避免 app-host 高频字符串帧把会话加载日志刷爆。 + // 聚合窗口结束后只打一条 summary,避免 AppHost 高频 wire 帧把会话加载日志刷爆。 stat.timer = setTimeout(() => flushAppHostTraffic(key), APP_HOST_TRAFFIC_FLUSH_MS); if (stat.timer && typeof stat.timer.unref === "function") stat.timer.unref(); appHostTraffic.set(key, stat); @@ -225,10 +232,11 @@ function createWsHub( } function appHostPayloadInfo(payload) { - // app-host-port-message.data 是官方 RPC 字符串;只统计长度,不解析内容,避免耦合官方协议细节。 - if (!payload || payload.type !== "app-host-port-message" || typeof payload.data !== "string") return null; + // AppHost message data 只统计字符串或 wire JSON 长度,不解析官方协议内容。 + if (!payload || payload.type !== "app-host-port-message") return null; + const data = typeof payload.data === "string" ? payload.data : JSON.stringify(payload.data); return { - bytes: byteLength(payload.data), + bytes: byteLength(data), portId: typeof payload.portId === "string" ? payload.portId : "", }; } @@ -416,15 +424,67 @@ function createWsHub( return ws.__codexAppHostRelays; } + function relayIsCurrent(relays, context) { + // map 身份和 active 状态共同构成 generation 护栏,旧回调不能触碰替换后的 relay。 + return !!context && context.terminalState === "active" && relays.get(context.portId) === context; + } + + function failAppHostRelay(relays, context, error, reason) { + if (!relayIsCurrent(relays, context)) return false; + context.terminalState = "error"; + relays.delete(context.portId); + if (!context.terminalNotified) { + context.terminalNotified = true; + safeSend( + context.ws, + { + type: "app-host-port-error", + portId: context.portId, + error: error instanceof Error ? error.message : String(error), + }, + { suppressDiagnostic: true } + ); + } + // 先更新索引再关闭底层端口,延迟的 onClose 只能看到 terminal 状态。 + try { + context.relay?.close(reason); + } catch {} + return true; + } + + function closeAppHostRelay(relays, context, reason, { notify = true, closePort = true } = {}) { + if (!relayIsCurrent(relays, context)) return false; + context.terminalState = reason === "replaced" ? "replaced" : "closed"; + relays.delete(context.portId); + if (notify && reason !== "replaced" && !context.terminalNotified) { + context.terminalNotified = true; + safeSend( + context.ws, + { type: "app-host-port-close", portId: context.portId, reason }, + { suppressDiagnostic: true } + ); + } + if (closePort) { + try { + context.relay?.close(reason); + } catch {} + } + return true; + } + function closeAppHostRelays(ws, reason) { const relays = ws.__codexAppHostRelays; if (!relays || relays.size === 0) return; // 页面断开时主动关闭官方端口,否则官方 app-host 服务会保留无主连接。 - for (const [portId, relay] of relays.entries()) { - relays.delete(portId); - try { - relay.close(reason); - } catch {} + for (const context of [...relays.values()]) { + let graceful = false; + if (reason === "client_disconnected") { + // 断开属于正常 peer-close,先给官方 listener 发送 null 再释放底层端口。 + try { + graceful = context.relay?.postMessage(null) === true; + } catch {} + } + closeAppHostRelay(relays, context, reason, { closePort: !graceful }); } } @@ -585,56 +645,92 @@ function createWsHub( const relays = appHostRelaysForSocket(ws); const existing = relays.get(portId); if (existing) { - // 同一个页面重复使用 portId 时以后到者为准,先关闭旧 relay 避免双写。 - try { - existing.close("replaced"); - } catch {} - relays.delete(portId); + // 替换前先摘除旧 context,延迟的旧回调不能误伤新 relay。 + closeAppHostRelay(relays, existing, "replaced", { notify: false }); } - while (!existing && relays.size >= Math.max(1, Number(maxAppHostRelays) || 1)) { - const [oldestPortId, oldestRelay] = relays.entries().next().value || []; - if (!oldestPortId) break; - relays.delete(oldestPortId); - try { - // 异常页面创建过多 MessagePort 时淘汰最旧端口,正常官方端口数量远低于此上限。 - oldestRelay?.close("relay_limit"); - } catch {} + while (relays.size >= Math.max(1, Number(maxAppHostRelays) || 1)) { + const oldestContext = relays.values().next().value; + if (!oldestContext) break; + closeAppHostRelay(relays, oldestContext, "relay_limit"); } + const context = { + clientId, + generation: ++nextAppHostRelayGeneration, + portId, + relay: null, + terminalNotified: false, + terminalState: "active", + ws, + }; + // 先登记 context,再调用工厂,覆盖工厂同步触发 onMessage/onError 的竞态。 + relays.set(portId, context); try { const relay = createAppHostRelay({ clientId, portId, remoteAddress: req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : "", onClose(reason) { - if (relays.get(portId) === relay) relays.delete(portId); - safeSend(ws, { type: "app-host-port-close", portId, reason }, { suppressDiagnostic: true }); + if (!relayIsCurrent(relays, context)) return; + closeAppHostRelay(relays, context, reason); if (DEBUG_LOGS) { diagnosticLog("ws-hub", "app_host_closed", { clientId: shortId(clientId), portId: shortId(portId), + generation: context.generation, reason, }); } }, onError(error) { - safeSend( - ws, - { - type: "app-host-port-error", - portId, + failAppHostRelay(relays, context, error, "relay_error"); + }, + onMessage(data) { + // 结构化值先编码为 JSON-safe AppHost wire 数据,再放入 WebSocket 控制帧。 + if (!relayIsCurrent(relays, context)) return; + let wireData; + try { + wireData = appHostMessageCodec.encodeMessageData(data); + } catch (error) { + diagnosticWarn("ws-hub", "app_host_message_encode_failed", { + clientId: shortId(clientId), error: error instanceof Error ? error.message : String(error), - }, + portId: shortId(portId), + }); + failAppHostRelay(relays, context, new Error("Invalid app-host message data"), "encode_failed"); + return; + } + const sent = safeSend( + context.ws, + { type: "app-host-port-message", portId, ...wireData }, { suppressDiagnostic: true } ); - }, - onMessage(data) { - // app-host RPC 是高频字符串流,只转发不逐条写日志,避免首屏日志刷屏和拖慢关键链路。 - safeSend(ws, { type: "app-host-port-message", portId, data }, { suppressDiagnostic: true }); + if (!sent) { + failAppHostRelay( + relays, + context, + new Error("Browser WebSocket is unavailable for app-host data"), + "forward_to_browser_failed" + ); + } }, }); - relays.set(portId, relay); - safeSend(ws, { type: "app-host-port-connected", portId }, { suppressDiagnostic: true }); + context.relay = relay; + if (context.terminalState !== "active") { + try { + relay.close("connect_failed"); + } catch {} + return true; + } + if (!safeSend(ws, { type: "app-host-port-connected", portId }, { suppressDiagnostic: true })) { + failAppHostRelay( + relays, + context, + new Error("Browser WebSocket is unavailable for app-host connection"), + "forward_to_browser_failed" + ); + return true; + } if (DEBUG_LOGS) { // app-host 端口连接/关闭是前端组件生命周期的一部分,默认只保留失败日志。 diagnosticLog("ws-hub", "app_host_connect", { @@ -648,24 +744,15 @@ function createWsHub( error: error instanceof Error ? error.message : String(error), portId: shortId(portId), }); - safeSend( - ws, - { - type: "app-host-port-error", - portId, - error: error instanceof Error ? error.message : String(error), - }, - { suppressDiagnostic: true } - ); + failAppHostRelay(relays, context, error, "connect_failed"); } return true; } function handleAppHostPortMessage(ws, req, message) { - // 浏览器端 MessagePort 的后续字符串帧都从这里回写到官方 Electron port。 + // 浏览器端 MessagePort 的后续 AppHost wire 帧先恢复值,再回写到官方 Electron port。 const clientId = normalizedWsClientId(ws, message); const portId = message && typeof message.portId === "string" ? message.portId : ""; - const data = message ? message.data : undefined; if (!clientId || ws.__codexWebClientId !== clientId || !validAppHostPortId(portId)) { diagnosticWarn("ws-hub", "app_host_message_rejected", { clientId: shortId(clientId), @@ -674,22 +761,34 @@ function createWsHub( }); return true; } - if (!(data == null || typeof data === "string")) { - // 官方 app-host 当前只使用字符串 JSON-RPC 帧;非字符串直接拒绝,避免污染官方端口。 - diagnosticWarn("ws-hub", "app_host_non_string_message_rejected", { + const relays = appHostRelaysForSocket(ws); + let data; + try { + data = appHostMessageCodec.decodeMessageData(message); + } catch (error) { + diagnosticWarn("ws-hub", "app_host_message_decode_failed", { clientId: shortId(clientId), - payloadType: typeof data, + error: error instanceof Error ? error.message : String(error), portId: shortId(portId), }); + const failedContext = relays.get(portId); + if (failedContext) { + failAppHostRelay(relays, failedContext, new Error("Invalid app-host message data"), "decode_failed"); + } else { + safeSend( + ws, + { type: "app-host-port-error", portId, error: "Invalid app-host message data" }, + { suppressDiagnostic: true } + ); + } return true; } - const relays = appHostRelaysForSocket(ws); - let relay = relays.get(portId); - if (!relay) { + let context = relays.get(portId); + if (!context) { // WS 重连会释放旧 socket 上的 relay,但浏览器 MessagePort 仍会继续发送;按原身份懒重建后再转发首帧。 handleAppHostConnect(ws, req, { ...message, type: "app-host-connect" }); - relay = relays.get(portId); - if (!relay) { + context = relays.get(portId); + if (!context) { diagnosticWarn("ws-hub", "app_host_message_missing_relay", { clientId: shortId(clientId), portId: shortId(portId), @@ -701,10 +800,34 @@ function createWsHub( // 观察器属于独立展示层;hub 仍只负责透明转发,不解析 App Server 协议或路由语义。 observeAppHostFrame?.({ clientId, data, direction: "client", portId }); } catch {} - if (WS_DEBUG_ENABLED && typeof data === "string") recordAppHostTraffic(ws, "browser-to-official", portId, byteLength(data)); - relay.postMessage(data); - // null 是关闭信号,发送给官方后即可从索引移除,后续 close 回调再到达也不会重复处理。 - if (data == null && relays.get(portId) === relay) relays.delete(portId); + if (WS_DEBUG_ENABLED) { + // 诊断只统计收到的 wire 帧,不为 codec 增加额外字节限制。 + const wireText = typeof message.data === "string" ? message.data : JSON.stringify(message.data); + recordAppHostTraffic(ws, "browser-to-official", portId, byteLength(wireText)); + } + if (!relayIsCurrent(relays, context)) return true; + try { + const forwarded = context.relay?.postMessage(data); + if (forwarded === false) throw new Error("Official app-host port is unavailable"); + } catch (error) { + failAppHostRelay(relays, context, error, "forward_to_official_failed"); + return true; + } + // nullish 交给官方 listener 后释放 context;官方 relay 会延迟关闭底层端口。 + if (data == null) closeAppHostRelay(relays, context, "browser_closed", { notify: false, closePort: false }); + return true; + } + + function handleAppHostPortError(ws, message) { + const clientId = normalizedWsClientId(ws, message); + const portId = message && typeof message.portId === "string" ? message.portId : ""; + if (!clientId || ws.__codexWebClientId !== clientId || !validAppHostPortId(portId)) return true; + const relays = appHostRelaysForSocket(ws); + const context = relays.get(portId); + if (context) { + // 浏览器侧已经报告失败,gateway 只清理该 context,避免反向再发一条 error。 + closeAppHostRelay(relays, context, "browser_error", { notify: false }); + } return true; } @@ -778,6 +901,7 @@ function createWsHub( } if (message.type === "app-host-connect") return handleAppHostConnect(ws, req, message); if (message.type === "app-host-port-message") return handleAppHostPortMessage(ws, req, message); + if (message.type === "app-host-port-error") return handleAppHostPortError(ws, message); return false; } @@ -908,5 +1032,5 @@ function createWsHub( module.exports = { createWsHub, - __test: { routeIdFromPayload }, + __test: { routeIdFromPayload, webSocketServerOptions }, }; diff --git a/gateway/runtime/model-router/presentation.cjs b/gateway/runtime/model-router/presentation.cjs index b76c59f..fe7824d 100644 --- a/gateway/runtime/model-router/presentation.cjs +++ b/gateway/runtime/model-router/presentation.cjs @@ -79,15 +79,17 @@ function createSmartSchedulingPresentation({ compatibilityService, modelRouter, } function observeAppHostFrame({ clientId, data, direction = "client" } = {}) { - if ( - direction !== "client" || - typeof data !== "string" || - (!data.includes("turn/") && !data.includes("thread/")) - ) { - return; - } + if (direction !== "client") return; try { - visitProtocolMessages(JSON.parse(data), (message) => { + const decoded = + data && typeof data === "object" + ? data + : typeof data === "string" && (data.includes("turn/") || data.includes("thread/")) + ? JSON.parse(data) + : null; + if (!decoded) return; + // 结构化 AppHost 帧已经由 transport adapter 解码,legacy string wire 帧仍按关键词预筛选后解析。 + visitProtocolMessages(decoded, (message) => { if (!["turn/start", "thread/settings/update"].includes(message?.method)) return; rememberClient(message.params?.threadId || message.params?.thread?.id, clientId); }); diff --git a/gateway/test/app-host-message-codec.test.cjs b/gateway/test/app-host-message-codec.test.cjs new file mode 100644 index 0000000..adbb180 --- /dev/null +++ b/gateway/test/app-host-message-codec.test.cjs @@ -0,0 +1,96 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const codecPath = require.resolve("../../web-shell/codex-app-host-message-codec.js"); +const codec = require(codecPath); + +test("round-trips AppHost primitives and byte views", () => { + // 每种二进制 view 都必须恢复为同名构造类型,不能退化成普通数组。 + const typedViews = { + Int8Array: new Int8Array([-1, 2]), + Uint8Array: new Uint8Array([0, 255]), + Uint8ClampedArray: new Uint8ClampedArray([0, 255]), + Int16Array: new Int16Array([-3, 4]), + Uint16Array: new Uint16Array([5, 6]), + Int32Array: new Int32Array([-7, 8]), + Uint32Array: new Uint32Array([9, 10]), + Float32Array: new Float32Array([1.5, -2.5]), + Float64Array: new Float64Array([3.5, -4.5]), + BigInt64Array: new BigInt64Array([-11n, 12n]), + BigUint64Array: new BigUint64Array([13n, 14n]), + }; + const source = { + array: [undefined, null, true, "text", NaN, Infinity, -Infinity, -0], + date: new Date("2026-09-01T00:00:00.000Z"), + bigint: 12345678901234567890n, + buffer: Uint8Array.from([0, 1, 255]).buffer, + dataView: new DataView(Uint8Array.from([9, 2, 3, 8]).buffer, 1, 2), + typedViews, + }; + const value = codec.decode(codec.encode(source)); + assert.equal(value.array[0], undefined); + assert.equal(value.array[4], NaN); + assert.equal(value.array[5], Infinity); + assert.equal(value.array[6], -Infinity); + assert.equal(Object.is(value.array[7], -0), true); + assert.equal(value.date.toISOString(), source.date.toISOString()); + assert.equal(value.bigint, source.bigint); + assert.deepEqual([...new Uint8Array(value.buffer)], [0, 1, 255]); + assert.deepEqual([...new Uint8Array(value.dataView.buffer)], [2, 3]); + for (const [name, view] of Object.entries(typedViews)) { + assert.equal(value.typedViews[name].constructor.name, name); + assert.deepEqual([...value.typedViews[name]], [...view]); + } +}); + +test("preserves legacy string/null and root undefined transport semantics", () => { + assert.deepEqual(codec.encodeMessageData("legacy"), { data: "legacy" }); + assert.deepEqual(codec.encodeMessageData(null), { data: null }); + const encodedUndefined = codec.encodeMessageData(undefined); + assert.equal(encodedUndefined.dataEncoding, codec.encoding); + assert.equal(codec.decodeMessageData(encodedUndefined), undefined); + assert.equal(codec.decodeMessageData({ data: "legacy" }), "legacy"); + assert.equal(codec.decodeMessageData({ data: null }), null); +}); + +test("enforces official depth and BigInt guardrails", () => { + let depth255 = 0; + for (let index = 0; index < 255; index += 1) depth255 = [depth255]; + assert.doesNotThrow(() => codec.encode(depth255)); + assert.doesNotThrow(() => codec.decode(codec.encode(depth255))); + let depth256 = 0; + for (let index = 0; index < 256; index += 1) depth256 = [depth256]; + assert.throws(() => codec.encode(depth256), /depth limit/); + + const maxDigits = BigInt("9".repeat(16_384)); + assert.doesNotThrow(() => codec.encode(maxDigits)); + assert.throws(() => codec.encode(BigInt(`1${"0".repeat(16_384)}`)), /BigInt digit limit/); + assert.equal(codec.decode(["bigint", `-${"9".repeat(16_384)}`]) < 0n, true); + assert.throws(() => codec.decode(["bigint", `1${"0".repeat(16_384)}`]), /bigint node/); + assert.doesNotThrow(() => codec.encode(-maxDigits)); + let wireDepth256 = ["null"]; + for (let index = 0; index < 256; index += 1) wireDepth256 = ["array", [wireDepth256]]; + assert.throws(() => codec.decode(wireDepth256), /depth limit/); +}); + +test("rejects cycles, malformed tuples, invalid bytes and unknown tags", () => { + const cycle = {}; + cycle.self = cycle; + assert.throws(() => codec.encode(cycle), /cycle/); + assert.throws(() => codec.decode(["string"]), /string node/); + assert.throws(() => codec.decode(["number", 1, 2]), /number node/); + assert.throws(() => codec.decode(["bytes", "Uint8Array", "bad"]), /base64/); + assert.throws(() => codec.decode(["bytes", "Int16Array", "AA=="]), /byte length/); + assert.throws(() => codec.decode(["bytes", "UnknownArray", ""]), /Unsupported app-host byte view/); + assert.throws(() => codec.decode(["object", [["duplicate", ["null"]], ["duplicate", ["null"]]]]), /Duplicate/); + assert.throws(() => codec.decode(["unknown"]), /Unknown app-host wire tag/); + assert.throws(() => codec.decodeMessageData({ dataEncoding: "unknown", data: null }), /Unsupported app-host data encoding/); +}); + +test("caps wide payloads and does not pollute CommonJS global scope", () => { + assert.doesNotThrow(() => codec.encode(Array.from({ length: 10_000 }, (_, index) => index))); + assert.throws(() => codec.encode(new Array(1_000_000)), /node limit/); + const wideWire = ["array", Array.from({ length: 1_000_000 }, () => ["undefined"])]; + assert.throws(() => codec.decode(wideWire), /node limit/); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, "__OpenCodexAppHostMessageCodec"), false); +}); diff --git a/gateway/test/app-host-provider.test.cjs b/gateway/test/app-host-provider.test.cjs new file mode 100644 index 0000000..842b132 --- /dev/null +++ b/gateway/test/app-host-provider.test.cjs @@ -0,0 +1,332 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const BRIDGE_SOURCE = fs.readFileSync( + path.resolve(__dirname, "..", "..", "web-shell", "internal", "providers", "codex-bridge-polyfill.js"), + "utf8" +); +const CODEC = require("../../web-shell/codex-app-host-message-codec.js"); + +function sourceFunctionDeclaration(source, name) { + // 测试只抽取生产函数本身,避免为浏览器 Provider 搭建完整 DOM 环境。 + const start = source.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `missing function ${name}`); + const bodyStart = source.indexOf("{", start); + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + } + assert.fail(`unterminated function ${name}`); +} + +class FakePort { + constructor() { + this.listeners = new Map(); + this.posted = []; + this.closed = false; + this.started = false; + this.throwOnPost = false; + } + addEventListener(type, callback) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type).add(callback); + } + start() { + this.started = true; + } + close() { + this.closed = true; + } + postMessage(value) { + if (this.throwOnPost) throw new Error("browser post failed"); + this.posted.push(value); + return true; + } + emit(type, data) { + for (const callback of this.listeners.get(type) || []) callback({ data }); + } +} + +function createHarness({ countStringify = false } = {}) { + const windowListeners = new Map(); + const sent = []; + const diagnostics = []; + const published = []; + let stringifyCount = 0; + let portSequence = 0; + const nativeStringify = JSON.stringify; + const ws = { + readyState: 1, + send(payload) { + sent.push(JSON.parse(payload)); + }, + }; + const w = { + WebSocket: { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }, + crypto: { randomUUID: () => `provider-port-${++portSequence}` }, + __OpenCodexAppHostMessageCodec: CODEC, + }; + const adapterHost = { + events: { + observe({ target, type, callback }) { + if (target !== w) throw new Error("unexpected event target"); + windowListeners.set(type, callback); + return () => windowListeners.delete(type); + }, + }, + }; + const declarations = [ + "appHostMessageCodec", + "encodeAppHostMessageData", + "decodeAppHostMessageData", + "appHostPortId", + "appHostWsPayload", + "sendAppHostWsPayload", + "flushAppHostRelayMessages", + "flushAllAppHostRelayMessages", + "markGatewayWsReady", + "appHostPendingPayloadChars", + "prepareAppHostRelayPayload", + "queueAppHostRelayPayload", + "sendAppHostRelayError", + "forceFinalizeAppHostRelay", + "failAppHostRelay", + "finalizeAppHostRelay", + "closeAppHostRelay", + "handleAppHostGatewayMessage", + "installAppHostMessagePortBridge", + ].map((name) => sourceFunctionDeclaration(BRIDGE_SOURCE, name)).join("\n"); + const context = { + w, + window: w, + adapterHost, + modificationScope: null, + modificationEffects: null, + providerGeneration: {}, + ws, + wsReady: true, + clientId: "provider-client", + appHostPortRelays: new Map(), + APP_HOST_RELAY_MAX_ENTRIES: 64, + APP_HOST_PENDING_MESSAGE_LIMIT: 2000, + APP_HOST_PENDING_MESSAGE_CHARS_LIMIT: 16 * 1024 * 1024, + diagnostics, + published, + clientDiagnostic(event, data) { + diagnostics.push({ event, data }); + }, + publishAppHostData(data, direction) { + published.push({ data, direction }); + }, + payloadShape(value) { + return Array.isArray(value) ? `array(${value.length})` : typeof value; + }, + websocketStateName() { + return "open"; + }, + settleWsReadyWaiters() {}, + }; + if (countStringify) { + // 统计 Provider 自己的序列化次数,不把 fake WebSocket 的 JSON.parse 算进去。 + context.JSON = { + parse: JSON.parse, + stringify(value) { + stringifyCount += 1; + return nativeStringify(value); + }, + }; + } + const install = vm.runInNewContext( + `${declarations}\n({ installAppHostMessagePortBridge, handleAppHostGatewayMessage, markGatewayWsReady })`, + context + ); + return { context, install, sent, windowListeners, published, get stringifyCount() { return stringifyCount; } }; +} + +function connectHarness(harness) { + const port = new FakePort(); + harness.install.installAppHostMessagePortBridge(); + harness.windowListeners.get("message")({ + source: harness.context.w, + data: { type: "connect-app-host" }, + ports: [port], + }); + assert.equal(port.started, true); + const connectFrame = harness.sent.at(-1); + assert.equal(connectFrame.type, "app-host-connect"); + const portId = connectFrame.portId; + return { port, portId }; +} + +test("production AppHost provider encodes structured values and sends undefined exactly once", () => { + const harness = createHarness(); + const { port, portId } = connectHarness(harness); + const value = { method: "turn/start", args: [undefined, 3n] }; + port.emit("message", value); + const structured = harness.sent.at(-1); + assert.equal(structured.type, "app-host-port-message"); + assert.equal(structured.portId, portId); + assert.equal(structured.dataEncoding, CODEC.encoding); + assert.deepEqual(CODEC.decodeMessageData(structured), value); + port.emit("message", "legacy"); + assert.equal(harness.sent.at(-1).data, "legacy"); + port.emit("message", undefined); + const terminalFrames = harness.sent.filter((message) => message.type === "app-host-port-message").slice(-1); + assert.equal(terminalFrames.length, 1); + assert.equal(CODEC.decodeMessageData(terminalFrames[0]), undefined); + assert.equal(harness.sent.some((message) => message.data === null), false); + assert.equal(port.closed, true); + assert.deepEqual(harness.published.at(-1), { data: undefined, direction: "client" }); +}); + +test("production AppHost provider reports downlink decode and post failures locally", () => { + const malformedHarness = createHarness(); + const malformed = connectHarness(malformedHarness); + malformedHarness.install.handleAppHostGatewayMessage({ + type: "app-host-port-message", + portId: malformed.portId, + dataEncoding: "unknown", + data: ["undefined"], + }); + assert.equal(malformed.port.closed, true); + assert.equal(malformedHarness.sent.filter((message) => message.type === "app-host-port-error").length, 1); + assert.equal(malformedHarness.sent.some((message) => message.data === null), false); + + const postHarness = createHarness(); + const post = connectHarness(postHarness); + post.port.throwOnPost = true; + postHarness.install.handleAppHostGatewayMessage({ + type: "app-host-port-message", + portId: post.portId, + ...CODEC.encodeMessageData({ method: "thread/settings/update" }), + }); + assert.equal(post.port.closed, true); + assert.equal(postHarness.sent.filter((message) => message.type === "app-host-port-error").length, 1); + assert.equal(postHarness.sent.some((message) => message.data === null), false); +}); + +test("production AppHost provider reports repeated browser failures only once", () => { + const messageErrorHarness = createHarness(); + const messageError = connectHarness(messageErrorHarness); + messageError.port.emit("messageerror"); + messageError.port.emit("messageerror"); + assert.equal( + messageErrorHarness.sent.filter((message) => message.type === "app-host-port-error").length, + 1 + ); + + const encodeHarness = createHarness(); + const encodeFailure = connectHarness(encodeHarness); + encodeFailure.port.emit("message", Symbol("unsupported")); + encodeFailure.port.emit("message", Symbol("unsupported-again")); + assert.equal(encodeHarness.sent.filter((message) => message.type === "app-host-port-error").length, 1); + assert.equal(encodeFailure.port.closed, true); +}); + +test("production AppHost provider keeps a terminal frame FIFO across websocket reconnect", () => { + const harness = createHarness(); + harness.context.wsReady = false; + const port = new FakePort(); + harness.install.installAppHostMessagePortBridge(); + harness.windowListeners.get("message")({ + source: harness.context.w, + data: { type: "connect-app-host" }, + ports: [port], + }); + const state = [...harness.context.appHostPortRelays.values()][0]; + assert.equal(harness.sent.length, 0); + port.emit("message", null); + assert.equal(state.closing, true); + assert.equal(state.pending.length, 2); + harness.context.wsReady = true; + harness.install.markGatewayWsReady(); + assert.deepEqual(harness.sent.map((message) => message.type), ["app-host-connect", "app-host-port-message"]); + assert.equal(harness.sent[1].data, null); + assert.equal(port.closed, true); +}); + +test("production AppHost provider force-closes a closing relay on gateway terminal events", () => { + const harness = createHarness(); + const { port, portId } = connectHarness(harness); + harness.context.wsReady = false; + port.emit("message", null); + const state = harness.context.appHostPortRelays.get(portId); + assert.equal(state.closing, true); + assert.equal(state.pending.length, 1); + + harness.install.handleAppHostGatewayMessage({ type: "app-host-port-error", portId, error: "relay failed" }); + assert.equal(state.closed, true); + assert.equal(state.pending.length, 0); + assert.equal(harness.context.appHostPortRelays.has(portId), false); + assert.equal(port.closed, true); + + // 关闭后的迟到 MessagePort 事件不得再次发布到消费者。 + const publishedCount = harness.published.length; + port.emit("message", { method: "turn/start" }); + assert.equal(harness.published.length, publishedCount); + + const secondPort = new FakePort(); + harness.windowListeners.get("message")({ + source: harness.context.w, + data: { type: "connect-app-host" }, + ports: [secondPort], + }); + const secondState = [...harness.context.appHostPortRelays.values()].find((candidate) => candidate.port !== port); + secondPort.emit("message", null); + harness.install.handleAppHostGatewayMessage({ + type: "app-host-port-close", + portId: secondState.portId, + reason: "official_closed", + }); + assert.equal(secondState.closed, true); + assert.equal(secondPort.closed, true); +}); + +test("production AppHost provider evicts closing relays without stalling at the relay limit", () => { + const harness = createHarness(); + harness.context.wsReady = false; + harness.install.installAppHostMessagePortBridge(); + const ports = []; + const connectEvent = harness.windowListeners.get("message"); + for (let index = 0; index < harness.context.APP_HOST_RELAY_MAX_ENTRIES; index += 1) { + const port = new FakePort(); + ports.push(port); + connectEvent({ source: harness.context.w, data: { type: "connect-app-host" }, ports: [port] }); + } + ports[0].emit("message", null); + assert.equal([...harness.context.appHostPortRelays.values()][0].closing, true); + + assert.doesNotThrow(() => { + connectEvent({ source: harness.context.w, data: { type: "connect-app-host" }, ports: [new FakePort()] }); + }); + assert.equal(ports[0].closed, true); + assert.equal(harness.context.appHostPortRelays.size, harness.context.APP_HOST_RELAY_MAX_ENTRIES); +}); + +test("production AppHost provider serializes queued frames once and reuses them after reconnect", () => { + const harness = createHarness({ countStringify: true }); + harness.context.wsReady = false; + const port = new FakePort(); + harness.install.installAppHostMessagePortBridge(); + harness.windowListeners.get("message")({ + source: harness.context.w, + data: { type: "connect-app-host" }, + ports: [port], + }); + port.emit("message", { method: "turn/start", params: { threadId: "thread-1" } }); + assert.equal(harness.sent.length, 0); + assert.equal(harness.stringifyCount, 2); + + // hello-ack 只发送已缓存字符串,不应再次遍历结构化 wire 数据。 + harness.context.wsReady = true; + harness.install.markGatewayWsReady(); + assert.equal(harness.stringifyCount, 2); + assert.deepEqual(harness.sent.map((message) => message.type), ["app-host-connect", "app-host-port-message"]); +}); diff --git a/gateway/test/model-router-presentation.test.cjs b/gateway/test/model-router-presentation.test.cjs index 1a26a2f..aa7ffd4 100644 --- a/gateway/test/model-router-presentation.test.cjs +++ b/gateway/test/model-router-presentation.test.cjs @@ -62,10 +62,10 @@ test("presentation correlates turns and model selections and sends safe route st }); presentation.observeAppHostFrame({ clientId: "client-3", - data: JSON.stringify({ + data: { method: "thread/settings/update", params: { threadId: "thread-3", model: "auto" }, - }), + }, }); router.emit({ status: "classifying", threadId: "thread-1" }); router.emit({ diff --git a/gateway/test/official-runtime.test.cjs b/gateway/test/official-runtime.test.cjs index c9df7f7..aecbb02 100644 --- a/gateway/test/official-runtime.test.cjs +++ b/gateway/test/official-runtime.test.cjs @@ -26,6 +26,17 @@ function threadStreamStateMessage(conversationId, sourceClientId, change) { }; } +test("official AppHost delivery treats only null as peer close", () => { + const delivered = []; + const closed = []; + const deliver = __test.deliverOfficialAppHostMessage; + assert.equal(deliver({ data: undefined }, (value) => delivered.push(value), (reason) => closed.push(reason)), true); + assert.equal(delivered.length, 1); + assert.equal(delivered[0], undefined); + assert.equal(deliver({ data: null }, (value) => delivered.push(value), (reason) => closed.push(reason)), false); + assert.deepEqual(closed, ["official_closed"]); +}); + test("bridges only the primary official renderer to the Web client", () => { const primary = { id: 1, isDestroyed: () => false }; const samePrimaryWrapper = { id: 1, isDestroyed: () => false }; diff --git a/gateway/test/smart-scheduling-summary.test.cjs b/gateway/test/smart-scheduling-summary.test.cjs index a4189c5..8c9a9e3 100644 --- a/gateway/test/smart-scheduling-summary.test.cjs +++ b/gateway/test/smart-scheduling-summary.test.cjs @@ -409,9 +409,16 @@ function createHarness() { sendClientMessage(message) { summary.handleAppHostData(JSON.stringify(message), "client"); }, + sendStructuredClientMessage(message) { + // 结构化 AppHost 帧不应先 stringify 再 parse,直接交给同一消费者。 + summary.handleAppHostData(message, "client"); + }, sendServerMessage(message) { summary.handleAppHostData(JSON.stringify(message), "server"); }, + sendStructuredServerMessage(message) { + summary.handleAppHostData(message, "server"); + }, sendViewMessage(payload) { windowListeners.get("opencodex:plugin-event")?.({ detail: { eventName: "view:message", payload }, @@ -601,7 +608,7 @@ test("root-routed new Auto task renders directly in an otherwise empty official type: "navigate-to-route", path: "/local/client-new-thread%3Atemporary", }); - harness.sendClientMessage({ + harness.sendStructuredClientMessage({ id: "turn-new", method: "turn/start", params: { threadId: "thread-new", model: "auto" }, @@ -777,13 +784,15 @@ test("manual selection wins over delayed selected and turn metadata", async () = await resolveRoute(harness, "thread-a", null); assert.equal(harness.activeRoute(), null); - harness.sendServerMessage({ - method: "turn/started", - params: { - threadId: "thread-a", - turn: { id: "turn-a" }, - _meta: { - "opencodex/smart-scheduling": { model: "luna", effort: "high" }, + harness.sendStructuredServerMessage({ + message: { + method: "turn/started", + params: { + threadId: "thread-a", + turn: { id: "turn-a" }, + _meta: { + "opencodex/smart-scheduling": { model: "luna", effort: "high" }, + }, }, }, }); diff --git a/gateway/test/static-assets.test.cjs b/gateway/test/static-assets.test.cjs index 38fae0b..4ebb314 100644 --- a/gateway/test/static-assets.test.cjs +++ b/gateway/test/static-assets.test.cjs @@ -211,12 +211,18 @@ test("runtime compatibility diagnostics are public, grouped, explained, and repo assert.match(diagnosticsStyles, /\.feature-title-line/); const loginShell = fs.readFileSync(WEB_SHELL_INDEX, "utf8"); assert.match(loginShell, /href="\/settings\/developer\/runtime-compatibility"/); + assert.ok(loginShell.indexOf("/codex-app-host-message-codec.js") < loginShell.indexOf("/opencodex-modification-activate.js")); const bootstrap = runtimeBootstrapSource(service); const compatibilityIndex = bootstrap.indexOf("OpenCodexRuntimeCompatibility"); const sidebarIndex = bootstrap.indexOf("__opencodexSidebarPreviewInstalled"); + const codecIndex = bootstrap.indexOf("opencodex-structured-clone-v1"); + const bridgeIndex = bootstrap.indexOf("__codexAppHostMessagePortBridgeInstalled"); assert.ok(compatibilityIndex >= 0); assert.ok(sidebarIndex > compatibilityIndex); + assert.ok(codecIndex >= 0 && bridgeIndex > codecIndex); + assert.equal(service.isPublicStaticPath("/codex-app-host-message-codec.js"), true); + assert.match(service.staticFile("/codex-app-host-message-codec.js"), /codex-app-host-message-codec\.js$/); }); test("runtime compatibility page follows the public authentication locale", (t) => { @@ -584,6 +590,11 @@ test("remote renderer defers plugin summary image bytes until an image mounts", test("bridge reconnects active app-host ports after websocket hello", () => { const bridge = fs.readFileSync(BRIDGE_POLYFILL, "utf-8"); + assert.match(bridge, /encodeAppHostMessageData\(portData\)/); + assert.match(bridge, /decodeAppHostMessageData\(message\)/); + assert.match(bridge, /failAppHostRelay\(state, "Invalid app-host message data", "encode_failed"\)/); + assert.match(bridge, /failAppHostRelay\(state, "Browser MessagePort is unavailable", "post_to_browser_failed"\)/); + assert.match(bridge, /prepareAppHostRelayPayload\(state, \{ type: "app-host-connect" \}\)/); assert.match(bridge, /state\.pending\.unshift\(connectPayload\)/); assert.match(bridge, /state\.pendingChars \+= appHostPendingPayloadChars\(connectPayload\)/); assert.match(bridge, /for \(const state of appHostPortRelays\.values\(\)\) state\.connected = false/); diff --git a/gateway/test/web-runtime-performance.test.cjs b/gateway/test/web-runtime-performance.test.cjs index 406c0a0..a7fd9ea 100644 --- a/gateway/test/web-runtime-performance.test.cjs +++ b/gateway/test/web-runtime-performance.test.cjs @@ -1582,12 +1582,15 @@ test("app-host oversized frames bypass the limit only when they can be sent imme const sent = []; const closed = []; function appHostWsPayload(state, payload) { return { portId: state.portId, ...payload }; } - function sendAppHostWsPayload(payload) { + function sendAppHostWsPayload(payload, serialized) { if (!sendable) return false; - sent.push(payload); + sent.push(serialized || payload); return true; } function clientDiagnostic() {} + function failAppHostRelay(state, error, reason) { + closeAppHostRelay(state, reason); + } function closeAppHostRelay(state, reason) { state.closed = true; state.pending.length = 0; @@ -1596,15 +1599,16 @@ test("app-host oversized frames bypass the limit only when they can be sent imme } function flushAppHostRelayMessages(state) { while (!state.closed && state.pending.length > 0) { - if (!sendAppHostWsPayload(state.pending[0])) return; - const payload = state.pending.shift(); - state.pendingChars = Math.max(0, state.pendingChars - appHostPendingPayloadChars(payload)); + const prepared = state.pending[0]; + if (!sendAppHostWsPayload(prepared.payload, prepared.serialized)) return; + state.pending.shift(); + state.pendingChars = Math.max(0, state.pendingChars - appHostPendingPayloadChars(prepared)); } } ${sourceSection( BRIDGE_SOURCE, " function appHostPendingPayloadChars", - "\n\n function closeAppHostRelay" + "\n\n function sendAppHostRelayError" )} return { closed, @@ -1614,16 +1618,16 @@ test("app-host oversized frames bypass the limit only when they can be sent imme }; })()` ); - const makeState = (portId) => ({ closed: false, flushing: false, pending: [], pendingChars: 0, portId }); + const makeState = (portId) => ({ closed: false, closing: false, flushing: false, pending: [], pendingChars: 0, portId }); const offlineLarge = makeState("offline-large"); - relay.queue(offlineLarge, { data: "x".repeat(300), type: "app-host-port-message" }); + relay.queue(offlineLarge, { data: "x".repeat(500), type: "app-host-port-message" }); assert.equal(offlineLarge.closed, true); assert.deepEqual(Array.from(relay.closed), ["queue_overflow"]); relay.setSendable(true); const onlineLarge = makeState("online-large"); - relay.queue(onlineLarge, { data: "x".repeat(300), type: "app-host-port-message" }); + relay.queue(onlineLarge, { data: "x".repeat(500), type: "app-host-port-message" }); assert.equal(onlineLarge.closed, false); assert.equal(onlineLarge.pending.length, 0); assert.equal(relay.sent.length, 1); @@ -1732,7 +1736,7 @@ test("browser Statsig defaults preserve the official new-worktree capability", ( assert.match(BRIDGE_SOURCE, /STATSIG_DEFAULT_FEATURE_OVERRIDES\s*=\s*\{[\s\S]*?"505458": true/); }); -test("token usage passive parsing bounds wide and cyclic payload traversal", () => { +test("token usage passive parsing bounds wide and cyclic payload traversal", async () => { const compatibilityHits = []; const window = { clearTimeout, @@ -1762,6 +1766,35 @@ test("token usage passive parsing bounds wide and cyclic payload traversal", () const capability = window.__OpenCodexCreateTokenUsageCapability(); const release = capability.acquireConsumer("performance-test"); + const structuredUsageMessage = { + method: "thread/tokenUsage/updated", + params: { + threadId: "structured-thread", + turnId: "structured-turn", + tokenUsage: { cachedInputTokens: 8, inputTokens: 10, outputTokens: 2 }, + }, + }; + capability.handleAppHostData(structuredUsageMessage); + const structuredUsage = await capability.getForTurn({ threadId: "structured-thread", turnId: "structured-turn" }); + const legacyUsageMessage = JSON.stringify({ + method: "thread/tokenUsage/updated", + params: { + threadId: "legacy-thread", + turnId: "legacy-turn", + tokenUsage: { cachedInputTokens: 8, inputTokens: 10, outputTokens: 2 }, + }, + }); + capability.handleAppHostData(legacyUsageMessage); + const legacyUsage = await capability.getForTurn({ threadId: "legacy-thread", turnId: "legacy-turn" }); + // 结构化值和旧字符串只差 transport 形状,normalized usage 必须保持一致。 + const comparableUsage = (usage) => { + const { threadId, turnId, updatedAt, ...rest } = usage || {}; + return rest; + }; + assert.deepEqual(comparableUsage(structuredUsage), comparableUsage(legacyUsage)); + assert.equal(structuredUsage.source, "app-host"); + assert.equal(legacyUsage.source, "app-host"); + compatibilityHits.length = 0; let passiveArrayReads = 0; const passiveWide = new Proxy(Array.from({ length: 50_000 }, () => "ordinary"), { get(target, key, receiver) { @@ -1949,7 +1982,7 @@ test("whole-document observer filters stay scoped to their feature mounts", () = ); assert.match(BRIDGE_SOURCE, /reconnectDeferredUntilVisible = true/); assert.match(BRIDGE_SOURCE, /const releaseSocket = modificationScope\?\.own/); - assert.match(BRIDGE_SOURCE, /closeAppHostRelay\(state, "page_replaced", false\)/); + assert.match(BRIDGE_SOURCE, /closeAppHostRelay\(state, "page_replaced"\)/); assert.match(BRIDGE_SOURCE, /document\.visibilityState === "hidden"/); assert.match(BRIDGE_SOURCE, /if \(!CLIENT_DIAGNOSTICS_ENABLED\) return/); assert.match(BRIDGE_SOURCE, /CLIENT_DIAGNOSTICS_ENABLED \? ipcDiagnosticSummary/); @@ -1960,7 +1993,10 @@ test("whole-document observer filters stay scoped to their feature mounts", () = assert.match(BRIDGE_SOURCE, /activeBrowserNotifications\.size > BROWSER_NOTIFICATION_MAX_ACTIVE/); assert.match(BRIDGE_SOURCE, /appHostPortRelays\.size >= APP_HOST_RELAY_MAX_ENTRIES/); assert.match(BRIDGE_SOURCE, /nextPendingChars > APP_HOST_PENDING_MESSAGE_CHARS_LIMIT/); - assert.match(BRIDGE_SOURCE, /nextPendingChars > APP_HOST_PENDING_MESSAGE_CHARS_LIMIT &&\s*sendAppHostWsPayload\(framedPayload\)/); + assert.match( + BRIDGE_SOURCE, + /nextPendingChars > APP_HOST_PENDING_MESSAGE_CHARS_LIMIT &&\s*sendAppHostWsPayload\(prepared\.payload, prepared\.serialized\)/ + ); assert.doesNotMatch(BRIDGE_SOURCE, /state\.pending\.length > 0 && nextPendingChars/); assert.match(BRIDGE_SOURCE, /state\.pending\.length = 0;[\s\S]*state\.pendingChars = 0/); assert.match(BRIDGE_SOURCE, /TERMINAL_QUEUE_MAX_PENDING_PER_SESSION/); diff --git a/gateway/test/ws-hub.test.cjs b/gateway/test/ws-hub.test.cjs index 62dc54a..05df677 100644 --- a/gateway/test/ws-hub.test.cjs +++ b/gateway/test/ws-hub.test.cjs @@ -4,6 +4,7 @@ const test = require("node:test"); const WebSocket = require("ws"); const { createWsHub, __test } = require("../runtime/ipc/ws-hub.cjs"); +const appHostMessageCodec = require("../../web-shell/codex-app-host-message-codec.js"); function waitForMessage(socket, predicate) { return new Promise((resolve, reject) => { @@ -36,6 +37,29 @@ function waitForClose(socket) { return new Promise((resolve) => socket.once("close", resolve)); } +function waitForCondition(predicate, description) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}`)), 2000); + const check = () => { + let matched = false; + try { + matched = predicate(); + } catch (error) { + clearTimeout(timer); + reject(error); + return; + } + if (matched) { + clearTimeout(timer); + resolve(); + return; + } + setImmediate(check); + }; + check(); + }); +} + test("recreates an app-host relay when the browser WebSocket reconnects", async (t) => { const server = http.createServer(); const relays = []; @@ -99,6 +123,316 @@ test("recreates an app-host relay when the browser WebSocket reconnects", async assert.deepEqual(relays[1].messages, ["thread/list"]); }); +test("bridges structured AppHost values while retaining legacy frames", async (t) => { + const server = http.createServer(); + const relays = []; + createWsHub(server, { + createAppHostRelay({ onMessage, onError, onClose }) { + const relay = { + messages: [], + emitClose: onClose, + emitError: onError, + emitMessage: onMessage, + close(reason) { + this.closeReason = reason; + }, + postMessage(value) { + this.messages.push(value); + return true; + }, + }; + relays.push(relay); + return relay; + }, + handleNotificationEvent() {}, + isAuthed: () => true, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + t.after(async () => { + socket.close(); + await new Promise((resolve) => server.close(resolve)); + }); + await waitForOpen(socket); + const clientId = "structured-client"; + const portId = "structured-port"; + socket.send(JSON.stringify({ type: "hello", clientId })); + await waitForMessage(socket, (message) => message.type === "hello-ack"); + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + await waitForMessage(socket, (message) => message.type === "app-host-port-connected"); + + const value = { type: "turn/start", nested: [undefined, 7n] }; + socket.send( + JSON.stringify({ + type: "app-host-port-message", + clientId, + portId, + ...appHostMessageCodec.encodeMessageData(value), + }) + ); + await waitForCondition(() => relays[0].messages.length >= 1, "first AppHost relay message"); + assert.deepEqual(relays[0].messages[0], value); + + socket.send(JSON.stringify({ type: "app-host-port-message", clientId, portId, data: "legacy" })); + await waitForCondition(() => relays[0].messages.length >= 2, "legacy AppHost relay message"); + assert.equal(relays[0].messages[1], "legacy"); + + const downlink = waitForMessage(socket, (message) => message.type === "app-host-port-message"); + relays[0].emitMessage(value); + const wire = await downlink; + assert.equal(wire.dataEncoding, appHostMessageCodec.encoding); + assert.deepEqual(appHostMessageCodec.decodeMessageData(wire), value); +}); + +test("reports one relay error and isolates stale replacement callbacks", async (t) => { + const server = http.createServer(); + const relays = []; + createWsHub(server, { + createAppHostRelay({ onMessage, onError, onClose }) { + const relayIndex = relays.length; + const relay = { + emitClose: onClose, + emitError: onError, + emitMessage: onMessage, + close(reason) { + onClose(reason); + }, + postMessage() { + if (relayIndex === 0) throw new Error("post failed"); + return true; + }, + }; + relays.push(relay); + return relay; + }, + handleNotificationEvent() {}, + isAuthed: () => true, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + t.after(async () => { + socket.close(); + await new Promise((resolve) => server.close(resolve)); + }); + await waitForOpen(socket); + const clientId = "failure-client"; + const portId = "failure-port"; + socket.send(JSON.stringify({ type: "hello", clientId })); + await waitForMessage(socket, (message) => message.type === "hello-ack"); + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + await waitForMessage(socket, (message) => message.type === "app-host-port-connected"); + socket.send( + JSON.stringify({ + type: "app-host-port-message", + clientId, + portId, + ...appHostMessageCodec.encodeMessageData({ request: "fails" }), + }) + ); + const error = await waitForMessage(socket, (message) => message.type === "app-host-port-error"); + assert.match(error.error, /post failed/); + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + await waitForMessage(socket, (message) => message.type === "app-host-port-connected"); + assert.equal(relays.length, 2); + relays[0].emitClose("late-close"); + relays[0].emitError(new Error("late-error")); + const downlink = waitForMessage(socket, (message) => message.type === "app-host-port-message"); + relays[1].emitMessage({ type: "replacement/ok" }); + await downlink; + assert.equal(relays.length, 2); +}); + +test("reports one terminal error for official encode and browser decode failures", async (t) => { + const server = http.createServer(); + const relays = []; + const events = []; + createWsHub(server, { + createAppHostRelay({ onMessage, onError, onClose }) { + const relay = { + emitClose: onClose, + emitError: onError, + emitMessage: onMessage, + close(reason) { + // 真实 MessagePort close 会触发 onClose,测试不能只检查 map 是否删除。 + onClose(reason); + }, + postMessage() { + return true; + }, + }; + relays.push(relay); + return relay; + }, + handleNotificationEvent() {}, + isAuthed: () => true, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + socket.on("message", (raw) => events.push(JSON.parse(String(raw)))); + t.after(async () => { + socket.close(); + await new Promise((resolve) => server.close(resolve)); + }); + await waitForOpen(socket); + const clientId = "terminal-event-client"; + const portId = "terminal-event-port"; + socket.send(JSON.stringify({ type: "hello", clientId })); + await waitForMessage(socket, (message) => message.type === "hello-ack"); + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + await waitForMessage(socket, (message) => message.type === "app-host-port-connected"); + + relays[0].emitMessage(Symbol("unsupported")); + await waitForMessage(socket, (message) => message.type === "app-host-port-error"); + relays[0].emitError(new Error("late-error")); + relays[0].emitClose("late-close"); + assert.equal(events.filter((message) => message.type === "app-host-port-error").length, 1); + assert.equal(events.filter((message) => message.type === "app-host-port-close").length, 0); + + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + await waitForMessage(socket, (message) => message.type === "app-host-port-connected"); + socket.send(JSON.stringify({ type: "app-host-port-message", clientId, portId, dataEncoding: "unknown", data: null })); + await waitForMessage( + socket, + (message) => message.type === "app-host-port-error" && message.portId === portId + ); + assert.equal(events.filter((message) => message.type === "app-host-port-error").length, 2); + assert.equal(events.filter((message) => message.type === "app-host-port-close").length, 0); +}); + +test("isolates a failed AppHost port from sibling ports and clients", async (t) => { + const server = http.createServer(); + const relays = []; + createWsHub(server, { + createAppHostRelay({ onMessage, onError, onClose, clientId, portId }) { + const relay = { + clientId, + emitClose: onClose, + emitError: onError, + emitMessage: onMessage, + portId, + close(reason) { + onClose(reason); + }, + postMessage() { + return true; + }, + }; + relays.push(relay); + return relay; + }, + handleNotificationEvent() {}, + isAuthed: () => true, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const first = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + const second = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + t.after(async () => { + first.close(); + second.close(); + await new Promise((resolve) => server.close(resolve)); + }); + await Promise.all([waitForOpen(first), waitForOpen(second)]); + const firstClient = "isolated-client-one"; + const secondClient = "isolated-client-two"; + first.send(JSON.stringify({ type: "hello", clientId: firstClient })); + second.send(JSON.stringify({ type: "hello", clientId: secondClient })); + await Promise.all([ + waitForMessage(first, (message) => message.type === "hello-ack"), + waitForMessage(second, (message) => message.type === "hello-ack"), + ]); + const connect = (socket, clientId, portId) => { + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + return waitForMessage(socket, (message) => message.type === "app-host-port-connected" && message.portId === portId); + }; + await Promise.all([ + connect(first, firstClient, "isolated-port-one"), + connect(first, firstClient, "isolated-port-two"), + connect(second, secondClient, "isolated-port-three"), + ]); + + const firstPortError = waitForMessage( + first, + (message) => message.type === "app-host-port-error" && message.portId === "isolated-port-one" + ); + const firstRelay = relays.find((relay) => relay.portId === "isolated-port-one"); + const secondRelay = relays.find((relay) => relay.portId === "isolated-port-two"); + const thirdRelay = relays.find((relay) => relay.portId === "isolated-port-three"); + assert.ok(firstRelay && secondRelay && thirdRelay); + firstRelay.emitError(new Error("isolated failure")); + await firstPortError; + + const secondPortMessage = waitForMessage( + first, + (message) => message.type === "app-host-port-message" && message.portId === "isolated-port-two" + ); + const thirdPortMessage = waitForMessage( + second, + (message) => message.type === "app-host-port-message" && message.portId === "isolated-port-three" + ); + secondRelay.emitMessage({ method: "thread/list" }); + thirdRelay.emitMessage({ method: "thread/read" }); + assert.deepEqual(await secondPortMessage, { + type: "app-host-port-message", + portId: "isolated-port-two", + data: ["object", [["method", ["string", "thread/list"]]]], + dataEncoding: appHostMessageCodec.encoding, + }); + assert.deepEqual(await thirdPortMessage, { + type: "app-host-port-message", + portId: "isolated-port-three", + data: ["object", [["method", ["string", "thread/read"]]]], + dataEncoding: appHostMessageCodec.encoding, + }); +}); + +test("closes only the relay when forwarding to the browser websocket fails", async (t) => { + const server = http.createServer(); + const relays = []; + const hub = createWsHub(server, { + createAppHostRelay({ onMessage, onError, onClose }) { + const relay = { + closeReason: "", + emitMessage: onMessage, + close(reason) { + this.closeReason = reason; + onClose(reason); + }, + postMessage() { + return true; + }, + }; + relays.push(relay); + return relay; + }, + handleNotificationEvent() {}, + isAuthed: () => true, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + const originalSend = socket.send.bind(socket); + t.after(async () => { + socket.send = originalSend; + socket.close(); + await new Promise((resolve) => server.close(resolve)); + }); + await waitForOpen(socket); + const clientId = "send-failure-client"; + const portId = "send-failure-port"; + socket.send(JSON.stringify({ type: "hello", clientId })); + await waitForMessage(socket, (message) => message.type === "hello-ack"); + socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId })); + await waitForMessage(socket, (message) => message.type === "app-host-port-connected"); + + const serverSocket = [...hub.clients][0]; + const originalServerSend = serverSocket.send.bind(serverSocket); + serverSocket.send = () => { + throw new Error("browser websocket send failed"); + }; + relays[0].emitMessage({ method: "turn/start" }); + assert.equal(relays[0].closeReason, "forward_to_browser_failed"); + serverSocket.send = originalServerSend; +}); + test("caps app-host relays per browser socket", async (t) => { const server = http.createServer(); const relays = []; @@ -266,3 +600,8 @@ test("bounds diagnostic route extraction for wide payload arrays", () => { assert.equal(__test.routeIdFromPayload(wide), ""); assert.ok(reads > 0 && reads <= 128, `diagnostic route scan read ${reads} array items`); }); + +test("keeps the WebSocket ingress boundary at 64 MiB", () => { + assert.equal(__test.webSocketServerOptions().maxPayload, 64 * 1024 * 1024); + assert.equal(__test.webSocketServerOptions({ maxPayloadBytes: 2 * 1024 * 1024 }).maxPayload, 2 * 1024 * 1024); +}); diff --git a/package.json b/package.json index ba539f8..11b2851 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "sync:version": "node scripts/sync-app-version.cjs", "check:version": "node scripts/sync-app-version.cjs --check", "pretest": "pnpm run build:gateway", - "test": "node --test gateway/test/auth-rate-limit.test.cjs gateway/test/auth-request.test.cjs gateway/test/compatibility-registry.test.cjs gateway/test/compatibility-service.test.cjs gateway/test/gateway-lifecycle.test.cjs gateway/test/history-preview.test.cjs gateway/test/http-utils.test.cjs gateway/test/local-files.test.cjs gateway/test/loopback-host.test.cjs gateway/test/mobile-sidebar-auto-collapse.test.cjs gateway/test/model-router-classifier.test.cjs gateway/test/model-router-injection-health.test.cjs gateway/test/model-router-presentation.test.cjs gateway/test/model-router-transport.test.cjs gateway/test/model-router.test.cjs gateway/test/modification-browser-host.test.cjs gateway/test/modification-equivalence.test.cjs gateway/test/modification-kernel.test.cjs gateway/test/modification-node-provider.test.cjs gateway/test/modification-production.test.cjs gateway/test/official-desktop-compat.test.cjs gateway/test/official-electron-module-hook.test.cjs gateway/test/official-runtime.test.cjs gateway/test/open-file-context.test.cjs gateway/test/picked-files.test.cjs gateway/test/plugin-config.test.cjs gateway/test/project-recent-sort.test.cjs gateway/test/runtime-compatibility-page.test.cjs gateway/test/service-control.test.cjs gateway/test/smart-model-router.integration.test.cjs gateway/test/smart-scheduling-summary.test.cjs gateway/test/static-assets.test.cjs gateway/test/virtual-model.test.cjs gateway/test/web-runtime-compatibility.test.cjs gateway/test/web-runtime-performance.test.cjs gateway/test/workspace-root-context.test.cjs gateway/test/workspace-root-picker.test.cjs gateway/test/workspace-roots.test.cjs gateway/test/ws-hub-client-ready.test.cjs gateway/test/ws-hub.test.cjs launcher/test/log-writer.test.cjs", + "test": "node --test gateway/test/app-host-message-codec.test.cjs gateway/test/app-host-provider.test.cjs gateway/test/auth-rate-limit.test.cjs gateway/test/auth-request.test.cjs gateway/test/compatibility-registry.test.cjs gateway/test/compatibility-service.test.cjs gateway/test/gateway-lifecycle.test.cjs gateway/test/history-preview.test.cjs gateway/test/http-utils.test.cjs gateway/test/local-files.test.cjs gateway/test/loopback-host.test.cjs gateway/test/mobile-sidebar-auto-collapse.test.cjs gateway/test/model-router-classifier.test.cjs gateway/test/model-router-injection-health.test.cjs gateway/test/model-router-presentation.test.cjs gateway/test/model-router-transport.test.cjs gateway/test/model-router.test.cjs gateway/test/modification-browser-host.test.cjs gateway/test/modification-equivalence.test.cjs gateway/test/modification-kernel.test.cjs gateway/test/modification-node-provider.test.cjs gateway/test/modification-production.test.cjs gateway/test/official-desktop-compat.test.cjs gateway/test/official-electron-module-hook.test.cjs gateway/test/official-runtime.test.cjs gateway/test/open-file-context.test.cjs gateway/test/picked-files.test.cjs gateway/test/plugin-config.test.cjs gateway/test/project-recent-sort.test.cjs gateway/test/runtime-compatibility-page.test.cjs gateway/test/service-control.test.cjs gateway/test/smart-model-router.integration.test.cjs gateway/test/smart-scheduling-summary.test.cjs gateway/test/static-assets.test.cjs gateway/test/virtual-model.test.cjs gateway/test/web-runtime-compatibility.test.cjs gateway/test/web-runtime-performance.test.cjs gateway/test/workspace-root-context.test.cjs gateway/test/workspace-root-picker.test.cjs gateway/test/workspace-roots.test.cjs gateway/test/ws-hub-client-ready.test.cjs gateway/test/ws-hub.test.cjs launcher/test/log-writer.test.cjs", "eval:model-router-prompts": "node gateway/dev/model-router-prompt-ab.cjs", "web:dev": "pnpm run build:gateway && node gateway/dev/run-gateway.cjs", "build": "pnpm run build:gateway", diff --git a/web-shell/codex-app-host-message-codec.js b/web-shell/codex-app-host-message-codec.js new file mode 100644 index 0000000..f0dcb22 --- /dev/null +++ b/web-shell/codex-app-host-message-codec.js @@ -0,0 +1,260 @@ +(function (root, factory) { + const codec = factory(); + // CommonJS 只导出模块;浏览器脚本才挂全局,避免网关加载时污染 Node globalThis。 + if (typeof module === "object" && module && module.exports) { + module.exports = codec; + } else if (root) { + root.__OpenCodexAppHostMessageCodec = codec; + } +})(typeof globalThis === "undefined" ? this : globalThis, function () { + "use strict"; + + const ENCODING = "opencodex-structured-clone-v1"; + const MAX_DEPTH = 256; + // 官方字符串 decoder 的 BigInt 防御边界为 16384 位,transport adapter 复用该上限。 + const MAX_BIGINT_DIGITS = 16_384; + // 节点上限只保护 gateway 的 CPU 和内存,不改变 WebSocket 的帧大小边界。 + const MAX_NODES = 1_000_000; + const TYPED_ARRAY_NAMES = new Set([ + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + ]); + + function traversalState() { + return { nodes: 0, seen: new WeakSet() }; + } + + function visitNode(state, depth) { + state.nodes += 1; + if (state.nodes > MAX_NODES) throw new TypeError("App-host message exceeds the node limit"); + // 官方 serializer 在 depth >= 256 时拒绝当前节点,因此允许深度为 0..255。 + if (depth >= MAX_DEPTH) throw new TypeError("App-host message exceeds the depth limit"); + } + + function bigintDigitCount(value) { + const text = typeof value === "string" ? value : value.toString(); + return text.startsWith("-") ? text.length - 1 : text.length; + } + + function rememberContainer(value, state) { + if (state.seen.has(value)) throw new TypeError("App-host message contains a cycle"); + state.seen.add(value); + } + + function bytesFromView(value) { + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + + function bytesToBase64(bytes) { + if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"); + } + let binary = ""; + // 分块转换,避免大型 RPC 二进制参数触发浏览器调用栈或参数数量限制。 + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); + } + + function base64ToBytes(value) { + if ( + typeof value !== "string" || + value.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + throw new TypeError("Invalid app-host base64 payload"); + } + if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { + const buffer = Buffer.from(value, "base64"); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; + } + + function byteViewName(value) { + if (value instanceof ArrayBuffer) return "ArrayBuffer"; + if (value instanceof DataView) return "DataView"; + const name = value && value.constructor && value.constructor.name; + // Node Buffer 在 MessagePort 边界会恢复为 Uint8Array,这里主动使用同一语义。 + if (name === "Buffer") return "Uint8Array"; + return TYPED_ARRAY_NAMES.has(name) ? name : ""; + } + + function encodeNode(value, state, depth) { + visitNode(state, depth); + if (value === null) return ["null"]; + if (value === undefined) return ["undefined"]; + if (typeof value === "string") return ["string", value]; + if (typeof value === "boolean") return ["boolean", value]; + if (typeof value === "bigint") { + if (bigintDigitCount(value) > MAX_BIGINT_DIGITS) { + throw new TypeError("App-host message exceeds the BigInt digit limit"); + } + return ["bigint", value.toString()]; + } + if (typeof value === "number") { + if (Number.isNaN(value)) return ["number", "nan"]; + if (value === Infinity) return ["number", "infinity"]; + if (value === -Infinity) return ["number", "-infinity"]; + if (Object.is(value, -0)) return ["number", "-0"]; + return ["number", value]; + } + if (!value || typeof value !== "object") { + throw new TypeError(`Unsupported app-host value type: ${typeof value}`); + } + + if (Object.prototype.toString.call(value) === "[object Date]") { + const timestamp = value.getTime(); + return ["date", Number.isNaN(timestamp) ? null : timestamp]; + } + + const viewName = byteViewName(value); + if (viewName) return ["bytes", viewName, bytesToBase64(bytesFromView(value))]; + + rememberContainer(value, state); + if (Array.isArray(value)) { + // 稀疏数组按官方 serializer 的逐索引读取规则编码为显式 undefined。 + const items = Array.from(value, (item) => encodeNode(item, state, depth + 1)); + state.seen.delete(value); + return ["array", items]; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + state.seen.delete(value); + throw new TypeError(`Unsupported app-host object: ${value.constructor?.name || "unknown"}`); + } + const entries = Object.keys(value).map((key) => [key, encodeNode(value[key], state, depth + 1)]); + state.seen.delete(value); + return ["object", entries]; + } + + function decodedByteView(name, base64) { + if (typeof name !== "string" || !TYPED_ARRAY_NAMES.has(name) && name !== "ArrayBuffer" && name !== "DataView") { + throw new TypeError(`Unsupported app-host byte view: ${String(name)}`); + } + const bytes = base64ToBytes(base64); + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + if (name === "ArrayBuffer") return buffer; + if (name === "DataView") return new DataView(buffer); + if (typeof globalThis[name] !== "function") throw new TypeError(`Unsupported app-host byte view: ${name}`); + try { + return new globalThis[name](buffer); + } catch (error) { + throw new TypeError(`Invalid ${name} byte length: ${error instanceof Error ? error.message : String(error)}`); + } + } + + function decodeNode(node, state, depth) { + visitNode(state, depth); + if (!Array.isArray(node) || typeof node[0] !== "string") throw new TypeError("Invalid app-host wire node"); + const requireNodeLength = (length) => { + if (node.length !== length) throw new TypeError(`Invalid app-host ${node[0]} node`); + }; + switch (node[0]) { + case "null": + requireNodeLength(1); + return null; + case "undefined": + requireNodeLength(1); + return undefined; + case "string": + requireNodeLength(2); + if (typeof node[1] !== "string") throw new TypeError("Invalid app-host string node"); + return node[1]; + case "boolean": + requireNodeLength(2); + if (typeof node[1] !== "boolean") throw new TypeError("Invalid app-host boolean node"); + return node[1]; + case "bigint": + requireNodeLength(2); + if ( + typeof node[1] !== "string" || + !/^-?\d+$/.test(node[1]) || + bigintDigitCount(node[1]) > MAX_BIGINT_DIGITS + ) { + throw new TypeError("Invalid app-host bigint node"); + } + return BigInt(node[1]); + case "number": + requireNodeLength(2); + if (typeof node[1] === "number" && Number.isFinite(node[1])) return node[1]; + if (node[1] === "nan") return NaN; + if (node[1] === "infinity") return Infinity; + if (node[1] === "-infinity") return -Infinity; + if (node[1] === "-0") return -0; + throw new TypeError("Invalid app-host number node"); + case "date": + requireNodeLength(2); + if (!(node[1] === null || (typeof node[1] === "number" && Number.isFinite(node[1])))) { + throw new TypeError("Invalid app-host date node"); + } + return new Date(node[1] === null ? NaN : node[1]); + case "bytes": + requireNodeLength(3); + return decodedByteView(node[1], node[2]); + case "array": + requireNodeLength(2); + if (!Array.isArray(node[1])) throw new TypeError("Invalid app-host array node"); + return node[1].map((item) => decodeNode(item, state, depth + 1)); + case "object": { + requireNodeLength(2); + if (!Array.isArray(node[1])) throw new TypeError("Invalid app-host object node"); + const value = {}; + for (const entry of node[1]) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") { + throw new TypeError("Invalid app-host object entry"); + } + if (Object.prototype.hasOwnProperty.call(value, entry[0])) throw new TypeError("Duplicate app-host object key"); + Object.defineProperty(value, entry[0], { + configurable: true, + enumerable: true, + value: decodeNode(entry[1], state, depth + 1), + writable: true, + }); + } + return value; + } + default: + throw new TypeError(`Unknown app-host wire tag: ${node[0]}`); + } + } + + function encode(value) { + return encodeNode(value, traversalState(), 0); + } + + function decode(value) { + return decodeNode(value, traversalState(), 0); + } + + function encodeMessageData(data) { + // 旧版字符串 RPC 保持原始帧,只有新版结构化值进入 codec。 + if (data === null || typeof data === "string") return { data }; + return { data: encode(data), dataEncoding: ENCODING }; + } + + function decodeMessageData(message) { + if (!message || typeof message !== "object") throw new TypeError("Invalid app-host transport message"); + if (message.dataEncoding === ENCODING) return decode(message.data); + if (message.dataEncoding != null) throw new TypeError(`Unsupported app-host data encoding: ${String(message.dataEncoding)}`); + if (message.data === null || typeof message.data === "string") return message.data; + throw new TypeError("Unencoded app-host data must be a string or null"); + } + + return { decode, decodeMessageData, encode, encodeMessageData, encoding: ENCODING }; +}); diff --git a/web-shell/index.html b/web-shell/index.html index 50ab2ec..1608a25 100644 --- a/web-shell/index.html +++ b/web-shell/index.html @@ -25,6 +25,7 @@ +