feat(s4): add web runtime sdk
This commit is contained in:
parent
574ebc3fa1
commit
1bf0a37564
271
apps/web/src/components/runtime/WebPlatformAdapter.test.ts
Normal file
271
apps/web/src/components/runtime/WebPlatformAdapter.test.ts
Normal 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
|
||||
};
|
||||
}
|
||||
284
apps/web/src/components/runtime/WebPlatformAdapter.ts
Normal file
284
apps/web/src/components/runtime/WebPlatformAdapter.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
227
docs/memorys/2026-06-05-S4Task3运行时SDK.md
Normal file
227
docs/memorys/2026-06-05-S4Task3运行时SDK.md
Normal 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,传 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。
|
||||
25
packages/runtime-sdk/package.json
Normal file
25
packages/runtime-sdk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
280
packages/runtime-sdk/src/index.ts
Normal file
280
packages/runtime-sdk/src/index.ts
Normal 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;
|
||||
}
|
||||
180
packages/runtime-sdk/src/runtime-sdk.spec.ts
Normal file
180
packages/runtime-sdk/src/runtime-sdk.spec.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
10
packages/runtime-sdk/tsconfig.json
Normal file
10
packages/runtime-sdk/tsconfig.json
Normal 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"]
|
||||
}
|
||||
26
packages/web-runtime/package.json
Normal file
26
packages/web-runtime/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
40
packages/web-runtime/src/Canvas2dRenderer.spec.ts
Normal file
40
packages/web-runtime/src/Canvas2dRenderer.spec.ts
Normal 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 }]
|
||||
};
|
||||
}
|
||||
114
packages/web-runtime/src/Canvas2dRenderer.ts
Normal file
114
packages/web-runtime/src/Canvas2dRenderer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
393
packages/web-runtime/src/WebRuntimeHost.spec.ts
Normal file
393
packages/web-runtime/src/WebRuntimeHost.spec.ts
Normal 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")}`;
|
||||
}
|
||||
372
packages/web-runtime/src/WebRuntimeHost.ts
Normal file
372
packages/web-runtime/src/WebRuntimeHost.ts
Normal 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;
|
||||
}
|
||||
3
packages/web-runtime/src/index.ts
Normal file
3
packages/web-runtime/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./Canvas2dRenderer";
|
||||
export * from "./WebRuntimeHost";
|
||||
export * from "./runtime-dependency-gate";
|
||||
32
packages/web-runtime/src/runtime-dependency-gate.spec.ts
Normal file
32
packages/web-runtime/src/runtime-dependency-gate.spec.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
60
packages/web-runtime/src/runtime-dependency-gate.ts
Normal file
60
packages/web-runtime/src/runtime-dependency-gate.ts
Normal 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;
|
||||
}
|
||||
10
packages/web-runtime/tsconfig.json
Normal file
10
packages/web-runtime/tsconfig.json
Normal 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
29
pnpm-lock.yaml
generated
@ -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':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user