feat(s4): add web runtime sdk

This commit is contained in:
zizi 2026-06-05 12:16:13 +08:00
parent 574ebc3fa1
commit 1bf0a37564
17 changed files with 2356 additions and 0 deletions

View File

@ -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<string, Set<(event: Record<string, unknown>) => void>>();
const windowListeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
const createTarget = (listenerMap: Map<string, Set<(event: Record<string, unknown>) => void>>) => ({
addEventListener: (type: string, handler: (event: Record<string, unknown>) => void) => {
const handlers = listenerMap.get(type) ?? new Set();
handlers.add(handler);
listenerMap.set(type, handlers);
},
removeEventListener: (type: string, handler: (event: Record<string, unknown>) => void) => {
listenerMap.get(type)?.delete(handler);
},
dispatch: (type: string, event: Record<string, unknown>) => {
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<string, string>();
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<typeof createTarget> & {
devicePixelRatio: number;
setTimeout?: (handler: () => void, timeout: number) => number;
clearTimeout?: (handle: number) => void;
} = {
devicePixelRatio: 1,
...createTarget(windowListeners)
};
return {
canvas,
context,
storage,
windowTarget
};
}

View File

@ -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<typeof setTimeout>;
readonly clearTimeout?: (handle: ReturnType<typeof setTimeout>) => 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<unknown>;
readonly text?: () => Promise<string>;
};
type WebFetcher = (url: string, init: { readonly method: "GET" | "POST"; readonly body?: string; readonly signal?: AbortSignal }) => Promise<WebFetchResponse>;
export type WebPlatformAdapterOptions = {
readonly canvasElement: WebCanvasElement;
readonly windowTarget: WebWindowTarget;
readonly storage: WebStorage;
readonly telemetry: TelemetryBuffer;
readonly fetcher: WebFetcher;
readonly endpoints?: Record<string, string>;
readonly retryDelayMs?: number;
};
export class WebPlatformAdapter {
private readonly canvasHandles = new Set<string>();
private readonly audioHandles = new Set<string>();
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<AudioHandleId> {
const handle = { handleId: `web-audio-${this.nextAudioId}` };
this.nextAudioId += 1;
this.audioHandles.add(handle.handleId);
return handle;
}
private async request(input: NetworkRequestDto): Promise<NetworkResultDto> {
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<WebFetchResponse> {
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<never>((_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<unknown> {
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<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getTimerApi(windowTarget: WebWindowTarget): {
readonly setTimeout: (handler: () => void, timeout: number) => ReturnType<typeof setTimeout>;
readonly clearTimeout: (handle: ReturnType<typeof setTimeout>) => 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");
}
}

View File

@ -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 <S4 Task3 新增文件>
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传 AbortSignalfinally 清 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/CIreview 判定为 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。

View File

@ -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"
}
}

View File

@ -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<void>;
readonly emitEvent: (event: TelemetryEventDto) => Promise<void>;
readonly emitLoadStart: (payload: unknown) => Promise<void>;
readonly emitLoadSuccess: (payload: unknown) => Promise<void>;
readonly emitLoadFailed: (payload: unknown) => Promise<void>;
readonly emitPlay: (payload: unknown) => Promise<void>;
readonly emitRuntimeError: (payload: unknown) => Promise<void>;
readonly flush: () => Promise<void>;
};
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<void>;
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<NetworkResultDto> => {
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<typeof setTimeout> | null = null;
let flushing: Promise<void> | 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<void> => {
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<void> =>
emitEvent({
name: event.name,
gameVersionId: options.gameVersionId,
payload: event.payload,
createdAt: now()
});
const flush = async (): Promise<void> => {
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<RuntimeSdkValidationResult> {
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<string, string>();
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<void>,
events: readonly TelemetryEventDto[],
retryAttempts: number,
retryBackoffMs: number
): Promise<void> {
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<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
function throwRuntimeSdkError(result: Exclude<RuntimeSdkValidationResult, { ok: true }>): 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;
}

View File

@ -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<string, string>();
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));
}
};
}

View File

@ -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"]
}

View File

@ -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"
}
}

View File

@ -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 }]
};
}

View File

@ -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;
}
}

View File

@ -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<ReturnType<typeof createManifest>>;
readonly expectedManifestChecksum?: string;
readonly files?: Record<string, string>;
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<RuntimeSdkContract["Input"]["onTouchStart"]>[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<RuntimeSdkContract["Input"]["onTouchStart"]>[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<RuntimeSdkContract["Input"]["onTouchStart"]>[0]) => void) => void;
readonly telemetryEvents: TelemetryEventDto[];
}): RuntimeSdkContract {
const subscribe = (handler: (event: Parameters<RuntimeSdkContract["Input"]["onTouchStart"]>[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<ReturnType<typeof createManifest>>;
}) {
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<T extends { readonly checksum?: string }>(value: T): Omit<T, "checksum"> {
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")}`;
}

View File

@ -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<string>;
readonly readBytes?: (relativePath: string) => Promise<Uint8Array>;
};
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<WebRuntimeLogicModule>;
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<string, string>;
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<InternalWebManifest>;
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<string, unknown> {
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<string> {
return checksumBytes(textEncoder().encode(value));
}
async function checksumBytes(value: Uint8Array): Promise<string> {
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;
}

View File

@ -0,0 +1,3 @@
export * from "./Canvas2dRenderer";
export * from "./WebRuntimeHost";
export * from "./runtime-dependency-gate";

View File

@ -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: [] });
});
});

View File

@ -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;
}

View File

@ -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"]
}

29
pnpm-lock.yaml generated
View File

@ -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':