diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts index fac4cc43..fc57e364 100644 --- a/samples/eve-browser-network/browser/main.ts +++ b/samples/eve-browser-network/browser/main.ts @@ -6,10 +6,29 @@ import { decodeCultNetOperationPayload, decodeCultNetPayload, } from "cultmesh-browser"; +import { defineDocumentType } from "@gamecult/cultcache-ts/document"; + +// A payload is read through the definition that says how its bytes are laid out. These are plain-map definitions: the +// provider's CultCache types are integer-keyed MessagePack, and the Eve documents are validated by their own parsers. +// CounterState is [counterId, count, receipts]. +const counterDocument = defineDocumentType({ + type: "sample.counter_state", + schemaId: "sample.counter_state.v1", + schema: { + parse: (value: unknown): [string, number, unknown] => { + if (!Array.isArray(value) || typeof value[0] !== "string" || typeof value[1] !== "number") { + throw new Error("The counter state document is not [counterId, count, receipts]."); + } + return [value[0], value[1], value[2]]; + }, + }, +}); +const eveDocument = (type: string) => defineDocumentType({ type, schemaId: `${type}.v1`, schema: { parse: (value: unknown) => value } }); +const surfaceDocument = eveDocument("gamecult.eve.surface"); +const advertisementDocument = eveDocument("gamecult.eve.provider_advertisement"); -// CounterState is an integer-keyed MessagePack object: [counterId, count, receipts]. const counterCount = (record: Parameters[0]) => - decodeCultNetPayload<[string, number, unknown]>(record)[1]; + decodeCultNetPayload(record, counterDocument)[1]; declare global { interface Window { @@ -63,8 +82,12 @@ try { recordKey: "sample.counter-ui", subscriptionId: "browser-provider", }); - const provider = parseEveProviderAdvertisement(decodeCultNetPayload(advertisement.current!)); - const currentSurface = parseEveSurfaceDocument(decodeCultNetPayload(surface.current!)); + // Eve's generated contract type and the lowering's own interface disagree on optional plugin requirements; the + // value is the one parseEveProviderAdvertisement validated. + const provider = parseEveProviderAdvertisement( + decodeCultNetPayload(advertisement.current!, advertisementDocument), + ) as unknown as NonNullable[2]>["provider"]; + const currentSurface = parseEveSurfaceDocument(decodeCultNetPayload(surface.current!, surfaceDocument)); renderEveSurface(currentSurface, host, { activeSurfaceId: "sample.counter", clientId: "sample.chromium", @@ -77,10 +100,8 @@ try { payload: { amount: 1 }, idempotencyKey: window.__sampleCommandId, }); - const receipt = parseEveCommandReceipt(decodeCultNetOperationPayload(response)) as { - receiptId: string; - count: number; - }; + const receipt = decodeCultNetOperationPayload<{ receiptId: string; count: number }>(response); + parseEveCommandReceipt(receipt); window.__sampleReceipt = receipt; receiptOutput.textContent = receipt.receiptId; }, diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs index c1388712..dfa8d931 100644 --- a/scripts/verify-eve-browser-network.mjs +++ b/scripts/verify-eve-browser-network.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { createServer as createHttpServer } from "node:http"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -38,8 +38,33 @@ let browser; try { const tsc = join(repoRoot, "node_modules", "typescript", "bin", "tsc"); + await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultcache-ts", "tsconfig.json"), "--pretty", "false"]); await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultnet-ts", "tsconfig.json"), "--pretty", "false"]); await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultmesh-browser", "tsconfig.json"), "--pretty", "false"]); + // esbuild strips types, so a package signature change would otherwise surface as a browser error. The page is + // type-checked against the same module map the bundle uses. + const moduleMap = { + "@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"), + "@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"), + "cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"), + "cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"), + }; + const sampleTsconfig = join(workRoot, "tsconfig.json"); + await writeFile(sampleTsconfig, JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "ES2022", + moduleResolution: "Bundler", + lib: ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"], + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + paths: Object.fromEntries(Object.entries(moduleMap).map(([name, target]) => [name, [target]])), + }, + files: [join(sampleRoot, "browser", "main.ts")], + })); + await run(process.execPath, [tsc, "-p", sampleTsconfig, "--pretty", "false"]); await build({ entryPoints: [join(sampleRoot, "browser", "main.ts")], outfile: bundlePath, @@ -48,12 +73,7 @@ try { platform: "browser", target: "es2022", sourcemap: true, - alias: { - "@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"), - "@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"), - "cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"), - "cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"), - }, + alias: moduleMap, logLevel: "warning", }); const bundle = await readFile(bundlePath, "utf8");