Files
windmill/cli/generate-dev-recorder.ts
Ruben Fiszel 2105540cca feat: add session recording to wmill app dev (#10457)
* feat: add session recording to wmill app dev

* fix: harden dev recorder shell, bundle staleness guard and save route

* fix: keep dev-server recordings out of the raw app sync diff

* style: drop em dashes from new cli comments

* fix: tighten dev recorder save route origin, naming and io

* test: pin that only the root recordings folder is skipped

* fix: survive an oversized recording upload and match paths on windows

* fix: keep the app at the root and settle runnables stranded by a reload

* fix: make the recorder bundle hash stable on a crlf checkout

* docs: state the preflight-free content type the origin check guards

* test: build the sync-skip fixture with the platform separator

* docs: align the origin-guard test comment with the code

* chore: mark generated .gen.ts files as generated
2026-08-02 22:54:37 +02:00

138 lines
4.6 KiB
TypeScript

/**
* 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. `<stdin>` is the entry above, not a file.
const sources = Object.keys(result.metafile.inputs)
.filter((f) => f !== "<stdin>")
.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)`,
);
}