diff --git a/apps/web/src/components/runtime/WebPlatformAdapter.test.ts b/apps/web/src/components/runtime/WebPlatformAdapter.test.ts new file mode 100644 index 00000000..94b958ff --- /dev/null +++ b/apps/web/src/components/runtime/WebPlatformAdapter.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTelemetryBuffer } from "../../../../../packages/runtime-sdk/src/index"; +import { WebPlatformAdapter } from "./WebPlatformAdapter"; +import type { WebPlatformAdapterOptions } from "./WebPlatformAdapter"; + +describe("S4 WebPlatformAdapter", () => { + it("exposes runtime SDK sections while keeping canvas handles opaque", () => { + const env = createDomEnvironment(); + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }), + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter", sender: async () => undefined }) + }).createSdk(); + + expect(Object.keys(sdk)).toEqual(["version", "Canvas", "Input", "Audio", "Network", "Storage", "Lifecycle", "Telemetry"]); + const handle = sdk.Canvas.createCanvas(320, 180); + + expect(handle).toEqual({ handleId: expect.stringMatching(/^web-canvas-/) }); + expect("canvas" in handle).toBe(false); + expect("getContext" in handle).toBe(false); + }); + + it("converts DOM pointer and keyboard events into RuntimeSdk DTOs", () => { + const env = createDomEnvironment(); + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }), + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-input", sender: async () => undefined }) + }).createSdk(); + const touchEvents: unknown[] = []; + const keyEvents: unknown[] = []; + + sdk.Input.onTouchStart((event) => touchEvents.push(event)); + sdk.Input.onKeyDown((event) => keyEvents.push(event)); + env.canvas.dispatch("pointerdown", { + clientX: 14, + clientY: 25, + pointerId: 7, + preventDefault: vi.fn() + }); + env.windowTarget.dispatch("keydown", { + key: "ArrowLeft", + preventDefault: vi.fn() + }); + + expect(touchEvents).toEqual([ + expect.objectContaining({ type: "touch_start", pointerId: "7", x: 4, y: 5, occurredAt: expect.any(String) }) + ]); + expect(keyEvents).toEqual([expect.objectContaining({ type: "key_down", key: "ArrowLeft", occurredAt: expect.any(String) })]); + }); + + it("renders commands through Canvas2D without exposing context to game logic", () => { + const env = createDomEnvironment(); + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }), + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-render", sender: async () => undefined }) + }).createSdk(); + const handle = sdk.Canvas.createCanvas(320, 180); + + sdk.Canvas.submitRenderCommands(handle, [ + { type: "clear", color: "#000000" }, + { type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ffffff" }, + { type: "text", text: "Coins 1", x: 8, y: 16, fill: "#ffffff", fontSize: 12 } + ]); + + expect(env.context.calls).toEqual([ + ["clearRect", 0, 0, 320, 180], + ["fillRect", 0, 0, 320, 180], + ["fillRect", 1, 2, 3, 4], + ["fillText", "Coins 1", 8, 16] + ]); + }); + + it("uses endpoint ids for network and retries failed requests", async () => { + const env = createDomEnvironment(); + const requested: string[] = []; + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + endpoints: { score_submit: "/api/runtime/score" }, + fetcher: async (url) => { + requested.push(url); + if (requested.length === 1) throw new Error("network down"); + return { ok: true, status: 202, json: async () => ({ accepted: true }) }; + }, + retryDelayMs: 0, + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-network", sender: async () => undefined }) + }).createSdk(); + + await expect( + sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 1_000, body: { score: 1 }, retry: { attempts: 2, backoffMs: 1 } }) + ).resolves.toEqual({ ok: true, status: 202, body: { accepted: true } }); + expect(requested).toEqual(["/api/runtime/score", "/api/runtime/score"]); + }); + + it("aborts hanging network requests with timeout and clears the attempt timer", async () => { + const env = createDomEnvironment(); + const timeoutHandlers: Array<() => void> = []; + const clearTimeoutSpy = vi.fn(); + env.windowTarget.setTimeout = vi.fn((handler: () => void) => { + timeoutHandlers.push(handler); + return 77; + }); + env.windowTarget.clearTimeout = clearTimeoutSpy; + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + endpoints: { score_submit: "/api/runtime/score" }, + fetcher: async () => new Promise(() => undefined), + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-timeout", sender: async () => undefined }) + }).createSdk(); + + const request = sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 50 }); + expect(timeoutHandlers).toHaveLength(1); + const triggerTimeout = timeoutHandlers[0]; + if (triggerTimeout === undefined) throw new Error("missing timeout handler"); + triggerTimeout(); + + await expect(request).resolves.toEqual({ ok: false, status: 0, errorCode: "WEB_PLATFORM_NETWORK_TIMEOUT" }); + expect(clearTimeoutSpy).toHaveBeenCalledWith(77); + }); + + it("retries after timeout or thrown attempts and can still succeed", async () => { + const env = createDomEnvironment(); + const timeoutHandlers: Array<() => void> = []; + env.windowTarget.setTimeout = vi.fn((handler: () => void) => { + timeoutHandlers.push(handler); + return timeoutHandlers.length; + }); + env.windowTarget.clearTimeout = vi.fn(); + const fetcher = vi + .fn() + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ ok: true, status: 202, json: async () => ({ accepted: true }) }); + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + endpoints: { score_submit: "/api/runtime/score" }, + fetcher, + retryDelayMs: 0, + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-retry-timeout", sender: async () => undefined }) + }).createSdk(); + + const request = sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 50, retry: { attempts: 3, backoffMs: 1 } }); + timeoutHandlers[0]?.(); + + await expect(request).resolves.toEqual({ ok: true, status: 202, body: { accepted: true } }); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("passes AbortSignal to each fetch attempt", async () => { + const env = createDomEnvironment(); + const signals: unknown[] = []; + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + endpoints: { score_submit: "/api/runtime/score" }, + fetcher: async (_url, init) => { + signals.push(init.signal); + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + }, + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-signal", sender: async () => undefined }) + }).createSdk(); + + await sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 50 }); + + expect(signals).toHaveLength(1); + expect(signals[0]).toBeInstanceOf(AbortSignal); + }); + + it("bridges storage and lifecycle events", async () => { + const env = createDomEnvironment(); + const sdk = new WebPlatformAdapter({ + canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"], + windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"], + storage: env.storage, + fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }), + telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-lifecycle", sender: async () => undefined }) + }).createSdk(); + const lifecycle: unknown[] = []; + + await sdk.Storage.setItem("save", "slot1", "value"); + expect(await sdk.Storage.getItem("save", "slot1")).toBe("value"); + await sdk.Storage.removeItem("save", "slot1"); + expect(await sdk.Storage.getItem("save", "slot1")).toBeNull(); + + sdk.Lifecycle.onPause((event) => lifecycle.push(event)); + sdk.Lifecycle.onResume((event) => lifecycle.push(event)); + env.windowTarget.dispatch("blur", {}); + env.windowTarget.dispatch("focus", {}); + + expect(lifecycle).toEqual([ + expect.objectContaining({ type: "pause", occurredAt: expect.any(String) }), + expect.objectContaining({ type: "resume", occurredAt: expect.any(String) }) + ]); + }); +}); + +function createDomEnvironment() { + const context = { + calls: [] as unknown[][], + fillStyle: "", + font: "", + clearRect: vi.fn((...args: unknown[]) => context.calls.push(["clearRect", ...args])), + fillRect: vi.fn((...args: unknown[]) => context.calls.push(["fillRect", ...args])), + fillText: vi.fn((...args: unknown[]) => context.calls.push(["fillText", ...args])), + drawImage: vi.fn((...args: unknown[]) => context.calls.push(["drawImage", ...args])) + }; + const listeners = new Map) => void>>(); + const windowListeners = new Map) => void>>(); + const createTarget = (listenerMap: Map) => void>>) => ({ + addEventListener: (type: string, handler: (event: Record) => void) => { + const handlers = listenerMap.get(type) ?? new Set(); + handlers.add(handler); + listenerMap.set(type, handlers); + }, + removeEventListener: (type: string, handler: (event: Record) => void) => { + listenerMap.get(type)?.delete(handler); + }, + dispatch: (type: string, event: Record) => { + listenerMap.get(type)?.forEach((handler) => handler(event)); + } + }); + const canvas = { + width: 320, + height: 180, + clientWidth: 320, + clientHeight: 180, + getBoundingClientRect: () => ({ left: 10, top: 20, width: 320, height: 180 }), + getContext: (type: string) => (type === "2d" ? context : null), + ...createTarget(listeners) + }; + const storageValues = new Map(); + const storage = { + getItem: (key: string) => storageValues.get(key) ?? null, + setItem: (key: string, value: string) => { + storageValues.set(key, value); + }, + removeItem: (key: string) => { + storageValues.delete(key); + } + }; + + const windowTarget: ReturnType & { + devicePixelRatio: number; + setTimeout?: (handler: () => void, timeout: number) => number; + clearTimeout?: (handle: number) => void; + } = { + devicePixelRatio: 1, + ...createTarget(windowListeners) + }; + + return { + canvas, + context, + storage, + windowTarget + }; +} diff --git a/apps/web/src/components/runtime/WebPlatformAdapter.ts b/apps/web/src/components/runtime/WebPlatformAdapter.ts new file mode 100644 index 00000000..ea49323a --- /dev/null +++ b/apps/web/src/components/runtime/WebPlatformAdapter.ts @@ -0,0 +1,284 @@ +import { createRuntimeSdk } from "../../../../../packages/runtime-sdk/src/index"; +import type { TelemetryBuffer } from "../../../../../packages/runtime-sdk/src/index"; +import type { + AudioHandleId, + AudioLoadRequestDto, + CanvasHandle, + InputEventDto, + NetworkRequestDto, + NetworkResultDto, + RenderCommand, + RuntimeLifecycleDto, + RuntimeSdkContract +} from "../../../../../packages/shared-contracts/src/runtime-sdk-contract"; + +type WebCanvasElement = { + width: number; + height: number; + clientWidth: number; + clientHeight: number; + getBoundingClientRect: () => { left: number; top: number; width: number; height: number }; + getContext: (type: "2d") => CanvasRenderingContext2D | null; + addEventListener: (type: string, handler: (event: Event) => void) => void; + removeEventListener: (type: string, handler: (event: Event) => void) => void; +}; + +type WebWindowTarget = { + readonly devicePixelRatio?: number; + readonly setTimeout?: (handler: () => void, timeout: number) => ReturnType; + readonly clearTimeout?: (handle: ReturnType) => void; + addEventListener: (type: string, handler: (event: Event) => void) => void; + removeEventListener: (type: string, handler: (event: Event) => void) => void; +}; + +type WebStorage = { + readonly getItem: (key: string) => string | null; + readonly setItem: (key: string, value: string) => void; + readonly removeItem: (key: string) => void; +}; + +type WebFetchResponse = { + readonly ok: boolean; + readonly status: number; + readonly json?: () => Promise; + readonly text?: () => Promise; +}; + +type WebFetcher = (url: string, init: { readonly method: "GET" | "POST"; readonly body?: string; readonly signal?: AbortSignal }) => Promise; + +export type WebPlatformAdapterOptions = { + readonly canvasElement: WebCanvasElement; + readonly windowTarget: WebWindowTarget; + readonly storage: WebStorage; + readonly telemetry: TelemetryBuffer; + readonly fetcher: WebFetcher; + readonly endpoints?: Record; + readonly retryDelayMs?: number; +}; + +export class WebPlatformAdapter { + private readonly canvasHandles = new Set(); + private readonly audioHandles = new Set(); + private readonly context: CanvasRenderingContext2D; + private nextCanvasId = 1; + private nextAudioId = 1; + + constructor(private readonly options: WebPlatformAdapterOptions) { + const context = options.canvasElement.getContext("2d"); + if (context === null) throw new Error("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE"); + this.context = context; + } + + createSdk(): RuntimeSdkContract { + return createRuntimeSdk({ + canvas: { + createCanvas: (width, height) => this.createCanvas(width, height), + submitRenderCommands: (handle, commands) => this.submitRenderCommands(handle, commands), + getViewport: () => ({ + width: this.options.canvasElement.clientWidth, + height: this.options.canvasElement.clientHeight, + devicePixelRatio: this.options.windowTarget.devicePixelRatio ?? 1 + }) + }, + input: { + onTouchStart: (handler) => this.onCanvasPointer("pointerdown", "touch_start", handler), + onTouchMove: (handler) => this.onCanvasPointer("pointermove", "touch_move", handler), + onTouchEnd: (handler) => this.onCanvasPointer("pointerup", "touch_end", handler), + onKeyDown: (handler) => this.onWindowKeyDown(handler) + }, + audio: { + load: (input) => this.loadAudio(input), + play: async () => undefined, + pause: () => undefined, + stop: () => undefined + }, + network: { + request: (input) => this.request(input) + }, + storage: { + getItem: async (namespace, key) => this.options.storage.getItem(storageKey(namespace, key)), + setItem: async (namespace, key, value) => this.options.storage.setItem(storageKey(namespace, key), value), + removeItem: async (namespace, key) => this.options.storage.removeItem(storageKey(namespace, key)) + }, + lifecycle: { + onShow: (handler) => this.onWindowLifecycle("focus", "show", handler), + onHide: (handler) => this.onWindowLifecycle("blur", "hide", handler), + onPause: (handler) => this.onWindowLifecycle("blur", "pause", handler), + onResume: (handler) => this.onWindowLifecycle("focus", "resume", handler), + onError: (handler) => this.onWindowLifecycle("error", "error", handler) + }, + telemetry: this.options.telemetry + }); + } + + private createCanvas(width: number, height: number): CanvasHandle { + const handle = { handleId: `web-canvas-${this.nextCanvasId}` }; + this.nextCanvasId += 1; + this.canvasHandles.add(handle.handleId); + this.options.canvasElement.width = width; + this.options.canvasElement.height = height; + return handle; + } + + private submitRenderCommands(handle: CanvasHandle, commands: readonly RenderCommand[]): void { + if (!this.canvasHandles.has(handle.handleId)) throw new Error("WEB_PLATFORM_CANVAS_HANDLE_UNKNOWN"); + + for (const command of commands) { + if (command.type === "clear") { + this.context.clearRect(0, 0, this.options.canvasElement.width, this.options.canvasElement.height); + if (command.color !== undefined) { + this.context.fillStyle = command.color; + this.context.fillRect(0, 0, this.options.canvasElement.width, this.options.canvasElement.height); + } + continue; + } + if (command.type === "rect") { + this.context.fillStyle = command.fill ?? "#ffffff"; + this.context.fillRect(command.x, command.y, command.width, command.height); + continue; + } + if (command.type === "text") { + this.context.fillStyle = command.fill ?? "#ffffff"; + this.context.font = `${command.fontSize ?? 12}px sans-serif`; + this.context.fillText(command.text, command.x, command.y); + } + } + } + + private onCanvasPointer(type: string, sdkType: InputEventDto["type"], handler: (event: InputEventDto) => void): () => void { + // DOM PointerEvent 在这里被压缩成平台无关 DTO,逻辑模块只能看到 x/y/pointerId。 + const listener = (event: Event): void => { + const pointer = event as PointerEvent; + event.preventDefault(); + const rect = this.options.canvasElement.getBoundingClientRect(); + const inputEvent: InputEventDto = { + type: sdkType, + x: pointer.clientX - rect.left, + y: pointer.clientY - rect.top, + occurredAt: new Date().toISOString() + }; + if (pointer.pointerId !== undefined) { + handler({ ...inputEvent, pointerId: String(pointer.pointerId) }); + return; + } + handler(inputEvent); + }; + this.options.canvasElement.addEventListener(type, listener); + return () => this.options.canvasElement.removeEventListener(type, listener); + } + + private onWindowKeyDown(handler: (event: InputEventDto) => void): () => void { + const listener = (event: Event): void => { + const keyboard = event as KeyboardEvent; + handler({ type: "key_down", key: keyboard.key, occurredAt: new Date().toISOString() }); + }; + this.options.windowTarget.addEventListener("keydown", listener); + return () => this.options.windowTarget.removeEventListener("keydown", listener); + } + + private onWindowLifecycle(type: string, sdkType: RuntimeLifecycleDto["type"], handler: (event: RuntimeLifecycleDto) => void): () => void { + const listener = (): void => { + handler({ type: sdkType, occurredAt: new Date().toISOString() }); + }; + this.options.windowTarget.addEventListener(type, listener); + return () => this.options.windowTarget.removeEventListener(type, listener); + } + + private async loadAudio(_input: AudioLoadRequestDto): Promise { + const handle = { handleId: `web-audio-${this.nextAudioId}` }; + this.nextAudioId += 1; + this.audioHandles.add(handle.handleId); + return handle; + } + + private async request(input: NetworkRequestDto): Promise { + const endpoint = this.options.endpoints?.[input.endpointId]; + if (endpoint === undefined) return { ok: false, status: 0, errorCode: "WEB_PLATFORM_ENDPOINT_UNKNOWN" }; + + const attempts = input.retry?.attempts ?? 1; + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const requestInit = input.body === undefined ? { method: input.method } : { method: input.method, body: JSON.stringify(input.body) }; + const response = await this.fetchWithAttemptTimeout(endpoint, requestInit, input.timeoutMs); + return { + ok: response.ok, + status: response.status, + body: await readResponseBody(response), + ...(response.ok ? {} : { errorCode: "WEB_PLATFORM_NETWORK_STATUS" }) + }; + } catch (error) { + lastError = error; + if (attempt < attempts) await delay(this.options.retryDelayMs ?? input.retry?.backoffMs ?? 0); + } + } + + return { ok: false, status: 0, errorCode: lastError instanceof Error ? lastError.message : "WEB_PLATFORM_NETWORK_FAILED" }; + } + + private async fetchWithAttemptTimeout( + endpoint: string, + requestInit: { readonly method: "GET" | "POST"; readonly body?: string }, + timeoutMs: number + ): Promise { + const controller = new AbortController(); + const timerApi = getTimerApi(this.options.windowTarget); + let timedOut = false; + // 每次 attempt 都有独立 AbortController,保证 timeout 后不会污染后续重试。 + const timer = timerApi.setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + try { + return await Promise.race([ + this.options.fetcher(endpoint, { ...requestInit, signal: controller.signal }), + new Promise((_resolve, reject) => { + controller.signal.addEventListener( + "abort", + () => { + if (timedOut) reject(new WebPlatformNetworkTimeoutError()); + }, + { once: true } + ); + }) + ]); + } catch (error) { + if (timedOut) throw new WebPlatformNetworkTimeoutError(); + throw error; + } finally { + timerApi.clearTimeout(timer); + } + } +} + +async function readResponseBody(response: WebFetchResponse): Promise { + if (response.json !== undefined) return response.json(); + if (response.text !== undefined) return response.text(); + return undefined; +} + +function storageKey(namespace: string, key: string): string { + return `huijing:${namespace}:${key}`; +} + +function delay(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getTimerApi(windowTarget: WebWindowTarget): { + readonly setTimeout: (handler: () => void, timeout: number) => ReturnType; + readonly clearTimeout: (handle: ReturnType) => void; +} { + return { + setTimeout: windowTarget.setTimeout?.bind(windowTarget) ?? globalThis.setTimeout.bind(globalThis), + clearTimeout: windowTarget.clearTimeout?.bind(windowTarget) ?? globalThis.clearTimeout.bind(globalThis) + }; +} + +class WebPlatformNetworkTimeoutError extends Error { + constructor() { + super("WEB_PLATFORM_NETWORK_TIMEOUT"); + } +} diff --git a/docs/memorys/2026-06-05-S4Task3运行时SDK.md b/docs/memorys/2026-06-05-S4Task3运行时SDK.md new file mode 100644 index 00000000..3be2e9d7 --- /dev/null +++ b/docs/memorys/2026-06-05-S4Task3运行时SDK.md @@ -0,0 +1,227 @@ +# 2026-06-05 S4 Task3 运行时 SDK + +## 结论 + +S4 Task3 `Implement Runtime SDK, Web Runtime Host, And Web Adapter` 已完成实现、controller 自测,并在两轮 quality finding 修复后通过 fresh review gate: + +- spec compliance review:首轮 PASS,二轮 PASS,三轮 PASS。 +- quality review:首轮 FAIL,二轮 FAIL,三轮 PASS。 + +可以提交 Task3。后续 S4 可进入 Task4 `Build Preview Runtime UI`;S5 已在独立 worktree 并行推进,不能把 S5 导入 smoke 或 final acceptance 写成已完成。 + +## 变更范围 + +- `packages/runtime-sdk/**` +- `packages/web-runtime/**` +- `apps/web/src/components/runtime/WebPlatformAdapter.ts` +- `apps/web/src/components/runtime/WebPlatformAdapter.test.ts` +- `pnpm-lock.yaml` + +未实现: + +- S4 Task4 preview page / `WebGameRuntime` +- S4 Task5 runtime smoke +- S5 mini-game conversion +- S6 feed / publish review +- S7 telemetry persistence routes +- S8 deploy readiness + +冻结合同未改: + +- `packages/shared-contracts/src/game-package.ts` +- `packages/shared-contracts/src/runtime-sdk-contract.ts` +- `packages/shared-contracts/src/game-logic-module.ts` +- `apps/api/src/modules/game-logic/artifact-repository.ts` + +## 子代理与 Review Gate + +| Role | Agent ID | Result | Blocking findings | +| --- | --- | --- | --- | +| implementer | `019e95c4-4e02-77f1-b4f5-ad24848f4383` | DONE | 无 | +| spec compliance review | `019e95de-3ec6-7493-9dfb-8569cde4f6d6` | PASS | 无 Critical / Important | +| quality review | `019e95de-c39b-75c0-ae63-38387a8578bd` | FAIL | Important: 缺外部 trusted manifest checksum anchor;`NetworkRequestDto.timeoutMs` 未执行 | +| fix implementer | `019e95e3-a272-73f0-8670-2657c17ccefd` | DONE | 修复 trusted manifest checksum anchor、manifest path 校验和 network timeout | +| spec compliance re-review | `019e95f0-5a79-7853-a460-c454ed589446` | PASS | 无 Critical / Important | +| quality re-review | `019e95f0-5b74-73c3-98a4-d396b23a0a3b` | FAIL | Important: `asset-manifest.json` 的 `entries[].path` 未在 runtime 侧 fail-closed | +| fix implementer | `019e95f4-0a24-7031-b6fb-a0dc23fa557f` | DONE | 修复 asset manifest entry path/hash 校验 | +| spec compliance final re-review | `019e95f9-f29c-7302-ad82-72fb16453cac` | PASS | 无 Critical / Important / Minor | +| quality final re-review | `019e95f9-f33e-7cd0-a0e6-600e54cbd59f` | PASS | 无 Critical / Important | + +子代理没有写 `docs/memorys`,本文件为主代理留痕。 + +## 实现摘要 + +- 新增 `@huijing/runtime-sdk` package: + - `createRuntimeSdk` + - `createTelemetryBuffer` + - `validateRuntimeAdapterSurface` + - load/play/runtime_error telemetry helper。 + - telemetry 10 条或 5 秒 batch。 + - bounded retry。 + - opaque `CanvasHandle` 和 render commands,不暴露 raw canvas/context。 +- 新增 `@huijing/web-runtime` package: + - `WebRuntimeHost` + - `Canvas2dRenderer` + - `runtime-dependency-gate` +- `WebRuntimeHost`: + - 使用外部 `expectedManifestChecksum` 作为 trusted anchor,即 `GamePackage.profiles.web.manifest.checksum`。 + - 在读取 manifest 内部路径前校验 manifest payload checksum 和 manifest declared checksum。 + - 对 `configPath`、`assetManifestPath`、`logicArtifactPath`、`packageLogicPath` 做受控相对路径校验。 + - 固定 `packageLogicPath === "logic/logic.mjs"`。 + - 校验 source checksum、package logic checksum、validation report id。 + - 校验 `asset-manifest.json` checksum。 + - 在读取每个 asset 前校验 `entries[].path` 是受控相对路径、`entries[].hash` 是非空字符串。 + - GameLogicModule source 在 import 前拒绝 `document` / `window`。 + - fixed tick + RAF render。 + - pause/resume reset accumulated delta。 + - emit `load_start` / `load_success` / `load_failed` / `play` / `runtime_error` telemetry。 +- `Canvas2dRenderer`: + - 渲染 grid、facilities、NPC、coin counter、objective panel 到平台无关 render commands。 +- `WebPlatformAdapter`: + - DOM/BOM 访问集中在 adapter 内。 + - Canvas/Input/Audio/Network/Storage/Lifecycle/Telemetry 映射到 `RuntimeSdkContract`。 + - network request 使用 endpointId。 + - 每个 network attempt 使用独立 `AbortController`。 + - 执行 `NetworkRequestDto.timeoutMs`,timeout 返回 `WEB_PLATFORM_NETWORK_TIMEOUT`。 + - finally 清理 timeout timer,保留 retry。 +- `runtime-dependency-gate`: + - 拒绝 Phaser/GDevelop/ct-js/Cocos/Laya runtime。 + - 拒绝 BrowserAdapter leak。 + - 拒绝未批准 PixiJS adapter usage。 + +## Controller 自测 + +修复后已运行并通过: + +```bash +git add -N +git diff --check +pnpm --filter @huijing/runtime-sdk test +pnpm --filter @huijing/web-runtime test +pnpm --filter @huijing/web test -- WebPlatformAdapter +pnpm --filter @huijing/runtime-sdk typecheck +pnpm --filter @huijing/web-runtime typecheck +pnpm --filter @huijing/web typecheck +pnpm check:s4-scope +git diff --check +``` + +关键输出: + +```text +@huijing/runtime-sdk: Test Files 1 passed (1), Tests 5 passed (5) +@huijing/web-runtime: Test Files 3 passed (3), Tests 34 passed (34) +@huijing/web WebPlatformAdapter: Test Files 5 passed (5), Tests 32 passed (32) +S4 scope check passed. +``` + +还运行过 `pnpm install --lockfile-only`,将新增 workspace package importers 写入 `pnpm-lock.yaml`。 + +## Review Evidence + +首轮 quality findings: + +```text +WebRuntimeHost checksum 校验缺外部可信锚点;manifest/config/logic/assets 可以被自洽篡改。 +WebPlatformAdapter 没有执行 NetworkRequestDto.timeoutMs。 +``` + +第一轮修复: + +```text +WebRuntimeHostOptions 增加 expectedManifestChecksum。 +manifest payload checksum 和 declared checksum 必须匹配外部 trusted anchor。 +manifest path 在读取 package 内部文件前做受控相对路径校验。 +WebPlatformAdapter 每 attempt 使用 AbortController + timeoutMs,传 AbortSignal,finally 清 timer。 +``` + +第二轮 quality finding: + +```text +asset-manifest.json entries[].path 未 fail-closed。 +若 asset-manifest 与 checksum-summary 同时被篡改成自洽内容,runtime 会读取未校验 path。 +``` + +第二轮修复: + +```text +parseAssetManifest 校验 entries 是数组。 +每个 entry 必须是 object。 +entries[].path 必须是受控相对路径。 +entries[].hash 必须是非空字符串。 +非法 asset path/hash 在 reader 读取前失败。 +``` + +最终 spec re-review 确认: + +```text +Scope 未越界,未新增 preview page/runtime smoke/S5-S8。 +trusted anchor 在读取 manifest 内部路径前执行。 +manifest 和 asset path/hash fail-closed。 +WebPlatformAdapter timeout/retry/AbortSignal 保留。 +冻结合同未修改。 +``` + +最终 quality re-review 确认: + +```text +上一轮 Important 已修复。 +parseAssetManifest 校验 entries 为数组、item 为 object、path 受控、hash 非空。 +调用发生在 verifyAssets 读取 entry.path 前。 +trusted manifest checksum anchor 未回退。 +WebPlatformAdapter timeout 未回退。 +未发现新的 Critical / Important。 +``` + +## SHA-256 + +| Path | SHA-256 | +| --- | --- | +| `pnpm-lock.yaml` | `4b3939754e2fdaf98d7f34e76c224e3285169e4fc0b693cc1306c321e8f70c8c` | +| `packages/runtime-sdk/package.json` | `8bcb9e89a83e802d55c5c6306543df33dff394089996ea5b0b448bcef1907e90` | +| `packages/runtime-sdk/src/index.ts` | `676fb7b839a3674a351a2d9d5a0637f8fbb391ab8ea0e2fad36203fc8cbf8c8a` | +| `packages/runtime-sdk/src/runtime-sdk.spec.ts` | `e4ffa5f7bc4d5fcd190f524d9dfc67ec716e3a51ab846bbeed3dfa438ab8fcc7` | +| `packages/runtime-sdk/tsconfig.json` | `15cceedbe9d737b9395c52206f72986c0c902a94742419b4394785cce6b17376` | +| `packages/web-runtime/package.json` | `4883482439eb119c1577607aa99980614f1c125a32f793c17bb0536cbcc91401` | +| `packages/web-runtime/src/Canvas2dRenderer.ts` | `8639667d2cd9c1eadec96dbfe51b6aae3c71736af9d0fe1039459227e10a874d` | +| `packages/web-runtime/src/Canvas2dRenderer.spec.ts` | `a874238b97507ead979012b7eddb7e5603fd7184028cf65b23bee9da127f1da7` | +| `packages/web-runtime/src/WebRuntimeHost.ts` | `65a1021aa0a7f9da289702f59d6557e3965155c423d09a715233d315c0890960` | +| `packages/web-runtime/src/WebRuntimeHost.spec.ts` | `fed0f19b9ac632378118539bf71db2f1a0dec80ebe73306754e235f5b6770e62` | +| `packages/web-runtime/src/runtime-dependency-gate.ts` | `7cd06c9e85a8f9855591938c2478ee585429c129163ea2715878752ec0632163` | +| `packages/web-runtime/src/runtime-dependency-gate.spec.ts` | `4e0140cf6c74341eeab840c2a00d0639f565cc48a7a6459101975afd42902065` | +| `packages/web-runtime/src/index.ts` | `ed041d0c04e358c8d7771a2aecd4ed75f990255116f2c7fbf7e40cc822cea70a` | +| `packages/web-runtime/tsconfig.json` | `15cceedbe9d737b9395c52206f72986c0c902a94742419b4394785cce6b17376` | +| `apps/web/src/components/runtime/WebPlatformAdapter.ts` | `67479f861e6ba77901b6875d06ff86d930b3caffdef6147a94e855cae7fb456f` | +| `apps/web/src/components/runtime/WebPlatformAdapter.test.ts` | `5110c9e757eeada4c5bfe5591136559b0d4d9a7cc4100a9318c40dfe487aa0f6` | +| `docs/superpowers/plans/2026-05-31-mvp-S4-web-runtime.md` | `d7153b464f764aa5dd5f500f74a1a3dcc0e1df1fdc2a94435923409158d87ffe` | +| `docs/superpowers/specs/2026-05-31-mvp-S4-web-runtime-design.md` | `693c806836322eed8df7d6deb71dc379c519d2687b6f4f3b60626186b55a4711` | + +## Git Status Snapshot + +写入本文档前 `git status --short --branch --untracked-files=all`: + +```text +## codex/s1-task9-s2-prep...origin/codex/s1-task9-s2-prep [ahead 14] + A apps/web/src/components/runtime/WebPlatformAdapter.test.ts + A apps/web/src/components/runtime/WebPlatformAdapter.ts + A packages/runtime-sdk/package.json + A packages/runtime-sdk/src/index.ts + A packages/runtime-sdk/src/runtime-sdk.spec.ts + A packages/runtime-sdk/tsconfig.json + A packages/web-runtime/package.json + A packages/web-runtime/src/Canvas2dRenderer.spec.ts + A packages/web-runtime/src/Canvas2dRenderer.ts + A packages/web-runtime/src/WebRuntimeHost.spec.ts + A packages/web-runtime/src/WebRuntimeHost.ts + A packages/web-runtime/src/index.ts + A packages/web-runtime/src/runtime-dependency-gate.spec.ts + A packages/web-runtime/src/runtime-dependency-gate.ts + A packages/web-runtime/tsconfig.json + M pnpm-lock.yaml +``` + +## Residual Notes + +- `runtime-dependency-gate` 当前是导出函数和单测,未接 root CLI/CI;review 判定为 non-blocker。S4 final gate 或 Task5 前应接入真实 bundle/lockfile 扫描命令。 +- `TelemetryBuffer` 在发送端永久失败时会保留事件并按 interval 重试;review 判定不丢事件且无并发 flush/timer leak 证据,后续 S7 可加最大保留和 circuit breaker。 +- S4 Task3 不提供 preview URL 或 runtime smoke evidence;这些属于 Task4/Task5。 diff --git a/packages/runtime-sdk/package.json b/packages/runtime-sdk/package.json new file mode 100644 index 00000000..f9e25d95 --- /dev/null +++ b/packages/runtime-sdk/package.json @@ -0,0 +1,25 @@ +{ + "name": "@huijing/runtime-sdk", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "lint": "pnpm --dir ../.. exec eslint packages/runtime-sdk", + "typecheck": "pnpm --dir ../.. exec tsc -p packages/runtime-sdk/tsconfig.json --noEmit", + "test": "pnpm --dir ../../apps/web exec vitest run --root ../.. packages/runtime-sdk", + "build": "pnpm --dir ../.. exec tsc -p packages/runtime-sdk/tsconfig.json", + "dev:smoke": "pnpm --dir ../../apps/web exec vitest run --root ../.. packages/runtime-sdk" + }, + "dependencies": { + "@huijing/shared-contracts": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "vitest": "^4.1.7" + } +} diff --git a/packages/runtime-sdk/src/index.ts b/packages/runtime-sdk/src/index.ts new file mode 100644 index 00000000..b6468fc9 --- /dev/null +++ b/packages/runtime-sdk/src/index.ts @@ -0,0 +1,280 @@ +import { + RuntimeSdkContractVersion, + createRuntimeSdkConformanceSuite, + validateAudioHandleId, + validateCanvasHandle, + validateInputEventDto, + validateNetworkRequestDto, + validateNetworkResultDto, + validateRuntimeHostBoundary, + validateRuntimeLifecycleDto, + validateRuntimeSdkContractShape, + validateTelemetryEventDto +} from "../../shared-contracts/src/runtime-sdk-contract"; +import type { + AudioHandleId, + AudioLoadRequestDto, + CanvasHandle, + InputEventDto, + NetworkRequestDto, + NetworkResultDto, + RenderCommand, + RuntimeLifecycleDto, + RuntimeSdkContract, + RuntimeSdkValidationResult, + TelemetryEventDto +} from "../../shared-contracts/src/runtime-sdk-contract"; + +export type RuntimeCanvasAdapter = { + readonly createCanvas: (width: number, height: number) => CanvasHandle; + readonly submitRenderCommands: (handle: CanvasHandle, commands: readonly RenderCommand[]) => void; + readonly getViewport: () => { width: number; height: number; devicePixelRatio: number }; +}; + +export type RuntimeInputAdapter = RuntimeSdkContract["Input"]; +export type RuntimeAudioAdapter = RuntimeSdkContract["Audio"]; +export type RuntimeNetworkAdapter = RuntimeSdkContract["Network"]; +export type RuntimeStorageAdapter = RuntimeSdkContract["Storage"]; +export type RuntimeLifecycleAdapter = RuntimeSdkContract["Lifecycle"]; + +export type TelemetryEmitInput = { + readonly name: string; + readonly payload: unknown; +}; + +export type TelemetryBuffer = { + readonly emit: (event: TelemetryEmitInput) => Promise; + readonly emitEvent: (event: TelemetryEventDto) => Promise; + readonly emitLoadStart: (payload: unknown) => Promise; + readonly emitLoadSuccess: (payload: unknown) => Promise; + readonly emitLoadFailed: (payload: unknown) => Promise; + readonly emitPlay: (payload: unknown) => Promise; + readonly emitRuntimeError: (payload: unknown) => Promise; + readonly flush: () => Promise; +}; + +export type RuntimeSdkCreateOptions = { + readonly canvas: RuntimeCanvasAdapter; + readonly input: RuntimeInputAdapter; + readonly audio: RuntimeAudioAdapter; + readonly network: RuntimeNetworkAdapter; + readonly storage: RuntimeStorageAdapter; + readonly lifecycle: RuntimeLifecycleAdapter; + readonly telemetry: TelemetryBuffer | RuntimeSdkContract["Telemetry"]; +}; + +export type TelemetryBufferOptions = { + readonly gameVersionId: string; + readonly sender: (events: readonly TelemetryEventDto[]) => Promise; + readonly batchSize?: number; + readonly flushIntervalMs?: number; + readonly retryAttempts?: number; + readonly retryBackoffMs?: number; + readonly now?: () => string; +}; + +const DEFAULT_BATCH_SIZE = 10; +const DEFAULT_FLUSH_INTERVAL_MS = 5_000; +const DEFAULT_RETRY_ATTEMPTS = 3; +const DEFAULT_RETRY_BACKOFF_MS = 50; + +export function createRuntimeSdk(options: RuntimeSdkCreateOptions): RuntimeSdkContract { + return { + version: RuntimeSdkContractVersion, + Canvas: { + createCanvas: (width, height) => { + const handle = options.canvas.createCanvas(width, height); + const result = validateCanvasHandle(handle); + if (!result.ok) throwRuntimeSdkError(result); + return handle; + }, + submitRenderCommands: (handle, commands) => { + const handleResult = validateCanvasHandle(handle); + if (!handleResult.ok) throwRuntimeSdkError(handleResult); + const commandResult = validateRuntimeHostBoundary(commands, "commands"); + if (!commandResult.ok) throwRuntimeSdkError(commandResult); + options.canvas.submitRenderCommands(handle, [...commands]); + }, + getViewport: () => options.canvas.getViewport() + }, + Input: options.input, + Audio: { + load: async (input: AudioLoadRequestDto) => { + const handle = await options.audio.load(input); + const result = validateAudioHandleId(handle); + if (!result.ok) throwRuntimeSdkError(result); + return handle; + }, + play: (handle, playOptions) => options.audio.play(handle, playOptions), + pause: (handle) => options.audio.pause(handle), + stop: (handle) => options.audio.stop(handle) + }, + Network: { + request: async (input: NetworkRequestDto): Promise => { + const requestResult = validateNetworkRequestDto(input); + if (!requestResult.ok) throwRuntimeSdkError(requestResult); + const result = await options.network.request(input); + const responseResult = validateNetworkResultDto(result); + if (!responseResult.ok) throwRuntimeSdkError(responseResult); + return result; + } + }, + Storage: options.storage, + Lifecycle: options.lifecycle, + Telemetry: { + emit: async (event) => { + const result = validateTelemetryEventDto(event); + if (!result.ok) throwRuntimeSdkError(result); + if ("emitEvent" in options.telemetry) { + await options.telemetry.emitEvent(event); + return; + } + await options.telemetry.emit(event); + } + } + }; +} + +export function createTelemetryBuffer(options: TelemetryBufferOptions): TelemetryBuffer { + const buffer: TelemetryEventDto[] = []; + const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; + const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + const retryAttempts = options.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS; + const retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS; + const now = options.now ?? (() => new Date().toISOString()); + let timer: ReturnType | null = null; + let flushing: Promise | null = null; + + const scheduleFlush = (): void => { + if (timer !== null || buffer.length === 0) return; + timer = setTimeout(() => { + timer = null; + void flush(); + }, flushIntervalMs); + }; + + const clearFlushTimer = (): void => { + if (timer === null) return; + clearTimeout(timer); + timer = null; + }; + + const emitEvent = async (event: TelemetryEventDto): Promise => { + const validation = validateTelemetryEventDto(event); + if (!validation.ok) throwRuntimeSdkError(validation); + buffer.push(event); + if (buffer.length >= batchSize) { + await flush(); + return; + } + scheduleFlush(); + }; + + const emit = (event: TelemetryEmitInput): Promise => + emitEvent({ + name: event.name, + gameVersionId: options.gameVersionId, + payload: event.payload, + createdAt: now() + }); + + const flush = async (): Promise => { + if (flushing !== null) { + await flushing; + return; + } + clearFlushTimer(); + if (buffer.length === 0) return; + const events = buffer.splice(0, buffer.length); + flushing = sendWithRetry(options.sender, events, retryAttempts, retryBackoffMs).catch((error: unknown) => { + buffer.unshift(...events); + throw error; + }); + try { + await flushing; + } finally { + flushing = null; + scheduleFlush(); + } + }; + + return { + emit, + emitEvent, + emitLoadStart: (payload) => emit({ name: "load_start", payload }), + emitLoadSuccess: (payload) => emit({ name: "load_success", payload }), + emitLoadFailed: (payload) => emit({ name: "load_failed", payload }), + emitPlay: (payload) => emit({ name: "play", payload }), + emitRuntimeError: (payload) => emit({ name: "runtime_error", payload }), + flush + }; +} + +export async function validateRuntimeAdapterSurface(adapter: RuntimeSdkContract): Promise { + const shape = validateRuntimeSdkContractShape(adapter); + if (!shape.ok) return shape; + + for (const testCase of createRuntimeSdkConformanceSuite(() => adapter)) { + const result = await testCase.run(); + if (!result.ok) return result; + } + + return { ok: true }; +} + +export function createMemoryRuntimeStorage(): RuntimeStorageAdapter { + const values = new Map(); + const storageKey = (namespace: string, key: string) => `${namespace}:${key}`; + return { + getItem: async (namespace, key) => values.get(storageKey(namespace, key)) ?? null, + setItem: async (namespace, key, value) => { + values.set(storageKey(namespace, key), value); + }, + removeItem: async (namespace, key) => { + values.delete(storageKey(namespace, key)); + } + }; +} + +export function dispatchInputEvent(handler: (event: InputEventDto) => void, event: InputEventDto): void { + const result = validateInputEventDto(event); + if (!result.ok) throwRuntimeSdkError(result); + handler(event); +} + +export function dispatchRuntimeLifecycle(handler: (event: RuntimeLifecycleDto) => void, event: RuntimeLifecycleDto): void { + const result = validateRuntimeLifecycleDto(event); + if (!result.ok) throwRuntimeSdkError(result); + handler(event); +} + +async function sendWithRetry( + sender: (events: readonly TelemetryEventDto[]) => Promise, + events: readonly TelemetryEventDto[], + retryAttempts: number, + retryBackoffMs: number +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= retryAttempts; attempt += 1) { + try { + await sender(events); + return; + } catch (error) { + lastError = error; + if (attempt < retryAttempts) await delay(retryBackoffMs); + } + } + throw lastError instanceof Error ? lastError : new Error("Telemetry sender failed"); +} + +function delay(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function throwRuntimeSdkError(result: Exclude): never { + const error = new Error(result.reasonCode) as Error & { reasonCode: string; path?: string }; + error.reasonCode = result.reasonCode; + if (result.path !== undefined) error.path = result.path; + throw error; +} diff --git a/packages/runtime-sdk/src/runtime-sdk.spec.ts b/packages/runtime-sdk/src/runtime-sdk.spec.ts new file mode 100644 index 00000000..0decf97a --- /dev/null +++ b/packages/runtime-sdk/src/runtime-sdk.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from "vitest"; +import { createRuntimeSdk, createTelemetryBuffer, validateRuntimeAdapterSurface } from "./index"; +import type { NetworkRequestDto, RenderCommand, TelemetryEventDto } from "../../shared-contracts/src/runtime-sdk-contract"; + +describe("S4 runtime SDK", () => { + it("emits load_start, load_success, and load_failed telemetry events", async () => { + const sent: TelemetryEventDto[][] = []; + const telemetry = createTelemetryBuffer({ + gameVersionId: "version-sdk-load", + sender: async (events) => { + sent.push(events); + } + }); + + await telemetry.emitLoadStart({ packageId: "package-a" }); + await telemetry.emitLoadSuccess({ packageId: "package-a" }); + await telemetry.emitLoadFailed({ packageId: "package-a", reasonCode: "MANIFEST_INVALID" }); + await telemetry.flush(); + + expect(sent.flat().map((event) => event.name)).toEqual(["load_start", "load_success", "load_failed"]); + expect(sent.flat().map((event) => event.gameVersionId)).toEqual(["version-sdk-load", "version-sdk-load", "version-sdk-load"]); + }); + + it("batches telemetry at 10 events or 5 seconds", async () => { + vi.useFakeTimers(); + try { + const sent: TelemetryEventDto[][] = []; + const telemetry = createTelemetryBuffer({ + gameVersionId: "version-sdk-batch", + now: () => "2026-06-05T00:00:00.000Z", + sender: async (events) => { + sent.push(events); + } + }); + + for (let index = 0; index < 9; index += 1) { + await telemetry.emit({ name: `play_${index}`, payload: { index } }); + } + expect(sent).toEqual([]); + + await telemetry.emit({ name: "play_9", payload: { index: 9 } }); + expect(sent).toHaveLength(1); + expect(sent[0]).toHaveLength(10); + + await telemetry.emit({ name: "runtime_idle", payload: {} }); + expect(sent).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(5_000); + expect(sent).toHaveLength(2); + expect(sent[1]?.map((event) => event.name)).toEqual(["runtime_idle"]); + } finally { + vi.useRealTimers(); + } + }); + + it("retries telemetry sender after network failure", async () => { + const attempts: TelemetryEventDto[][] = []; + const telemetry = createTelemetryBuffer({ + gameVersionId: "version-sdk-retry", + retryBackoffMs: 0, + sender: async (events) => { + attempts.push(events); + if (attempts.length === 1) { + throw new Error("network down"); + } + } + }); + + await telemetry.emitLoadSuccess({ packageId: "package-a" }); + await telemetry.flush(); + + expect(attempts).toHaveLength(2); + expect(attempts[0]?.[0]?.name).toBe("load_success"); + }); + + it("validates adapter surface for canvas input audio network storage and lifecycle", async () => { + const requests: NetworkRequestDto[] = []; + const adapter = createRuntimeSdk({ + canvas: { + createCanvas: () => ({ handleId: "canvas-1" }), + getViewport: () => ({ width: 320, height: 180, devicePixelRatio: 2 }), + submitRenderCommands: (_handle, commands) => { + expect(commands).toEqual([{ type: "clear", color: "#000000" } satisfies RenderCommand]); + } + }, + input: createNoopInputAdapter(), + audio: createNoopAudioAdapter(), + network: { + request: async (request) => { + requests.push(request); + return { ok: true, status: 200, body: { ok: true } }; + } + }, + storage: createMemoryStorageAdapter(), + lifecycle: createNoopLifecycleAdapter(), + telemetry: createTelemetryBuffer({ gameVersionId: "version-sdk-adapter", sender: async () => undefined }) + }); + + await expect(validateRuntimeAdapterSurface(adapter)).resolves.toEqual({ ok: true }); + requests.length = 0; + await adapter.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 1_000, retry: { attempts: 2, backoffMs: 1 } }); + + expect(requests).toHaveLength(1); + }); + + it("returns opaque CanvasHandle and render commands without raw host objects", () => { + const submitted: RenderCommand[][] = []; + const adapter = createRuntimeSdk({ + canvas: { + createCanvas: () => ({ handleId: "canvas-opaque" }), + getViewport: () => ({ width: 320, height: 180, devicePixelRatio: 1 }), + submitRenderCommands: (_handle, commands) => submitted.push(commands) + }, + input: createNoopInputAdapter(), + audio: createNoopAudioAdapter(), + network: { request: async () => ({ ok: true, status: 200 }) }, + storage: createMemoryStorageAdapter(), + lifecycle: createNoopLifecycleAdapter(), + telemetry: createTelemetryBuffer({ gameVersionId: "version-sdk-opaque", sender: async () => undefined }) + }); + + const handle = adapter.Canvas.createCanvas(320, 180); + adapter.Canvas.submitRenderCommands(handle, [ + { type: "clear", color: "#101820" }, + { type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ffffff" } + ]); + + expect(handle).toEqual({ handleId: "canvas-opaque" }); + expect("canvas" in handle).toBe(false); + expect("getContext" in handle).toBe(false); + expect(submitted[0]).toEqual([ + { type: "clear", color: "#101820" }, + { type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ffffff" } + ]); + }); +}); + +function createNoopInputAdapter() { + const subscribe = () => () => undefined; + return { + onTouchStart: subscribe, + onTouchMove: subscribe, + onTouchEnd: subscribe, + onKeyDown: subscribe + }; +} + +function createNoopAudioAdapter() { + return { + load: async () => ({ handleId: "audio-1" }), + play: async () => undefined, + pause: () => undefined, + stop: () => undefined + }; +} + +function createNoopLifecycleAdapter() { + const subscribe = () => () => undefined; + return { + onShow: subscribe, + onHide: subscribe, + onPause: subscribe, + onResume: subscribe, + onError: subscribe + }; +} + +function createMemoryStorageAdapter() { + const values = new Map(); + const storageKey = (namespace: string, key: string) => `${namespace}:${key}`; + return { + getItem: async (namespace: string, key: string) => values.get(storageKey(namespace, key)) ?? null, + setItem: async (namespace: string, key: string, value: string) => { + values.set(storageKey(namespace, key), value); + }, + removeItem: async (namespace: string, key: string) => { + values.delete(storageKey(namespace, key)); + } + }; +} diff --git a/packages/runtime-sdk/tsconfig.json b/packages/runtime-sdk/tsconfig.json new file mode 100644 index 00000000..69f848b3 --- /dev/null +++ b/packages/runtime-sdk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "../../packages", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.spec.ts"] +} diff --git a/packages/web-runtime/package.json b/packages/web-runtime/package.json new file mode 100644 index 00000000..99ea00da --- /dev/null +++ b/packages/web-runtime/package.json @@ -0,0 +1,26 @@ +{ + "name": "@huijing/web-runtime", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "lint": "pnpm --dir ../.. exec eslint packages/web-runtime", + "typecheck": "pnpm --dir ../.. exec tsc -p packages/web-runtime/tsconfig.json --noEmit", + "test": "pnpm --dir ../../apps/web exec vitest run --root ../.. packages/web-runtime", + "build": "pnpm --dir ../.. exec tsc -p packages/web-runtime/tsconfig.json", + "dev:smoke": "pnpm --dir ../../apps/web exec vitest run --root ../.. packages/web-runtime" + }, + "dependencies": { + "@huijing/runtime-sdk": "workspace:*", + "@huijing/shared-contracts": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "vitest": "^4.1.7" + } +} diff --git a/packages/web-runtime/src/Canvas2dRenderer.spec.ts b/packages/web-runtime/src/Canvas2dRenderer.spec.ts new file mode 100644 index 00000000..89a6a8cf --- /dev/null +++ b/packages/web-runtime/src/Canvas2dRenderer.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { Canvas2dRenderer } from "./Canvas2dRenderer"; +import type { SimulationRenderState } from "./Canvas2dRenderer"; + +describe("S4 Canvas2dRenderer", () => { + it("renders grid, facilities, NPC, coin counter, and objective panel", () => { + const renderer = new Canvas2dRenderer({ cellSize: 20 }); + const commands = renderer.render(createRenderState()); + + expect(commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "clear" }), + expect.objectContaining({ type: "rect", x: 0, y: 0, width: 20, height: 20 }), + expect.objectContaining({ type: "rect", x: 20, y: 20, width: 40, height: 20, fill: "#6f8f3a" }), + expect.objectContaining({ type: "text", text: "Coffee Bar", x: 24, y: 36 }), + expect.objectContaining({ type: "rect", x: 80, y: 40, width: 16, height: 16, fill: "#2f6fed" }), + expect.objectContaining({ type: "text", text: "Guest", x: 80, y: 38 }), + expect.objectContaining({ type: "text", text: "Coins 42", x: 8, y: 18 }), + expect.objectContaining({ type: "text", text: expect.stringContaining("Serve 5 guests"), x: 8 }) + ]) + ); + }); +}); + +function createRenderState(): SimulationRenderState { + return { + grid: { width: 6, height: 5 }, + facilities: [ + { + id: "coffee-bar", + label: "Coffee Bar", + position: { x: 1, y: 1 }, + size: { width: 2, height: 1 } + } + ], + npcs: [{ id: "guest-1", label: "Guest", position: { x: 4, y: 2 } }], + resources: { coins: 42 }, + objectives: [{ label: "Serve 5 guests", progress: 3, target: 5 }] + }; +} diff --git a/packages/web-runtime/src/Canvas2dRenderer.ts b/packages/web-runtime/src/Canvas2dRenderer.ts new file mode 100644 index 00000000..012de42c --- /dev/null +++ b/packages/web-runtime/src/Canvas2dRenderer.ts @@ -0,0 +1,114 @@ +import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract"; + +export type SimulationRenderState = { + readonly grid: { readonly width: number; readonly height: number }; + readonly facilities: readonly SimulationFacility[]; + readonly npcs: readonly SimulationNpc[]; + readonly resources: { readonly coins: number }; + readonly objectives: readonly SimulationObjective[]; +}; + +export type SimulationFacility = { + readonly id: string; + readonly label: string; + readonly position: { readonly x: number; readonly y: number }; + readonly size: { readonly width: number; readonly height: number }; +}; + +export type SimulationNpc = { + readonly id: string; + readonly label: string; + readonly position: { readonly x: number; readonly y: number }; +}; + +export type SimulationObjective = { + readonly label: string; + readonly progress: number; + readonly target: number; +}; + +export type Canvas2dRendererOptions = { + readonly cellSize?: number; +}; + +export class Canvas2dRenderer { + private readonly cellSize: number; + + constructor(options: Canvas2dRendererOptions = {}) { + this.cellSize = options.cellSize ?? 24; + } + + render(state: SimulationRenderState): RenderCommand[] { + const width = state.grid.width * this.cellSize; + const height = state.grid.height * this.cellSize; + const commands: RenderCommand[] = [{ type: "clear", color: "#16201d" }]; + + for (let y = 0; y < state.grid.height; y += 1) { + for (let x = 0; x < state.grid.width; x += 1) { + commands.push({ + type: "rect", + x: x * this.cellSize, + y: y * this.cellSize, + width: this.cellSize, + height: this.cellSize, + fill: (x + y) % 2 === 0 ? "#20342e" : "#263b34" + }); + } + } + + for (const facility of state.facilities) { + commands.push({ + type: "rect", + x: facility.position.x * this.cellSize, + y: facility.position.y * this.cellSize, + width: facility.size.width * this.cellSize, + height: facility.size.height * this.cellSize, + fill: "#6f8f3a" + }); + commands.push({ + type: "text", + text: facility.label, + x: facility.position.x * this.cellSize + 4, + y: facility.position.y * this.cellSize + 16, + fill: "#ffffff", + fontSize: 12 + }); + } + + for (const npc of state.npcs) { + commands.push({ + type: "rect", + x: npc.position.x * this.cellSize, + y: npc.position.y * this.cellSize, + width: this.cellSize - 4, + height: this.cellSize - 4, + fill: "#2f6fed" + }); + commands.push({ + type: "text", + text: npc.label, + x: npc.position.x * this.cellSize, + y: npc.position.y * this.cellSize - 2, + fill: "#ffffff", + fontSize: 11 + }); + } + + commands.push({ type: "text", text: `Coins ${state.resources.coins}`, x: 8, y: 18, fill: "#ffffff", fontSize: 14 }); + + const panelTop = height + 36; + commands.push({ type: "rect", x: 0, y: height + 16, width, height: 36, fill: "#101820" }); + for (const [index, objective] of state.objectives.entries()) { + commands.push({ + type: "text", + text: `${objective.label} ${objective.progress}/${objective.target}`, + x: 8, + y: panelTop + index * 16, + fill: "#ffffff", + fontSize: 12 + }); + } + + return commands; + } +} diff --git a/packages/web-runtime/src/WebRuntimeHost.spec.ts b/packages/web-runtime/src/WebRuntimeHost.spec.ts new file mode 100644 index 00000000..da4dc2f7 --- /dev/null +++ b/packages/web-runtime/src/WebRuntimeHost.spec.ts @@ -0,0 +1,393 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import type { RuntimeSdkContract, TelemetryEventDto } from "../../shared-contracts/src/runtime-sdk-contract"; +import { createTelemetryBuffer } from "../../runtime-sdk/src/index"; +import { WebRuntimeHost } from "./WebRuntimeHost"; +import type { WebRuntimeHostPackageReader, WebRuntimeHostScheduler, WebRuntimeLogicModule } from "./WebRuntimeHost"; + +describe("S4 WebRuntimeHost", () => { + it("loads GamePackage web manifest after checksum validation and emits load telemetry", async () => { + const logic = createLogicModule(); + const context = createHostContext({ logic }); + const host = new WebRuntimeHost(context.options); + + await host.load(); + + expect(context.loaderCalls).toEqual(["logic/logic.mjs"]); + expect(logic.init).toHaveBeenCalledOnce(); + expect(context.telemetryEvents.map((event) => event.name)).toEqual(["load_start", "load_success"]); + }); + + it("rejects manifest payload checksum mismatch from trusted GamePackage profile before package reads", async () => { + const context = createHostContext({ expectedManifestChecksum: "sha256:trusted-package-profile" }); + + await expect(new WebRuntimeHost(context.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_EXPECTED_MANIFEST_CHECKSUM_MISMATCH" }); + expect(context.readCalls).toEqual(["manifest.json"]); + expect(context.loaderCalls).toEqual([]); + }); + + it.each([ + ["configPath", ""], + ["configPath", "../config.json"], + ["configPath", "configs\\config.json"], + ["configPath", "configs%5Cconfig.json"], + ["assetManifestPath", "https://cdn.example.com/asset-manifest.json"], + ["assetManifestPath", "%2Fasset-manifest.json"], + ["logicArtifactPath", "/logic/logic.mjs"], + ["packageLogicPath", "logic%2F..%2Flogic.mjs"] + ] as const)("rejects uncontrolled manifest %s before package reads", async (field, unsafePath) => { + const context = createHostContext({ manifestOverrides: { [field]: unsafePath } }); + + await expect(new WebRuntimeHost(context.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_MANIFEST_PATH_UNCONTROLLED", path: field }); + expect(context.readCalls).toEqual(["manifest.json"]); + expect(context.loaderCalls).toEqual([]); + }); + + it("rejects package logic paths outside the fixed S4 runtime path before loading logic", async () => { + const context = createHostContext({ manifestOverrides: { logicArtifactPath: "logic/other.mjs", packageLogicPath: "logic/other.mjs" } }); + + await expect(new WebRuntimeHost(context.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_PACKAGE_LOGIC_PATH_UNSUPPORTED" }); + expect(context.readCalls).toEqual(["manifest.json"]); + expect(context.loaderCalls).toEqual([]); + }); + + it("rejects manifest tamper even when package checksums are internally self-consistent", async () => { + const base = createHostContext(); + const tampered = createHostContext({ + expectedManifestChecksum: base.expectedManifestChecksum, + manifestOverrides: { logicModuleId: "logic-module-tampered" }, + selfConsistentChecksumSummary: true + }); + + await expect(new WebRuntimeHost(tampered.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_EXPECTED_MANIFEST_CHECKSUM_MISMATCH" }); + expect(tampered.readCalls).toEqual(["manifest.json"]); + expect(tampered.loaderCalls).toEqual([]); + }); + + it("rejects logic before import when source checksum package checksum or validation report id mismatch", async () => { + const sourceMismatch = createHostContext({ manifestOverrides: { sourceChecksum: "sha256:bad-source" } }); + await expect(new WebRuntimeHost(sourceMismatch.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_SOURCE_CHECKSUM_MISMATCH" }); + expect(sourceMismatch.loaderCalls).toEqual([]); + + const packageMismatch = createHostContext({ manifestOverrides: { packageLogicChecksum: "sha256:bad-package" } }); + await expect(new WebRuntimeHost(packageMismatch.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_PACKAGE_LOGIC_CHECKSUM_MISMATCH" }); + expect(packageMismatch.loaderCalls).toEqual([]); + + const reportMismatch = createHostContext({ manifestOverrides: { validationReportId: "validation-report-other" } }); + await expect(new WebRuntimeHost(reportMismatch.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_VALIDATION_REPORT_MISMATCH" }); + expect(reportMismatch.loaderCalls).toEqual([]); + }); + + it("rejects GameLogicModule source that directly accesses document or window before import", async () => { + const unsafeLogic = createHostContext({ logicText: "export function init() { return window.location.href; }\n" }); + + await expect(new WebRuntimeHost(unsafeLogic.options).load()).rejects.toMatchObject({ reasonCode: "WEB_RUNTIME_LOGIC_HOST_GLOBAL_FORBIDDEN" }); + expect(unsafeLogic.loaderCalls).toEqual([]); + }); + + it.each([ + ["empty path", ""], + ["absolute path", "/etc/passwd"], + ["backslash path", "assets\\map.json"], + ["URL scheme", "https://cdn.example.com/map.json"], + ["decode traversal", "assets%2F..%2Fsecret.json"] + ] as const)("rejects asset manifest entry %s before reading the asset", async (_label, unsafePath) => { + const context = createHostContext({ + assetManifestEntries: [{ path: unsafePath, hash: checksum("self-consistent"), sizeBytes: 15, type: "json" }], + files: { [unsafePath]: "self-consistent" } + }); + + await expect(new WebRuntimeHost(context.options).load()).rejects.toMatchObject({ + reasonCode: "WEB_RUNTIME_ASSET_MANIFEST_PATH_UNCONTROLLED", + path: "entries.0.path" + }); + expect(context.readCalls).not.toContain(unsafePath); + expect(context.loaderCalls).toEqual([]); + }); + + it.each([ + ["empty hash", ""], + ["missing hash", undefined] + ] as const)("rejects asset manifest entry %s before reading the asset", async (_label, hash) => { + const entry = hash === undefined ? { path: "assets/map.json", sizeBytes: 3, type: "json" } : { path: "assets/map.json", hash, sizeBytes: 3, type: "json" }; + const context = createHostContext({ assetManifestEntries: [entry] }); + + await expect(new WebRuntimeHost(context.options).load()).rejects.toMatchObject({ + reasonCode: "WEB_RUNTIME_ASSET_MANIFEST_HASH_INVALID", + path: "entries.0.hash" + }); + expect(context.readCalls).not.toContain("assets/map.json"); + expect(context.loaderCalls).toEqual([]); + }); + + it("runs fixed logic tick with RAF render", async () => { + const logic = createLogicModule({ + renderResult: [{ type: "text", text: "tick", x: 1, y: 2 }] + }); + const context = createHostContext({ logic }); + const host = new WebRuntimeHost(context.options); + await host.load(); + + context.scheduler.step(16); + context.scheduler.step(16); + context.scheduler.step(16); + + expect(logic.update).toHaveBeenCalledTimes(3); + expect(logic.render).toHaveBeenCalledTimes(3); + expect(context.submittedCommands.at(-1)).toEqual([{ type: "text", text: "tick", x: 1, y: 2 }]); + expect(context.telemetryEvents.map((event) => event.name)).toContain("play"); + }); + + it("pause and resume call lifecycle hooks and reset accumulated delta", async () => { + const logic = createLogicModule(); + const context = createHostContext({ logic }); + const host = new WebRuntimeHost(context.options); + await host.load(); + + context.scheduler.step(10); + host.pause(); + context.scheduler.step(80); + host.resume(); + context.scheduler.step(16); + + expect(logic.onPause).toHaveBeenCalledOnce(); + expect(logic.onResume).toHaveBeenCalledOnce(); + expect(logic.update).toHaveBeenCalledTimes(1); + }); + + it("forwards input to GameLogicModule without exposing document or window", async () => { + const logic = createLogicModule(); + const context = createHostContext({ logic }); + const host = new WebRuntimeHost(context.options); + await host.load(); + + context.dispatchInput({ type: "touch_start", x: 4, y: 5, occurredAt: "2026-06-05T00:00:00.000Z" }); + + expect(logic.handleInput).toHaveBeenCalledWith( + expect.anything(), + { type: "touch_start", x: 4, y: 5, occurredAt: "2026-06-05T00:00:00.000Z" }, + expect.objectContaining({ Canvas: expect.any(Object), Input: expect.any(Object) }) + ); + expect(logic.handleInput).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ target: expect.anything() }), expect.anything()); + }); + + it("emits runtime_error telemetry when update fails", async () => { + const logic = createLogicModule({ updateError: new Error("boom") }); + const context = createHostContext({ logic }); + const host = new WebRuntimeHost(context.options); + await host.load(); + + expect(() => context.scheduler.step(16)).toThrow("boom"); + expect(context.telemetryEvents.map((event) => event.name)).toContain("runtime_error"); + }); +}); + +type HostContextOptions = { + readonly logic?: WebRuntimeLogicModule; + readonly logicText?: string; + readonly assetManifestEntries?: readonly unknown[]; + readonly manifestOverrides?: Partial>; + readonly expectedManifestChecksum?: string; + readonly files?: Record; + readonly selfConsistentChecksumSummary?: boolean; +}; + +function createHostContext(options: HostContextOptions = {}) { + const configText = stableJson({ id: "config-1", versionId: "version-host", templateType: "simulation_management" }); + const logicText = options.logicText ?? "export const logic = true;\n"; + const assetManifestEntries = options.assetManifestEntries ?? [{ path: "assets/map.json", hash: checksum("map"), sizeBytes: 3, type: "json" }]; + const assetManifestText = stableJson({ id: "asset-manifest-version-host", entries: assetManifestEntries }); + const manifest = createManifest({ configText, logicText, assetManifestText, overrides: options.manifestOverrides }); + const manifestText = stableJson(manifest); + const expectedManifestChecksum = options.expectedManifestChecksum ?? manifest.checksum; + const checksumSummaryText = stableJson({ + sourceLogic: { checksum: checksum(logicText), validationReportId: "validation-report-logic-1" }, + packageLogic: { path: manifest.packageLogicPath, checksum: checksum(logicText) }, + declaredManifestChecksum: options.selfConsistentChecksumSummary ? manifest.checksum : expectedManifestChecksum, + files: { + "config.json": checksum(configText), + [manifest.packageLogicPath]: checksum(logicText), + "asset-manifest.json": checksum(assetManifestText), + "manifest.json": checksum(manifestText), + "manifest.payload": manifest.checksum + } + }); + const files = new Map([ + ["manifest.json", manifestText], + ["config.json", configText], + ["logic/logic.mjs", logicText], + ["asset-manifest.json", assetManifestText], + ["assets/map.json", "map"], + ["checksum-summary.json", checksumSummaryText], + ...Object.entries(options.files ?? {}) + ]); + const telemetryEvents: TelemetryEventDto[] = []; + const submittedCommands: unknown[][] = []; + const loaderCalls: string[] = []; + const readCalls: string[] = []; + const scheduler = createManualScheduler(); + let inputHandler: ((event: Parameters[0]) => void) | null = null; + const sdk = createHostSdk({ + onSubmit: (commands) => submittedCommands.push(commands), + onInputSubscribe: (handler) => { + inputHandler = handler; + }, + telemetryEvents + }); + + return { + loaderCalls, + options: { + gameVersionId: "version-host", + expectedManifestChecksum, + reader: { + readText: async (relativePath: string) => { + readCalls.push(relativePath); + const content = files.get(relativePath); + if (content === undefined) throw new Error(`missing ${relativePath}`); + return content; + } + } satisfies WebRuntimeHostPackageReader, + createAdapter: () => sdk, + loadLogicModule: async (relativePath: string) => { + loaderCalls.push(relativePath); + return options.logic ?? createLogicModule(); + }, + telemetry: createTelemetryBuffer({ + gameVersionId: "version-host", + sender: async (events) => { + telemetryEvents.push(...events); + } + }), + scheduler, + fixedTickMs: 16 + }, + expectedManifestChecksum, + readCalls, + scheduler, + telemetryEvents, + submittedCommands, + dispatchInput: (event: Parameters[0]) => { + inputHandler?.(event); + } + }; +} + +function createLogicModule(options: { readonly renderResult?: unknown[]; readonly updateError?: Error } = {}) { + return { + init: vi.fn(() => ({ coins: 0 })), + update: vi.fn((state: unknown) => { + if (options.updateError) throw options.updateError; + return state; + }), + render: vi.fn(() => options.renderResult ?? []), + handleInput: vi.fn((state: unknown) => state), + onPause: vi.fn(), + onResume: vi.fn() + } satisfies WebRuntimeLogicModule; +} + +function createHostSdk(options: { + readonly onSubmit: (commands: unknown[]) => void; + readonly onInputSubscribe: (handler: (event: Parameters[0]) => void) => void; + readonly telemetryEvents: TelemetryEventDto[]; +}): RuntimeSdkContract { + const subscribe = (handler: (event: Parameters[0]) => void) => { + options.onInputSubscribe(handler); + return () => undefined; + }; + return { + version: "mvp-s3-runtime-sdk-v1", + Canvas: { + createCanvas: () => ({ handleId: "host-canvas" }), + getViewport: () => ({ width: 320, height: 180, devicePixelRatio: 1 }), + submitRenderCommands: (_handle, commands) => options.onSubmit(commands) + }, + Input: { + onTouchStart: subscribe, + onTouchMove: subscribe, + onTouchEnd: subscribe, + onKeyDown: subscribe + }, + Audio: { + load: async () => ({ handleId: "audio" }), + play: async () => undefined, + pause: () => undefined, + stop: () => undefined + }, + Network: { request: async () => ({ ok: true, status: 200 }) }, + Storage: { + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined + }, + Lifecycle: { + onShow: () => () => undefined, + onHide: () => () => undefined, + onPause: () => () => undefined, + onResume: () => () => undefined, + onError: () => () => undefined + }, + Telemetry: { + emit: async (event) => { + options.telemetryEvents.push(event); + } + } + }; +} + +function createManualScheduler(): WebRuntimeHostScheduler & { step: (deltaMs: number) => void } { + let frame: ((timeMs: number) => void) | null = null; + let nowMs = 0; + return { + requestFrame: (callback) => { + frame = callback; + return 1; + }, + cancelFrame: () => { + frame = null; + }, + step: (deltaMs) => { + nowMs += deltaMs; + frame?.(nowMs); + }, + now: () => nowMs + }; +} + +function createManifest(input: { + readonly configText: string; + readonly logicText: string; + readonly assetManifestText: string; + readonly overrides?: Partial>; +}) { + const withoutChecksum = { + entry: "runtime/index.js", + configPath: "config.json", + logicArtifactPath: "logic/logic.mjs", + assetManifestPath: "asset-manifest.json", + runtimeVersion: "web-runtime-mvp-v1", + logicModuleId: "logic-module-1", + sourceArtifactPath: "dist/game-logic/version-host/logic.mjs", + sourceChecksum: checksum(input.logicText), + validationReportId: "validation-report-logic-1", + packageLogicPath: "logic/logic.mjs", + packageLogicChecksum: checksum(input.logicText) + }; + const merged = { ...withoutChecksum, ...input.overrides }; + return { ...merged, checksum: checksum(stableJson(omitChecksum(merged))) }; +} + +function omitChecksum(value: T): Omit { + const copy = { ...value }; + delete copy.checksum; + return copy; +} + +function stableJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function checksum(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} diff --git a/packages/web-runtime/src/WebRuntimeHost.ts b/packages/web-runtime/src/WebRuntimeHost.ts new file mode 100644 index 00000000..56cdbda3 --- /dev/null +++ b/packages/web-runtime/src/WebRuntimeHost.ts @@ -0,0 +1,372 @@ +import type { + CanvasHandle, + InputEventDto, + RenderCommand, + RuntimeSdkContract, + TelemetryEventDto +} from "../../shared-contracts/src/runtime-sdk-contract"; + +export type WebRuntimeHostPackageReader = { + readonly readText: (relativePath: string) => Promise; + readonly readBytes?: (relativePath: string) => Promise; +}; + +export type WebRuntimeHostScheduler = { + readonly requestFrame: (callback: (timeMs: number) => void) => number; + readonly cancelFrame: (handle: number) => void; + readonly now: () => number; +}; + +export type WebRuntimeLogicModule = { + readonly init: (sdk: RuntimeSdkContract, config: unknown) => unknown; + readonly update: (state: unknown, tick: { readonly deltaMs: number; readonly elapsedMs: number }, sdk: RuntimeSdkContract) => unknown; + readonly render: (state: unknown, sdk: RuntimeSdkContract) => readonly RenderCommand[]; + readonly handleInput: (state: unknown, input: InputEventDto, sdk: RuntimeSdkContract) => unknown; + readonly onPause: (state: unknown, sdk: RuntimeSdkContract) => void; + readonly onResume: (state: unknown, sdk: RuntimeSdkContract) => void; +}; + +export type WebRuntimeHostOptions = { + readonly gameVersionId: string; + readonly expectedManifestChecksum: string; + readonly reader: WebRuntimeHostPackageReader; + readonly createAdapter: () => RuntimeSdkContract; + readonly loadLogicModule: (packageLogicPath: string) => Promise; + readonly scheduler?: WebRuntimeHostScheduler; + readonly fixedTickMs?: number; +}; + +type InternalWebManifest = { + readonly entry: string; + readonly configPath: string; + readonly logicArtifactPath: string; + readonly assetManifestPath: string; + readonly runtimeVersion: string; + readonly logicModuleId: string; + readonly sourceArtifactPath: string; + readonly sourceChecksum: string; + readonly validationReportId: string; + readonly packageLogicPath: string; + readonly packageLogicChecksum: string; + readonly checksum: string; +}; + +type ChecksumSummary = { + readonly files?: Record; + readonly sourceLogic?: { readonly checksum?: string; readonly validationReportId?: string }; + readonly packageLogic?: { readonly path?: string; readonly checksum?: string }; + readonly declaredManifestChecksum?: string; +}; + +type AssetManifest = { + readonly entries: readonly AssetManifestEntry[]; +}; + +type AssetManifestEntry = { + readonly path: string; + readonly hash: string; +}; + +const MANIFEST_PATH = "manifest.json"; +const CHECKSUM_SUMMARY_PATH = "checksum-summary.json"; +const DEFAULT_FIXED_TICK_MS = 16; +const FIXED_PACKAGE_LOGIC_PATH = "logic/logic.mjs"; + +export class WebRuntimeHost { + private readonly scheduler: WebRuntimeHostScheduler; + private readonly fixedTickMs: number; + private sdk: RuntimeSdkContract | null = null; + private logic: WebRuntimeLogicModule | null = null; + private state: unknown; + private canvasHandle: CanvasHandle | null = null; + private rafHandle: number | null = null; + private running = false; + private paused = false; + private lastFrameMs: number | null = null; + private accumulatedDeltaMs = 0; + private elapsedMs = 0; + private playEmitted = false; + + constructor(private readonly options: WebRuntimeHostOptions) { + this.fixedTickMs = options.fixedTickMs ?? DEFAULT_FIXED_TICK_MS; + this.scheduler = options.scheduler ?? createDefaultScheduler(); + } + + async load(): Promise { + this.sdk = this.options.createAdapter(); + await this.emitTelemetry("load_start", { gameVersionId: this.options.gameVersionId }); + + try { + const manifestText = await this.options.reader.readText(MANIFEST_PATH); + const manifest = parseManifest(manifestText); + await this.verifyTrustedManifest(manifest); + assertControlledManifestPaths(manifest); + const configText = await this.options.reader.readText(manifest.configPath); + const logicText = await this.options.reader.readText(manifest.packageLogicPath); + const assetManifestText = await this.options.reader.readText(manifest.assetManifestPath); + const checksumSummary = parseChecksumSummary(await this.options.reader.readText(CHECKSUM_SUMMARY_PATH)); + await this.verifyPackage({ manifest, manifestText, configText, logicText, assetManifestText, checksumSummary }); + + this.logic = await this.options.loadLogicModule(manifest.packageLogicPath); + this.canvasHandle = this.sdk.Canvas.createCanvas(this.sdk.Canvas.getViewport().width, this.sdk.Canvas.getViewport().height); + this.state = this.logic.init(this.sdk, JSON.parse(configText)); + this.subscribeInput(); + this.running = true; + this.lastFrameMs = this.scheduler.now(); + this.rafHandle = this.scheduler.requestFrame((timeMs) => this.frame(timeMs)); + await this.emitTelemetry("load_success", { logicModuleId: manifest.logicModuleId }); + } catch (error) { + await this.emitTelemetry("load_failed", { reasonCode: reasonCodeFrom(error) }); + throw error; + } + } + + pause(): void { + if (!this.running || this.paused) return; + this.paused = true; + this.accumulatedDeltaMs = 0; + this.lastFrameMs = null; + this.logic?.onPause(this.state, this.requireSdk()); + } + + resume(): void { + if (!this.running || !this.paused) return; + this.paused = false; + this.accumulatedDeltaMs = 0; + this.lastFrameMs = this.scheduler.now(); + this.logic?.onResume(this.state, this.requireSdk()); + } + + stop(): void { + this.running = false; + if (this.rafHandle !== null) this.scheduler.cancelFrame(this.rafHandle); + this.rafHandle = null; + } + + private async verifyPackage(input: { + readonly manifest: InternalWebManifest; + readonly manifestText: string; + readonly configText: string; + readonly logicText: string; + readonly assetManifestText: string; + readonly checksumSummary: ChecksumSummary; + }): Promise { + const manifestPayload = { ...input.manifest }; + delete (manifestPayload as { checksum?: string }).checksum; + assertEqual(await checksum(stableJson(manifestPayload)), input.manifest.checksum, "WEB_RUNTIME_MANIFEST_PAYLOAD_CHECKSUM_MISMATCH"); + assertEqual(await checksum(input.manifestText), input.checksumSummary.files?.[MANIFEST_PATH], "WEB_RUNTIME_MANIFEST_FILE_CHECKSUM_MISMATCH"); + assertEqual(await checksum(input.configText), input.checksumSummary.files?.[input.manifest.configPath], "WEB_RUNTIME_CONFIG_CHECKSUM_MISMATCH"); + assertEqual(input.checksumSummary.sourceLogic?.checksum, input.manifest.sourceChecksum, "WEB_RUNTIME_SOURCE_CHECKSUM_MISMATCH"); + assertEqual(input.checksumSummary.sourceLogic?.validationReportId, input.manifest.validationReportId, "WEB_RUNTIME_VALIDATION_REPORT_MISMATCH"); + assertEqual(input.checksumSummary.packageLogic?.path, input.manifest.packageLogicPath, "WEB_RUNTIME_PACKAGE_LOGIC_PATH_MISMATCH"); + assertEqual(input.checksumSummary.packageLogic?.checksum, input.manifest.packageLogicChecksum, "WEB_RUNTIME_PACKAGE_LOGIC_CHECKSUM_MISMATCH"); + assertEqual(await checksum(input.logicText), input.manifest.packageLogicChecksum, "WEB_RUNTIME_PACKAGE_LOGIC_CHECKSUM_MISMATCH"); + assertEqual(input.manifest.logicArtifactPath, input.manifest.packageLogicPath, "WEB_RUNTIME_LOGIC_PATH_MISMATCH"); + assertEqual(await checksum(input.assetManifestText), input.checksumSummary.files?.[input.manifest.assetManifestPath], "WEB_RUNTIME_ASSET_MANIFEST_CHECKSUM_MISMATCH"); + assertEqual(input.checksumSummary.declaredManifestChecksum, input.manifest.checksum, "WEB_RUNTIME_DECLARED_MANIFEST_CHECKSUM_MISMATCH"); + assertGameLogicModuleBoundary(input.logicText); + await this.verifyAssets(parseAssetManifest(input.assetManifestText)); + } + + private async verifyTrustedManifest(manifest: InternalWebManifest): Promise { + const manifestPayload = { ...manifest }; + delete (manifestPayload as { checksum?: string }).checksum; + const payloadChecksum = await checksum(stableJson(manifestPayload)); + + // GamePackage.profiles.web.manifest.checksum 是外部可信锚点;通过前不能读取 manifest 内声明的任何包路径。 + assertEqual(payloadChecksum, this.options.expectedManifestChecksum, "WEB_RUNTIME_EXPECTED_MANIFEST_CHECKSUM_MISMATCH"); + assertEqual(manifest.checksum, this.options.expectedManifestChecksum, "WEB_RUNTIME_MANIFEST_DECLARED_CHECKSUM_MISMATCH"); + } + + private async verifyAssets(assetManifest: AssetManifest): Promise { + for (const entry of assetManifest.entries) { + const content = this.options.reader.readBytes === undefined ? textEncoder().encode(await this.options.reader.readText(entry.path)) : await this.options.reader.readBytes(entry.path); + assertEqual(await checksumBytes(content), entry.hash, "WEB_RUNTIME_ASSET_CHECKSUM_MISMATCH"); + } + } + + private subscribeInput(): void { + const sdk = this.requireSdk(); + const handler = (input: InputEventDto): void => { + if (this.logic === null) return; + this.state = this.logic.handleInput(this.state, input, sdk); + }; + sdk.Input.onTouchStart(handler); + sdk.Input.onTouchMove(handler); + sdk.Input.onTouchEnd(handler); + sdk.Input.onKeyDown(handler); + } + + private frame(timeMs: number): void { + if (!this.running) return; + try { + if (!this.paused) { + if (this.lastFrameMs !== null) this.accumulatedDeltaMs += timeMs - this.lastFrameMs; + this.lastFrameMs = timeMs; + while (this.accumulatedDeltaMs >= this.fixedTickMs) { + this.elapsedMs += this.fixedTickMs; + this.state = this.requireLogic().update(this.state, { deltaMs: this.fixedTickMs, elapsedMs: this.elapsedMs }, this.requireSdk()); + this.accumulatedDeltaMs -= this.fixedTickMs; + if (!this.playEmitted) { + this.playEmitted = true; + void this.emitTelemetry("play", { elapsedMs: this.elapsedMs }); + } + } + const commands = this.requireLogic().render(this.state, this.requireSdk()); + this.requireSdk().Canvas.submitRenderCommands(this.requireCanvasHandle(), [...commands]); + } + this.rafHandle = this.scheduler.requestFrame((nextTimeMs) => this.frame(nextTimeMs)); + } catch (error) { + void this.emitTelemetry("runtime_error", { reasonCode: reasonCodeFrom(error) }); + throw error; + } + } + + private async emitTelemetry(name: string, payload: unknown): Promise { + await this.requireSdk().Telemetry.emit({ + name, + gameVersionId: this.options.gameVersionId, + payload, + createdAt: new Date().toISOString() + } satisfies TelemetryEventDto); + } + + private requireSdk(): RuntimeSdkContract { + if (this.sdk === null) throwRuntimeFailure("WEB_RUNTIME_SDK_NOT_READY"); + return this.sdk; + } + + private requireLogic(): WebRuntimeLogicModule { + if (this.logic === null) throwRuntimeFailure("WEB_RUNTIME_LOGIC_NOT_READY"); + return this.logic; + } + + private requireCanvasHandle(): CanvasHandle { + if (this.canvasHandle === null) throwRuntimeFailure("WEB_RUNTIME_CANVAS_NOT_READY"); + return this.canvasHandle; + } +} + +function parseManifest(content: string): InternalWebManifest { + const value = JSON.parse(content) as Partial; + for (const field of [ + "entry", + "configPath", + "logicArtifactPath", + "assetManifestPath", + "runtimeVersion", + "logicModuleId", + "sourceArtifactPath", + "sourceChecksum", + "validationReportId", + "packageLogicPath", + "packageLogicChecksum", + "checksum" + ] as const) { + if (typeof value[field] !== "string") throwRuntimeFailure("WEB_RUNTIME_MANIFEST_INVALID", field); + } + return value as InternalWebManifest; +} + +function parseChecksumSummary(content: string): ChecksumSummary { + return JSON.parse(content) as ChecksumSummary; +} + +function parseAssetManifest(content: string): AssetManifest { + const value = JSON.parse(content) as { readonly entries?: readonly unknown[] }; + if (!Array.isArray(value.entries)) throwRuntimeFailure("WEB_RUNTIME_ASSET_MANIFEST_INVALID", "entries"); + const entries = value.entries.map((entry, index): AssetManifestEntry => { + if (!isRecord(entry)) throwRuntimeFailure("WEB_RUNTIME_ASSET_MANIFEST_ENTRY_INVALID", `entries.${index}`); + if (typeof entry.path !== "string") throwRuntimeFailure("WEB_RUNTIME_ASSET_MANIFEST_PATH_UNCONTROLLED", `entries.${index}.path`); + assertControlledRelativePath(entry.path, `entries.${index}.path`, "WEB_RUNTIME_ASSET_MANIFEST_PATH_UNCONTROLLED"); + if (typeof entry.hash !== "string" || entry.hash.length === 0) throwRuntimeFailure("WEB_RUNTIME_ASSET_MANIFEST_HASH_INVALID", `entries.${index}.hash`); + return { path: entry.path, hash: entry.hash }; + }); + return { entries }; +} + +function assertControlledManifestPaths(manifest: InternalWebManifest): void { + assertControlledRelativePath(manifest.configPath, "configPath"); + assertControlledRelativePath(manifest.assetManifestPath, "assetManifestPath"); + assertControlledRelativePath(manifest.logicArtifactPath, "logicArtifactPath"); + assertControlledRelativePath(manifest.packageLogicPath, "packageLogicPath"); + + // S4 runtime 只加载 builder 固定产物路径,避免 manifest 把 loader 指到任意模块。 + assertEqual(manifest.packageLogicPath, FIXED_PACKAGE_LOGIC_PATH, "WEB_RUNTIME_PACKAGE_LOGIC_PATH_UNSUPPORTED"); + assertEqual(manifest.logicArtifactPath, manifest.packageLogicPath, "WEB_RUNTIME_LOGIC_PATH_MISMATCH"); +} + +function assertControlledRelativePath(value: string, field: string, reasonCode = "WEB_RUNTIME_MANIFEST_PATH_UNCONTROLLED"): void { + if (!isControlledRelativePath(value)) throwRuntimeFailure(reasonCode, field); +} + +function isControlledRelativePath(value: string): boolean { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + return false; + } + + // 包内资源路径只接受 POSIX 相对路径;原始值和 decode 后值都必须通过,避免编码绕过目录边界。 + for (const candidate of [value, decoded]) { + if (candidate.length === 0 || candidate.startsWith("/") || candidate.startsWith("\\") || candidate.includes("\\")) return false; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false; + if (candidate.split("/").some((segment) => segment.length === 0 || segment === "." || segment === "..")) return false; + } + + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertEqual(actual: unknown, expected: unknown, reasonCode: string): void { + if (actual !== expected) throwRuntimeFailure(reasonCode); +} + +function assertGameLogicModuleBoundary(logicText: string): void { + // S3 validator 已做主校验;运行时导入前再 fail-closed,避免被篡改的包直接碰 Web 宿主全局。 + if (/\b(?:document|window)\b/.test(logicText)) { + throwRuntimeFailure("WEB_RUNTIME_LOGIC_HOST_GLOBAL_FORBIDDEN", "packageLogicPath"); + } +} + +function stableJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +async function checksum(value: string): Promise { + return checksumBytes(textEncoder().encode(value)); +} + +async function checksumBytes(value: Uint8Array): Promise { + const bytes = new Uint8Array(value.byteLength); + bytes.set(value); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer); + return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +function textEncoder(): TextEncoder { + return new TextEncoder(); +} + +function createDefaultScheduler(): WebRuntimeHostScheduler { + return { + requestFrame: (callback) => globalThis.requestAnimationFrame(callback), + cancelFrame: (handle) => globalThis.cancelAnimationFrame(handle), + now: () => performance.now() + }; +} + +function reasonCodeFrom(error: unknown): string { + return typeof error === "object" && error !== null && "reasonCode" in error && typeof error.reasonCode === "string" ? error.reasonCode : "WEB_RUNTIME_ERROR"; +} + +function throwRuntimeFailure(reasonCode: string, path?: string): never { + const error = new Error(reasonCode) as Error & { reasonCode: string; path?: string }; + error.reasonCode = reasonCode; + if (path !== undefined) error.path = path; + throw error; +} diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts new file mode 100644 index 00000000..45706994 --- /dev/null +++ b/packages/web-runtime/src/index.ts @@ -0,0 +1,3 @@ +export * from "./Canvas2dRenderer"; +export * from "./WebRuntimeHost"; +export * from "./runtime-dependency-gate"; diff --git a/packages/web-runtime/src/runtime-dependency-gate.spec.ts b/packages/web-runtime/src/runtime-dependency-gate.spec.ts new file mode 100644 index 00000000..bdafab99 --- /dev/null +++ b/packages/web-runtime/src/runtime-dependency-gate.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { scanRuntimeDependencyGate } from "./runtime-dependency-gate"; + +describe("S4 runtime dependency and bundle gate", () => { + it.each([ + ["Phaser runtime", "import Phaser from 'phaser';", "FULL_ENGINE_RUNTIME_FORBIDDEN"], + ["GDevelop runtime", "import gd from 'gdevelop-runtime';", "FULL_ENGINE_RUNTIME_FORBIDDEN"], + ["ct-js runtime", "import ct from 'ct-js';", "FULL_ENGINE_RUNTIME_FORBIDDEN"], + ["Cocos runtime", "import { director } from 'cocos';", "FULL_ENGINE_RUNTIME_FORBIDDEN"], + ["Laya runtime", "import * as Laya from 'layaair';", "FULL_ENGINE_RUNTIME_FORBIDDEN"], + ["browser adapter leak", "import BrowserAdapter from '@pixi/browser-adapter';", "BROWSER_ADAPTER_LEAK_FORBIDDEN"], + ["unapproved PixiJS usage", "import { Application } from 'pixi.js';", "UNAPPROVED_PIXI_USAGE_FORBIDDEN"] + ])("rejects %s", (_label, content, reasonCode) => { + const result = scanRuntimeDependencyGate({ + files: [{ path: "packages/web-runtime/src/runtime.ts", content }] + }); + + expect(result).toEqual({ ok: false, violations: [expect.objectContaining({ reasonCode })] }); + }); + + it("scans package manifests lockfiles and emitted bundles without rejecting the thin runtime", () => { + const result = scanRuntimeDependencyGate({ + files: [ + { path: "packages/web-runtime/package.json", content: '{ "dependencies": { "@huijing/runtime-sdk": "workspace:*" } }' }, + { path: "pnpm-lock.yaml", content: "packages/web-runtime:\n dependencies:\n '@huijing/runtime-sdk': workspace:*\n" }, + { path: "packages/web-runtime/dist/runtime/index.js", content: "export const runtime = 'thin';" } + ] + }); + + expect(result).toEqual({ ok: true, violations: [] }); + }); +}); diff --git a/packages/web-runtime/src/runtime-dependency-gate.ts b/packages/web-runtime/src/runtime-dependency-gate.ts new file mode 100644 index 00000000..1f276915 --- /dev/null +++ b/packages/web-runtime/src/runtime-dependency-gate.ts @@ -0,0 +1,60 @@ +export type RuntimeDependencyGateFile = { + readonly path: string; + readonly content: string; +}; + +export type RuntimeDependencyGateViolation = { + readonly path: string; + readonly reasonCode: "FULL_ENGINE_RUNTIME_FORBIDDEN" | "BROWSER_ADAPTER_LEAK_FORBIDDEN" | "UNAPPROVED_PIXI_USAGE_FORBIDDEN"; + readonly evidence: string; +}; + +export type RuntimeDependencyGateResult = + | { readonly ok: true; readonly violations: readonly [] } + | { readonly ok: false; readonly violations: readonly RuntimeDependencyGateViolation[] }; + +const FULL_ENGINE_PATTERNS = [ + /\bfrom\s+["']phaser(?:\/[^"']*)?["']/i, + /\bfrom\s+["']gdevelop(?:[-/][^"']*)?["']/i, + /\bfrom\s+["']ct-js(?:\/[^"']*)?["']/i, + /\bfrom\s+["'](?:@cocos\/[^"']+|cocos(?:\/[^"']*)?)["']/i, + /\bfrom\s+["'](?:laya|layaair)(?:\/[^"']*)?["']/i, + /"(?:(?:phaser)|(?:gdevelop(?:-[^"]*)?)|(?:ct-js)|(?:cocos)|(?:laya)|(?:layaair))"\s*:/i, + /\b(?:phaser|gdevelop-runtime|ct-js|cocos|layaair)\b/i +] as const; + +const BROWSER_ADAPTER_PATTERNS = [/\bBrowserAdapter\b/, /browser-adapter/i, /@pixi\/browser/i] as const; +const PIXI_PATTERNS = [/\bfrom\s+["']pixi\.js(?:\/[^"']*)?["']/i, /\bfrom\s+["']@pixi\/[^"']+["']/i, /"pixi\.js"\s*:/i] as const; + +export function scanRuntimeDependencyGate(input: { readonly files: readonly RuntimeDependencyGateFile[] }): RuntimeDependencyGateResult { + const violations: RuntimeDependencyGateViolation[] = []; + + for (const file of input.files) { + const browserAdapterEvidence = matchEvidence(file.content, BROWSER_ADAPTER_PATTERNS); + if (browserAdapterEvidence !== null) { + violations.push({ path: file.path, reasonCode: "BROWSER_ADAPTER_LEAK_FORBIDDEN", evidence: browserAdapterEvidence }); + continue; + } + + const pixiEvidence = matchEvidence(file.content, PIXI_PATTERNS); + if (pixiEvidence !== null) { + violations.push({ path: file.path, reasonCode: "UNAPPROVED_PIXI_USAGE_FORBIDDEN", evidence: pixiEvidence }); + continue; + } + + const engineEvidence = matchEvidence(file.content, FULL_ENGINE_PATTERNS); + if (engineEvidence !== null) { + violations.push({ path: file.path, reasonCode: "FULL_ENGINE_RUNTIME_FORBIDDEN", evidence: engineEvidence }); + } + } + + return violations.length === 0 ? { ok: true, violations: [] } : { ok: false, violations }; +} + +function matchEvidence(content: string, patterns: readonly RegExp[]): string | null { + for (const pattern of patterns) { + const match = content.match(pattern); + if (match?.[0] !== undefined) return match[0]; + } + return null; +} diff --git a/packages/web-runtime/tsconfig.json b/packages/web-runtime/tsconfig.json new file mode 100644 index 00000000..69f848b3 --- /dev/null +++ b/packages/web-runtime/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "../../packages", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.spec.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d8f7c33..ccfde695 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,19 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + packages/runtime-sdk: + dependencies: + '@huijing/shared-contracts': + specifier: workspace:* + version: link:../shared-contracts + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + packages/shared-contracts: devDependencies: '@types/node': @@ -131,6 +144,22 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + packages/web-runtime: + dependencies: + '@huijing/runtime-sdk': + specifier: workspace:* + version: link:../runtime-sdk + '@huijing/shared-contracts': + specifier: workspace:* + version: link:../shared-contracts + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + packages: '@borewit/text-codec@0.2.2':