mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
2105540cca
* 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
138 lines
4.6 KiB
TypeScript
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)`,
|
|
);
|
|
}
|