/** * Bundles the frontend's raw-app session recorder into a browser script the dev * server can serve, and writes it as a string constant the CLI bundle carries. * * The recorder is not re-implemented here: `wmill app dev --recording` must * produce the exact recording format the players read, and the event * classification it encodes is the hard part. The one adaptation is the `$state` * rune: outside Svelte the store's fields are read through its getters, so the * rune is stripped and the values become plain `let`s. * * Run `bun run gen:dev-recorder` after touching anything under * frontend/src/lib/components/recording/; test/dev_recorder_bundle_unit.test.ts * fails when the committed bundle no longer matches those sources. */ import * as esbuild from "esbuild"; import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; const REPO_ROOT = join(import.meta.dir, ".."); const RECORDING_DIR = join( REPO_ROOT, "frontend", "src", "lib", "components", "recording", ); const ENTRY = "./rawAppRecording.svelte"; const OUT_FILE = join( import.meta.dir, "src", "commands", "app", "devRecorderBundle.gen.ts", ); /** Hash of everything that went into the bundle. Over the modules esbuild * actually pulled in rather than a hand-kept list, so a module added to the * recorder's import graph can't slip past the staleness check. Line endings are * normalized: a CRLF checkout is the same source, and must not read as drift. */ export function hashRecorderSources( sources: string[], root: string = REPO_ROOT, ): string { const hash = createHash("sha256"); for (const file of sources) { hash.update(file); hash.update( readFileSync(join(root, file), "utf-8").replace(/\r\n/g, "\n"), ); } return hash.digest("hex"); } /** Reactivity has no meaning in a plain browser script, and the store exposes * every rune-backed field through a getter, so `$state(x)` collapses to `x`. * Every other rune is compiled away by Svelte and would survive here as an * undefined identifier, so refuse rather than emit a bundle that throws. */ function stripRunes(source: string, file: string): string { const unsupported = [ ...(source.match(/\$state[\w.]*\s*\(?/g) ?? []).filter( (r) => r.replace(/\s/g, "") !== "$state(", ), ...(source.match( /\$(derived|effect|props|bindable|inspect|host)[\w.]*/g, ) ?? []), ]; if (unsupported.length > 0) { throw new Error( `${file} uses ${ [...new Set(unsupported)].join(", ") }, which this generator cannot strip. ` + `Keep the recorder store free of runes beyond \`$state(...)\`.`, ); } return source.replace(/\$state\s*\(/g, "("); } const runeStripper: esbuild.Plugin = { name: "strip-svelte-runes", setup(build) { build.onLoad({ filter: /\.svelte\.ts$/ }, (args) => ({ contents: stripRunes(readFileSync(args.path, "utf-8"), args.path), loader: "ts", })); }, }; if (import.meta.main) { const result = await esbuild.build({ stdin: { contents: `export { createRawAppRecording } from '${ENTRY}'`, resolveDir: RECORDING_DIR, loader: "ts", }, plugins: [runeStripper], bundle: true, format: "iife", globalName: "__wmillRecorder", target: "es2020", minify: true, metafile: true, write: false, legalComments: "none", }); const js = result.outputFiles[0].text; // Repo-relative and sorted, so the hash does not move with the checkout path // or with esbuild's traversal order. `` is the entry above, not a file. const sources = Object.keys(result.metafile.inputs) .filter((f) => f !== "") .map((f) => relative(REPO_ROOT, join(import.meta.dir, f))) .sort(); writeFileSync( OUT_FILE, `// Generated by cli/generate-dev-recorder.ts. Do not edit.\n` + `// Run \`bun run gen:dev-recorder\` from cli/ to rebuild it from\n` + `// frontend/src/lib/components/recording/.\n\n` + `/** Repo-relative sources bundled below. */\n` + `export const DEV_RECORDER_SOURCES = ${ JSON.stringify(sources, null, 2) };\n\n` + `/** SHA-256 of those sources, as of this build. */\n` + `export const DEV_RECORDER_SOURCE_HASH = ${ JSON.stringify(hashRecorderSources(sources)) };\n\n` + `/** IIFE exposing \`createRawAppRecording\` on \`window.__wmillRecorder\`. */\n` + `export const DEV_RECORDER_BUNDLE = ${JSON.stringify(js)};\n`, "utf-8", ); console.log( `Wrote ${OUT_FILE} (${ Math.round(js.length / 1024) } KB of bundled JS from ${sources.length} sources)`, ); }