feat: record and replay raw app sessions step by step (#10318)

* feat: record and replay raw app sessions step by step

* fix: address review findings on raw app session recorder

* fix: stamp replay target before pruning the snapshot clone

* fix: redact step metadata, lock down replayed frames, fix control pre-state

* feat: add a checkpoint timeline to the app recording player

* fix: parser-based replay CSP, fold label clicks, drop stale frame indices

* fix: scrub redacted attributes, keep scroll, neutralize replay navigation

* fix: bound replay payloads, strip namespaced nav links, keep control pre-frames

* fix: strip SMIL navigation, redact metadata sources, capture pre-edit on beforeinput

* fix: redact template content, drop shadow templates, make replays inert

* test: pin snapshot redaction and replay sanitization with DOM tests

* fix: allow-list no-record attributes and cover a marked document root

* fix: classify input types positively so pickers get pre-change frames

* fix: one step per control interaction and bound step metadata

* fix: keep button inputs recordable and coalesce only continuous controls

* fix: no frames for coalesced repeats and drop inline styles when redacting

* fix: fold only the label's own click and keep marked stylesheets out

* fix: keep label-forwarded and radio-group pre-frames, fold submitter clicks

* fix: bound key pre-frames to their gesture and clear ancestor pointer frames

* fix: age-bound pre-frames and treat a radio group as one target

* fix: consume pre-frames per interaction and coalesce on the browser repeat flag

* fix: spend only the pre-frame a step actually used

* fix: settle a step from its successor's pre-state and drop stale pointer frames

* fix: bound remote frame payloads and snapshot stylesheets as rendered

* fix: let a control change spend its own frame and dedupe Enter activations

* fix: record Escape on controls and drop disabled stylesheets

* feat: collapse the replay step list by default behind a toggle

* fix: neutralize disabled sheets in place and fold Enter submissions

* fix: withhold redacted control state, fold key repeats, validate remote metadata

* fix: drop noscript markup and fold implicit form submissions

* fix: mask a select whose chosen option is redacted

* fix: mask redacted select choices before the clone diverges

* fix: run clone-paired passes before removals and fold only Enter submissions

* feat: record a raw app demo from the publish flow instead of the viewer

* fix: wait for in-flight runnable jobs before settling a step

* feat: record from the editor menu and replay publicly at /replay

* feat: export the app recording player and its loader for the hub

* feat: publish from folders only, drop iframe sharing

* fix: observe runnable responses where they land and mount the hub recording route

* fix: respect the app's sandbox opt-in when recording a session

* fix: let stop wait for the runnable the last step is still running

* fix: filter redacted class/id to styled tokens and gate publish on admin

* fix: drop marked sheets from the token vocabulary and bound the replay error

* test: pin the remote app-recording validator

* fix: carry in-flight runnables across a reload and fold held keys into one step

* fix: bind runnable responses off the request and honor base in the replay handoff

* fix: close the settling step when a new fill starts and always re-read stylesheets

* fix: empty the no-record marker so it carries nothing of its own

* fix: decode css escapes so utility classes survive redaction

* fix: read keyDriven from the frame the change starts from

* docs: condense recorder comments to the invariant each protects

* fix: rewrite only real url() tokens and accept leading css escapes

* feat: play flow, script and pipeline recordings on the public /replay page (#10327)

* feat: play flow, script and pipeline recordings on the public /replay page

* fix: render a recorded approval result inert while replaying

* fix: bound an asset sample's cell product and validate recording headers

* fix: make a replayed approval step inert and bound nested recording structures

* fix: stop recorded markup from fetching and bound flow/script render trees

* fix: gate recorded markdown at its renderer and close remaining render-budget gaps

* fix: replace per-key render caps with one structural budget per recorded value

* fix: bound component fan-out and text alongside the structural budget

* fix: make component fan-out cumulative and cap the parsed data-test checklist

* fix: bound the whole recording, graph contents, metadata strings and timer bursts

* fix: keep the published loader path, charge object keys, refuse huge serialized fan-out

* fix: cap flat maps a renderer turns into rows (args, schema properties)

* fix: refuse structure hidden past the depth ceiling and bound errored samples

* fix: count array-shaped argument collections against the row cap

* feat: paint canvas pixels into the snapshot

* fix: budget canvas encoding per snapshot and bound the unknown-kind error

* fix: cap flow graph overlay fan-out and condense budget comments

* docs: teach the raw-app prompt about data-wm-no-record
This commit is contained in:
Ruben Fiszel
2026-07-26 11:51:59 +02:00
committed by GitHub
parent 2bf7746cdd
commit e80fee86b3
34 changed files with 5111 additions and 750 deletions
+34
View File
@@ -23318,6 +23318,40 @@ paths:
schema:
type: string
/w/{workspace}/hub/raw_apps/{id}/recording:
post:
summary: attach a recorded session to a hub raw app
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubRawAppRecording
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
description: hub id of the raw app
schema:
type: integer
format: int64
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RecordingBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/scripts/{ask_id}/recording:
post:
summary: attach a recording to a hub script
+10
View File
@@ -23,6 +23,7 @@ pub fn workspaced_service() -> Router {
.route("/apps", post(publish_app))
.route("/raw_apps", post(publish_raw_app))
.route("/raw_apps/{id}/embed", post(publish_raw_app_embed))
.route("/raw_apps/{id}/recording", post(publish_raw_app_recording))
.route(
"/scripts/{ask_id}/recording",
post(publish_script_recording),
@@ -327,6 +328,15 @@ struct RecordingBody {
project_slug: ProjectSlug,
}
async fn publish_raw_app_recording(
ctx: HubPublishCtx,
Path((_workspace, id)): Path<(String, i64)>,
Json(body): Json<RecordingBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(&format!("/raw_apps/{}/recording", id), &body)
.await
}
async fn publish_script_recording(
ctx: HubPublishCtx,
Path((_workspace, ask_id)): Path<(String, i64)>,
+13
View File
@@ -5719,6 +5719,18 @@ const user = await backend.get_user({ user_id: '123' });
The frontend cannot reach datatables, workspace items, or external services on its own it goes through \`backend.<key>(args)\` for everything server-side.
### Keeping data out of recorded demos
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
\`\`\`tsx
<label data-wm-no-record>
Customer SSN <input value={ssn} onChange={onSsn} />
</label>
\`\`\`
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
## Backend runnables
Each runnable has a unique key (used to call it from the frontend) and one of four types:
@@ -5821,6 +5833,7 @@ def main(user_id: str):
3. **Keep runnables focused** one function per runnable; small surface area.
4. **Use descriptive keys** \`get_user\`, not \`a\`.
5. **Always whitelist tables** adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
`,
"triggers": `---
name: triggers
+589 -52
View File
@@ -133,6 +133,7 @@
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte": "^2.45.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.1.1",
"json-refs": "^3.0.15",
"json-schema-to-zod": "^2.7.0",
"path-browserify": "^1.0.1",
@@ -231,6 +232,173 @@
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@aws-crypto/sha256-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-4.0.0.tgz",
@@ -329,6 +497,40 @@
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@bramus/specificity/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/@bramus/specificity/node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@chevrotain/types": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
@@ -806,6 +1008,26 @@
"@codingame/monaco-vscode-view-title-bar-service-override": "25.0.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz",
@@ -880,7 +1102,6 @@
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -892,7 +1113,6 @@
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -903,7 +1123,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1010,6 +1229,24 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@@ -1419,7 +1656,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1568,7 +1804,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1585,7 +1820,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1602,7 +1836,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1619,7 +1852,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1636,7 +1868,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1653,7 +1884,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1670,7 +1900,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1687,7 +1916,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1704,7 +1932,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1721,7 +1948,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1738,7 +1964,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1755,7 +1980,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1772,7 +1996,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1791,7 +2014,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1808,7 +2030,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2114,7 +2335,6 @@
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -3427,6 +3647,16 @@
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -4915,6 +5145,58 @@
"lodash-es": "^4.17.21"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@@ -5008,6 +5290,13 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -6942,6 +7231,19 @@
"license": "ISC",
"peer": true
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-tags": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
@@ -7267,6 +7569,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -7350,7 +7659,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -7392,6 +7701,167 @@
"node": ">=0.1.90"
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/jsdom/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/jsdom/node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/jsdom/node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/jsdom/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/jsdom/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -7885,7 +8355,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7906,7 +8375,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7927,7 +8395,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7948,7 +8415,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7969,7 +8435,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7990,7 +8455,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8011,7 +8475,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8032,7 +8495,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8053,7 +8515,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8074,7 +8535,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8095,7 +8555,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8229,10 +8688,10 @@
}
},
"node_modules/lru-cache": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"license": "ISC",
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
@@ -11847,6 +12306,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
@@ -12789,21 +13261,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13072,6 +13529,13 @@
"node": ">= 10"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
@@ -13382,6 +13846,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
"integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.9"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
"integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13405,6 +13889,19 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -13543,7 +14040,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -14257,6 +14754,19 @@
"node": ">=14.0.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -14279,6 +14789,16 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -14551,12 +15071,29 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
"license": "CC0-1.0"
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+14
View File
@@ -49,6 +49,7 @@
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte": "^2.45.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.1.1",
"json-refs": "^3.0.15",
"json-schema-to-zod": "^2.7.0",
"path-browserify": "^1.0.1",
@@ -309,10 +310,23 @@
"svelte": "./package/components/recording/PipelineRecordingReplay.svelte",
"default": "./package/components/recording/PipelineRecordingReplay.svelte"
},
"./components/RawAppRecordingReplay.svelte": {
"types": "./package/components/recording/RawAppRecordingReplay.svelte.d.ts",
"svelte": "./package/components/recording/RawAppRecordingReplay.svelte",
"default": "./package/components/recording/RawAppRecordingReplay.svelte"
},
"./components/recording/types": {
"types": "./package/components/recording/types.d.ts",
"default": "./package/components/recording/types.js"
},
"./components/recording/rawAppRecordingLoad": {
"types": "./package/components/recording/rawAppRecordingLoad.d.ts",
"default": "./package/components/recording/rawAppRecordingLoad.js"
},
"./components/recording/rawAppSnapshot": {
"types": "./package/components/recording/rawAppSnapshot.d.ts",
"default": "./package/components/recording/rawAppSnapshot.js"
},
"./components/FlowWrapper.svelte": {
"types": "./package/components/FlowWrapper.svelte.d.ts",
"svelte": "./package/components/FlowWrapper.svelte",
@@ -45,6 +45,7 @@
import { getContext, hasContext, createEventDispatcher, onDestroy, untrack } from 'svelte'
import { toJsonStr } from '$lib/utils'
import { userStore } from '$lib/stores'
import { isOfflineReplay, isReplaying } from './recording/offlineReplay.svelte'
import ResultStreamDisplay from './ResultStreamDisplay.svelte'
import { twMerge } from 'tailwind-merge'
import DOMPurify from 'dompurify'
@@ -83,6 +84,16 @@
| 'pdf'
| undefined
let resultKind: ResultKind = $state()
/** Kinds whose renderer leaves the page: S3/ducklake previews fetch the file or
* table, and `approval` renders buttons that `fetch` URLs carried in the result.
* A recording is caller-supplied, so on the public page those would aim a
* credentialed request at an arbitrary origin. */
const REPLAY_INERT_KINDS: ResultKind[] = ['s3object', 's3object-list', 'materialized', 'approval']
/** Kinds whose markup pulls subresources: DOMPurify stops scripting but keeps
* `<img src>` and SVG `<image href>`, and `map` tiles are requests by
* construction. Kinds absent here carry their bytes as `data:` and reach nothing.
* Inert only on the public page, which promises to issue no requests. */
const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map']
let length = $state(1)
let hasBigInt = $state(false)
@@ -350,7 +361,10 @@
keys.includes('filename') &&
keys.includes('autodownload')
) {
if (result.autodownload) {
// Not via REPLAY_INERT_KINDS: this download is a side effect *inside* kind
// inference, already done by the time a caller could reclassify. Replaying
// must never write caller-chosen bytes into the viewer's downloads.
if (result.autodownload && !isReplaying()) {
const a = document.createElement('a')
a.href = 'data:application/octet-stream;base64,' + result.file
@@ -583,8 +597,19 @@
$effect(() => {
;[result]
const replaying = isReplaying()
const offlineReplay = isOfflineReplay()
untrack(() => {
resultKind = inferResultKind(result)
// A recording carries the result JSON, nothing the result points at, and a
// replay has no session to go get it: show the recorded value instead.
const inert =
(replaying && REPLAY_INERT_KINDS.includes(resultKind)) ||
(offlineReplay && OFFLINE_INERT_KINDS.includes(resultKind))
if (inert) {
resultKind = 'json'
largeObject = false
}
})
})
$effect(() => {
@@ -600,19 +625,27 @@
)
})
// Per-test breakdown of a managed `// materialize` run, rendered as a
// checklist above the raw result. On success it rides the result
// (`data_tests: [{ test, violating }]`, a one-row array). On failure the job
// result is the error, whose message is the worker's breakdown text — parsed
// back into the same shape so the checklist shows on both outcomes. Both
// formats are produced by this repo's worker (see duckdb_executor.rs); the
// derivation is inert (undefined) for every other DisplayResult use.
// Per-test breakdown of a managed `// materialize` run. On success it rides the
// result as `data_tests`; on failure the job result is the error, whose message
// is the worker's breakdown text, parsed back into the same shape so the
// checklist shows either way. Inert for every other DisplayResult use.
let dataTests = $derived.by(() => {
// Both structured shapes carry `[{ test, violating, sample? }]`; the
// sample (bounded violating-row rows) may arrive as a JSON string (the
// worker keeps it string-typed through the summary row) and is optional
// by contract — anything malformed degrades to no sample, never to a
// dropped checklist.
// `DataTestsResult` renders an item per entry, and the message-derived branch
// below builds them from *lines of text*, so no bound on the result's structure
// can see them. A run with this many tests is unreadable anyway, and the cap has
// to live where the parse happens rather than be predicted from the payload.
const MAX_RENDERED = 1000
// A per-test `sample` arrives as its own JSON string, so nothing that measures
// the enclosing result's structure can see inside it — parsing an 8 MB string of
// `{}` would allocate millions of objects before any row cap applied. Bound the
// text first, then the rows.
const MAX_SAMPLE_CHARS = 256 * 1024
const MAX_SAMPLE_ROWS = 1000
const capped = <T,>(tests: T[]): T[] =>
tests.length > MAX_RENDERED ? tests.slice(0, MAX_RENDERED) : tests
// Both shapes carry `[{ test, violating, sample? }]`. The sample may arrive as
// a JSON string and is optional by contract, so anything malformed degrades to
// no sample — never to a dropped checklist.
const normalize = (
dt: any
): Array<{ test: string; violating: number; sample?: Record<string, any>[] }> | undefined => {
@@ -634,13 +667,15 @@
let sample = x.sample
if (typeof sample === 'string') {
try {
sample = JSON.parse(sample)
sample = sample.length > MAX_SAMPLE_CHARS ? undefined : JSON.parse(sample)
} catch {
sample = undefined
}
}
if (!Array.isArray(sample) || !sample.every((r) => r && typeof r === 'object')) {
sample = undefined
} else if (sample.length > MAX_SAMPLE_ROWS) {
sample = sample.slice(0, MAX_SAMPLE_ROWS)
}
return { test: x.test, violating: x.violating, sample }
})
@@ -648,17 +683,18 @@
// Success: structured column on the summary row.
const row = Array.isArray(result) ? (result as any)?.[0] : (result as any)
const fromRow = normalize(row?.data_tests)
if (fromRow) return fromRow
if (fromRow) return capped(fromRow)
// Failure: the worker attaches the same structured breakdown (plus
// per-failed-test samples) to the error payload.
const fromError = normalize((result as any)?.error?.data_tests)
if (fromError) return fromError
if (fromError) return capped(fromError)
// Failure fallback for results predating the structured error payload:
// parse the worker's breakdown out of the error message.
const msg = (result as any)?.error?.message
if (typeof msg === 'string' && msg.includes('data tests failed on')) {
const out: Array<{ test: string; violating: number }> = []
for (const line of msg.split('\n')) {
if (out.length >= MAX_RENDERED) break
const fail = line.match(/^\s*✗\s*(.+?)\s*—\s*(\d+)\s+violating/)
const pass = line.match(/^\s*✓\s*(.+?)\s*$/)
if (fail) out.push({ test: fail[1], violating: parseInt(fail[2], 10) })
@@ -10,6 +10,7 @@
import SchemaForm from './SchemaForm.svelte'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
import { isReplaying } from './recording/offlineReplay.svelte'
interface Props {
isOwner: boolean
@@ -82,68 +83,80 @@
}
}
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
// Everything this panel shows (description, resume form enums, approval page) is
// fetched from the suspended job — a recording carries none of it — and Resume /
// Cancel act on a job that only exists in the recording. So a replay states the
// recorded fact and offers nothing to click.
let replaying = $derived(isReplaying())
$effect(() => {
job && untrack(() => getDefaultArgs())
if (!replaying) {
job && untrack(() => getDefaultArgs())
}
})
</script>
<div class="w-full h-full text-xs text-primary">
{#if description != undefined}
<DisplayResult {workspaceId} noControls result={description} language={job?.language} />
<div class="mt-2"></div>
{/if}
<div>
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
{#if replaying}
<div class="w-full text-xs text-secondary">This step was waiting for approval.</div>
{:else}
<div class="w-full h-full text-xs text-primary">
{#if description != undefined}
<DisplayResult {workspaceId} noControls result={description} language={job?.language} />
<div class="mt-2"></div>
{/if}
<div>
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button
title="Cancel the step"
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading || actionTaken}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{/if}
<div>
<Button
title="Cancel the step"
iconOnly
startIcon={{ icon: X }}
variant="default"
variant="accent"
onClick={() => continu(true)}
disabled={loading || actionTaken}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
>
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
</div>
{/if}
<div>
<Button
variant="accent"
onClick={() => continu(true)}
disabled={loading || actionTaken}
unifiedSize="md"
>
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
{#if approvalPageUrl}
<a
href={approvalPageUrl}
target="_blank"
rel="noreferrer"
class="text-accent flex items-center gap-1 whitespace-nowrap"
>
Approval page <ExternalLink size={12} />
</a>
{/if}
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume`
variable
</Tooltip>
{/if}
</div>
{#if approvalPageUrl}
<a
href={approvalPageUrl}
target="_blank"
rel="noreferrer"
class="text-accent flex items-center gap-1 whitespace-nowrap"
>
Approval page <ExternalLink size={12} />
</a>
{/if}
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume` variable
</Tooltip>
{/if}
</div>
</div>
</div>
{/if}
+13 -1
View File
@@ -1,16 +1,28 @@
<script lang="ts">
import { Markdown } from 'svelte-exmarkdown'
import { markdownPlugins as plugins } from './markdownPlugins'
import { isOfflineReplay } from './recording/offlineReplay.svelte'
interface Props {
md: string
noPadding?: boolean
}
let { md, noPadding }: Props = $props()
// Rendering markdown turns `![](url)` into a real `<img>`, i.e. a request. On the
// public replay page the source is a recording from an arbitrary origin and the
// page promises to issue none, so show the text instead. Gated here rather than
// at each call site because every recorded annotation (flow notes, group notes,
// step descriptions) reaches markdown through this one component.
let asPlainText = $derived(isOfflineReplay())
</script>
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
<Markdown {md} {plugins} />
{#if asPlainText}
<p class="whitespace-pre-wrap">{md}</p>
{:else}
<Markdown {md} {plugins} />
{/if}
</div>
<style global>
+15 -4
View File
@@ -23,6 +23,7 @@
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
import { JobService } from '$lib/gen'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { isReplaying } from './recording/offlineReplay.svelte'
import Tooltip from './Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import QueuePosition from './QueuePosition.svelte'
@@ -87,6 +88,11 @@
let loadedFromObjectStore = $state('')
// A replayed job only exists inside the recording: its logs are whatever was
// captured, so every path that would go ask the backend for more has to stay
// shut. `jobId` still comes through for the reset-on-change bookkeeping.
let replaying = $derived(isReplaying())
// `content` is the WM_LOGS_SKIPPED sentinel when the job was fetched with
// no_logs=true. If an older in-memory value accidentally has real bytes
// concatenated after the sentinel, treat that as skipped too and refetch.
@@ -94,10 +100,12 @@
let resolvedSkippedLogs: string | undefined = $state(undefined)
let fetchedSkippedJobId: string | undefined = $state(undefined)
let effectiveContent = $derived(isLogsSkipped ? resolvedSkippedLogs : content)
let resolvingSkippedLogs = $derived(isLogsSkipped && !!jobId && resolvedSkippedLogs === undefined)
let resolvingSkippedLogs = $derived(
isLogsSkipped && !!jobId && !replaying && resolvedSkippedLogs === undefined
)
$effect(() => {
if (!isLogsSkipped || !jobId || fetchedSkippedJobId === jobId) {
if (!isLogsSkipped || !jobId || replaying || fetchedSkippedJobId === jobId) {
return
}
const id = jobId
@@ -248,7 +256,10 @@
let truncatedContent = $derived(
truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
)
let prefixInfo = $derived(findPrefixInfo(truncatedContent))
// The "Show more..." button behind this reads the rest of the logs out of the
// instance's object store, which a replay has no access to; leaving `prefixInfo`
// unset renders the `[windmill]` line as the plain log line it is.
let prefixInfo = $derived(replaying ? undefined : findPrefixInfo(truncatedContent))
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
$effect.pre(() => {
truncatedContent && scrollToBottom()
@@ -363,7 +374,7 @@
<NoWorkerWithTagWarning {tagLabel} {tag} />
</div>
{/if}
{#if jobId}
{#if jobId && !replaying}
<QueuePosition {jobId} />
{/if}
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { Drawer, DrawerContent } from '$lib/components/common'
import { base } from '$lib/base'
import RawAppRecordSession from '$lib/components/workspaceSettings/RawAppRecordSession.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { isMac, userPathPrefix } from '$lib/utils'
import { editPathFor } from '$lib/components/workspacePicker'
@@ -10,17 +12,13 @@
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
import {
enterpriseLicense,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import {
Bug,
DiffIcon,
EllipsisVertical,
FileJson,
Circle,
History,
PanelLeft,
PanelLeftClose,
@@ -290,6 +288,7 @@
let saveDrawerOpen = $state(false)
let historyBrowserDrawerOpen = $state(false)
let recordDrawer = $state<Drawer | undefined>(undefined)
let deploymentMsg: string | undefined = $state(undefined)
// Top-bar responsive collapse — container width, not viewport.
@@ -577,6 +576,14 @@
displayName: 'Edit in YAML',
icon: FileJson,
action: () => onOpenYamlEditor?.()
},
{
displayName: 'Record demo',
icon: Circle,
action: () => {
recordDrawer?.openDrawer()
},
disabled: !savedApp
}
])
@@ -699,6 +706,41 @@
</DrawerContent>
</Drawer>
<!-- Full screen: the demo is recorded at the size it replays at. -->
<Drawer bind:this={recordDrawer} size="100vw">
<DrawerContent
title="Record a demo — {savedApp?.path ?? appPath}"
on:close={() => recordDrawer?.closeDrawer()}
>
<div class="flex flex-col h-full min-h-0 gap-2">
<div class="text-xs text-secondary flex flex-col gap-1">
<span>
Use the app the way someone else would — each interaction becomes a step, and the
recording captures the page as they would see it. Passwords are masked; add
<span class="font-mono">data-wm-no-record</span> to anything else that should stay out.
</span>
<span>
Stop recording, then <b>Download</b> the JSON. It is self-contained: open it on
<a href="{base}/replay" target="_blank" rel="noreferrer" class="text-blue-500 underline">
{base}/replay
</a>
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
it can be fetched (S3, GitHub raw, your docs site) and link
<span class="font-mono">{base}/replay?src=&lt;url&gt;</span> to have it load itself. Windmill
keeps no copy.
</span>
</div>
<div class="flex-1 min-h-0">
{#if recordDrawer}
<!-- opWorkspace, not the selected one: a session edits an app that may
live in another workspace, and recording must load and run it there. -->
<RawAppRecordSession workspace={opWorkspace ?? ''} path={savedApp?.path ?? appPath} />
{/if}
</div>
</div>
</DrawerContent>
</Drawer>
<AppJobsDrawer
bind:open={jobsDrawerOpen}
on:clear={() => {
@@ -11,9 +11,21 @@
secret: string | undefined
path: string
runnables: Record<string, Runnable>
/** Called with the bundle's iframe once it is mounted. The session recorder
* (publish flow) needs it to read the app's DOM, which is only possible on
* the unsandboxed path. */
oniframe?: (iframe: HTMLIFrameElement | undefined) => void
}
let { workspace, user, secret, path, runnables }: Props = $props()
let { workspace, user, secret, path, runnables, oniframe }: Props = $props()
$effect(() => {
const el = unsandboxed ? iframe : undefined
oniframe?.(el)
// Withdraw it on teardown: a consumer that keeps the reference (the session
// recorder) would otherwise go on addressing a document that is gone.
return () => oniframe?.(undefined)
})
let iframe = $state() as HTMLIFrameElement | undefined
@@ -0,0 +1,364 @@
<script lang="ts">
/**
* Player for a raw-app session recording: walks the captured interactions one
* step at a time, rendering each step's DOM snapshot in a scripting-disabled
* iframe with the element the user acted on highlighted.
*/
import Button from '$lib/components/common/button/Button.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import {
ChevronLeft,
ChevronRight,
PanelLeftClose,
PanelLeftOpen,
CircleDot,
InfoIcon,
Keyboard,
ListChecks,
MousePointerClick,
Navigation,
Pause,
Play,
SendHorizonal,
TextCursorInput,
ToggleLeft,
TriangleAlert
} from 'lucide-svelte'
import { onDestroy } from 'svelte'
import { withHighlightStyles, type RawAppInteractionKind } from './rawAppSnapshot'
import type { RawAppRecording } from './types'
let { recording }: { recording: RawAppRecording } = $props()
/** 0 = the app as it was when recording started; 1..n = after step n-1 fired. */
let stepIndex = $state(0)
/** Within a step: the DOM the user acted on, then the DOM the app settled into. */
let phase = $state<'before' | 'after'>('before')
let playing = $state(false)
let paneWidth = $state(0)
/** The step list is navigation; the app is the content. Closed by default so a
* replay opens at nearly the recorded width, with the timeline and the step bar
* still saying where you are. */
let listOpen = $state(false)
let stepList: HTMLElement | undefined = $state(undefined)
const KIND_ICONS: Record<RawAppInteractionKind, any> = {
click: MousePointerClick,
fill: TextCursorInput,
select: ListChecks,
toggle: ToggleLeft,
submit: SendHorizonal,
key: Keyboard,
navigate: Navigation
}
let steps = $derived(recording.steps ?? [])
let step = $derived(stepIndex > 0 ? steps[stepIndex - 1] : undefined)
let frameIdx = $derived(
step ? (phase === 'after' ? (step.after ?? step.before) : (step.before ?? step.after)) : 0
)
let html = $derived(frameIdx !== undefined ? recording.frames?.[frameIdx] : undefined)
let srcdoc = $derived(html !== undefined ? withHighlightStyles(html) : undefined)
// Belt and braces with the loader's validation: these numbers land in a `style`
// string, and the player also renders recordings handed to it directly.
function size(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 20000
? Math.round(v)
: fallback
}
let frameWidth = $derived(size(recording.viewport?.width, 1280))
let frameHeight = $derived(size(recording.viewport?.height, 800))
let scale = $derived(paneWidth ? Math.min(1, paneWidth / frameWidth) : 1)
/** Left offset (%) of each step's checkpoint on the timeline: its recorded
* time, then a spreading pass so bursts of fast interactions stay clickable. */
let marks = $derived.by(() => {
const total = recording.total_duration_ms || steps[steps.length - 1]?.t || 1
const min = steps.length > 1 ? Math.min(6, 92 / (steps.length - 1)) : 0
let last = -Infinity
return steps.map((s) => {
const at = Math.max(0, Math.min(96, (s.t / total) * 92 + 4))
const pos = Math.max(at, last + min)
last = pos
return Math.min(99, pos)
})
})
// Keep the current step visible: during playback the list scrolls past the
// viewport within a few steps, and a list reopened after navigating elsewhere
// would otherwise show the top. Reading `listOpen` re-runs this on reopen,
// when scrolling a hidden element would have done nothing.
$effect(() => {
if (!listOpen) return
const row = stepList?.querySelector(`[data-step="${stepIndex}"]`)
row?.scrollIntoView({ block: 'nearest' })
})
function goto(index: number, at: 'before' | 'after' = 'before') {
stepIndex = Math.max(0, Math.min(steps.length, index))
phase = stepIndex === 0 ? 'before' : at
}
let timer: ReturnType<typeof setTimeout> | undefined = undefined
function clearTimer() {
if (timer) clearTimeout(timer)
timer = undefined
}
/** Auto-advance: hold on the interaction, then on its outcome for as long as
* the user actually paused before the next one (clamped so a long think-time
* doesn't stall the playback). */
function schedule() {
clearTimer()
if (!playing) return
if (stepIndex === 0) {
timer = setTimeout(() => {
goto(1)
schedule()
}, 800)
return
}
if (phase === 'before') {
timer = setTimeout(() => {
phase = 'after'
schedule()
}, 900)
return
}
if (stepIndex >= steps.length) {
playing = false
return
}
const gap = Math.min(2000, Math.max(400, steps[stepIndex].t - steps[stepIndex - 1].t))
timer = setTimeout(() => {
goto(stepIndex + 1)
schedule()
}, gap)
}
function togglePlay() {
if (playing) {
playing = false
clearTimer()
return
}
if (stepIndex >= steps.length && phase === 'after') goto(0)
playing = true
schedule()
}
function step_(index: number) {
playing = false
clearTimer()
goto(index)
}
onDestroy(clearTimer)
function fmtTime(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`
}
</script>
<div class="flex flex-col h-full min-h-0 gap-2">
<div class="flex items-center justify-between gap-2 shrink-0">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-lg font-semibold text-emphasis truncate">
{recording.app_path || 'Untitled app'}
</h2>
<span class="text-xs text-secondary px-2 py-0.5 bg-surface-secondary rounded shrink-0">
{steps.length} step{steps.length === 1 ? '' : 's'}
</span>
<Tooltip placement="bottom">
<InfoIcon size={16} class="text-tertiary" />
{#snippet text()}
<span class="text-2xs">
{recording.workspace ? `${recording.workspace} · ` : ''}Recorded {new Date(
recording.recorded_at
).toLocaleString()} ·
{fmtTime(recording.total_duration_ms)} ·
{recording.viewport?.width}×{recording.viewport?.height}
</span>
{/snippet}
</Tooltip>
{#if recording.truncated}
<span class="flex items-center gap-1 text-2xs text-yellow-600 dark:text-yellow-400">
<TriangleAlert size={14} /> recording truncated
</span>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="border"
size="xs"
iconOnly
title={listOpen ? 'Hide steps' : 'Show steps'}
startIcon={{ icon: listOpen ? PanelLeftClose : PanelLeftOpen }}
onclick={() => (listOpen = !listOpen)}
/>
<Button
variant="border"
size="xs"
iconOnly
startIcon={{ icon: ChevronLeft }}
disabled={stepIndex === 0}
onclick={() => step_(stepIndex - 1)}
/>
<Button
variant="border"
size="xs"
startIcon={{ icon: playing ? Pause : Play }}
disabled={steps.length === 0}
onclick={togglePlay}
>
{playing ? 'Pause' : 'Play'}
</Button>
<Button
variant="border"
size="xs"
iconOnly
startIcon={{ icon: ChevronRight }}
disabled={stepIndex >= steps.length}
onclick={() => step_(stepIndex + 1)}
/>
{#if step}
<div class="ml-1">
<ToggleButtonGroup
selected={phase}
on:selected={(e) => {
// The group also fires when playback advances the phase itself;
// only a real click (a phase we are not already on) pauses.
if (e.detail === phase) return
playing = false
clearTimer()
phase = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton size="sm" value="before" label="Interaction" {item} />
<ToggleButton size="sm" value="after" label="Result" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
</div>
</div>
<!-- Timeline: one checkpoint per interaction, placed at the time it happened.
Clicking a checkpoint jumps the player to that step. -->
<div class="shrink-0 px-2 pt-1 pb-4">
<div class="relative h-6">
<div class="absolute inset-x-0 top-3 h-px bg-surface-selected"></div>
<div
class="absolute left-0 top-3 h-px bg-blue-500 transition-all"
style="width: {stepIndex === 0 ? 0 : (marks[stepIndex - 1] ?? 0)}%"
></div>
<button
class="absolute top-1.5 -translate-x-1/2 size-3 rounded-full border-2 {stepIndex === 0
? 'bg-blue-500 border-blue-500'
: 'bg-surface border-surface-selected hover:border-blue-400'}"
style="left: 0%"
title="Initial state"
aria-label="Initial state"
onclick={() => step_(0)}
></button>
{#each steps as s, i (i)}
{@const active = stepIndex === i + 1}
<button
class="absolute -translate-x-1/2 rounded-full border-2 {active
? 'top-1 size-4 bg-blue-500 border-blue-500'
: 'top-1.5 size-3 border-surface-selected hover:border-blue-400 ' +
(stepIndex > i + 1 ? 'bg-blue-200 dark:bg-blue-900' : 'bg-surface')}"
style="left: {marks[i]}%"
title="{i + 1}. {s.label} (+{fmtTime(s.t)})"
aria-label="Step {i + 1}: {s.label}"
onclick={() => step_(i + 1)}
></button>
{/each}
</div>
<div class="flex justify-between text-2xs text-tertiary">
<span>0s</span>
<span>{fmtTime(recording.total_duration_ms)}</span>
</div>
</div>
<div class="flex flex-1 min-h-0 gap-2">
<div
bind:this={stepList}
class="w-72 shrink-0 overflow-auto border rounded-md bg-surface-secondary {listOpen
? ''
: 'hidden'}"
>
<button
data-step="0"
class="w-full text-left px-3 py-2 border-b border-l-2 flex items-center gap-2 {stepIndex ===
0
? 'bg-surface border-l-blue-500'
: 'border-l-transparent hover:bg-surface-hover'}"
onclick={() => step_(0)}
>
<CircleDot size={14} class="text-tertiary shrink-0" />
<span class="text-xs text-primary">Initial state</span>
</button>
{#each steps as s, i (i)}
{@const Icon = KIND_ICONS[s.kind] ?? MousePointerClick}
<button
data-step={i + 1}
class="w-full text-left px-3 py-2 border-b border-l-2 flex items-start gap-2 {stepIndex ===
i + 1
? 'bg-surface border-l-blue-500'
: 'border-l-transparent hover:bg-surface-hover'}"
onclick={() => step_(i + 1)}
>
<span class="text-2xs text-tertiary w-5 shrink-0 pt-0.5">{i + 1}</span>
<Icon size={14} class="text-tertiary shrink-0 mt-0.5" />
<span class="min-w-0 flex-1">
<span class="block text-xs text-primary break-words">{s.label}</span>
<span class="block text-2xs text-tertiary">+{fmtTime(s.t)}</span>
</span>
</button>
{/each}
</div>
<div
class="flex-1 min-w-0 overflow-auto border rounded-md bg-surface"
bind:clientWidth={paneWidth}
>
{#if srcdoc !== undefined}
<!-- A recording is untrusted markup, and `?src=` loads one from any URL,
so the snapshot renders under the empty sandbox: no scripting, and an
opaque origin that can't reach the viewer's Windmill session. -->
<!-- The wrapper carries the scaled-down box; the iframe keeps the recorded
viewport size so the app lays out exactly as it did. -->
<div style="width: {frameWidth * scale}px; height: {frameHeight * scale}px;">
<iframe
title="app-snapshot"
{srcdoc}
sandbox=""
class="bg-white border-none block"
style="width: {frameWidth}px; height: {frameHeight}px; transform: scale({scale}); transform-origin: top left;"
></iframe>
</div>
{:else}
<div class="h-full flex items-center justify-center text-sm text-secondary p-4 text-center">
No snapshot was captured for this step.
</div>
{/if}
</div>
</div>
{#if step}
<div class="shrink-0 text-xs text-secondary border rounded-md px-3 py-2 bg-surface-secondary">
<span class="font-semibold text-primary">Step {stepIndex}/{steps.length}:</span>
{step.label}
{#if step.selector}
<span class="text-2xs text-tertiary font-mono ml-2">{step.selector}</span>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,83 @@
<script lang="ts">
/**
* Mounts a validated recording in the player its kind needs. Shared by the
* in-workspace page and the public one so both agree on the dispatch, the
* layout each kind wants and the render-failure fallback.
*/
import FlowRecordingReplay from './FlowRecordingReplay.svelte'
import ScriptRecordingReplay from './ScriptRecordingReplay.svelte'
import PipelineRecordingReplay from './PipelineRecordingReplay.svelte'
import RawAppRecordingReplay from './RawAppRecordingReplay.svelte'
import { setActiveReplay } from './flowRecording.svelte'
import type { LoadedRecording } from './rawAppRecordingLoad'
import { Button } from '$lib/components/common'
import { TriangleAlert, Upload } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
loaded: LoadedRecording
/** How to get back to the recording picker. */
onreset?: () => void
/** Drop the header row that hosts the reset control, so the public page stays
* chrome-less. The failure card still offers `onreset` — a recording that dies
* on render must not dead-end a visitor whose URL refetches the same file. */
hideHeader?: boolean
class?: string
}
let { loaded, onreset, hideHeader = false, class: className = '' }: Props = $props()
// The pipeline and app players fill the viewport (steps left, detail right,
// like their editors); the flow/script players keep a centered scrolling page.
let fillsViewport = $derived(loaded.kind === 'pipeline' || loaded.kind === 'app')
function reset() {
setActiveReplay(undefined)
onreset?.()
}
</script>
<div
class={twMerge(
'w-full',
fillsViewport ? 'flex flex-col h-full min-h-0' : 'max-w-7xl mx-auto',
className
)}
>
{#if onreset && !hideHeader}
<div class="flex justify-end mb-2 shrink-0">
<Button variant="border" size="xs" onclick={reset} startIcon={{ icon: Upload }}>
Load another recording
</Button>
</div>
{/if}
<div class={fillsViewport ? 'flex-1 min-h-0' : ''}>
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
{#if loaded.kind === 'app'}
<RawAppRecordingReplay recording={loaded.recording} />
{:else if loaded.kind === 'pipeline'}
<PipelineRecordingReplay recording={loaded.recording} />
{:else if loaded.kind === 'script'}
<ScriptRecordingReplay recording={loaded.recording} />
{:else}
<FlowRecordingReplay recording={loaded.recording} />
{/if}
<!-- Shown when a malformed recording crashes on render or in an effect;
load-time validation and the JobLoader guards cover the rest. -->
{#snippet failed()}
<div class="flex flex-col items-center justify-center h-full gap-2 text-center">
<TriangleAlert class="text-red-500" size={28} />
<p class="max-w-md text-sm text-secondary">
This recording could not be replayed — it may be malformed or from an incompatible
version.
</p>
{#if onreset}
<Button variant="border" size="xs" onclick={reset} startIcon={{ icon: Upload }}>
Load another recording
</Button>
{/if}
</div>
{/snippet}
</svelte:boundary>
</div>
</div>
@@ -0,0 +1,48 @@
/**
* Offline mode for the public replay page: the recording JSON is the whole data
* source, so nothing may reach the backend the visitor has no session and the
* page may not even be served by the instance the recording came from.
*
* Two layers, because the player tree reaches far (LogViewer, DisplayResult,
* FlowStatusViewer and everything they open):
* - components call {@link isReplaying} before fetching, or before rendering
* something whose `src`/`href` points at `/api`, so the UI degrades to the
* recorded data instead of showing a broken state;
* - {@link setOfflineReplay} additionally rejects every generated `*Service`
* call at the API client, so a path nobody thought to gate still issues no
* request. `EventSource` bypasses that client, but the only ones in the tree
* are JobLoader's (short-circuited by `getActiveReplay`) and the recorder's.
*/
import { OpenAPI } from '$lib/gen'
import { getActiveReplay } from './flowRecording.svelte'
let offline = $state(false)
function rejectRequest(): never {
throw new Error('Offline replay: this page renders a recording and cannot call the API')
}
/** True on the public page only. Use this for recorded *markup* whose rendering
* would fetch subresources (`<img src>`, map tiles): the threat is content from an
* arbitrary `?src=` origin on a page that promises to touch nothing, so the answer
* is to not render it there. In-workspace the recording is one the user opened
* themselves and the page makes no such promise, so it still renders. */
export function isOfflineReplay(): boolean {
return offline
}
/** True whenever the UI shows recorded data rather than a live job: the whole
* public page, or an in-workspace player while it replays a job stream. Use this
* for anything about *staleness or side effects* both cases want the recorded
* value, not a fresh read (re-querying a ducklake table would answer for *now*
* while the player replays a past run), and neither should let recorded data act. */
export function isReplaying(): boolean {
return offline || getActiveReplay() != undefined
}
export function setOfflineReplay(on: boolean) {
if (on === offline) return
offline = on
if (on) OpenAPI.interceptors.request.use(rejectRequest)
else OpenAPI.interceptors.request.eject(rejectRequest)
}
@@ -0,0 +1,858 @@
/**
* Raw-app session recorder: watches a same-origin app iframe and turns what the
* user does into a step-by-step recording one step per interaction (click,
* fill, select, toggle, submit, key), each carrying the DOM before it and the
* DOM once the app settled after it.
*
* The bundle only runs same-origin when the app is not sandbox-isolated (see
* RawAppPreview): `start` returns false when the document can't be read, and the
* caller surfaces that instead of silently recording nothing.
*/
import type { RawAppRecording, RawAppStep } from './types'
import {
cssSelectorFor,
describeElement,
isElementNode,
isRedacted,
isTag,
REC_TARGET_ATTR,
redactedDescription,
MAX_RECORDED_STEPS,
MAX_TOTAL_FRAME_CHARS,
maskValue,
serializeDocument,
stepLabel,
textWithoutRedacted,
type RawAppInteractionKind
} from './rawAppSnapshot'
/** A step's "after" frame is taken once mutations stop for this long… */
const SETTLE_QUIET_MS = 400
/** but never later than this after the interaction (an app that animates or
* polls forever would otherwise keep the frame pending). */
const SETTLE_MAX_MS = 3000
/** A step that launched a backend job waits for it instead: the DOM goes quiet
* while the job runs, so the ordinary settle would capture the spinner as the
* outcome. Still bounded a job can outlive anyone's patience. */
const SETTLE_JOB_MAX_MS = 60000
/** Typing is one step per field, committed after this much inactivity. */
const FILL_DEBOUNCE_MS = 800
/** `<input>` types that act as buttons: they report a click and never a change. */
const BUTTON_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'image'])
/** `<input>` types whose value moves continuously, so repeats are one gesture. */
const CONTINUOUS_INPUT_TYPES = new Set([
'range',
'color',
'date',
'time',
'datetime-local',
'month',
'week'
])
/** `<input>` types a user types into. An input with no `type` is one of them. */
const TEXT_INPUT_TYPES = new Set([
'',
'text',
'search',
'url',
'tel',
'email',
'password',
'number'
])
/** A step's value is shown in a one-line label; a pasted novel is not. */
const MAX_STEP_VALUE_CHARS = 200
/** Repeats of the same interaction on the same control (a held arrow key, a
* drag along a slider) are one step, not one per event. */
const CONTROL_COALESCE_MS = 250
/** A form submit this soon after a click inside it is that click's consequence. */
const SUBMIT_FOLD_MS = 500
type PendingFill = {
el: Element
before: string | undefined
timer: ReturnType<typeof setTimeout>
}
export type RawAppRecordingStore = {
readonly active: boolean
readonly stepCount: number
/** Stop is waiting on a runnable the last step kicked off. */
readonly stopping: boolean
/** Attach to a same-origin app iframe. False when its document is unreachable. */
start(iframe: HTMLIFrameElement, opts: { appPath: string; workspace?: string }): boolean
/** Async because a step still waiting on a backend job has to land before its
* outcome can be captured. */
stop(): Promise<RawAppRecording>
download(recording: RawAppRecording): void
}
export function createRawAppRecording(): RawAppRecordingStore {
let active = $state(false)
let stepCount = $state(0)
let startTime = 0
let appPath = ''
let workspace: string | undefined = undefined
let iframeEl: HTMLIFrameElement | undefined = undefined
let steps: RawAppStep[] = []
let frames: string[] = []
let frameIndexes = new Map<string, number>()
let framesBytes = 0
let truncated = false
/** Set once an interaction was refused: from then on the recording can only
* grow, so the expensive snapshotting stops but the last accepted step still
* gets its outcome. */
let capped = false
let viewport = { width: 0, height: 0 }
let baseHref = ''
let detachers: (() => void)[] = []
let pendingFill: PendingFill | undefined = undefined
/** Element and time of the last recorded step, for coalescing repeats of one
* interaction. The time is refreshed on every repeat, so a sustained gesture
* does not split once it outlives the window. */
let lastStepEl: Element | undefined = undefined
let lastStepAt = 0
/** Pre-interaction snapshot taken on pointerdown, before a click handler runs.
* Lives until a step spends it or focus leaves what it describes. */
let pendingPointer: { el: Element; html: string | undefined } | undefined = undefined
/** Same, taken on keydown before the key changes the focused field or control.
* `repeat` marks it as opening an auto-repeating gesture (a held arrow), whose
* changes are one step rather than one per event. */
let pendingKey: { el: Element; html: string | undefined; repeat: boolean } | undefined = undefined
type Settle = {
step: RawAppStep
observer: MutationObserver
startedAt: number
timer: ReturnType<typeof setTimeout>
cap: ReturnType<typeof setTimeout>
}
let settle: Settle | undefined = undefined
/** Request ids the app is waiting on. Outlives a bridge rebind: a reload's
* bootstrap requests are seen by the outgoing watch and answered after the next
* one binds, so clearing it there would strand them. */
const inFlight = new Set<unknown>()
/** Document the response listener is currently bound to, so a navigation is
* noticed the moment the new document speaks rather than only at `load`. */
let boundDoc: Document | undefined = undefined
/** Runnable calls the app is waiting on right now (`inFlight.size`, as state). */
let pendingJobs = 0
let unwatchBridge: (() => void) | undefined = undefined
let stopping = $state(false)
/** Resolve once nothing is in flight, the step's job budget is spent, or the
* document is gone. Polled rather than driven off the bridge listener so a
* response that never arrives still ends the wait. */
function drainPendingJobs(startedAt: number): Promise<void> {
return new Promise((resolve) => {
const tick = () => {
if (pendingJobs === 0 || Date.now() - startedAt >= SETTLE_JOB_MAX_MS || !doc()) {
resolve()
return
}
setTimeout(tick, SETTLE_QUIET_MS)
}
tick()
})
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
function doc(): Document | undefined {
try {
return iframeEl?.contentDocument ?? undefined
} catch (_) {
return undefined
}
}
/** Store a snapshot and return its index. Only frames a step actually
* references get here: a pending snapshot is carried as HTML until the step
* that needs it exists, so nothing has to be garbage-collected or renumbered
* later (and stale indices can't outlive a compaction). */
function frameIndex(html: string | undefined): number | undefined {
if (html === undefined) return undefined
const existing = frameIndexes.get(html)
if (existing !== undefined) return existing
if (framesBytes + html.length > MAX_TOTAL_FRAME_CHARS) {
// Nothing more can be stored, so nothing more should be serialized either.
truncated = true
capped = true
return undefined
}
const index = frames.length
frames.push(html)
frameIndexes.set(html, index)
framesBytes += html.length
return index
}
/** Serialize the app document. The result is plain HTML it becomes a frame
* only once a step claims it (see {@link frameIndex}). Serializing is the
* expensive part and runs on the app's own event path, so a recording that can
* no longer accept steps must stop doing it. */
function capture(target?: Element | null): string | undefined {
if (capped) return undefined
const d = doc()
if (!d) return undefined
try {
return serializeDocument(d, { target, baseHref })
} catch (e) {
console.warn('raw app recorder: snapshot failed', e)
return undefined
}
}
/** A pre-interaction frame reused as the previous step's outcome must not carry
* the incoming target's highlight. */
function unstamp(html: string): string {
return html.replace(` ${REC_TARGET_ATTR}=""`, '')
}
function clearSettle() {
if (!settle) return
settle.observer.disconnect()
clearTimeout(settle.timer)
clearTimeout(settle.cap)
settle = undefined
}
/** Snapshot the app once it stops mutating, as the step's outcome. */
function scheduleSettle(step: RawAppStep) {
clearSettle()
const d = doc()
if (!d) return
const finish = () => {
// A backend call is in flight: the app is waiting, not done. Settling now
// would record the spinner as this interaction's result — the quiet period
// cannot see the difference, because a document waiting on a job is quiet.
if (pendingJobs > 0 && settle && Date.now() - settle.startedAt < SETTLE_JOB_MAX_MS) {
clearTimeout(settle.timer)
settle.timer = setTimeout(finish, SETTLE_QUIET_MS)
return
}
clearSettle()
step.after = frameIndex(capture())
}
const observer = new MutationObserver(() => {
if (!settle) return
clearTimeout(settle.timer)
settle.timer = setTimeout(finish, SETTLE_QUIET_MS)
})
observer.observe(d, { subtree: true, childList: true, attributes: true, characterData: true })
settle = {
step,
observer,
startedAt: Date.now(),
timer: setTimeout(finish, SETTLE_QUIET_MS),
// The hard cap only bounds a *mutating* app; one waiting on a job is
// bounded by SETTLE_JOB_MAX_MS in `finish` instead.
cap: setTimeout(finish, SETTLE_MAX_MS)
}
}
/** Close out the settling step: the DOM the next interaction starts from IS its
* outcome. Must use that interaction's own pre-frame, unstamped `capture()`
* here would already include the new effect, and an older frame predates the
* step being settled. */
function settlePendingStep(before: string | undefined) {
if (!settle) return
const pending = settle.step
clearSettle()
const fresh =
before !== undefined && (pendingPointer?.html === before || pendingKey?.html === before)
pending.after = frameIndex(fresh ? unstamp(before) : capture())
}
function pushStep(
kind: RawAppInteractionKind,
el: Element | undefined,
before: string | undefined,
value?: string,
/** This change came from a key the browser is repeating, so it continues the
* gesture already recorded rather than starting a new step. */
keyDriven = false
) {
if (!active) return
const t = Date.now() - startTime
const last = steps[steps.length - 1]
// A repeat already means "same key, still held", so it folds untimed; gating
// it would split every held key at the OS repeat delay. The window is only for
// a continuous control, where nothing else separates a drag from the next
// press.
const coalesces =
!!last &&
!!el &&
sameTarget(lastStepEl, el) &&
last.kind === kind &&
(keyDriven || (isContinuousControl(el) && t - lastStepAt < CONTROL_COALESCE_MS))
// Before the cap check, so the last accepted step still gets its outcome when
// the next interaction is the one refused. Skipped mid-gesture: each repeat's
// outcome would be indexed and immediately superseded.
if (!coalesces) settlePendingStep(before)
if (steps.length >= MAX_RECORDED_STEPS && !coalesces) {
truncated = true
capped = true
return
}
// A no-record subtree opted out of the recording entirely: its text is what
// names the element and what a select/file step carries as a value, so the
// step metadata has to be redacted here too — snapshot scrubbing can't
// reach into `steps`.
const redacted = !!el && isRedacted(el)
const target =
bound(el ? (redacted ? redactedDescription(el) : describeElement(el)) : 'the app') ??
'the app'
// Metadata is stored and rendered like a frame is; an unbounded paste would
// otherwise slip past the snapshot budget in `value` and `label`.
const bounded =
value && value.length > MAX_STEP_VALUE_CHARS
? `${value.slice(0, MAX_STEP_VALUE_CHARS)}`
: value
// Whether a marked control is ticked is withheld from the snapshot, so the
// step must not answer it either. A masked "checked" would read back as
// Unchecked, so a redacted toggle carries no value and gets a neutral label.
const shown =
!redacted || !bounded ? bounded : kind === 'toggle' ? undefined : maskValue(bounded)
const label = stepLabel(kind, target, shown)
if (coalesces && last) {
// One gesture, one step: updated in place, indexing nothing for a repeat, and
// the window runs from here so a sustained hold never splits.
last.value = shown
last.label = label
lastStepAt = t
scheduleSettle(last)
return
}
const step: RawAppStep = {
t,
kind,
label,
target,
selector: el && !redacted ? bound(cssSelectorFor(el)) : undefined,
value: shown,
// A key repeat skips its snapshot because it expects to fold into the step
// already recorded; when it turns out to start one instead, it still needs
// a state to open on. Only for keys: for a `change`, a capture taken now
// would be the outcome, not the interaction.
before: frameIndex(before ?? (kind === 'key' ? capture(el) : undefined))
}
steps.push(step)
// Spend only the frame this step used: reusing it later rewinds the replay,
// while clearing both would take one belonging to an interaction still in
// flight (a control clicked mid-debounce commits the fill first).
if (before !== undefined && pendingPointer?.html === before) pendingPointer = undefined
if (before !== undefined && pendingKey?.html === before) pendingKey = undefined
lastStepEl = el
lastStepAt = t
stepCount = steps.length
scheduleSettle(step)
}
/** A label's own click duplicates the control's report of the same interaction.
* Only the label's: the forwarded click must not fold, or a button-shaped
* control (which never fires `change`) would vanish. */
function labelDrivesControl(el: Element): boolean {
const label = el.closest('label') as HTMLLabelElement | null
const control = label?.control
if (!control || el === control || control.contains(el)) return false
return !el.closest('a, button, input, select, textarea')
}
/** Whether this key can change a control: activation, option picking, or the
* first letter of a `<select>` typeahead. */
function mutatingKey(e: KeyboardEvent): boolean {
if (e.ctrlKey || e.metaKey || e.altKey) return false
return (
e.key.length === 1 ||
[' ', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(
e.key
)
)
}
/** A control whose value `change` reports rather than typing: a select, or any
* input that is not a text field a checkbox, a range, a date picker. They
* change on keys that produce no `beforeinput`, so their pre-change frame has
* to come from `keydown`. */
function isControl(el: Element): boolean {
if (isTag(el, 'SELECT')) return true
if (!isTag(el, 'INPUT')) return false
const type = (el as HTMLInputElement).type
return !isTextEntry(el) && !BUTTON_INPUT_TYPES.has(type)
}
/** The control a form submits through. */
function isSubmitter(el: Element): boolean {
if (isTag(el, 'BUTTON')) return ((el as HTMLButtonElement).type || 'submit') === 'submit'
return isTag(el, 'INPUT') && ['submit', 'image'].includes((el as HTMLInputElement).type)
}
/** Whether the step just recorded was an Enter inside this form the only key
* that submits, so an Escape before a submission must not swallow it. */
function justPressedKeyInside(form: Element | null | undefined): boolean {
const last = steps[steps.length - 1]
return (
last?.kind === 'key' &&
last.value === 'Enter' &&
!!form &&
!!lastStepEl &&
form.contains(lastStepEl) &&
Date.now() - startTime - lastStepAt < SUBMIT_FOLD_MS
)
}
/** A field where Enter inserts a newline rather than committing anything. */
function isMultiline(el: Element): boolean {
return isTag(el, 'TEXTAREA') || (el as HTMLElement).isContentEditable
}
/** An element the browser activates with Enter or Space by dispatching a click,
* which is the event the interaction is recorded from. */
function isActivatable(el: Element): boolean {
if (isTag(el, 'BUTTON') || isTag(el, 'SUMMARY')) return true
if (isTag(el, 'A') && el.hasAttribute('href')) return true
return isTag(el, 'INPUT') && BUTTON_INPUT_TYPES.has((el as HTMLInputElement).type)
}
/** A control the user sweeps rather than sets once: repeats within a burst are
* one interaction. A checkbox is not one two quick clicks are two toggles. */
function isContinuousControl(el: Element): boolean {
return isTag(el, 'INPUT') && CONTINUOUS_INPUT_TYPES.has((el as HTMLInputElement).type)
}
/** Metadata is stored and rendered like a frame is; an unbounded paste (or a
* pathological selector) would otherwise slip past the snapshot budget. */
function bound(text: string | undefined): string | undefined {
if (text === undefined) return undefined
return text.length > MAX_STEP_VALUE_CHARS ? `${text.slice(0, MAX_STEP_VALUE_CHARS)}` : text
}
/** A field whose value the user types into, character by character. Listed
* positively: everything else an `<input>` can be (a range, a colour, a date,
* a checkbox) is a control the browser mutates on keys that produce no
* `beforeinput`, and must take the control path instead. */
function isTextEntry(el: Element): boolean {
if (isTag(el, 'TEXTAREA')) return true
if ((el as HTMLElement).isContentEditable) return true
return isTag(el, 'INPUT') && TEXT_INPUT_TYPES.has((el as HTMLInputElement).type)
}
function currentValue(el: Element): string {
// A contenteditable host can be recordable while a node inside it is not.
const raw = isTag(el, 'INPUT')
? (el as HTMLInputElement).value
: isTag(el, 'TEXTAREA')
? (el as HTMLTextAreaElement).value
: textWithoutRedacted(el)
const secret =
(isTag(el, 'INPUT') && (el as HTMLInputElement).type === 'password') || isRedacted(el)
return secret ? maskValue(raw) : raw
}
/** The pre-interaction frame for `el`, if the pointerdown that started this
* interaction landed on it or on its label / an ancestor, which is what a
* click on `<label>Urgent</label>` looks like. */
function pointerFrameFor(el: Element): string | undefined {
const from = pendingPointer?.el
if (!from) return undefined
if (from === el || from.contains(el) || el.contains(from)) return pendingPointer?.html
const labels = (el as HTMLInputElement).labels
if (labels && Array.from(labels).some((l) => l === from || l.contains(from)))
return pendingPointer?.html
return undefined
}
/** One interaction target: the same element, or another radio of the same
* group arrows move the selection between them, so `keydown`, `change` and
* the step already recorded can each land on a different member. */
function sameTarget(a: Element | undefined, b: Element | undefined): boolean {
if (!a || !b) return false
if (a === b) return true
const x = a as HTMLInputElement
const y = b as HTMLInputElement
return (
x.type === 'radio' && y.type === 'radio' && !!x.name && x.name === y.name && x.form === y.form
)
}
/** The pre-key snapshot, when the key landed on this interaction target. */
function keyFrameFor(el: Element): string | undefined {
if (!pendingKey) return undefined
return sameTarget(pendingKey.el, el) ? pendingKey.html : undefined
}
function commitFill() {
if (!pendingFill) return
const { el, before } = pendingFill
clearTimeout(pendingFill.timer)
pendingFill = undefined
// Spent, so a later burst in this field snapshots itself instead of rewinding.
// A pointerdown on something else is that control's own pre-change frame and
// must survive, which is why `pointerFrameFor` decides what is cleared.
if (pointerFrameFor(el) !== undefined) pendingPointer = undefined
if (pendingKey?.el === el) pendingKey = undefined
pushStep('fill', el, before, currentValue(el))
}
function attach(d: Document) {
const on = (type: string, fn: (e: any) => void) => {
d.addEventListener(type, fn, true)
detachers.push(() => d.removeEventListener(type, fn, true))
}
on('pointerdown', (e: PointerEvent) => {
const el = isElementNode(e.target) ? e.target : undefined
if (!el) return
pendingPointer = { el, html: capture(el) }
})
on('click', (e: MouseEvent) => {
const el = isElementNode(e.target) ? e.target : undefined
if (!el) return
// Before any early return: a fill still inside its debounce belongs before
// whatever this click records, and the control paths below never commit it.
if (pendingFill && pendingFill.el !== el) commitFill()
// Controls report their own semantic step on `change`; a click on a text
// field is just focus. Recording those too would double every step. Derived
// from `isControl` so a type moving between the two can't double-record.
if (isTextEntry(el) || isControl(el) || isTag(el, 'OPTION')) return
// Enter in a field is already recorded; the browser then synthesises this
// click on the form's default submitter to carry it out.
if (e.detail === 0 && isSubmitter(el) && justPressedKeyInside(el.closest('form'))) return
// A click on a <label> is also delivered to its control, which reports the
// real step on `change`. Recording the label click too would double one
// physical action, with the second step appearing to rewind the state.
if (labelDrivesControl(el)) return
// `pendingPointer` is NOT cleared here: a click on a <label> is followed by
// the control's own `change`, which needs the same pre-click frame. The
// next pointerdown replaces it.
pushStep('click', el, pointerFrameFor(el) ?? capture(el))
})
on('focusin', (e: FocusEvent) => {
const el = isElementNode(e.target) ? e.target : undefined
// Focus moved somewhere the pending pointerdown does not describe (Tab to
// another field), so that frame no longer states what was just before.
if (pendingPointer && (!el || pointerFrameFor(el) === undefined)) pendingPointer = undefined
})
// Fires before the DOM changes for every edit, including the ones no keydown
// describes: paste, cut, undo, drag-and-drop, IME composition.
on('beforeinput', (e: Event) => {
const el = isElementNode(e.target) ? e.target : undefined
if (!el || !isTextEntry(el) || pendingFill?.el === el) return
if (pointerFrameFor(el) === undefined) pendingKey = { el, html: capture(el), repeat: false }
})
on('input', (e: Event) => {
const el = isElementNode(e.target) ? e.target : undefined
if (!el || !isTextEntry(el)) return
if (pendingFill && pendingFill.el !== el) commitFill()
if (!pendingFill) {
// The pre-keystroke DOM is gone by the time `input` fires: use the frame
// taken on the pointerdown that focused the field, or on the keydown
// that produced this character.
const pointerBefore = pointerFrameFor(el)
const keyBefore = keyFrameFor(el)
const before = pointerBefore ?? keyBefore ?? capture(el)
// Arming a fill starts an interaction before any step exists. Typing moves
// the `value` *property*, invisible to the observer, so the previous
// settle would otherwise capture this field mid-edit as its outcome.
settlePendingStep(before)
pendingFill = { el, before, timer: setTimeout(commitFill, FILL_DEBOUNCE_MS) }
} else {
clearTimeout(pendingFill.timer)
pendingFill.timer = setTimeout(commitFill, FILL_DEBOUNCE_MS)
}
})
on('change', (e: Event) => {
const el = isElementNode(e.target) ? e.target : undefined
if (!el) return
if (isTextEntry(el)) {
commitFill()
return
}
if (pendingFill) commitFill()
// `change` fires after the control already holds its new value, so a
// snapshot taken here is the outcome, not the interaction. Only a frame
// taken before the key or pointer that caused it will do.
const pointerBefore = pointerFrameFor(el)
const keyBefore = keyFrameFor(el)
const before = pointerBefore ?? keyBefore
// Left pending: `pushStep` spends the frame it used, and needs this one to
// recognise the pre-state when settling the step before it. `repeat` is read
// from the frame this change starts from — a held key leaves the flag set,
// so a later pointer takeover would otherwise fold into a finished gesture.
const keyDriven =
pointerBefore === undefined && keyBefore !== undefined && !!pendingKey?.repeat
if (isTag(el, 'SELECT')) {
const options = Array.from((el as HTMLSelectElement).selectedOptions)
const selected = options.map((o) => o.label || o.value).join(', ')
// The <select> itself can be recordable while the option picked is not;
// `pushStep` only looks at the event target, so mask it here.
const secret = options.some((o) => isRedacted(o))
pushStep('select', el, before, secret ? maskValue(selected) : selected, keyDriven)
} else if (isTag(el, 'INPUT')) {
const input = el as HTMLInputElement
if (['checkbox', 'radio'].includes(input.type)) {
pushStep('toggle', el, before, input.checked ? 'checked' : 'unchecked', keyDriven)
} else if (input.type === 'file') {
pushStep(
'fill',
el,
before,
Array.from(input.files ?? [])
.map((f) => f.name)
.join(', ')
)
} else {
// Range, date, color…: a value the user picked rather than typed.
pushStep('fill', el, before, currentValue(el), keyDriven)
}
}
})
on('submit', (e: Event) => {
const el = isElementNode(e.target) ? e.target : undefined
commitFill()
// Clicking a submit button — or pressing Enter in a field — records that
// interaction; the submit that follows is the same action, so it only
// becomes its own step when nothing in this form just acted (a
// programmatic submit, or one triggered from outside the form).
const last = steps[steps.length - 1]
const justActedInside =
(last?.kind === 'click' || (last?.kind === 'key' && last.value === 'Enter')) &&
!!lastStepEl &&
!!el &&
el.contains(lastStepEl) &&
Date.now() - startTime - lastStepAt < SUBMIT_FOLD_MS
if (justActedInside) return
pushStep('submit', el, capture(el))
})
on('keydown', (e: KeyboardEvent) => {
const el = isElementNode(e.target) ? e.target : undefined
// Last moment the pre-change DOM exists for a control key (space, arrows).
// Text fields go through `beforeinput`, which also sees paste and undo.
// Snapshotting runs on the app's event path, so inert keys must not trigger it.
if (el && isControl(el) && mutatingKey(e)) {
// An auto-repeat continues the gesture the first press opened: keep that
// frame (re-serializing per repeat would clone the whole document at the
// key-repeat rate) and only note that the gesture is now repeating.
if (e.repeat && pendingKey && keyFrameFor(el) !== undefined) pendingKey.repeat = true
else pendingKey = { el, html: capture(el), repeat: e.repeat }
}
if (e.key !== 'Enter' && e.key !== 'Escape') return
// Enter is already reported as a change, a click, or the fill's own newline,
// so recording it again would double the interaction. Escape becomes none of
// those and is only ever recorded here.
if (e.key === 'Enter' && el && (isControl(el) || isActivatable(el) || isMultiline(el))) return
// Enter in a field ends the edit: the fill step must land before the key.
commitFill()
// A held key is one interaction: `pushStep` folds the repeats into the step
// the first press opened rather than serializing per repeat.
pushStep('key', el, e.repeat ? undefined : capture(el), e.key, e.repeat)
})
}
function detach() {
detachers.forEach((fn) => fn())
detachers = []
}
/** Outstanding `reqId`s on the bundle's bridge (RawAppBackgroundRunner) are the
* only sign a step waits on the backend: the request leaves the iframe without
* touching the DOM. By id, so an async dispatch awaited later still counts; on
* both windows, since request and response travel opposite ways. */
function watchRunnableBridge(iframe: HTMLIFrameElement) {
const bundle = iframe.contentWindow
const onResponse = (e: MessageEvent) => {
const data = e.data
if (!data || typeof data !== 'object' || e.source !== window) return
const { type, reqId } = data as { type?: unknown; reqId?: unknown }
if (typeof type !== 'string' || !type.endsWith('Res')) return
inFlight.delete(reqId)
pendingJobs = inFlight.size
}
// Bound off the request, not just `load`: a reloaded document can be answered
// while a slow subresource still holds `load` open, and the request both
// proves the document is live and always precedes its own response.
const bindResponses = () => {
const d = doc()
if (!d || d === boundDoc) return
// Same handler identity, so re-adding it to a window that kept the old
// registration is a no-op.
bundle?.addEventListener('message', onResponse)
boundDoc = d
}
const onRequest = (e: MessageEvent) => {
const data = e.data
if (!data || typeof data !== 'object' || e.source !== bundle) return
const { type, reqId } = data as { type?: unknown; reqId?: unknown }
if (typeof type !== 'string' || type.endsWith('Res') || reqId === undefined) return
bindResponses()
inFlight.add(reqId)
pendingJobs = inFlight.size
}
window.addEventListener('message', onRequest)
bindResponses()
// Only the listeners: `inFlight` outlives a rebind on purpose (see its
// declaration), and stop() is what finally empties it.
return () => {
window.removeEventListener('message', onRequest)
bundle?.removeEventListener('message', onResponse)
boundDoc = undefined
}
}
/** A reload replaces the document the listeners are bound to. Anything the old
* one had in flight (a debounced fill, a pending outcome) refers to detached
* nodes and must be dropped, not carried into the new page's timeline. */
function onIframeLoad() {
detach()
// The interaction that triggered the load never settled: its outcome is the
// document that has just replaced the one it acted on.
const reloadedFrom = settle?.step
clearSettle()
if (pendingFill) clearTimeout(pendingFill.timer)
pendingFill = undefined
pendingPointer = undefined
pendingKey = undefined
const d = doc()
if (!d) return
attach(d)
// Half the runnable bridge is bound to the document's window, which the
// reload replaced: rebind the listeners. What is in flight carries over —
// the new document's bootstrap requests were seen by the outgoing watch.
if (iframeEl) {
unwatchBridge?.()
unwatchBridge = watchRunnableBridge(iframeEl)
}
const loaded = capture()
if (reloadedFrom) reloadedFrom.after = frameIndex(loaded)
// Recording started before the app had loaded: this document IS the initial
// state, not a navigation away from one.
if (frames.length === 0) {
frameIndex(loaded)
return
}
// The wrapper is a blob: URL, so only the in-app hash is meaningful here.
pushStep('navigate', undefined, loaded, d.location?.hash || undefined)
}
return {
get active() {
return active
},
get stepCount() {
return stepCount
},
/** Stop is waiting on a runnable the last step kicked off. */
get stopping() {
return stopping
},
start(iframe: HTMLIFrameElement, opts: { appPath: string; workspace?: string }): boolean {
iframeEl = iframe
const d = doc()
if (!d?.documentElement) {
iframeEl = undefined
return false
}
active = true
startTime = Date.now()
appPath = opts.appPath
workspace = opts.workspace
steps = []
lastStepEl = undefined
lastStepAt = 0
stepCount = 0
frames = []
frameIndexes = new Map()
framesBytes = 0
truncated = false
capped = false
baseHref = typeof window !== 'undefined' ? window.location.origin : ''
viewport = {
width: iframe.clientWidth || d.documentElement.clientWidth,
height: iframe.clientHeight || d.documentElement.clientHeight
}
// frames[0] is the app as recording started, the one frame no step claims.
// Capturing a not-yet-loaded `about:blank` would open the replay empty and
// make the real initial DOM look like a navigation, so defer to `load`.
if (d.readyState === 'complete' && d.location?.href !== 'about:blank') {
frameIndex(capture())
}
attach(d)
// NOT in `detachers`: onIframeLoad calls detach(), which would otherwise
// remove the very listener that rebinds the recorder on the next reload.
iframe.addEventListener('load', onIframeLoad)
inFlight.clear()
pendingJobs = 0
unwatchBridge = watchRunnableBridge(iframe)
return true
},
async stop(): Promise<RawAppRecording> {
commitFill()
// Interactions stop counting the moment Stop is pressed, but the bridge
// stays up through the drain below.
detach()
active = false
// The last step is still waiting on the backend: capturing now would ship
// its spinner as the outcome, and the bridge is torn down right after, so
// nothing could correct it later. Same budget the scheduled settle spends.
if (settle && pendingJobs > 0) {
stopping = true
const startedAt = settle.startedAt
await drainPendingJobs(startedAt)
// The response still has to render before it is the outcome.
if (doc()) await wait(SETTLE_QUIET_MS)
stopping = false
}
// The step the user just finished has no settled frame yet — take it now
// rather than ship a step with no outcome.
if (settle) {
const step = settle.step
clearSettle()
step.after = frameIndex(capture())
}
unwatchBridge?.()
unwatchBridge = undefined
inFlight.clear()
pendingJobs = 0
iframeEl?.removeEventListener('load', onIframeLoad)
pendingPointer = undefined
pendingKey = undefined
iframeEl = undefined
const recording: RawAppRecording = {
version: 1,
type: 'app',
recorded_at: new Date().toISOString(),
app_path: appPath,
workspace,
total_duration_ms: Date.now() - startTime,
viewport,
frames,
steps,
truncated: truncated || undefined
}
// Multi-MB snapshots must not outlive the recording they were taken for.
steps = []
frames = []
frameIndexes = new Map()
framesBytes = 0
return recording
},
download(recording: RawAppRecording) {
const blob = new Blob([JSON.stringify(recording)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `app-recording-${(recording.app_path || 'untitled').replace(/\//g, '-')}-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
}
}
@@ -0,0 +1,600 @@
/**
* `parseRecording` and the per-kind validators are what stand between a `?src=` URL
* anyone can point the public replay page at and a player that indexes into the
* payload and renders it. Each rejection below is a way a caller-supplied recording
* could otherwise reach the DOM or the render loop unbounded.
*/
import { describe, expect, it } from 'vitest'
import {
MAX_COMPONENT_FANOUT,
MAX_EVENTS_PER_JOB,
MAX_FLOW_MODULES,
MAX_RECORDED_JOBS,
MAX_MAP_ROWS,
MAX_SAMPLE_CELLS,
MAX_SERIALIZED_FANOUT_CHARS,
MAX_TIMELINE_FRAMES,
MAX_SAMPLE_COLUMNS,
MAX_VALUE_DEPTH,
MAX_VALUE_NODES,
MAX_RECORDING_NODES,
MAX_VALUE_STRING_CHARS,
isAppRecording,
parseRecording
} from './rawAppRecordingLoad'
import { MAX_RECORDED_STEPS, MAX_STEP_TEXT_CHARS } from './rawAppSnapshot'
const valid = () => ({
version: 1,
type: 'app',
recorded_at: '2026-07-25T00:00:00.000Z',
app_path: 'f/demo/app',
workspace: 'demo',
total_duration_ms: 1200,
viewport: { width: 1280, height: 720 },
frames: ['<html></html>', '<html><body>x</body></html>'],
steps: [{ t: 100, kind: 'click', label: 'Clicked button', before: 0, after: 1 }]
})
const rejects: [string, (r: ReturnType<typeof valid>) => unknown][] = [
['a non-object payload', () => 42],
['another recording type', (r) => ({ ...r, type: 'flow' })],
['a future version', (r) => ({ ...r, version: 2 })],
['a frame that is not a string', (r) => ({ ...r, frames: ['<html></html>', 7] })],
['a step index past the last frame', (r) => ({ ...r, steps: [{ ...r.steps[0], after: 9 }] })],
['a negative step index', (r) => ({ ...r, steps: [{ ...r.steps[0], before: -1 }] })],
['an unknown interaction kind', (r) => ({ ...r, steps: [{ ...r.steps[0], kind: 'exec' }] })],
['a NaN timestamp', (r) => ({ ...r, steps: [{ ...r.steps[0], t: NaN }] })],
[
'a label past the text budget',
(r) => ({ ...r, steps: [{ ...r.steps[0], label: 'x'.repeat(MAX_STEP_TEXT_CHARS + 1) }] })
],
[
'more steps than the recorder can produce',
(r) => ({
...r,
steps: Array.from({ length: MAX_RECORDED_STEPS + 1 }, () => ({ ...r.steps[0] }))
})
],
['a non-numeric viewport', (r) => ({ ...r, viewport: { width: '1280px', height: 720 } })],
['a viewport beyond any screen', (r) => ({ ...r, viewport: { width: 1e6, height: 720 } })],
['a negative duration', (r) => ({ ...r, total_duration_ms: -1 })],
['a missing recorded_at', (r) => ({ ...r, recorded_at: undefined })]
]
describe('isAppRecording', () => {
it('accepts a well-formed recording', () => {
expect(isAppRecording(valid())).toBe(true)
// Steps may legitimately lack a snapshot the recorder had to skip.
expect(isAppRecording({ ...valid(), steps: [{ t: 1, kind: 'click', label: 'x' }] })).toBe(true)
})
it.each(rejects)('rejects %s', (_name, mutate) => {
expect(isAppRecording(mutate(valid()))).toBe(false)
})
})
const job = (events = 1) => ({
initial_job: { id: 'j' },
events: Array.from({ length: events }, (_, t) => ({ t, data: { completed: true } }))
})
const header = { recorded_at: '2026-07-25T00:00:00.000Z', total_duration_ms: 1000 }
const pipeline = (extra: Record<string, unknown> = {}) => ({
version: 1,
type: 'pipeline',
folder: 'orders',
...header,
graph: { runnables: [], assets: [], edges: [], triggers: [] },
timeline: [],
jobs: {},
...extra
})
const kindOf = (data: unknown) => {
const res = parseRecording(data)
return res.ok ? res.loaded.kind : `error: ${res.error}`
}
const rejected = (data: unknown) => parseRecording(data).ok === false
describe('parseRecording', () => {
it('routes each kind to its player, defaulting a type-less payload to flow', () => {
expect(kindOf({ version: 1, flow_path: 'f', ...header, jobs: { j: job() } })).toBe('flow')
expect(
kindOf({ version: 1, type: 'flow', flow_path: 'f', ...header, jobs: { j: job() } })
).toBe('flow')
expect(
kindOf({
version: 1,
type: 'script',
script_path: 's',
...header,
code: 'echo hi',
language: 'bash',
job: job()
})
).toBe('script')
expect(kindOf(pipeline())).toBe('pipeline')
expect(
kindOf({
version: 1,
type: 'app',
recorded_at: header.recorded_at,
total_duration_ms: 0,
viewport: { width: 800, height: 600 },
frames: ['<html></html>'],
steps: [{ t: 0, kind: 'click', label: 'Clicked button', before: 0 }]
})
).toBe('app')
})
it('rejects payloads a player would choke on rather than falling through to flow', () => {
// A `?src=` origin is arbitrary: a claimed kind must be validated as that
// kind, never silently reinterpreted.
expect(rejected({ version: 1, type: 'script', code: 'x', language: 'bash' })).toBe(true)
expect(rejected({ version: 1, type: 'pipeline', timeline: [], jobs: {} })).toBe(true)
expect(rejected({ version: 1, type: 'somethingelse', jobs: {} })).toBe(true)
expect(rejected({ version: 2, jobs: {} })).toBe(true)
expect(rejected(null)).toBe(true)
expect(rejected([{ version: 1 }])).toBe(true)
// Header fields are required by the types and rendered by every player.
expect(rejected({ version: 1, jobs: { j: job() } })).toBe(true)
expect(rejected({ version: 1, flow_path: 'f', recorded_at: 'x', jobs: { j: job() } })).toBe(
true
)
// JobLoader replays each event in a timer, where a throw escapes every
// boundary, so a non-object event has to be caught at load.
expect(
rejected({
version: 1,
flow_path: 'f',
...header,
jobs: { j: { initial_job: {}, events: ['nope'] } }
})
).toBe(true)
// Cardinality, not just shape: each job mounts a JobLoader.
const tooManyJobs = Object.fromEntries(
Array.from({ length: MAX_RECORDED_JOBS + 1 }, (_, i) => [`j${i}`, job(0)])
)
expect(rejected({ version: 1, flow_path: 'f', ...header, jobs: tooManyJobs })).toBe(true)
})
it('holds every recorded value to one structural render budget', () => {
// One rule, asserted over the shapes it has to hold for: a value a component
// expands eagerly may not carry more than MAX_VALUE_NODES of structure, at any
// depth and under any key.
const wide = (n: number) => Array.from({ length: n }, () => 0)
const overBudget = MAX_VALUE_NODES + 1
const flowJob = (initial: unknown, eventData?: unknown) => ({
version: 1,
flow_path: 'f',
...header,
jobs: {
j: {
initial_job: initial,
events: eventData ? [{ t: 0, data: eventData }] : []
}
}
})
// args (a JobArgs row per key) and flow_status.modules (a subtree per entry).
expect(rejected(flowJob({ id: 'j', args: { a: wide(overBudget) } }))).toBe(true)
expect(rejected(flowJob({ id: 'j', flow_status: { modules: wide(overBudget) } }))).toBe(true)
// Same via an event rather than the initial job.
expect(rejected(flowJob({ id: 'j' }, { flow_status: { modules: wide(overBudget) } }))).toBe(
true
)
// render_all fans out into nested DisplayResults, and the renderer recurses —
// so a nested budget, not the top-level array's length.
const nested = Array.from({ length: 400 }, () => ({ render_all: wide(400) }))
expect(
rejected(
flowJob({ id: 'j' }, { completed: true, job: { id: 'j', result: { render_all: nested } } })
)
).toBe(true)
// data_tests is a sibling key whose renderer also fans out per entry; nobody
// had to name it for the budget to cover it.
expect(
rejected(
flowJob(
{ id: 'j' },
{ completed: true, job: { id: 'j', result: { data_tests: wide(overBudget) } } }
)
)
).toBe(true)
// Depth is its own hazard: a renderer recursing over this blows the stack long
// before the node count would.
let deep: unknown = 0
for (let i = 0; i < MAX_VALUE_DEPTH + 2; i++) deep = { nest: deep }
expect(rejected(flowJob({ id: 'j', args: { a: deep } }))).toBe(true)
expect(kindOf(flowJob({ id: 'j', args: { a: 1, b: 'two' } }))).toBe('flow')
})
it('bounds what a script and a flow render immediately', () => {
const wide = (n: number) => Array.from({ length: n }, () => 0)
const script = (extra: Record<string, unknown>) => ({
version: 1,
type: 'script',
script_path: 's',
...header,
code: 'x',
language: 'bash',
job: job(),
...extra
})
// SchemaForm recurses into nested properties, so the cap cannot be top-level.
expect(
rejected(
script({
schema: { properties: { outer: { properties: { inner: wide(MAX_VALUE_NODES + 1) } } } }
})
)
).toBe(true)
expect(kindOf(script({ args: { a: 1 } }))).toBe('script')
// `args` and `schema.properties` arrive as the root of the walk on this kind.
const wideMap = Object.fromEntries(
Array.from({ length: MAX_MAP_ROWS + 1 }, (_, i) => [`a${i}`, 1])
)
expect(rejected(script({ args: wideMap }))).toBe(true)
expect(rejected(script({ args: Array.from({ length: MAX_MAP_ROWS + 1 }, () => 0) }))).toBe(true)
expect(rejected(script({ schema: { properties: wideMap } }))).toBe(true)
const flowWith = (value: unknown) => ({
version: 1,
flow_path: 'f',
...header,
jobs: { j: job() },
flow: { value }
})
const modules = (n: number) =>
Array.from({ length: n }, (_, i) => ({ id: `m${i}`, value: { type: 'identity' } }))
expect(rejected(flowWith({ modules: modules(MAX_FLOW_MODULES + 1) }))).toBe(true)
// The module count is the total across the nested tree, not the top-level
// array's length — a branch or loop body renders nodes just the same.
expect(
rejected(
flowWith({
modules: [
{ id: 'loop', value: { type: 'forloopflow', modules: modules(MAX_FLOW_MODULES + 1) } }
]
})
)
).toBe(true)
// A branch is a node and an edge even with no modules in it.
expect(
rejected(
flowWith({
modules: [
{
id: 'b',
value: {
type: 'branchall',
branches: Array.from({ length: MAX_FLOW_MODULES + 1 }, () => ({ modules: [] }))
}
}
]
})
)
).toBe(true)
// input_transforms renders a table row per entry — a per-module sibling the
// module count never saw, and the budget covers without naming it.
expect(
rejected(
flowWith({
modules: [
{
id: 'a',
value: { type: 'rawscript', input_transforms: { x: wide(MAX_VALUE_NODES + 1) } }
}
]
})
)
).toBe(true)
// Notes and groups each mount a graph node, so they are capped by component
// fan-out and not merely by structure: minimal entries are cheap enough that
// the node budget would admit tens of thousands of them.
expect(rejected(flowWith({ modules: [], notes: wide(MAX_COMPONENT_FANOUT + 1) }))).toBe(true)
expect(rejected(flowWith({ modules: [], groups: wide(MAX_COMPONENT_FANOUT + 1) }))).toBe(true)
// Split across both, since one graph draws them together.
expect(
rejected(
flowWith({
modules: [],
notes: wide(MAX_COMPONENT_FANOUT),
groups: wide(MAX_COMPONENT_FANOUT)
})
)
).toBe(true)
expect(kindOf(flowWith({ modules: [], notes: wide(10), groups: wide(10) }))).toBe('flow')
expect(kindOf(flowWith({ modules: modules(3) }))).toBe('flow')
})
it('bounds the three things that cost differently: structure, text, components', () => {
const wide = (n: number) => Array.from({ length: n }, () => 0)
const flowJob = (result: unknown) => ({
version: 1,
flow_path: 'f',
...header,
jobs: {
j: {
initial_job: { id: 'j' },
events: [{ t: 0, data: { completed: true, job: { id: 'j', result } } }]
}
}
})
// Fan-out is cumulative across the value, not per array: nesting sidesteps a
// per-array cap while mounting a component per leaf.
const side = 40
expect(side * side).toBeGreaterThan(MAX_COMPONENT_FANOUT)
expect(
rejected(
flowJob({ render_all: Array.from({ length: side }, () => ({ render_all: wide(side) })) })
)
).toBe(true)
// Component fan-out: well inside the node budget, but one nested DisplayResult
// per entry is orders of magnitude more expensive than a node.
const fanout = MAX_COMPONENT_FANOUT + 1
expect(fanout).toBeLessThan(MAX_VALUE_NODES)
expect(rejected(flowJob({ render_all: wide(fanout) }))).toBe(true)
expect(rejected(flowJob({ data_tests: wide(fanout) }))).toBe(true)
// ...including the serialized form, since a string is one node however many
// components the renderer parses out of it.
expect(rejected(flowJob({ data_tests: JSON.stringify(wide(fanout)) }))).toBe(true)
expect(rejected(flowJob({ error: { data_tests: JSON.stringify(wide(fanout)) } }))).toBe(true)
expect(kindOf(flowJob({ render_all: wide(10) }))).toBe('flow')
// Text: a long string is one node, so only the character budget sees it.
expect(rejected(flowJob({ big: 'x'.repeat(MAX_VALUE_STRING_CHARS + 1) }))).toBe(true)
// A flat map rendered one row per entry: cheap per node, so the node budget
// alone lets ~100k rows through.
const rows = (n: number) =>
Object.fromEntries(Array.from({ length: n }, (_, i) => [`a${i}`, 1]))
expect(MAX_MAP_ROWS + 1).toBeLessThan(MAX_VALUE_NODES)
expect(rejected(flowJob({ args: rows(MAX_MAP_ROWS + 1) }))).toBe(true)
// `args` is only conventionally a map: an array gets a row per entry too.
expect(rejected(flowJob({ args: Array.from({ length: MAX_MAP_ROWS + 1 }, () => 0) }))).toBe(
true
)
expect(kindOf(flowJob({ args: rows(5) }))).toBe('flow')
// An object *key* is rendered text too (JobArgs prints it), and charging only
// values would let a huge key through.
expect(rejected(flowJob({ ['k'.repeat(MAX_VALUE_STRING_CHARS + 1)]: 1 }))).toBe(true)
// A serialized fan-out larger than validation is willing to decode is refused
// rather than measured — decoding it to size it would be the DoS.
expect(
rejected(
flowJob({
data_tests: JSON.stringify([
{ test: 't', violating: 1, pad: 'x'.repeat(MAX_SERIALIZED_FANOUT_CHARS) }
])
})
)
).toBe(true)
})
it('budgets the whole flow object, not just its value', () => {
// `schema` renders through SchemaViewer (Input Schema tab, Input node) exactly
// as `value` renders through the graph, so the budget belongs one level up.
const properties = Object.fromEntries(
Array.from({ length: MAX_VALUE_NODES }, (_, i) => [`p${i}`, { type: 'string' }])
)
const res = parseRecording({
version: 1,
flow_path: 'f',
...header,
jobs: { j: job() },
flow: { schema: { properties }, value: { modules: [] } }
})
expect(res.ok).toBe(false)
// A module's inline `content` is the flow kind's equivalent of a script's
// `code`, and text is not structure.
expect(
rejected({
version: 1,
flow_path: 'f',
...header,
jobs: { j: job() },
flow: {
value: {
modules: [
{
id: 'a',
value: { type: 'rawscript', content: 'x'.repeat(MAX_VALUE_STRING_CHARS + 1) }
}
]
}
}
})
).toBe(true)
})
it('bounds graph contents, rendered metadata strings and scheduled timers', () => {
// The canvas emits a node and edge per nested graph entry, so the four
// top-level array lengths were never the bound.
expect(
rejected(
pipeline({
graph: {
runnables: [
{
path: 'f/a/b',
usage_kind: 'script',
data_tests: Array.from({ length: MAX_VALUE_NODES }, (_, i) => ({ test: `t${i}` }))
}
],
assets: [],
edges: [],
triggers: []
}
})
)
).toBe(true)
// Metadata strings land in a header, a highlighter class or an error panel, and
// were only `typeof === 'string'`.
const long = 'x'.repeat(5000)
expect(
rejected({
version: 1,
type: 'script',
script_path: 's',
...header,
code: 'x',
language: long,
job: job()
})
).toBe(true)
expect(rejected(pipeline({ codes: { 'f/a/b': { content: 'x', language: long } } }))).toBe(true)
expect(
rejected(
pipeline({
assetSamples: { 'ducklake:main/t': { kind: 'ducklake', path: 'main/t', error: long } }
})
)
).toBe(true)
// A pipeline's timeline frames all become timers in a single pass too.
expect(
rejected(
pipeline({
timeline: Array.from({ length: MAX_TIMELINE_FRAMES + 1 }, () => ({ t: 0, statuses: {} }))
})
)
).toBe(true)
// One job's events all become timers in a single pass.
expect(
rejected({
version: 1,
flow_path: 'f',
...header,
jobs: {
j: {
initial_job: { id: 'j' },
events: Array.from({ length: MAX_EVENTS_PER_JOB + 1 }, () => ({
t: 0,
data: { progress: 1 }
}))
}
}
})
).toBe(true)
})
it('refuses a huge recording before it looks at what any field means', () => {
// The backstop needs no key name, so a field no validator mentions is still
// bounded.
const filler = Array.from({ length: 5000 }, () => Array.from({ length: 500 }, () => 0))
const res = parseRecording({
version: 1,
flow_path: 'f',
...header,
jobs: { j: job() },
some_future_field: filler
})
expect(res.ok).toBe(false)
expect(res.ok ? '' : res.error).toMatch(new RegExp(`more than ${MAX_RECORDING_NODES} values`))
// Structure hidden behind wrappers deeper than the ceiling must be refused, not
// silently uncounted — a backstop that gives up is not a backstop.
let buried: unknown = filler
for (let i = 0; i < MAX_VALUE_DEPTH + 2; i++) buried = { nest: buried }
expect(rejected({ version: 1, flow_path: 'f', ...header, jobs: { j: job() }, buried })).toBe(
true
)
})
it('bounds an errored asset sample, which still renders its own fields', () => {
// The error branch shows the message instead of the table, but `uri` and
// `rowCount` render either way.
expect(
rejected(
pipeline({
assetSamples: {
'ducklake:main/t': {
kind: 'ducklake',
path: 'main/t',
error: 'table missing',
uri: 'x'.repeat(MAX_VALUE_STRING_CHARS + 1)
}
}
})
)
).toBe(true)
expect(
kindOf(
pipeline({
assetSamples: {
'ducklake:main/t': {
kind: 'ducklake',
path: 'main/t',
uri: 'ducklake://main/t',
error: 'table missing'
}
}
})
)
).toBe('pipeline')
})
it('tells an oversized recording apart from a corrupt one', () => {
// A wide for-loop flow records a job per iteration, so a real capture can trip
// this — it must not read as "your recorder wrote a broken file".
const tooManyJobs = Object.fromEntries(
Array.from({ length: MAX_RECORDED_JOBS + 1 }, (_, i) => [`j${i}`, job(0)])
)
const res = parseRecording({ version: 1, flow_path: 'f', ...header, jobs: tooManyJobs })
expect(res.ok ? '' : res.error).toMatch(/holds \d+ jobs/)
const corrupt = parseRecording({ version: 1, flow_path: 'f', ...header, jobs: 'nope' })
expect(corrupt.ok ? '' : corrupt.error).toBe('Invalid flow recording format.')
// The render budget is the cap a real capture is most likely to trip, so it
// names the value and what about it was too big.
const fat = parseRecording({
version: 1,
flow_path: 'f',
...header,
jobs: {
j: {
initial_job: { id: 'j' },
events: [
{
t: 0,
data: {
completed: true,
job: {
id: 'j',
result: { render_all: Array.from({ length: MAX_COMPONENT_FANOUT + 1 }, () => 0) }
}
}
}
]
}
}
})
expect(fat.ok ? '' : fat.error).toMatch(/a recorded job carries more than \d+ `render_all`/)
})
it('bounds an asset sample on its rendered cells, not just per axis', () => {
const sample = (rows: number, columns: number) => ({
assetSamples: {
'ducklake:main/t': {
kind: 'ducklake',
path: 'main/t',
uri: 'ducklake://main/t',
rows: Array.from({ length: rows }, () => ({})),
columns: Array.from({ length: columns }, (_, i) => ({ field: `c${i}` }))
}
}
})
// Each axis is individually under its cap, and rows of empty objects carry no
// structure to count, so only the cell-product cap rejects this — which is why
// the structural budget does not replace it.
const overBudget = MAX_SAMPLE_CELLS / MAX_SAMPLE_COLUMNS + 1
expect(rejected(pipeline(sample(overBudget, MAX_SAMPLE_COLUMNS)))).toBe(true)
expect(kindOf(pipeline(sample(10, 20)))).toBe('pipeline')
})
})
@@ -0,0 +1,584 @@
/**
* Validation for a recording arriving from outside an uploaded file or a
* `?src=` URL. Both replay pages go through this: a recording is caller-supplied
* data that a player indexes into and renders per step, so its shape, its
* cardinality and its sizes all have to hold before anything mounts.
*
* Covers every kind of recording, not only raw-app ones: the file name is fixed by
* `package.json`'s `./components/recording/rawAppRecordingLoad` export, which the hub
* imports, so renaming it means changing the hub in the same breath.
*/
import type {
FlowRecording,
PipelineRecording,
RawAppRecording,
RecordedJob,
ScriptRecording
} from './types'
import {
MAX_RECORDED_STEPS,
MAX_STEP_TEXT_CHARS,
MAX_TOTAL_FRAME_CHARS,
RAW_APP_INTERACTION_KINDS,
type RawAppInteractionKind
} from './rawAppSnapshot'
/** Upper bound on a fetched recording (JSON of capped frames) so an arbitrary
* origin can't exhaust the tab before validation runs. */
export const MAX_RECORDING_BYTES = 100 * 1024 * 1024
/** Total structure in the whole recording, applied before any per-kind validator.
* The per-value budgets below each cover only a field they name, so a field added
* later is unbounded until someone names it; this one needs no field, which is what
* makes the set closed. Far above any real capture, far below what a tab survives. */
export const MAX_RECORDING_NODES = 2_000_000
/* Caps on the job-stream recordings (flow/script/pipeline). Each recorded job
* mounts a JobLoader and each of its events costs a `setTimeout` created up front,
* so the counts not just the byte size decide whether the tab survives. */
export const MAX_RECORDED_JOBS = 2000
/** `JobLoader.watchJob` schedules every event of a job in one pass, so this is a
* count of timers created at once; events at `t: 0` all fire in the same frame, each
* one a reactive update. Generous against reality (a long streaming job records on
* the order of thousands) and survivable when they all land together. */
export const MAX_EVENTS_PER_JOB = 5000
export const MAX_RECORDED_JOB_EVENTS = 20_000
/** The backstop: structure one recorded value may expand into, where a value is
* what a component renders eagerly. Deliberately generous it is not the precise
* bound but the one that catches keys nobody has named; anything mounting a
* component per entry costs far more and gets {@link MAX_COMPONENT_FANOUT} on top. */
export const MAX_VALUE_NODES = 100_000
/** Total characters of text in one value. Strings are one node however long they
* are, so this is the part of "how big is this value" the node count structurally
* cannot see: a flow module's inline `content`/`lock` is syntax-highlighted in one
* pass, exactly like the `code` that {@link MAX_CODE_CHARS} already covers. */
export const MAX_VALUE_STRING_CHARS = 8 * 1024 * 1024
/** Depth is its own hazard: a renderer recursing over a deeply nested value blows
* the stack long before the node count matters. Well above real data (recursive
* results nest tens deep) and well below what overflows a JS stack. */
export const MAX_VALUE_DEPTH = 256
/** Entries whose renderer mounts a *component* each rather than a cell or a row
* `render_all`, `data_tests`, and a flow's graph overlays. Two orders of magnitude
* below the node budget, because that is roughly the cost ratio. Counted through the
* serialized form too: a JSON string is one node whatever it decodes to. */
export const MAX_COMPONENT_FANOUT = 1000
/** How much serialized text a fan-out collection may be before validation stops
* measuring it and just refuses. A real checklist is a few KB; anything near the text
* budget would cost hundreds of MB to decode, and decoding it to find out how big it
* is would be the denial of service. */
export const MAX_SERIALIZED_FANOUT_CHARS = 256 * 1024
/** Entries in one flat map a renderer turns into a row each. This and
* {@link MAX_COMPONENT_FANOUT} are lists of keys, so they are inherently incomplete:
* cost depends on the renderer, not the shape. Add the key of any new renderer that
* iterates a recorded collection {@link MAX_RECORDING_NODES} alone is too coarse. */
export const MAX_MAP_ROWS = 2000
/** `PipelineRecordingReplay.startReplay` schedules every frame in one pass, so this
* counts timers created at once frames at `t: 0` all land in the same tick, and
* each reassigns the whole per-node status map and rebuilds the derived id/state maps
* over its entire key set. Same bound as {@link MAX_EVENTS_PER_JOB}, same reason. */
export const MAX_TIMELINE_FRAMES = 5000
export const MAX_FRAME_STATUSES = 5000
/** Graph elements each become a rendered canvas node or edge. */
export const MAX_GRAPH_ELEMENTS = 2000
/* An asset sample renders as a `rows × columns` table of plain `<td>`s, so the
* product is what costs, and the per-axis caps alone would allow millions of
* cells from a tiny payload of empty row objects. */
export const MAX_SAMPLE_ROWS = 5000
export const MAX_SAMPLE_COLUMNS = 500
/** Not subsumed by {@link MAX_VALUE_NODES}: the table is the cross product of two
* independent arrays, so rows of *empty* objects carry no structure to count yet
* still render a cell each. Structure and cross products are different bounds. */
export const MAX_SAMPLE_CELLS = 100_000
/** Captured source is syntax-highlighted in one pass. */
export const MAX_CODE_CHARS = 4 * 1024 * 1024
/** `FlowGraphV2` builds and lays out a node per module, recursing into branches and
* loops, plus one per note or group. Kept alongside the render budget because it
* gives the flow-specific count a name; the budget is what makes it exhaustive. */
export const MAX_FLOW_MODULES = 5000
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
const isShortText = (v: unknown, required = false) =>
required
? typeof v === 'string' && v.length <= MAX_STEP_TEXT_CHARS
: v === undefined || (typeof v === 'string' && v.length <= MAX_STEP_TEXT_CHARS)
const isSize = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 20000
const isBoundedCode = (v: unknown) => typeof v === 'string' && v.length <= MAX_CODE_CHARS
const isBoundedArray = (v: unknown, max: number): v is unknown[] =>
Array.isArray(v) && v.length <= max
const isObjectArray = (v: unknown, max: number) => isBoundedArray(v, max) && v.every(isObject)
/** True when `data` is a well-formed app recording this build can replay. */
export function isAppRecording(data: unknown): data is RawAppRecording {
if (!isObject(data) || data.version !== 1 || data.type !== 'app') return false
// Every frame is parsed and re-serialized before the iframe parses it again,
// so a single huge frame would freeze the tab even under the download cap.
const validFrames =
Array.isArray(data.frames) &&
data.frames.length <= 2 * MAX_RECORDED_STEPS + 1 &&
data.frames.every((f) => typeof f === 'string') &&
(data.frames as string[]).reduce((sum, f) => sum + f.length, 0) <= MAX_TOTAL_FRAME_CHARS
if (!validFrames) return false
const frameCount = (data.frames as string[]).length
// An index must address a frame that exists; `undefined` stays legitimate for
// a capture the recorder had to skip.
const isIndex = (v: unknown) =>
v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < frameCount)
const validSteps =
Array.isArray(data.steps) &&
data.steps.length <= MAX_RECORDED_STEPS &&
data.steps.every(
(s: unknown) =>
isObject(s) &&
// Finite, not merely numeric: the timeline positions each checkpoint at
// `t / total_duration_ms`, and a NaN there places nothing.
Number.isFinite(s.t) &&
RAW_APP_INTERACTION_KINDS.includes(s.kind as RawAppInteractionKind) &&
isShortText(s.label, true) &&
isShortText(s.target) &&
isShortText(s.selector) &&
isShortText(s.value) &&
isIndex(s.before) &&
isIndex(s.after)
)
// The viewport lands in the snapshot iframe's `style`, and the duration is
// divided into every step timestamp by the timeline.
const validViewport =
isObject(data.viewport) && isSize(data.viewport.width) && isSize(data.viewport.height)
const validDuration =
typeof data.total_duration_ms === 'number' &&
Number.isFinite(data.total_duration_ms) &&
data.total_duration_ms >= 0
const validHeader =
isShortText(data.app_path) && isShortText(data.workspace) && isShortText(data.recorded_at, true)
return validSteps && validViewport && validDuration && validHeader
}
/** Keys whose renderer mounts a component per entry. See {@link MAX_COMPONENT_FANOUT}. */
const COMPONENT_FANOUT_KEYS = ['render_all', 'data_tests']
/** Keys holding a flat map rendered as one row per entry: `args` (`JobArgs` sorts the
* keys and mounts a row each) and a schema's `properties` (`SchemaForm`/`SchemaViewer`
* a field each). Capped per collection rather than cumulatively: a hundred small
* schemas nested in a flow are fine, one map of 90k keys is not. */
const MAP_ROW_KEYS = ['args', 'properties']
/** Entry count of a fan-out collection, decoding the serialized form renderers
* accept a JSON string is one node however many components it expands into. Only
* decodes what could be a legitimate checklist: parsing megabytes here would commit
* the very allocation this prevents, so anything larger is refused unmeasured. */
function fanoutLength(v: unknown): number {
if (Array.isArray(v)) return v.length
if (typeof v === 'string') {
if (v.length > MAX_SERIALIZED_FANOUT_CHARS) return Infinity
try {
const decoded = JSON.parse(v)
return Array.isArray(decoded) ? decoded.length : 0
} catch {
return 0
}
}
return 0
}
/** Entries a renderer would turn into rows, for {@link MAX_MAP_ROWS}. Counts arrays
* as well as objects: `args` is only *conventionally* a map, and an array of 90k
* primitives costs one node each and gets a row each just the same. */
const rowCount = (v: unknown) =>
Array.isArray(v) ? v.length : isObject(v) ? Object.keys(v).length : 0
/** Why one recorded value is too big to render, or `undefined`. Three bounds
* because none subsumes the others: structure misses a 60MB string, text misses a
* collection that mounts a component per entry, and fan-out misses everything
* unnamed. Bails on the first blown bound so the walk is never itself the attack. */
function describeValueOverflow(
v: unknown,
budget = { nodes: MAX_VALUE_NODES, chars: MAX_VALUE_STRING_CHARS, fanout: MAX_COMPONENT_FANOUT },
depth = 0
): string | undefined {
if (depth > MAX_VALUE_DEPTH) return `nested more than ${MAX_VALUE_DEPTH} levels deep`
if (typeof v === 'string') {
budget.chars -= v.length
return budget.chars < 0 ? `more than ${MAX_VALUE_STRING_CHARS} characters of text` : undefined
}
if (Array.isArray(v)) {
budget.nodes -= v.length
if (budget.nodes < 0) return `more than ${MAX_VALUE_NODES} values to render`
for (const item of v) {
const over = describeValueOverflow(item, budget, depth + 1)
if (over) return over
}
return undefined
}
if (isObject(v)) {
const keys = Object.keys(v)
budget.nodes -= keys.length
if (budget.nodes < 0) return `more than ${MAX_VALUE_NODES} values to render`
for (const k of keys) {
// A key is rendered text as much as a value is — `JobArgs` prints it — so it
// is charged against the same budget.
budget.chars -= k.length
if (budget.chars < 0) return `more than ${MAX_VALUE_STRING_CHARS} characters of text`
// Cumulative across the value, not per array: `render_all` nests, so 300
// arrays of 300 are 90k components with no single array over the cap.
if (COMPONENT_FANOUT_KEYS.includes(k)) {
budget.fanout -= fanoutLength(v[k])
if (budget.fanout < 0) {
return `more than ${MAX_COMPONENT_FANOUT} \`${k}\`-style entries, each of which mounts its own component`
}
}
if (MAP_ROW_KEYS.includes(k) && rowCount(v[k]) > MAX_MAP_ROWS) {
return `a \`${k}\` of more than ${MAX_MAP_ROWS} entries, each of which renders a row`
}
const over = describeValueOverflow(v[k], budget, depth + 1)
if (over) return over
}
return undefined
}
return undefined
}
/** Structure in the whole recording, for the {@link MAX_RECORDING_NODES} backstop.
* Separate from {@link describeValueOverflow} because it deliberately knows nothing
* about keys or renderers it just refuses to let an arbitrary payload be huge. */
function countRecordingNodes(v: unknown, budget = { n: MAX_RECORDING_NODES + 1 }): number {
let count = 0
// Past the ceiling the answer is `Infinity`, not the partial count: giving up on
// the walk and reporting what was seen so far would let any amount of structure
// hide behind a few hundred wrappers, which is the opposite of a backstop.
let tooDeep = false
const walk = (x: unknown, depth: number) => {
if (tooDeep || budget.n <= 0) return
if (depth > MAX_VALUE_DEPTH) {
tooDeep = true
return
}
if (Array.isArray(x)) {
count += x.length
budget.n -= x.length
for (const i of x) walk(i, depth + 1)
} else if (isObject(x)) {
const keys = Object.keys(x)
count += keys.length
budget.n -= keys.length
for (const k of keys) walk(x[k], depth + 1)
}
}
walk(v, 0)
return tooDeep ? Infinity : count
}
/** True when one recorded value is renderable. Apply this to each value a component
* expands eagerly (a job's args/result/flow_status, a flow definition, an asset
* sample); the *number* of such values is bounded separately. */
const withinRenderBudget = (v: unknown) => describeValueOverflow(v) === undefined
/** JobLoader replays each `event.data` in a `setTimeout`, where a throw escapes
* every Svelte boundary, so a malformed event must be refused at load. Each job
* state is also held to the render budget, which covers everything hanging off it
* at any depth. */
function isRecordedJob(j: unknown): j is RecordedJob {
return (
isObject(j) &&
isObject(j.initial_job) &&
withinRenderBudget(j.initial_job) &&
isBoundedArray(j.events, MAX_EVENTS_PER_JOB) &&
j.events.every((e) => isObject(e) && isObject(e.data) && withinRenderBudget(e.data))
)
}
/** The `jobs` map every job-stream recording carries, bounded on both the number
* of streams and the total number of events across them. */
function isJobsMap(v: unknown): v is Record<string, RecordedJob> {
if (!isObject(v)) return false
const jobs = Object.values(v)
if (jobs.length > MAX_RECORDED_JOBS) return false
let events = 0
for (const j of jobs) {
if (!isRecordedJob(j)) return false
events += j.events.length
if (events > MAX_RECORDED_JOB_EVENTS) return false
}
return true
}
/** The header every recording renders: a title, a `recorded_at` each player parses
* as a date, and a duration it divides by. Required by the types, so a payload
* missing them shows `Invalid Date` and `NaN` instead. */
function hasValidHeader(data: Record<string, unknown>, pathField: string): boolean {
return (
isShortText(data[pathField], true) &&
isShortText(data.recorded_at, true) &&
typeof data.total_duration_ms === 'number' &&
Number.isFinite(data.total_duration_ms) &&
data.total_duration_ms >= 0
)
}
/** Total modules across a flow definition's nested structure (branches, loops),
* stopping as soon as the budget is blown so a hostile tree can't make the walk
* itself the denial of service. */
function countFlowModules(modules: unknown, budget: number): number {
if (!Array.isArray(modules)) return 0
let n = 0
for (const m of modules) {
if (++n > budget) return n
if (!isObject(m) || !isObject(m.value)) continue
for (const key of ['modules', 'default'] as const) {
n += countFlowModules((m.value as Record<string, unknown>)[key], budget - n)
if (n > budget) return n
}
const branches = (m.value as Record<string, unknown>).branches
if (Array.isArray(branches)) {
for (const b of branches) {
// The branch itself is a node and an edge even when it holds no modules,
// so counting only its contents would let empty branches ride free.
if (++n > budget) return n
if (!isObject(b)) continue
n += countFlowModules(b.modules, budget - n)
if (n > budget) return n
}
}
}
return n
}
/** True when `data` is a well-formed script recording. */
export function isScriptRecording(data: unknown): data is ScriptRecording {
if (!isObject(data) || data.version !== 1 || data.type !== 'script') return false
// `code` is highlighted in one pass and `language` selects the grammar.
return (
hasValidHeader(data, 'script_path') &&
isRecordedJob(data.job) &&
isBoundedCode(data.code) &&
// Selects a highlighter grammar and is rendered in the player's header.
isShortText(data.language, true) &&
// These arrive as the root of the walk, where there is no enclosing key for
// MAP_ROW_KEYS to match, so their own row counts are checked here.
rowCount(data.args) <= MAX_MAP_ROWS &&
rowCount((data.schema as Record<string, unknown> | undefined)?.properties) <= MAX_MAP_ROWS &&
withinRenderBudget(data.schema) &&
withinRenderBudget(data.args)
)
}
/** True when `data` is a well-formed pipeline recording. */
export function isPipelineRecording(data: unknown): data is PipelineRecording {
if (!isObject(data) || data.version !== 1 || data.type !== 'pipeline') return false
const g = data.graph
const validGraph =
isObject(g) &&
// The canvas emits a node and an edge per nested entry too (a runnable's custom
// `data_tests`, its column lineage), so the whole graph goes through the budget
// rather than just the lengths of the four top-level arrays.
withinRenderBudget(g) &&
isObjectArray(g.runnables, MAX_GRAPH_ELEMENTS) &&
isObjectArray(g.assets, MAX_GRAPH_ELEMENTS) &&
isObjectArray(g.edges, MAX_GRAPH_ELEMENTS) &&
isBoundedArray(g.triggers, MAX_GRAPH_ELEMENTS) &&
g.triggers.every((t) => isObject(t) && typeof t.trigger_kind === 'string') &&
(g.macro_edges === undefined || isObjectArray(g.macro_edges, MAX_GRAPH_ELEMENTS)) &&
(g.test_edges === undefined || isObjectArray(g.test_edges, MAX_GRAPH_ELEMENTS))
const validTimeline =
isBoundedArray(data.timeline, MAX_TIMELINE_FRAMES) &&
data.timeline.every(
(f) =>
isObject(f) &&
isObject(f.statuses) &&
Object.keys(f.statuses).length <= MAX_FRAME_STATUSES &&
withinRenderBudget(f.statuses) &&
Object.values(f.statuses).every(isObject)
)
// A sample renders `rows`/`columns` unless it carries a non-empty `error`.
const validSamples =
data.assetSamples === undefined ||
(isObject(data.assetSamples) &&
Object.values(data.assetSamples).every(
(s) =>
isObject(s) &&
// Both branches render the sample's own fields (`uri`, `rowCount`), so the
// budget applies either way; only the table is extra.
withinRenderBudget(s) &&
((isShortText(s.error, true) && s.error !== '') ||
(isObjectArray(s.rows, MAX_SAMPLE_ROWS) &&
isObjectArray(s.columns, MAX_SAMPLE_COLUMNS) &&
(s.rows as unknown[]).length * (s.columns as unknown[]).length <= MAX_SAMPLE_CELLS))
))
const validCodes =
data.codes === undefined ||
(isObject(data.codes) &&
Object.values(data.codes).every(
(c) => isObject(c) && isBoundedCode(c.content) && isShortText(c.language, true)
))
return (
hasValidHeader(data, 'folder') &&
validGraph &&
validTimeline &&
isJobsMap(data.jobs) &&
validSamples &&
validCodes
)
}
/** True when `data` is a well-formed flow recording. `type` is absent on
* recordings taken before the discriminator existed. */
export function isFlowRecording(data: unknown): data is FlowRecording {
if (!isObject(data) || data.version !== 1) return false
if (data.type !== undefined && data.type !== 'flow') return false
if (!hasValidHeader(data, 'flow_path') || !isJobsMap(data.jobs)) return false
if (data.flow === undefined) return true
// The player hands the whole `flow` to FlowViewer, so `schema` renders (Input
// Schema tab, Input node) just like `value` does — budget one level up.
if (!isObject(data.flow) || !withinRenderBudget(data.flow)) return false
const value = data.flow.value
if (value === undefined) return true
if (!isObject(value)) return false
// The graph mounts a node per note and per group alongside the modules, so they
// fan out like `render_all` does — the structural budget alone would admit tens
// of thousands of minimal entries and lock the tab before Play.
if (fanoutLength(value.notes) + fanoutLength(value.groups) > MAX_COMPONENT_FANOUT) return false
return countFlowModules(value.modules, MAX_FLOW_MODULES) <= MAX_FLOW_MODULES
}
/** A recording that passed validation, tagged with the player it needs. */
export type LoadedRecording =
| { kind: 'app'; recording: RawAppRecording }
| { kind: 'script'; recording: ScriptRecording }
| { kind: 'pipeline'; recording: PipelineRecording }
| { kind: 'flow'; recording: FlowRecording }
/** The cap a well-formed but oversized recording tripped, if any. A genuine
* capture can hit these (a wide for-loop flow records a job per iteration), so it
* must not be reported with the same message as a corrupt file. */
function describeOverflow(data: Record<string, unknown>): string | undefined {
const jobs = isObject(data.jobs) ? Object.values(data.jobs) : []
if (jobs.length > MAX_RECORDED_JOBS) {
return `This recording holds ${jobs.length} jobs, more than the ${MAX_RECORDED_JOBS} this player can replay.`
}
const events = jobs.reduce(
(sum: number, j) => sum + (isObject(j) && Array.isArray(j.events) ? j.events.length : 0),
0
)
if (events > MAX_RECORDED_JOB_EVENTS) {
return `This recording holds ${events} job events, more than the ${MAX_RECORDED_JOB_EVENTS} this player can replay.`
}
if (Array.isArray(data.timeline) && data.timeline.length > MAX_TIMELINE_FRAMES) {
return `This recording holds ${data.timeline.length} timeline frames, more than the ${MAX_TIMELINE_FRAMES} this player can animate.`
}
// The render budget is the cap a legitimate capture is most likely to trip (the
// recorders stringify job results verbatim), so name the value that blew it and
// what about it was too big instead of reporting a format error.
const samples = isObject(data.assetSamples) ? Object.values(data.assetSamples) : []
for (const [label, value] of [
['a recorded job', jobs.find((j) => !withinRenderBudget(j))],
['this flow definition', withinRenderBudget(data.flow) ? undefined : data.flow],
["this script's inputs", withinRenderBudget(data.schema) ? undefined : data.schema],
['a recorded asset sample', samples.find((s) => !withinRenderBudget(s))]
] as const) {
const over = value === undefined ? undefined : describeValueOverflow(value)
if (over) return `Cannot replay: ${label} carries ${over}.`
}
// Checked at the root of the walk by `isScriptRecording`, where there is no
// enclosing key for the walk itself to report on.
for (const [label, size] of [
["this script's arguments", rowCount(data.args)],
[
"this script's schema",
rowCount((data.schema as Record<string, unknown> | undefined)?.properties)
]
] as const) {
if (size > MAX_MAP_ROWS) {
return `Cannot replay: ${label} holds ${size} entries, more than the ${MAX_MAP_ROWS} this player renders.`
}
}
return undefined
}
/** Classify a parsed recording and validate it against the player that would
* mount it. The `type` discriminator picks the validator, so a malformed payload
* reports the kind it claimed to be instead of falling through to `flow`. */
export function parseRecording(
data: unknown
): { ok: true; loaded: LoadedRecording } | { ok: false; error: string } {
if (!isObject(data) || data.version !== 1) {
return { ok: false, error: 'This file is not a Windmill recording.' }
}
// Before anything looks at what the fields mean: no recording, whatever it holds,
// may carry more structure than a tab can render. This is what makes the bound
// exhaustive rather than a list of the fields someone remembered.
if (countRecordingNodes(data) > MAX_RECORDING_NODES) {
return {
ok: false,
error: `This recording carries more than ${MAX_RECORDING_NODES} values, more than this player can render.`
}
}
const type = data.type === undefined ? 'flow' : data.type
const invalid = (kind: string) => ({
ok: false as const,
error: describeOverflow(data) ?? `Invalid ${kind} recording format.`
})
switch (type) {
case 'app':
return isAppRecording(data)
? { ok: true, loaded: { kind: 'app', recording: data } }
: invalid('app')
case 'script':
return isScriptRecording(data)
? { ok: true, loaded: { kind: 'script', recording: data } }
: invalid('script')
case 'pipeline':
return isPipelineRecording(data)
? { ok: true, loaded: { kind: 'pipeline', recording: data } }
: invalid('pipeline')
case 'flow':
return isFlowRecording(data)
? { ok: true, loaded: { kind: 'flow', recording: data } }
: invalid('flow')
default: {
// `type` is caller-controlled and only structurally bounded: a payload can
// carry megabytes in it and reach the page as text. Name it only when it is
// short enough to be a kind rather than a payload.
const named = typeof type === 'string' && type.length <= 32 ? ` (${type})` : ''
return {
ok: false,
error: `This recording is of an unknown kind${named} — it may need a newer Windmill.`
}
}
}
}
/** Fetch a recording from `url`, enforcing the download cap while streaming. */
export async function fetchRecording(
url: string,
onProgress?: (loaded: number, total: number) => void
): Promise<unknown> {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
const total = Number(res.headers.get('content-length')) || 0
if (total > MAX_RECORDING_BYTES) throw new Error(`Recording is too large (${total} bytes)`)
const reader = res.body?.getReader()
if (!reader) {
const text = await res.text()
if (text.length > MAX_RECORDING_BYTES) throw new Error('Recording exceeded the size limit')
return JSON.parse(text)
}
const chunks: Uint8Array[] = []
let loaded = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
chunks.push(value)
loaded += value.length
if (loaded > MAX_RECORDING_BYTES) {
await reader.cancel()
throw new Error('Recording exceeded the size limit')
}
onProgress?.(loaded, total)
}
return JSON.parse(await new Blob(chunks as BlobPart[]).text())
}
@@ -0,0 +1,334 @@
/**
* The two contracts of this module that are worth pinning: a snapshot never
* carries what the app marked no-record, and a replayed frame can neither run
* nor fetch nor navigate. Both are non-obvious against the DOM (a template's
* content is serialized but not queryable, attributes match on local name, the
* policy has to be structurally first) and both are where a later simplification
* would silently reopen something.
*/
import { describe, expect, it } from 'vitest'
import { serializeDocument, withHighlightStyles } from './rawAppSnapshot'
function docFrom(body: string): Document {
return new DOMParser().parseFromString(
`<html><head></head><body>${body}</body></html>`,
'text/html'
)
}
describe('serializeDocument redaction', () => {
it('drops the content, attributes and template fragment of a no-record element', () => {
const doc = docFrom(
`<div data-wm-no-record="why-hidden-92000" title="salary 92000" data-token="sk-secret" aria-label="confidential">visible secret</div>` +
`<select><option data-wm-no-record label="codename falcon">falcon</option></select>` +
`<template data-wm-no-record><input value="template secret"></template>`
)
const svgLink = doc.createElementNS('http://www.w3.org/2000/svg', 'image')
svgLink.setAttribute('data-wm-no-record', '')
svgLink.setAttributeNS(
'http://www.w3.org/1999/xlink',
'xlink:href',
'https://host/signed-secret'
)
doc.body.appendChild(svgLink)
const html = serializeDocument(doc)
for (const secret of [
'visible secret',
'salary 92000',
'sk-secret',
'confidential',
'codename falcon',
'falcon',
'template secret',
'signed-secret',
// The marker itself is author-written free text; it survives so the element
// can still be styled, but carries nothing of its own.
'why-hidden-92000'
]) {
expect(html).not.toContain(secret)
}
expect(html).toContain('data-wm-no-record=""')
})
it('redacts a marked document root down to its constrained attributes', () => {
// Marking the root marks every stylesheet with it, so no CSS survives to
// justify keeping a class: only attributes whose values cannot carry content
// are left.
const doc = docFrom(
`<style>.theme-dark { color: white }</style><p>everything here is private</p>`
)
doc.documentElement.setAttribute('data-wm-no-record', '')
doc.documentElement.setAttribute('class', 'theme-dark')
doc.documentElement.setAttribute('hidden', '')
doc.documentElement.setAttribute('cite', 'https://host/private-source')
const html = serializeDocument(doc)
expect(html).not.toContain('everything here is private')
expect(html).not.toContain('private-source')
expect(html).not.toContain('theme-dark')
expect(html).toContain('hidden')
})
it('keeps no attribute that could carry content, listed or not', () => {
const doc = docFrom(
`<style>.frame { border: 1px solid }</style>` +
`<iframe data-wm-no-record srcdoc="<p>embedded secret</p>" data-anything="future secret"` +
` cite="/cited" style="--code: salary-92000" class="frame"></iframe>`
)
const html = serializeDocument(doc)
expect(html).not.toContain('embedded secret')
expect(html).not.toContain('future secret')
expect(html).not.toContain('/cited')
expect(html).not.toContain('salary-92000')
// The layout-bearing class survives: the snapshot's own CSS selects on it.
expect(html).toContain('class="frame"')
})
it('does not let a marked stylesheet justify keeping the class it selects', () => {
// The marked sheet is scrubbed, so its selectors are not part of the
// snapshot's vocabulary: honouring them would launder the very token the
// author marked the sheet to withhold.
const doc = docFrom(
`<style data-wm-no-record>.salary-92000 { color: red }</style>` +
`<div data-wm-no-record class="salary-92000">x</div>`
)
const html = serializeDocument(doc)
expect(html).not.toContain('salary-92000')
})
it('keeps only the class and id tokens the snapshot styles', () => {
// `class` and `id` stay on a redacted element so its box keeps its shape,
// but the values are the app's to choose and can name what the marker hides.
const doc = docFrom(
`<style>.card { padding: 4px }</style>` +
`<div data-wm-no-record class="card customer-acme-secret" id="salary-92000">x</div>`
)
const html = serializeDocument(doc)
expect(html).toContain('class="card"')
expect(html).not.toContain('customer-acme-secret')
expect(html).not.toContain('salary-92000')
})
it('withholds even the state of a redacted control', () => {
// Whether a marked box is ticked is exactly what the marker exists to hide;
// the step's value is masked to match, so label and snapshot agree.
const doc = docFrom(`<input type="checkbox" data-wm-no-record aria-label="acquisition target">`)
const box = doc.querySelector('input') as HTMLInputElement
box.checked = true
const html = serializeDocument(doc)
expect(html).not.toContain('checked')
expect(html).not.toContain('acquisition target')
})
it('does not launder a marked stylesheet into the snapshot by inlining it', () => {
// The inliner exists for sheets whose rules live only in the CSSOM (an empty
// `<style>` filled with `insertRule`, or a `<link>`); it must skip a marked
// owner, or the replacement node would carry the CSS past redaction.
// The live document, not a parsed one: only a document with a browsing
// context exposes `style.sheet`, which is what the inliner reads.
const style = document.createElement('style')
style.setAttribute('data-wm-no-record', '')
document.head.appendChild(style)
try {
style.sheet?.insertRule(`.a { content: "sentinel-92000"; }`, 0)
expect(style.sheet?.cssRules.length).toBe(1)
expect(style.textContent).toBe('')
expect(serializeDocument(document)).not.toContain('sentinel-92000')
} finally {
style.remove()
}
})
it('snapshots a stylesheet as it renders, not as it was authored', () => {
// `insertRule` and friends never touch the element's text, so a <style> that
// shipped with CSS and was then mutated at runtime would replay stale.
const style = document.createElement('style')
style.textContent = `.authored { color: red; }`
document.head.appendChild(style)
try {
style.sheet?.insertRule(`.added-at-runtime { color: blue; }`, 0)
const html = serializeDocument(document)
expect(html).toContain('added-at-runtime')
expect(html).toContain('authored')
} finally {
style.remove()
}
})
it('keeps a utility class whose selector starts with an escape', () => {
// `2xl:block` compiles to `.\\32 xl\\:block` and `!flex` to `.\\!flex`; demanding an
// ASCII first character drops both from the placeholder.
const doc = docFrom(
`<style>.\\32 xl\\:block { display: block } .\\!flex { display: flex }</style>` +
`<div data-wm-no-record class="2xl:block !flex">x</div>`
)
const html = serializeDocument(doc)
expect(html).toContain('class="2xl:block !flex"')
})
it('keeps a utility class whose selector is escaped', () => {
// A framework writes `md:flex` as `.md\\:flex`; reading the selector up to the
// backslash would drop the real token and leave the placeholder unstyled.
const doc = docFrom(
`<style>.md\\:flex { display: flex } .w-1\\/2 { width: 50% }</style>` +
`<div data-wm-no-record class="md:flex w-1/2 not-styled-92000">x</div>`
)
const html = serializeDocument(doc)
expect(html).toContain('class="md:flex w-1/2"')
expect(html).not.toContain('not-styled-92000')
})
it('paints a canvas into the snapshot, but never a redacted one', () => {
// A canvas keeps its picture in a bitmap `outerHTML` cannot see, so without
// this the chart replays blank. A marked one must stay blank: the painted
// background rides on `style`, which redaction strips.
const doc = docFrom(
`<canvas id="chart"></canvas><div data-wm-no-record><canvas id="secret"></canvas></div>`
)
for (const c of Array.from(doc.querySelectorAll('canvas'))) {
Object.defineProperty(c, 'width', { value: 200 })
Object.defineProperty(c, 'height', { value: 100 })
Object.defineProperty(c, 'toDataURL', {
value: () => `data:image/webp;base64,PIXELS-${c.id}`
})
Object.defineProperty(c, 'getBoundingClientRect', {
value: () => ({ width: 200, height: 100 })
})
}
const html = serializeDocument(doc)
expect(html).toContain('PIXELS-chart')
expect(html).not.toContain('PIXELS-secret')
})
it('stops encoding once a snapshot has spent its canvas budget', () => {
// Encoding is synchronous and on the app's event path, so a wall of charts
// must not each cost an encode — the per-canvas cap alone would allow it.
const doc = docFrom(
Array.from({ length: 6 }, (_, i) => `<canvas id="c${i}"></canvas>`).join('')
)
const encoded: string[] = []
for (const c of Array.from(doc.querySelectorAll('canvas'))) {
Object.defineProperty(c, 'width', { value: 2000 })
Object.defineProperty(c, 'height', { value: 1500 }) // 3M pixels each
Object.defineProperty(c, 'getBoundingClientRect', {
value: () => ({ width: 200, height: 150 })
})
Object.defineProperty(c, 'toDataURL', {
value: () => {
encoded.push(c.id)
return `data:image/webp;base64,PIXELS-${c.id}`
}
})
}
serializeDocument(doc)
expect(encoded).toEqual(['c0', 'c1'])
})
it('keeps a disabled sheet inert without shifting what follows it', () => {
// Neutralizing a disabled sheet must not remove its node: every later path
// resolution — other sheets, and the target stamp — is by sibling index.
const off = document.createElement('style')
off.textContent = `.disabled-rule { color: red; }`
const on = document.createElement('style')
document.head.append(off, on)
try {
off.sheet!.disabled = true
on.sheet?.insertRule(`.live-rule { color: green; }`, 0)
const html = serializeDocument(document)
expect(html).toContain('live-rule')
expect(html).not.toContain('disabled-rule')
expect(html).toContain('media="not all"')
} finally {
off.remove()
on.remove()
}
})
it('drops <noscript> rather than trusting the redaction pass to see into it', () => {
// With scripting on, a <noscript>'s markup is one text node — invisible to
// the redaction pass, but `outerHTML` writes it back out. (A parser with
// scripting off, like this one, exposes it as elements instead, so the
// assertion that matters is that the element does not survive at all.)
const doc = docFrom(`<noscript><div data-wm-no-record>noscript secret</div></noscript>`)
const html = serializeDocument(doc)
expect(html).not.toContain('<noscript')
expect(html).not.toContain('noscript secret')
})
it('shows that a redacted option was chosen without saying which', () => {
// The first select sits inside a marked container, whose subtree redaction
// drops it from the clone entirely: the masking must already have run by
// then, or pairing by index would silently skip every select in the document.
const doc = docFrom(
`<div data-wm-no-record><select><option>hidden</option></select></div>` +
`<select><option>Public</option><option data-wm-no-record>Confidential case 92000</option></select>`
)
const select = doc.querySelectorAll('select')[1] as HTMLSelectElement
select.selectedIndex = 1
const html = serializeDocument(doc)
expect(html).not.toContain('Confidential case 92000')
// Neither option is asserted as the choice; one masked entry stands in.
expect(html).not.toContain('Public')
expect(html).toContain('•••')
})
it('masks a password without leaking its length, and keeps other values', () => {
const doc = docFrom(`<input type="password"><input type="text">`)
const [password, text] = Array.from(doc.querySelectorAll('input')) as HTMLInputElement[]
password.value = 'hunter2-hunter2-hunter2'
text.value = 'ordinary'
const html = serializeDocument(doc)
expect(html).not.toContain('hunter2')
expect(html).toContain('••••••••')
expect(html).toContain('ordinary')
})
it('stamps the interaction target even when a removed node precedes it', () => {
// The stamp is resolved by child index, so it has to be applied before the
// script/template removals shift later siblings.
const doc = docFrom(`<script type="application/json">{}</script><button id="go">Go</button>`)
const target = doc.getElementById('go')!
const html = serializeDocument(doc, { target })
expect(html).toMatch(/<button id="go" data-wm-rec-target=""/)
expect(html).not.toContain('<script')
})
})
describe('withHighlightStyles', () => {
it('puts the policy ahead of markup a <head> match would step over', () => {
const out = withHighlightStyles(`<body><img src="/probe"><header>hi</header></body>`)
expect(out.indexOf('Content-Security-Policy')).toBeLessThan(out.indexOf('/probe'))
})
it('leaves nothing that can execute, fetch or navigate', () => {
const out = withHighlightStyles(
`<html><head><meta http-equiv="refresh" content="0;url=/x"></head><body>` +
`<script>fetch('/x')</script><div onclick="fetch('/y')">hi</div>` +
`<a href="/nav">link</a><form action="/post"><button formaction="/post2">go</button></form>` +
`<svg><a xlink:href="/svg-nav"><set attributeName="href" to="/smil-nav"/></a></svg>` +
`<div><template shadowrootmode="open"><a href="/shadow-nav">shadow</a></template></div>` +
`<link rel="preload" href="/pre" as="script"></body></html>`
)
for (const gone of [
'<script',
'onclick',
'refresh',
'href=',
'action=',
'<set',
'<template',
'preload'
]) {
expect(out).not.toContain(gone)
}
})
})
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { rewriteCssUrls } from './rawAppSnapshot'
describe('rewriteCssUrls', () => {
it('resolves relative references against the stylesheet, not the document', () => {
const css = rewriteCssUrls(
`.a { background: url(img/bg.png) } .b { background: url("../fonts/f.woff2") }`,
'http://localhost:3000/api/w/demo/apps_u/get_data/v/abc.css'
)
expect(css).toBe(
`.a { background: url(http://localhost:3000/api/w/demo/apps_u/get_data/v/img/bg.png) } ` +
`.b { background: url("http://localhost:3000/api/w/demo/apps_u/get_data/fonts/f.woff2") }`
)
})
it('leaves url() inside a string or comment alone', () => {
// A quoted `url(...)` is text the replay renders verbatim, not a reference.
const css =
`.hint::before { content: "url(icon.svg)" } ` +
`/* url(commented.svg) */ ` +
`.a { background: url(real.svg) }`
expect(rewriteCssUrls(css, 'http://host/css/app.css')).toBe(
`.hint::before { content: "url(icon.svg)" } ` +
`/* url(commented.svg) */ ` +
`.a { background: url(http://host/css/real.svg) }`
)
})
it('leaves references that are already resolvable alone', () => {
const css = `.a { background: url(data:image/gif;base64,R0lGOD) } .b { background: url(https://cdn/x.png) }`
expect(rewriteCssUrls(css, 'http://localhost:3000/app.css')).toBe(css)
})
})
@@ -0,0 +1,675 @@
/**
* DOM snapshotting for raw-app session recordings: turns a live (same-origin)
* app document into a self-contained HTML string that renders offline in a
* script-less iframe, plus the small helpers that describe the element a user
* interacted with.
*/
/** Stamped on the element a step acted on, so the player can highlight it
* without re-running a selector against a snapshot the app may have re-rendered. */
export const REC_TARGET_ATTR = 'data-wm-rec-target'
/** App authors mark sensitive nodes with this attribute: their content is left
* out of every snapshot and their values never reach a step. */
export const NO_RECORD_ATTR = 'data-wm-no-record'
/** Hard cap on the steps a recording may hold. The player renders a row per
* step, so the loader enforces it too on recordings it did not produce. */
export const MAX_RECORDED_STEPS = 500
/** Upper bound on any one string a step carries (its label, value, selector).
* The recorder truncates well below this; the loader refuses more. */
export const MAX_STEP_TEXT_CHARS = 1000
/** Snapshots are whole documents; the recorder stops storing them past this, and
* the loader refuses a recording that claims more every frame is parsed and
* re-serialized before it is handed to the iframe. */
export const MAX_TOTAL_FRAME_CHARS = 40 * 1024 * 1024
export const RAW_APP_INTERACTION_KINDS = [
'click',
'fill',
'select',
'toggle',
'submit',
'key',
'navigate'
] as const
export type RawAppInteractionKind = (typeof RAW_APP_INTERACTION_KINDS)[number]
/** Resolve `url(...)` references of an inlined stylesheet against the sheet's
* own URL: once its rules move into a `<style>` in the document, a relative
* reference would otherwise resolve against the document instead of the sheet. */
export function rewriteCssUrls(css: string, sheetHref: string): string {
const resolve = (match: string, quote: string, raw: string) => {
const url = raw.trim()
if (!url || /^(data:|blob:|about:|https?:|\/\/|#)/i.test(url)) return match
try {
return `url(${quote}${new URL(url, sheetHref).href}${quote})`
} catch (_) {
return match
}
}
// Only real url() tokens: `content: "url(x.svg)"` is a string the replay must
// render verbatim, so strings (and comments, which can hold anything) are
// copied through and never scanned.
let out = ''
let i = 0
while (i < css.length) {
const ch = css[i]
if (ch === '"' || ch === "'") {
const end = skipString(css, i)
out += css.slice(i, end)
i = end
} else if (ch === '/' && css[i + 1] === '*') {
const end = css.indexOf('*/', i + 2)
const stop = end === -1 ? css.length : end + 2
out += css.slice(i, stop)
i = stop
} else {
const m = /^url\(\s*(['"]?)([^'")]+)\1\s*\)/i.exec(css.slice(i))
if (m) {
out += resolve(m[0], m[1], m[2])
i += m[0].length
} else {
out += ch
i++
}
}
}
return out
}
/** Index just past the string starting at `start`, honouring CSS backslash
* escapes so an escaped quote does not end it early. */
function skipString(css: string, start: number): number {
const quote = css[start]
let i = start + 1
while (i < css.length) {
if (css[i] === '\\') i += 2
else if (css[i] === quote) return i + 1
else i++
}
return css.length
}
/* The recorded document lives in another realm (the app's iframe), where
* `instanceof Element` / `instanceof HTMLInputElement` are always false against
* this window's constructors. Every node test here goes through nodeType/tagName
* instead, and callers must do the same. */
/** Realm-agnostic `instanceof Element`. */
export function isElementNode(node: unknown): node is Element {
return !!node && typeof node === 'object' && (node as Node).nodeType === 1
}
/** Realm-agnostic tag test, e.g. `isTag(el, 'INPUT')`. */
export function isTag(el: Element, tagName: string): boolean {
return el.tagName === tagName
}
/** Never carry a typed secret into a recording that gets downloaded and shared.
* Fixed width: the length of a masked value is itself information. */
export function maskValue(value: string): string {
return value ? '••••••••' : ''
}
/** Index path from `root` down to `el` (element children only), so a node can be
* located again in a structural clone of the same tree. */
function nodePath(root: Element, el: Element): number[] | undefined {
const path: number[] = []
let cur: Element | null = el
while (cur && cur !== root) {
const parent: Element | null = cur.parentElement
if (!parent) return undefined
path.unshift(Array.prototype.indexOf.call(parent.children, cur))
cur = parent
}
return cur === root ? path : undefined
}
function resolvePath(root: Element, path: number[]): Element | undefined {
let cur: Element | undefined = root
for (const i of path) {
cur = cur?.children[i] as Element | undefined
if (!cur) return undefined
}
return cur
}
/** What may stay on a no-record element: enough to keep occupying the same
* space, nothing that can carry content. An allow-list on purpose, since any
* deny-list fails open. `class`/`id` are here by name only, their values
* filtered by {@link styledTokens}; `style` and `checked` are deliberately not. */
const REDACTION_KEEPS_ATTRS = new Set([
'class',
'colspan',
'cols',
'disabled',
'height',
'hidden',
'id',
'multiple',
'open',
'readonly',
'rows',
'rowspan',
'size',
'type',
'width'
])
/** True when the element sits under an app-declared no-record subtree. */
export function isRedacted(el: Element): boolean {
return !!el.closest(`[${NO_RECORD_ATTR}]`)
}
/** Name a no-record element by its kind alone its text, label and placeholder
* are exactly what the app asked to keep out of the recording. */
export function redactedDescription(el: Element): string {
const tag = el.tagName.toLowerCase()
const type = (el.getAttribute('type') ?? 'text').toLowerCase()
const role = tag === 'input' ? `input[${type}]` : tag
return `${role} (redacted)`
}
/** A selector's identifier, escapes included and in any position: a utility
* framework writes `md:flex` as `.md\:flex` and `2xl:block` as `.\32 xl\:block`,
* so a matcher that stops at a backslash or demands one before it reads the
* wrong token and costs a redacted placeholder the styling it is kept for. */
const IDENT_CHAR = String.raw`(?:[-\w\u00a0-\uffff]|\\[0-9a-fA-F]{1,6}[ \t\r\n\f]?|\\[^\r\n\f0-9a-fA-F])`
const CLASS_SELECTOR = new RegExp(String.raw`\.(${IDENT_CHAR}+)`, 'g')
const ID_SELECTOR = new RegExp(String.raw`#(${IDENT_CHAR}+)`, 'g')
/** CSS escapes back to the literal text an attribute holds: `\:` is `:`, and a
* hex escape (`\3a `) is its code point. */
function unescapeCssIdent(ident: string): string {
return ident.replace(/\\([0-9a-fA-F]{1,6})[ \t\r\n\f]?|\\([^])/g, (_, hex, ch) =>
hex ? String.fromCodePoint(parseInt(hex, 16)) : ch
)
}
/** Tokens the snapshot's surviving stylesheets select on. A redacted element
* keeps `class`/`id` for its shape, but the values are author-written and can
* name what the marker withholds; a token the CSS selects on is styling
* vocabulary, one it never mentions buys nothing. Errs towards dropping. */
function styledTokens(clone: Element): { classes: Set<string>; ids: Set<string> } {
const classes = new Set<string>()
const ids = new Set<string>()
for (const style of Array.from(clone.querySelectorAll('style'))) {
// A marked stylesheet is scrubbed too, so its selectors are not vocabulary:
// letting `<style data-wm-no-record>.salary-92000{}</style>` justify keeping
// that class would launder the token the sheet was marked to withhold.
if (isRedacted(style)) continue
const css = style.textContent ?? ''
for (const m of css.matchAll(CLASS_SELECTOR)) classes.add(unescapeCssIdent(m[1]))
for (const m of css.matchAll(ID_SELECTOR)) ids.add(unescapeCssIdent(m[1]))
}
return { classes, ids }
}
/** Strip everything the app marked no-record: the descendants of a marked
* element, and every attribute outside {@link REDACTION_KEEPS_ATTRS} content
* hides in `title`, `data-*`, `label`, `srcdoc`, a namespaced `xlink:href`, so
* only what the replay needs for layout and control state survives. */
function redactMarkedSubtrees(doc: Document, root: Element) {
const styled = styledTokens(root)
// `querySelectorAll` skips the element it is called on, so a marked root
// (`<html data-wm-no-record>`) has to be handled explicitly.
const marked: Element[] = [
...(root.hasAttribute(NO_RECORD_ATTR) ? [root] : []),
...Array.from(root.querySelectorAll(`[${NO_RECORD_ATTR}]`))
]
for (const n of marked) {
n.replaceChildren(doc.createTextNode('•••'))
// The marker is kept (an app may style `[data-wm-no-record]`) but emptied: its
// value is author-written free text that nothing downstream reads, so leaving
// it verbatim would be the one way past the allow-list below.
n.setAttribute(NO_RECORD_ATTR, '')
for (const attr of Array.from(n.attributes)) {
if (attr.name === NO_RECORD_ATTR) continue
const name = attr.localName.toLowerCase()
if (!REDACTION_KEEPS_ATTRS.has(name)) {
n.removeAttributeNode(attr)
} else if (name === 'class') {
const kept = attr.value.split(/\s+/).filter((c) => c && styled.classes.has(c))
if (kept.length) n.setAttribute('class', kept.join(' '))
else n.removeAttributeNode(attr)
} else if (name === 'id' && !styled.ids.has(attr.value)) {
n.removeAttributeNode(attr)
}
}
}
}
/** A `<select>` whose chosen option is marked cannot be serialized honestly:
* keeping `selected` says which one was picked, dropping it says the first one
* was. Replace its options with a single masked, selected one the replay then
* shows that a choice was made without disclosing it. */
/** Encoding runs synchronously on the app's own event path, several times per
* interaction, so it is budgeted twice: no single canvas larger than this, and
* no more than {@link MAX_SNAPSHOT_CANVAS_PIXELS} across one snapshot. A
* dashboard of charts would otherwise stall every pointerdown, and the frame cap
* cannot help it is only checked once the work is already done. */
const MAX_CANVAS_PIXELS = 4_000_000
const MAX_SNAPSHOT_CANVAS_PIXELS = 8_000_000
/** A canvas holds its picture in a bitmap `outerHTML` knows nothing about, so a
* cloned one replays blank. Paint it into the clone's own background instead of
* swapping the element for an `<img>`, which would lose whatever the app's CSS
* says about `canvas`. WebGL without `preserveDrawingBuffer` and cross-origin
* tainted canvases cannot be read at all; both keep today's blank. */
function paintCanvases(doc: Document, clone: Element) {
const live = doc.querySelectorAll('canvas')
const copies = clone.querySelectorAll('canvas')
if (live.length !== copies.length) return
let budget = MAX_SNAPSHOT_CANVAS_PIXELS
for (let i = 0; i < live.length; i++) {
const source = live[i] as HTMLCanvasElement
if (!source.width || !source.height) continue
const pixels = source.width * source.height
if (pixels > MAX_CANVAS_PIXELS || pixels > budget) continue
budget -= pixels
let url: string
try {
url = source.toDataURL('image/webp', 0.85)
} catch (_) {
continue // tainted by cross-origin pixels
}
if (!url.startsWith('data:image/')) continue
// The replay runs without scripting, where a canvas renders its (empty)
// fallback content: it is no longer a replaced element, so it has no intrinsic
// size and — while it stays `display: inline` — ignores width and height
// entirely. Carry over the box it actually rendered at, and a display that
// accepts one.
const rect = source.getBoundingClientRect()
if (!rect.width || !rect.height) continue
const display = doc.defaultView?.getComputedStyle(source).display
const box = !display || display === 'inline' ? 'inline-block' : display
const copy = copies[i]
const style = copy.getAttribute('style')
copy.setAttribute(
'style',
`${style ? style + ';' : ''}display:${box};box-sizing:border-box` +
`;width:${rect.width}px;height:${rect.height}px` +
`;background-image:url("${url}");background-size:100% 100%;background-repeat:no-repeat`
)
}
}
function maskSelectsWithRedactedChoice(doc: Document, clone: Element) {
const live = doc.querySelectorAll('select')
const copies = clone.querySelectorAll('select')
if (live.length !== copies.length) return
for (let i = 0; i < live.length; i++) {
const select = live[i] as HTMLSelectElement
if (!Array.from(select.selectedOptions).some((o) => isRedacted(o))) continue
const masked = doc.createElement('option')
masked.setAttribute('selected', '')
masked.textContent = '•••'
copies[i].replaceChildren(masked)
}
}
/** Copy live form state (which lives in properties, not attributes, so
* `outerHTML` would lose it) onto the clone. Passwords and anything the app
* marked no-record are masked. */
function freezeFormState(doc: Document, clone: Element) {
const selector = 'input, textarea, select'
const live = doc.querySelectorAll(selector)
const copies = clone.querySelectorAll(selector)
if (live.length !== copies.length) return
for (let i = 0; i < live.length; i++) {
const el = live[i]
const copy = copies[i]
if (el.tagName !== copy.tagName) return
if (isTag(el, 'INPUT')) {
const input = el as HTMLInputElement
const copyInput = copy as HTMLInputElement
if (input.type === 'checkbox' || input.type === 'radio') {
if (input.checked) copyInput.setAttribute('checked', '')
else copyInput.removeAttribute('checked')
} else if (input.type === 'password' || isRedacted(input)) {
copyInput.setAttribute('value', maskValue(input.value))
} else if (input.type !== 'file') {
copyInput.setAttribute('value', input.value)
}
} else if (isTag(el, 'TEXTAREA')) {
copy.textContent = (el as HTMLTextAreaElement).value
} else if (isTag(el, 'SELECT')) {
const select = el as HTMLSelectElement
const copySelect = copy as HTMLSelectElement
for (let j = 0; j < select.options.length; j++) {
const option = copySelect.options[j]
if (!option) continue
if (select.options[j].selected) option.setAttribute('selected', '')
else option.removeAttribute('selected')
}
}
}
}
/** Rule text, with `@import` replaced by the rules it pulls in: the replay CSP
* refuses the fetch, so an unexpanded import is simply lost styling. An import
* that carries a cascade layer replays as unlayered rules at the import's
* position closer to the live rendering than dropping it, but not identical. */
function expandRules(rules: CSSRuleList): string {
return Array.from(rules)
.map((rule) => {
const imported = (rule as CSSImportRule).styleSheet
if (!imported) return rule.cssText
try {
const inner = expandRules(imported.cssRules)
const media = imported.media?.mediaText
return media ? `@media ${media} {\n${inner}\n}` : inner
} catch (_) {
return rule.cssText
}
})
.join('\n')
}
/** Re-read every time rather than cached against a sampled probe: an app that
* restyles at runtime (a theme toggle rewriting a rule in place) leaves any
* sample of the sheet unchanged, and the snapshot would then replay styling the
* user never saw. A probe cheap enough to be worth caching cannot be exact,
* because the cost being avoided *is* reading the rules. Measured at ~18ms for
* 11k rules across 177 sheets, which is small next to the rest of a snapshot. */
function sheetCss(sheet: CSSStyleSheet, rules: CSSRuleList): string {
let css = expandRules(rules)
if (sheet.href) css = rewriteCssUrls(css, sheet.href)
// `cssRules` drops the sheet-level media condition the `<link media>` carried,
// so an unwrapped inline copy would apply print-only CSS to every replay.
const media = sheet.media?.mediaText
if (media) css = `@media ${media} {\n${css}\n}`
return css
}
/** Inline what the browser has actually parsed: rules of linked stylesheets (so
* the snapshot renders without the API being reachable) and of CSS-in-JS sheets
* built with `insertRule` (whose `<style>` node clones out empty). Sheets we
* can't read (cross-origin) keep their `<link>`, and ones with no owner node
* (`adoptedStyleSheets`) are out of reach entirely as is anything inside a
* shadow root, which `outerHTML` does not serialize. */
function inlineStyleSheets(doc: Document, root: Element, clone: Element) {
for (const sheet of Array.from(doc.styleSheets)) {
const owner = sheet.ownerNode
if (!isElementNode(owner)) continue
if (sheet.disabled) {
// `disabled` is a property, not an attribute: cloned through, the sheet
// would come back to life on replay. Neutralize it in place — removing the
// node would shift the sibling indices that every later path resolution in
// this function, and the target stamp after it, resolve against.
const path = nodePath(root, owner)
const target = path ? resolvePath(clone, path) : undefined
if (target) {
target.setAttribute('media', 'not all')
if (target.tagName === 'STYLE') target.textContent = ''
}
continue
}
// Inlining replaces the node, which would leave the marker behind and carry
// the sheet's text into the snapshot. Leave marked sheets for redaction.
if (isRedacted(owner)) continue
let rules: CSSRuleList
try {
const cssRules = (sheet as CSSStyleSheet).cssRules
if (!cssRules) continue
rules = cssRules
} catch (_) {
continue
}
const path = nodePath(root, owner)
if (!path) continue
const target = resolvePath(clone, path)
if (!target) continue
const css = sheetCss(sheet as CSSStyleSheet, rules)
if (owner.tagName === 'LINK') {
const style = doc.createElement('style')
style.textContent = css
target.replaceWith(style)
} else if (owner.tagName === 'STYLE') {
// The parsed rules are the truth: `insertRule`/`deleteRule` and edits to a
// rule's style never touch the element's source text, so copying the text
// through would replay the sheet as it was authored, not as it renders.
target.textContent = css
}
}
}
export type SnapshotOptions = {
/** Element to stamp with {@link REC_TARGET_ATTR} (the step's interaction target). */
target?: Element | null
/** Base URL for the snapshot's relative resources (the recording origin). */
baseHref?: string
}
/** Serialize a live document into standalone HTML: current form state frozen in,
* stylesheets inlined, scripts and inline handlers dropped (the player renders
* snapshots with scripting disabled). */
export function serializeDocument(doc: Document, opts: SnapshotOptions = {}): string {
const root = doc.documentElement
const clone = root.cloneNode(true) as Element
// These read the live document and write to the matching node in the clone,
// pairing by position — so they must all run before anything is removed from
// it. Freezing, inlining, painting and redacting only mutate nodes in place.
freezeFormState(doc, clone)
inlineStyleSheets(doc, root, clone)
paintCanvases(doc, clone)
maskSelectsWithRedactedChoice(doc, clone)
redactMarkedSubtrees(doc, clone)
// Stamp the target BEFORE anything is removed from the clone: the live tree is
// what `nodePath` indexes against, so a single removed node (a data `<script>`
// preceding the target, say) would shift every later sibling and stamp the
// wrong element.
if (opts.target) {
const path = nodePath(root, opts.target)
const target = path ? resolvePath(clone, path) : undefined
target?.setAttribute(REC_TARGET_ATTR, '')
}
// Templates render nothing, and their content fragment is invisible to
// `querySelectorAll` while `outerHTML` still serializes it — so the passes
// below would miss scripts and handlers hiding inside one. `<noscript>` is the
// same trap from the other side: with scripting on its markup is one text node,
// so redaction cannot see into it, yet it would render on a script-less replay.
clone.querySelectorAll('template, noscript').forEach((n) => n.remove())
clone.querySelectorAll('script').forEach((n) => n.remove())
clone.querySelectorAll('meta[http-equiv="refresh" i]').forEach((n) => n.remove())
clone.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
}
})
// A snapshot clones out scrolled back to the top, which can leave the
// interaction target off-screen on replay. Shifting the root reproduces the
// scrolled view (and leaves `position: fixed` chrome where it belongs). Scroll
// inside nested overflow containers has no static-CSS equivalent and is lost.
const view = doc.defaultView
const scrollY = Math.round(view?.scrollY ?? doc.documentElement.scrollTop ?? 0)
const scrollX = Math.round(view?.scrollX ?? doc.documentElement.scrollLeft ?? 0)
if (scrollY > 0 || scrollX > 0) {
const scrolled = doc.createElement('style')
scrolled.textContent = `html { margin-top: -${scrollY}px !important; margin-left: -${scrollX}px !important; }`
clone.querySelector('head')?.appendChild(scrolled)
}
const head = clone.querySelector('head')
if (opts.baseHref && head && !head.querySelector('base')) {
const base = doc.createElement('base')
base.setAttribute('href', opts.baseHref)
head.prepend(base)
}
return `<!DOCTYPE html>${clone.outerHTML}`
}
const NAVIGATION_ATTRS = new Set(['href', 'action', 'formaction', 'ping', 'target', 'download'])
/** Locked-down policy for a replayed snapshot. The player's empty sandbox stops
* scripting, but not subresource loads: without this, markup inside a recording
* fetched from an arbitrary `?src=` URL could still beacon the viewer or issue
* same-site GETs against their Windmill session. The cost is that images and
* fonts the recorder could not inline (remote URLs) do not render on replay.
* Injected at the very top of <head> so it applies before anything is fetched. */
const REPLAY_CSP = `default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:`
/** A replay is a picture of a past session, not a working app, so clicks are
* turned off at the root. `pointer-events` inherits rather than cascades, so an
* element that sets its own value (Tailwind's `pointer-events-auto`, overlay
* CSS) still takes clicks this is a broad default, NOT a guarantee, and the
* attribute stripping below remains the actual defense. It also costs
* mouse text-selection inside the replayed frame. */
const INERT_CSS = `html { pointer-events: none !important; }`
const HIGHLIGHT_CSS = `[${REC_TARGET_ATTR}] {
outline: 3px solid #ef4444 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.25) !important;
}`
/** Prepare a recorded frame for replay: policy first in `<head>`, target
* highlight, nothing executable. Parsed rather than string-spliced because a
* `?src=` recording can defeat a `<head>` regex (`<body><img src=/probe><header>`)
* and land the policy after the request it must prevent. Not in the player: a
* literal `<style>` there is parsed as the component's own style block. */
export function withHighlightStyles(frame: string): string {
let doc: Document
try {
doc = new DOMParser().parseFromString(frame, 'text/html')
} catch (_) {
return ''
}
const head = doc.head ?? doc.documentElement.insertBefore(doc.createElement('head'), doc.body)
const csp = doc.createElement('meta')
csp.setAttribute('http-equiv', 'Content-Security-Policy')
csp.setAttribute('content', REPLAY_CSP)
head.prepend(csp)
// The recorder strips these at capture time; a hand-made or hostile recording
// has not been through it.
doc.querySelectorAll('script, meta[http-equiv="refresh" i]').forEach((n) => n.remove())
doc.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
}
})
// A replay is a static picture, so nothing in it may navigate: the sandbox
// stops a *top-level* navigation and the CSP governs fetches, but neither stops
// a link from navigating the snapshot frame itself — which is a request.
// Matched on the attribute's local name, so an SVG `xlink:href` goes too.
doc.querySelectorAll('a, area, form, button, input').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (NAVIGATION_ATTRS.has(attr.localName.toLowerCase())) el.removeAttributeNode(attr)
}
})
// SVG animation is declarative — it runs without scripts — and `<set
// attributeName="href">` would put back the link just stripped.
doc.querySelectorAll('set, animate, animateTransform, animateMotion').forEach((n) => n.remove())
// `querySelectorAll` cannot see into a template's content fragment, so markup
// hidden there escapes every pass above and comes alive as a shadow root when
// the frame is parsed. A snapshot has no use for templates: drop them.
doc.querySelectorAll('template').forEach((n) => n.remove())
// Resource hints exist only to fetch; the CSP already refuses them, so this is
// about not asking rather than not getting.
doc
.querySelectorAll(
'link[rel~="preload" i], link[rel~="prefetch" i], link[rel~="preconnect" i], link[rel~="dns-prefetch" i]'
)
.forEach((n) => n.remove())
const style = doc.createElement('style')
style.textContent = `${INERT_CSS}\n${HIGHLIGHT_CSS}`
head.appendChild(style)
return `<!DOCTYPE html>${doc.documentElement.outerHTML}`
}
/** Text of an element with any no-record subtree left out: the element itself
* may be recordable while something inside it is not. */
export function textWithoutRedacted(el: Element | null | undefined): string {
if (!el || isRedacted(el)) return ''
let source: Element = el
if (el.querySelector(`[${NO_RECORD_ATTR}]`)) {
source = el.cloneNode(true) as Element
source.querySelectorAll(`[${NO_RECORD_ATTR}]`).forEach((n) => n.remove())
}
return (source.textContent ?? '').replace(/\s+/g, ' ').trim()
}
function textOf(el: Element | null | undefined, max = 40): string {
const text = textWithoutRedacted(el)
return text.length > max ? `${text.slice(0, max)}` : text
}
/** Short human name of an element, preferring what a user would call it
* (its label / accessible name) over its markup. */
export function describeElement(el: Element): string {
const tag = el.tagName.toLowerCase()
const type = (el.getAttribute('type') ?? 'text').toLowerCase()
const role = tag === 'input' ? `input[${type}]` : tag
// An element can be recordable while its associated label is not (the label is
// where a form usually puts the sensitive wording).
const label = (el as HTMLInputElement).labels?.[0]
const name =
el.getAttribute('aria-label') ||
(label && !isRedacted(label) ? textOf(label) : '') ||
// A button-shaped <input> has no text content: its `value` is its caption.
(tag === 'input' && ['button', 'submit', 'reset'].includes(type)
? el.getAttribute('value')
: '') ||
el.getAttribute('placeholder') ||
el.getAttribute('title') ||
textOf(el) ||
el.getAttribute('name') ||
el.getAttribute('id') ||
''
return name ? `${role} "${name}"` : role
}
/** Best-effort CSS selector for the element, recorded for reference (the player
* highlights via {@link REC_TARGET_ATTR}, not this). */
export function cssSelectorFor(el: Element): string {
const parts: string[] = []
let cur: Element | null = el
let depth = 0
while (cur && depth < 5) {
const tag = cur.tagName.toLowerCase()
if (cur.id) {
parts.unshift(`#${cur.id}`)
break
}
const cls =
typeof cur.className === 'string'
? cur.className.trim().split(/\s+/).filter(Boolean)[0]
: undefined
const parent: Element | null = cur.parentElement
let part = cls ? `${tag}.${cls}` : tag
if (parent) {
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur!.tagName)
if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(cur) + 1})`
}
parts.unshift(part)
cur = parent
depth++
}
return parts.join(' > ')
}
/** One-line description of a step, shown in the player's step list. */
export function stepLabel(kind: RawAppInteractionKind, target: string, value?: string): string {
switch (kind) {
case 'click':
return `Clicked ${target}`
case 'fill':
return `Filled ${target} with "${value ?? ''}"`
case 'select':
return `Selected "${value ?? ''}" in ${target}`
case 'toggle':
// A redacted control reports no state, and "Unchecked" would then be a
// claim rather than an omission: say only that it was toggled.
if (!value) return `Toggled ${target}`
return `${value === 'checked' ? 'Checked' : 'Unchecked'} ${target}`
case 'submit':
return `Submitted ${target}`
case 'key':
return `Pressed ${value ?? 'key'} in ${target}`
case 'navigate':
return value ? `Navigated to ${value}` : 'Reloaded the app'
}
}
@@ -1,5 +1,6 @@
import type { AssetKind, Job, OpenFlow } from '$lib/gen'
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
import type { RawAppInteractionKind } from './rawAppSnapshot'
export type RecordedEvent = {
t: number
@@ -97,6 +98,39 @@ export type PipelineRecordedCode = {
language: string
}
/** One user interaction in a raw-app session recording. `before`/`after` index
* into {@link RawAppRecording.frames}: the DOM the user acted on, and the DOM
* once the app settled. Either may be absent when the snapshot budget ran out. */
export type RawAppStep = {
t: number
kind: RawAppInteractionKind
/** One-line description shown in the player, e.g. `Clicked button "Save"`. */
label: string
/** The element as a user would name it, e.g. `button "Save"`. */
target: string
selector?: string
value?: string
before?: number
after?: number
}
export type RawAppRecording = {
version: 1
type: 'app'
recorded_at: string
app_path: string
workspace?: string
total_duration_ms: number
/** Size of the app viewport at record time, replayed at the same scale. */
viewport: { width: number; height: number }
/** Deduplicated, self-contained DOM snapshots referenced by the steps. */
frames: string[]
steps: RawAppStep[]
/** Set when a limit cut the recording short: the snapshot budget (later steps
* ship without frames) or the step cap (later interactions are absent). */
truncated?: boolean
}
/** Minimal interface that both flow and script recording stores implement */
export interface ActiveRecording {
recordInitialJob(jobId: string, job: Job): void
@@ -1,5 +1,4 @@
<script lang="ts">
import { base } from '$lib/base'
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import WorkspaceDeployLayout from '$lib/components/WorkspaceDeployLayout.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
@@ -11,7 +10,7 @@
import {
useDeployToHubSession,
canRecord,
canShareAsIframe,
canRecordSession,
sanitizeSlug,
isValidSlug,
type DeployItem
@@ -20,17 +19,16 @@
import Toggle from '../Toggle.svelte'
import MigrationSqlEditor from './MigrationSqlEditor.svelte'
import PipelineRecordingReplay from '$lib/components/recording/PipelineRecordingReplay.svelte'
import RawAppRecordSession from './RawAppRecordSession.svelte'
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
import {
Check,
ChevronDown,
Cloud,
Code2,
Copy,
Database,
Eye,
ExternalLink,
Globe,
Image as ImageIcon,
Info,
LayoutDashboard,
@@ -57,11 +55,12 @@
})
let recordDrawer = $state<Drawer | undefined>()
let appRecordDrawer = $state<Drawer | undefined>()
let appRecordTarget = $state<DeployItem | undefined>(undefined)
let pipelinePreviewDrawer = $state<Drawer | undefined>()
// Inline pipeline graph above the item list, collapsed by default so the
// selection list stays the first thing in view.
let pipelineGraphOpen = $state(false)
let publishDrawer = $state<Drawer | undefined>()
let resourceDrawer = $state<Drawer | undefined>()
let triggerDrawer = $state<Drawer | undefined>()
let bundleDrawer = $state<Drawer | undefined>()
@@ -81,6 +80,11 @@
async function confirmBundle() {
await deployHub.session?.publishBundle(() => bundleDrawer?.closeDrawer())
}
function openAppRecord(it: DeployItem) {
appRecordTarget = it
appRecordDrawer?.openDrawer()
}
function openRecord(it: DeployItem) {
recordDrawer?.openDrawer()
void deployHub.session?.openRecord(it)
@@ -91,15 +95,6 @@
async function savePipelineRecording() {
await deployHub.session?.savePipelineRecording()
}
function openPublish(it: DeployItem) {
const s = deployHub.session
if (!s) return
s.publishTarget = it
publishDrawer?.openDrawer()
}
async function confirmPublish() {
if (await deployHub.session?.confirmPublish()) publishDrawer?.closeDrawer()
}
// Client-side mirror of the Hub's logo constraints (it re-validates server-side).
const MAX_LOGO_BYTES = 512 * 1024
let logoDragOver = $state(false)
@@ -150,15 +145,6 @@
await handleLogoFile(e.dataTransfer?.files?.[0])
}
async function copyIframeSnippet(url: string) {
const snippet = `<iframe src="${url}" width="100%" height="600" frameborder="0"></iframe>`
try {
await navigator.clipboard.writeText(snippet)
sendUserToast('Iframe snippet copied to clipboard')
} catch {
sendUserToast('Failed to copy snippet', true)
}
}
</script>
{#if deployHub.session}
@@ -201,9 +187,9 @@
class={stepNum === 2 ? 'text-primary' : stepNum > 2 ? 'opacity-60' : 'opacity-40'}
>
<span class="font-mono text-emphasis">{stepNum > 2 ? '✓' : '2.'}</span>
<span class="font-semibold text-primary">Generate iframes &amp; recordings</span> — share
public apps as iframes, capture one execution per script/flow, and record the whole data-pipeline
cascade as one interactive replay.
<span class="font-semibold text-primary">Record demos</span> — capture one execution
per script/flow, a session per raw app, and the whole data-pipeline cascade as one interactive
replay.
</li>
<li
class={stepNum === 3 ? 'text-primary' : stepNum > 3 ? 'opacity-60' : 'opacity-40'}
@@ -219,9 +205,7 @@
Step 1: Bundle your project
</span>
{:else if s.phase === 'draft'}
<span class="text-sm font-semibold text-primary">
Step 2: Generate iframes &amp; recordings
</span>
<span class="text-sm font-semibold text-primary"> Step 2: Record demos </span>
{:else if s.phase === 'under_review'}
<span class="text-sm font-semibold text-primary">Step 3: Awaiting review</span>
{:else}
@@ -366,9 +350,9 @@
{#if s.phase === 'draft'}
<div class="flex flex-col gap-1 pb-3">
<span class="text-xs text-secondary">
A recording captures one real run of a script or flow inputs, logs, step outputs
and result — replayable on the Hub so visitors see it work before forking. Public
apps can also be shared as live iframes. Optional, but recommended.
A recording captures one real run of a script or flow (inputs, logs, step outputs
and result), or one session of someone using a raw app, replayable on the Hub so
visitors see it work before forking. Optional, but recommended.
</span>
</div>
{/if}
@@ -631,52 +615,28 @@
<Badge color="yellow" size="xs">No recording</Badge>
{/if}
{/if}
{#if s.phase !== 'predeploy' && canShareAsIframe(it)}
{#if it.published}
{#if s.phase === 'draft' && canRecordSession(it)}
{#if it.rec === 'recorded'}
<Badge color="green" size="xs">
<Globe size={10} class="mr-0.5" />Public
<Check size={10} class="mr-0.5" />Recorded
</Badge>
{#if it.publicUrl}
<a
href={it.publicUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink size={12} /> Open
</a>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Copy }}
onclick={() => copyIframeSnippet(it.publicUrl!)}
>
Copy iframe
</Button>
{:else if s.phase !== 'under_review'}
<!-- Public but its URL didn't resolve: offer a retry, keep Unpublish. -->
<Button
size="xs"
variant="subtle"
startIcon={{ icon: RotateCcw }}
onclick={() => openPublish(it)}
>
Retry link
</Button>
{/if}
{#if s.phase !== 'under_review'}
<Button size="xs" variant="subtle" onclick={() => s.unpublishApp(it)}
>Unpublish</Button
>
{/if}
{:else if s.phase !== 'under_review'}
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Globe }}
onclick={() => openPublish(it)}
startIcon={{ icon: RotateCcw }}
onclick={() => openAppRecord(it)}
>
Share as iframe
Re-record
</Button>
{:else}
<Badge color="yellow" size="xs">No recording</Badge>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Play }}
onclick={() => openAppRecord(it)}
>
Record demo
</Button>
{/if}
{/if}
@@ -822,6 +782,37 @@
</DrawerContent>
</Drawer>
<!-- Full screen on purpose: the demo is recorded at the size it will replay,
and a recording driven in a narrow drawer replays as a narrow app. -->
<Drawer bind:this={appRecordDrawer} size="100vw">
<DrawerContent
title={appRecordTarget ? `Record demo — ${appRecordTarget.path}` : 'Record demo'}
on:close={() => appRecordDrawer?.closeDrawer()}
>
<span class="text-xs text-secondary">
Use the app the way a visitor would. Each interaction becomes a step, replayable on the
Hub page so people see what it does before forking it.
</span>
{#if appRecordTarget}
{#key appRecordTarget.key}
<div class="h-full min-h-[600px] pt-2">
<RawAppRecordSession
workspace={s.workspace}
path={appRecordTarget.path}
onsave={s.hubItemIds[appRecordTarget.key]
? async (recording) => {
const ok = await s.saveAppRecording(appRecordTarget!, recording)
if (ok) appRecordDrawer?.closeDrawer()
return ok
}
: undefined}
/>
</div>
{/key}
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={pipelinePreviewDrawer} size="1100px">
<DrawerContent
title="Pipeline recording preview"
@@ -835,65 +826,6 @@
</DrawerContent>
</Drawer>
<Drawer bind:this={publishDrawer} size="600px">
<DrawerContent
title={s.publishTarget ? `Share as iframe — ${s.publishTarget.path}` : 'Share as iframe'}
on:close={() => publishDrawer?.closeDrawer()}
>
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
Expose <span class="font-mono text-emphasis">{s.publishTarget?.path}</span> at a public URL
so it can be embedded as an iframe (e.g. on the Hub, a docs page, or your own site). Anyone
with the URL will be able to interact with it.
</p>
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
<div class="flex items-center gap-2">
<TriangleAlert
size={14}
class={s.workspaceRateLimit
? 'text-secondary'
: 'text-orange-600 dark:text-orange-400'}
/>
<span class="text-sm font-semibold">Rate limit (workspace-wide)</span>
<Tooltip>
Caps public app executions per minute per server. Applies to all public apps in this
workspace.
</Tooltip>
</div>
{#if s.workspaceRateLimit && s.workspaceRateLimit > 0}
<span class="text-xs text-secondary">
Currently <span class="font-mono text-emphasis">{s.workspaceRateLimit}</span> executions
/ minute / server.
</span>
{:else}
<span class="text-xs text-orange-700 dark:text-orange-300">
No rate limit configured — anyone with the URL can hit this app at any rate.
</span>
{/if}
<a
href="{base}/workspace_settings?tab=default_app"
class="text-[11px] text-blue-600 underline"
onclick={() => publishDrawer?.closeDrawer()}
>
Edit in Workspace settings → Apps
</a>
</div>
</div>
{#snippet actions()}
<Button variant="default" onclick={() => publishDrawer?.closeDrawer()}>Cancel</Button>
<Button
variant="accent"
loading={s.publishing}
startIcon={{ icon: Globe }}
onclick={confirmPublish}
>
Generate iframe
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<Drawer bind:this={resourceDrawer} size="640px">
<DrawerContent title="Resource dependencies" on:close={() => resourceDrawer?.closeDrawer()}>
<div class="flex flex-col gap-4">
@@ -0,0 +1,208 @@
<script lang="ts">
/**
* Records a raw app's session for its Hub page: the app runs full-screen, the
* user drives it, and what they did becomes a step-by-step recording visitors
* can replay before forking the project. A raw app cannot demo itself with a
* job log the way a script or flow does — this is its equivalent.
*/
import { Button } from '$lib/components/common'
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
import RawAppRecordingReplay from '$lib/components/recording/RawAppRecordingReplay.svelte'
import { createRawAppRecording } from '$lib/components/recording/rawAppRecording.svelte'
import type { RawAppRecording } from '$lib/components/recording/types'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
import { AppService } from '$lib/gen'
import { userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Check, Circle, Download, Loader2, Square } from 'lucide-svelte'
import { onDestroy, setContext } from 'svelte'
interface Props {
workspace: string
path: string
/** Saves the finished recording against the Hub item. Absent while the
* project has no Hub draft yet, which leaves Download as the only action. */
onsave?: (recording: RawAppRecording) => Promise<boolean>
}
let { workspace, path, onsave }: Props = $props()
const recorder = createRawAppRecording()
let app = $state<any>(undefined)
// The publisher's isolation opt-in decides this, exactly as in the viewer:
// forcing the unsandboxed path here would run a bundle its own author marked
// untrusted same-origin with the session of whoever is recording it. An
// isolated app simply cannot be recorded — its DOM is unreachable by design.
let sandboxed = $derived(app?.policy?.sandbox === true)
setContext('IS_APP_UNSANDBOXED', {
get value() {
return app !== undefined && !sandboxed
}
})
let loadError = $state<string | undefined>(undefined)
let iframe = $state<HTMLIFrameElement | undefined>(undefined)
let recording = $state<RawAppRecording | undefined>(undefined)
let saving = $state(false)
async function load() {
try {
const loaded: any = await AppService.getAppByPath({ workspace, path })
if (!loaded?.bundle_secret) {
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace,
path
})
}
app = loaded
} catch (e: any) {
loadError = e?.body ?? e?.message ?? String(e)
}
}
load()
// Showing the replay unmounts the preview, so "Record again" has to wait for the
// fresh one: `iframe` still points at the removed document until it mounts and
// hands its own back.
let startWhenReady = $state(false)
function start() {
if (recording) {
recording = undefined
startWhenReady = true
return
}
beginRecording()
}
function beginRecording() {
if (!iframe) return
if (!recorder.start(iframe, { appPath: path, workspace })) {
sendUserToast('Cannot record this app: its bundle runs sandbox-isolated', true)
return
}
sendUserToast(
'Recording — walk through the app as a visitor would. Passwords are masked; mark ' +
'sensitive elements with data-wm-no-record to leave them out.'
)
}
function onIframe(el: HTMLIFrameElement | undefined) {
iframe = el
if (el && startWhenReady) {
startWhenReady = false
beginRecording()
}
}
async function stop() {
recording = await recorder.stop()
}
async function save() {
if (!recording || !onsave) return
saving = true
try {
if (await onsave(recording)) recording = undefined
} finally {
saving = false
}
}
onDestroy(() => {
// Not awaited: the drawer is going away, and the recorder resolves on its own
// once the document it was reading is gone.
if (recorder.active) recorder.stop()
})
</script>
<div class="flex flex-col h-full min-h-0 gap-2">
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<span class="text-sm font-semibold text-primary">{path}</span>
{#if sandboxed}
<span class="text-xs text-secondary">Sandbox-isolated: not recordable</span>
{:else if recorder.active || recorder.stopping}
<span class="flex items-center gap-1 text-xs text-primary">
<Circle size={10} class="text-red-500 animate-pulse" fill="currentColor" />
{recorder.stepCount} step{recorder.stepCount === 1 ? '' : 's'}
</span>
<Button
size="xs"
variant="border"
loading={recorder.stopping}
startIcon={{ icon: Square }}
onclick={stop}
>
{recorder.stopping ? 'Waiting for the job…' : 'Stop recording'}
</Button>
{:else}
<!-- While the replay is showing there is no preview and so no iframe; the
re-record path remounts one and starts against it. -->
<Button
size="xs"
variant="accent"
disabled={!iframe && !recording}
startIcon={{ icon: Circle }}
onclick={start}
>
{recording ? 'Record again' : 'Start recording'}
</Button>
{/if}
{#if recording}
<span class="text-xs text-secondary">
{recording.steps.length} step{recording.steps.length === 1 ? '' : 's'} captured
</span>
<div class="ml-auto flex items-center gap-2">
<Button
size="xs"
variant="border"
startIcon={{ icon: Download }}
onclick={() => recorder.download(recording!)}
>
Download
</Button>
{#if onsave}
<Button
size="xs"
variant="accent"
loading={saving}
startIcon={{ icon: Check }}
onclick={save}
>
Save as recording
</Button>
{/if}
</div>
{/if}
</div>
{#if loadError}
<div class="text-sm text-red-600 dark:text-red-400">Could not load the app: {loadError}</div>
{:else if sandboxed}
<div class="text-sm text-secondary max-w-2xl">
This app is opted into sandbox isolation, so it runs in an opaque-origin frame that nothing on
this page can read — including the recorder. Turn isolation off in the app's settings to
record a demo of it.
</div>
{:else if !app}
<div class="flex items-center gap-2 text-sm text-secondary">
<Loader2 size={14} class="animate-spin" /> Loading the app…
</div>
{:else if recording}
<!-- What was captured, in the same player the Hub page will use. -->
<div class="flex-1 min-h-0">
<RawAppRecordingReplay {recording} />
</div>
{:else}
<div class="flex-1 min-h-0 border rounded-md overflow-hidden">
<RawAppPreview
{workspace}
user={$userStore}
secret={app.bundle_secret}
{path}
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
oniframe={onIframe}
/>
</div>
{/if}
</div>
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { canRecordSession, mergeAppTableOrigin, type DeployItem } from './deployToHubItems'
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
return { rec: 'none', ...over }
}
describe('canRecordSession', () => {
it('offers the recorder only for app-table raw apps', () => {
expect(
canRecordSession(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true }))
).toBe(true)
// Legacy entries live in the `raw_app` table, which the record surface's
// AppService loader cannot see: offering the action opens a dead drawer.
expect(canRecordSession(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false)
expect(canRecordSession(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(false)
})
})
describe('mergeAppTableOrigin', () => {
it('restores the origin so a reopened draft stays recordable', () => {
const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })]
const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })]
expect(canRecordSession(mergeAppTableOrigin(drafts, workspace)[0])).toBe(true)
})
it('keeps the reference when nothing changes, and ignores unmatched drafts', () => {
const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })]
expect(mergeAppTableOrigin(drafts, drafts)).toBe(drafts)
const orphan = [item({ key: 'raw_app:f/gone', path: 'f/gone', kind: 'raw_app' })]
expect(
mergeAppTableOrigin(orphan, [
item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })
])
).toBe(orphan)
})
})
@@ -0,0 +1,47 @@
/**
* The publish flow's item shape and the pure predicates over it. Kept apart from
* the session store so they can be exercised without dragging in the API client
* and the editor bundle the store pulls behind it.
*/
import type { Kind } from '$lib/utils_deployable'
export type RecStatus = 'none' | 'recorded'
export interface DeployItem {
key: string
path: string
kind: Kind
summary?: string
rec: RecStatus
[k: string]: unknown
}
export const canRecord = (k: Kind) => k === 'script' || k === 'flow'
// A raw app has no run to capture: its demo is a recorded session of someone
// using it, driven in the record drawer and replayed on the Hub page. Legacy raw
// apps live only in the `raw_app` table, and the record surface loads the app
// (bundle secret, runnables) through AppService, so it can only offer the action
// for apps stored in the `app` table.
export const canRecordSession = (it: DeployItem): boolean =>
it.kind === 'raw_app' && it.appTable === true
// Hub rehydration carries draft membership, not where an app is stored. Copy the
// app-table origin from the loaded workspace items onto matching draft items so a
// reopened draft still knows which raw apps can be recorded. Returns the original
// array unchanged when nothing needs merging (stable reference).
export function mergeAppTableOrigin(
draftItems: DeployItem[],
workspaceItems: DeployItem[]
): DeployItem[] {
if (draftItems.length === 0 || workspaceItems.length === 0) return draftItems
const byKey = new Map(workspaceItems.map((w) => [w.key, w]))
let changed = false
const merged = draftItems.map((d) => {
const w = byKey.get(d.key)
if (!w || w.appTable === d.appTable) return d
changed = true
return { ...d, appTable: w.appTable }
})
return changed ? merged : draftItems
}
@@ -7,12 +7,10 @@ import {
RawAppService,
ResourceService,
ScriptService,
WorkspaceService,
ScheduleService
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { sleep, emptySchema } from '$lib/utils'
import { computeSecretUrl } from '$lib/components/apps/editor/appDeploy.svelte'
import {
buildProjectBundle,
buildPathMap,
@@ -37,6 +35,12 @@ import {
type GeneratedMigration
} from './projectMigrations'
import type { Kind } from '$lib/utils_deployable'
import {
canRecord,
canRecordSession,
mergeAppTableOrigin,
type DeployItem
} from './deployToHubItems'
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
import {
CASCADE_JOB_TIMEOUT_MS,
@@ -44,7 +48,7 @@ import {
DATA_ASSET_KINDS
} from '$lib/components/assets/AssetGraph/cascadeRun'
import { capturePipelineRecording } from '$lib/components/recording/pipelineRecording.svelte'
import type { PipelineRecording } from '$lib/components/recording/types'
import type { PipelineRecording, RawAppRecording } from '$lib/components/recording/types'
import {
TRIGGER_KINDS,
listAllWorkspaceTriggers,
@@ -56,48 +60,14 @@ import {
} from '../triggers/workspaceTriggersList'
export type Phase = 'predeploy' | 'draft' | 'under_review' | 'live'
export type RecStatus = 'none' | 'recorded'
export interface DeployItem {
key: string
path: string
kind: Kind
summary?: string
rec: RecStatus
published?: boolean
publicUrl?: string
[k: string]: unknown
}
export const canRecord = (k: Kind) => k === 'script' || k === 'flow'
// Legacy raw apps live only in the `raw_app` table, but the iframe share flow
// drives AppService (the `app` table), so it can only target apps stored there.
export const canShareAsIframe = (it: DeployItem): boolean =>
it.kind === 'app' || (it.kind === 'raw_app' && it.appTable === true)
// Hub rehydration only carries draft membership, not the live share state of an
// app. Copy the public-execution flag, public URL, and app-table origin from the
// loaded workspace items onto matching draft items so a still-public app keeps its
// Public badge, Unpublish, and iframe controls after its draft is reopened. Returns
// the original array unchanged when nothing needs merging (stable reference).
export function mergeShareState(
draftItems: DeployItem[],
workspaceItems: DeployItem[]
): DeployItem[] {
if (draftItems.length === 0 || workspaceItems.length === 0) return draftItems
const byKey = new Map(workspaceItems.map((w) => [w.key, w]))
let changed = false
const merged = draftItems.map((d) => {
const w = byKey.get(d.key)
if (!w) return d
if (w.published !== d.published || w.publicUrl !== d.publicUrl || w.appTable !== d.appTable) {
changed = true
return { ...d, published: w.published, publicUrl: w.publicUrl, appTable: w.appTable }
}
return d
})
return changed ? merged : draftItems
}
// Re-exported so the publish-flow components keep one import site.
export {
canRecord,
canRecordSession,
mergeAppTableOrigin,
type DeployItem,
type RecStatus
} from './deployToHubItems'
export function sanitizeSlug(s: string): string {
return s
.toLowerCase()
@@ -201,7 +171,6 @@ export class DeployToHubSession {
schedulePreviews = $state<Record<string, string[]>>({})
manualDeselected = $state<Set<string>>(new Set())
loading = $state(false)
workspaceRateLimit = $state<number | undefined>(undefined)
deploymentStatus = $state<
Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
>({})
@@ -228,9 +197,6 @@ export class DeployToHubSession {
pipelineRunError = $state<string | undefined>(undefined)
pipelineRecorded = $state(false)
publishTarget = $state<DeployItem | undefined>()
publishing = $state(false)
hubName = $state('')
hubSummary = $state('')
hubReadme = $state('')
@@ -301,9 +267,9 @@ export class DeployToHubSession {
)
// Derived (not merged at load time) so it settles regardless of which of the
// racing loads (#loadWorkspace / rehydrateFromHub) finishes last.
draftItemsWithLocalState = $derived(mergeShareState(this.draftItems, this.workspaceItems))
draftItemsWithOrigin = $derived(mergeAppTableOrigin(this.draftItems, this.workspaceItems))
items = $derived(
this.phase === 'predeploy' ? this.filteredWorkspaceItems : this.draftItemsWithLocalState
this.phase === 'predeploy' ? this.filteredWorkspaceItems : this.draftItemsWithOrigin
)
selectedItems = $derived(
this.phase === 'predeploy'
@@ -315,7 +281,9 @@ export class DeployToHubSession {
this.phase === 'predeploy' &&
this.selectedItemKeys.length === this.filteredWorkspaceItems.length
)
recordableItems = $derived(this.items.filter((i) => canRecord(i.kind)))
// A raw app's recorded session counts towards the project's recordings just as
// a script's captured run does — both are what a visitor replays.
recordableItems = $derived(this.items.filter((i) => canRecord(i.kind) || canRecordSession(i)))
allRecorded = $derived(
this.recordableItems.length > 0 && this.recordableItems.every((i) => i.rec === 'recorded')
)
@@ -479,23 +447,16 @@ export class DeployToHubSession {
const workspace = this.workspace
this.loading = true
try {
const [apps, rawApps, flows, scripts, settings] = await Promise.all([
const [apps, rawApps, flows, scripts] = await Promise.all([
this.#listAllPages((p) => AppService.listApps({ workspace, ...p })),
this.#listAllPages((p) => RawAppService.listRawApps({ workspace, ...p })),
this.#listAllPages((p) => FlowService.listFlows({ workspace, ...p })),
this.#listAllPages((p) => ScriptService.listScripts({ workspace, ...p })),
WorkspaceService.getSettings({ workspace }).catch(() => undefined)
this.#listAllPages((p) => ScriptService.listScripts({ workspace, ...p }))
])
if (this.#disposed) return
this.workspaceRateLimit = settings?.public_app_execution_limit_per_minute
const next: DeployItem[] = []
const publicApps = apps.filter((a) => a.execution_mode === 'anonymous')
const publicUrls = await Promise.all(publicApps.map((a) => this.#resolvePublicUrl(a.path)))
const publicUrlByPath = new Map(publicApps.map((a, i) => [a.path, publicUrls[i]]))
for (const a of apps) {
const isPublic = a.execution_mode === 'anonymous'
// Raw apps live in the `app` table (value = files/runnables) but must be
// published to the Hub as raw apps, not low-code apps.
const isRaw = (a as any).raw_app === true
@@ -505,9 +466,7 @@ export class DeployToHubSession {
kind: isRaw ? 'raw_app' : 'app',
appTable: isRaw || undefined,
summary: a.summary,
rec: 'none',
published: isPublic,
publicUrl: isPublic ? publicUrlByPath.get(a.path) : undefined
rec: 'none'
})
}
for (const a of rawApps) {
@@ -571,15 +530,6 @@ export class DeployToHubSession {
}
}
async #resolvePublicUrl(path: string): Promise<string | undefined> {
try {
const secret = await AppService.getPublicSecretOfApp({ workspace: this.workspace, path })
return computeSecretUrl(secret)
} catch {
return undefined
}
}
async rehydrateFromHub() {
try {
const res = await fetch(`/api/w/${this.workspace}/hub/project${this.#folderQs()}`, {
@@ -1198,33 +1148,6 @@ export class DeployToHubSession {
}
}
}
// A re-bundle clears the Hub-side embed (idempotent replace), so re-push it
// for any raw app that is already public — keeps the live iframe in sync
// without forcing an unpublish/share round-trip. Updates by hub id, safe in parallel.
const embedResults = await Promise.all(
bundle.items
.filter((it) => it.kind === 'raw_app')
.map(async (it) => {
const hubId = this.hubItemIds[`${it.kind}:${it.path}`]
const src = itemsSnapshot.find((i) => i.kind === 'raw_app' && i.path === it.path)
if (!hubId || !src?.published) return 0
// The re-bundle cleared the embed; a public raw app with no resolved URL
// can't have its iframe restored, so it's an incomplete publish too —
// count it (like a push failure) so the draft can't become submit-ready.
if (!src.publicUrl) {
sendUserToast(`Cannot restore the iframe for ${it.path}: missing public URL`, true)
return 1
}
try {
await this.#pushRawAppEmbed(hubId, src.publicUrl)
return 0
} catch (e: any) {
sendUserToast(`Failed to sync iframe for ${it.path}: ${e?.message ?? e}`, true)
return 1
}
})
)
failures += embedResults.reduce((a: number, b) => a + b, 0)
if (this.#disposed) return
try {
await this.#pushTriggers(slug, resourcePathMap, triggersSnapshot)
@@ -1530,6 +1453,27 @@ export class DeployToHubSession {
}
}
/** Save a recorded raw-app session as that app's Hub recording. */
async saveAppRecording(it: DeployItem, recording: RawAppRecording): Promise<boolean> {
const hubId = this.hubItemIds[it.key]
if (!hubId) {
sendUserToast(`Push the bundle to the Hub first before saving recordings`, true)
return false
}
try {
await this.#postHub(`/hub/raw_apps/${hubId}/recording`, {
recording,
project_slug: this.hubSlug
})
this.#patchItem(it.key, { rec: 'recorded' })
sendUserToast(`Recording saved — ${recording.steps.length} steps`)
return true
} catch (e: any) {
sendUserToast(`Failed to save recording: ${e?.message ?? e}`, true)
return false
}
}
/** Save the current successful run as the Hub recording. Returns true on success. */
async saveRecording(): Promise<boolean> {
const it = this.recordTarget
@@ -1688,88 +1632,6 @@ export class DeployToHubSession {
return false
}
}
// Set the Hub raw app's live-iframe URL (or clear it with null). The Hub renders
// from external_embed_url; project_slug scopes ownership.
async #pushRawAppEmbed(hubId: number, url: string | null) {
await this.#postHub(`/hub/raw_apps/${hubId}/embed`, {
external_embed_url: url,
project_slug: this.hubSlug
})
}
// Flip an app/raw app between public (anonymous) and private (publisher) and keep
// the Hub raw-app iframe in sync. Returns the resolved public URL when shared.
async #setAppShared(it: DeployItem, shared: boolean): Promise<string | null> {
const workspace = this.workspace
const hubId = it.kind === 'raw_app' ? this.hubItemIds[it.key] : undefined
// Sharing a raw app as an iframe needs its Hub item to wire the embed. Fail
// before flipping the app public so it can't be left anonymous with no embed.
if (shared && it.kind === 'raw_app' && !hubId) {
throw new Error('Push the bundle to the Hub first to share the live iframe')
}
const app = await AppService.getAppByPath({ workspace, path: it.path })
const prevMode = (app.policy?.execution_mode ?? 'publisher') as 'anonymous' | 'publisher'
const nextMode = (shared ? 'anonymous' : 'publisher') as 'anonymous' | 'publisher'
const setMode = (mode: 'anonymous' | 'publisher', message: string) =>
AppService.updateApp({
workspace,
path: it.path,
requestBody: {
policy: { ...(app.policy ?? {}), execution_mode: mode },
deployment_message: message
}
})
// Undo the policy flip so the app's public state stays consistent when a later
// step of the share fails. Best-effort: a revert failure must not mask the cause.
const rollback = () => setMode(prevMode, 'Revert iframe share').catch(() => {})
await setMode(nextMode, shared ? 'Share as iframe' : 'Unshare iframe')
const url = shared ? ((await this.#resolvePublicUrl(it.path)) ?? null) : null
// A share with no resolvable public URL is incomplete (no embeddable link, no
// Unpublish control); don't leave the app anonymous while reporting success.
if (shared && url === null) {
await rollback()
throw new Error(`Could not resolve the public URL for ${it.path}`)
}
if (hubId && it.kind === 'raw_app' && (!shared || url)) {
try {
await this.#pushRawAppEmbed(hubId, shared ? url : null)
} catch (e) {
await rollback()
throw e
}
}
return url
}
/** Make the publish target public. Returns true on success. */
async confirmPublish(): Promise<boolean> {
const it = this.publishTarget
if (!it || !canShareAsIframe(it)) return false
this.publishing = true
try {
const url = await this.#setAppShared(it, true)
this.#patchItem(it.key, { published: true, publicUrl: url ?? undefined })
sendUserToast(`${it.path} is now public`)
return true
} catch (e: any) {
sendUserToast(`Failed to publish: ${e?.message ?? e}`, true)
return false
} finally {
this.publishing = false
}
}
unpublishApp = async (it: DeployItem) => {
if (!canShareAsIframe(it)) return
try {
await this.#setAppShared(it, false)
this.#patchItem(it.key, { published: false, publicUrl: undefined })
sendUserToast('App unpublished')
} catch (e: any) {
sendUserToast(`Failed to unpublish: ${e?.message ?? e}`, true)
}
}
}
/**
@@ -1,54 +0,0 @@
import { describe, it, expect } from 'vitest'
import { canShareAsIframe, mergeShareState, type DeployItem } from './deployToHubSession.svelte'
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
return { rec: 'none', ...over }
}
describe('canShareAsIframe', () => {
it('allows low-code apps and app-table raw apps', () => {
expect(canShareAsIframe(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(true)
expect(
canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true }))
).toBe(true)
})
it('hides the action for legacy raw apps (raw_app table only)', () => {
// Legacy entries from RawAppService carry no appTable flag; AppService can't load them.
expect(canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false)
})
it('never offers the action for flows or scripts', () => {
expect(canShareAsIframe(item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' }))).toBe(false)
})
})
describe('mergeShareState', () => {
it('carries live public-share state from workspace items onto matching drafts', () => {
const drafts = [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })]
const workspace = [
item({
key: 'app:f/a',
path: 'f/a',
kind: 'app',
published: true,
publicUrl: 'https://x/app'
})
]
const merged = mergeShareState(drafts, workspace)
expect(merged[0].published).toBe(true)
expect(merged[0].publicUrl).toBe('https://x/app')
})
it('restores the app-table origin so app-table raw apps stay shareable', () => {
const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })]
const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })]
expect(canShareAsIframe(mergeShareState(drafts, workspace)[0])).toBe(true)
})
it('returns the same reference when nothing changes', () => {
const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })]
expect(mergeShareState(drafts, drafts)).toBe(drafts)
})
it('leaves drafts without a workspace match untouched', () => {
const drafts = [item({ key: 'app:f/gone', path: 'f/gone', kind: 'app' })]
const merged = mergeShareState(drafts, [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })])
expect(merged).toBe(drafts)
})
})
@@ -1,26 +1,16 @@
<script lang="ts">
import FlowRecordingReplay from '$lib/components/recording/FlowRecordingReplay.svelte'
import ScriptRecordingReplay from '$lib/components/recording/ScriptRecordingReplay.svelte'
import PipelineRecordingReplay from '$lib/components/recording/PipelineRecordingReplay.svelte'
import type {
FlowRecording,
PipelineRecording,
ScriptRecording
} from '$lib/components/recording/types'
import RecordingPlayer from '$lib/components/recording/RecordingPlayer.svelte'
import {
fetchRecording,
parseRecording,
type LoadedRecording
} from '$lib/components/recording/rawAppRecordingLoad'
import { sendUserToast } from '$lib/toast'
import { Button } from '$lib/components/common'
import FileInput from '$lib/components/common/fileInput/FileInput.svelte'
import { Loader2, TriangleAlert, Upload } from 'lucide-svelte'
import { setActiveReplay } from '$lib/components/recording/flowRecording.svelte'
import { Loader2 } from 'lucide-svelte'
import { onMount } from 'svelte'
let flowRecording: FlowRecording | undefined = $state(undefined)
let scriptRecording: ScriptRecording | undefined = $state(undefined)
let pipelineRecording: PipelineRecording | undefined = $state(undefined)
// Upper bound on a `?src=` fetched recording (JSON with capped samples/logs) so
// an arbitrary origin can't OOM the tab with an endless/huge response.
const MAX_RECORDING_BYTES = 100 * 1024 * 1024
let loaded: LoadedRecording | undefined = $state(undefined)
// Auto-download state: when the page is opened with `?src=<url>` it fetches
// the recording JSON at that URL (with progress) instead of showing the
@@ -30,112 +20,26 @@
let downloadedBytes = $state(0)
let downloadError = $state<string | undefined>(undefined)
function reset() {
flowRecording = undefined
scriptRecording = undefined
pipelineRecording = undefined
}
/** Parse recording JSON text and route it to the right player. Returns false
/** Validate a parsed recording and route it to the right player. Returns false
* (and toasts) when the payload isn't a recognized recording. */
function loadRecordingFromText(content: string): boolean {
let data: any
try {
data = JSON.parse(content)
} catch (err) {
sendUserToast('Failed to load recording: ' + err, true)
function accept(data: unknown): boolean {
const res = parseRecording(data)
if (!res.ok) {
sendUserToast(res.error, true)
return false
}
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
// A RecordedJob whose events all carry an object `data`: JobLoader replays
// each `event.data` in a `setTimeout`, whose throw a Svelte boundary can't
// catch, so a malformed event must be rejected at load. Shared by all three
// recording types (flow/script/pipeline all mount jobs through JobLoader).
const isRecordedJob = (j: unknown): boolean =>
isObject(j) &&
isObject(j.initial_job) &&
Array.isArray(j.events) &&
j.events.every((e) => isObject(e) && isObject(e.data))
const isJobsMap = (v: unknown): boolean => isObject(v) && Object.values(v).every(isRecordedJob)
// A non-object payload (JSON `null`, an array, a scalar) has no `.version`
// to read — guard before dereferencing so it toasts instead of throwing.
if (!isObject(data)) {
sendUserToast('Invalid recording format', true)
return false
}
if (data.version !== 1) {
sendUserToast('Invalid recording format', true)
return false
}
if (data.type === 'script') {
if (!isRecordedJob(data.job)) {
sendUserToast('Invalid script recording format', true)
return false
}
reset()
scriptRecording = data as ScriptRecording
} else if (data.type === 'pipeline') {
// Load-time check on caller-controlled input (upload / `?src=` fetch) so the
// common malformed shapes toast here; the render boundary below is the
// catch-all for anything deeper.
const objectArray = (v: unknown) => Array.isArray(v) && v.every(isObject)
const optionalObjectArray = (v: unknown) => v === undefined || objectArray(v)
const optionalRecord = (v: unknown, valid: (x: Record<string, unknown>) => boolean) =>
v === undefined || (isObject(v) && Object.values(v).every((x) => isObject(x) && valid(x)))
const g = data.graph as Record<string, unknown> | null
const validGraph =
isObject(g) &&
objectArray(g.runnables) &&
objectArray(g.assets) &&
objectArray(g.edges) &&
Array.isArray(g.triggers) &&
g.triggers.every((t) => isObject(t) && typeof t.trigger_kind === 'string') &&
optionalObjectArray(g.macro_edges) &&
optionalObjectArray(g.test_edges)
const validTimeline =
Array.isArray(data.timeline) &&
data.timeline.every(
(f: unknown) =>
isObject(f) && isObject(f.statuses) && Object.values(f.statuses).every(isObject)
)
const validJobs = isJobsMap(data.jobs)
// A sample renders `rows`/`columns` unless it carries a non-empty `error`.
const validSamples = optionalRecord(
data.assetSamples,
(s) =>
(typeof s.error === 'string' && s.error !== '') ||
(objectArray(s.rows) && objectArray(s.columns))
)
const validCodes = optionalRecord(
data.codes,
(c) => typeof c.content === 'string' && typeof c.language === 'string'
)
if (!(validGraph && validTimeline && validJobs && validSamples && validCodes)) {
sendUserToast('Invalid pipeline recording format', true)
return false
}
reset()
pipelineRecording = data as PipelineRecording
} else {
// Flow recording (type === 'flow' or type is absent for backwards compat).
// Validate jobs structurally — the `?src=` loader accepts remote payloads
// and FlowRecordingReplay mounts them straight into JobLoader, outside the
// pipeline boundary.
if (!isJobsMap(data.jobs)) {
sendUserToast('Invalid flow recording format', true)
return false
}
reset()
flowRecording = data as FlowRecording
}
loaded = res.loaded
return true
}
function handleFileChange(event: CustomEvent<(string | ArrayBuffer | null)[]>) {
const content = event.detail?.[0]
if (!content || typeof content !== 'string') return
loadRecordingFromText(content)
try {
accept(JSON.parse(content))
} catch (err) {
sendUserToast('Failed to load recording: ' + err, true)
}
}
/** Fetch a recording JSON from `url`, streaming so a progress bar can show,
@@ -146,43 +50,11 @@
downloadPercent = undefined
downloadedBytes = 0
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
const total = Number(res.headers.get('content-length')) || 0
// `?src=` is an arbitrary (possibly permissive-CORS) origin, so cap the
// download: reject an oversized advertised length up front and abort the
// stream once the running total exceeds the limit, so an endless/huge
// response can't exhaust the tab before validation runs.
if (total > MAX_RECORDING_BYTES) {
throw new Error(
`Recording is too large (${fmtBytes(total)}, max ${fmtBytes(MAX_RECORDING_BYTES)})`
)
}
const reader = res.body?.getReader()
let text: string
if (reader) {
const chunks: Uint8Array[] = []
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value) {
chunks.push(value)
downloadedBytes += value.length
if (downloadedBytes > MAX_RECORDING_BYTES) {
await reader.cancel()
throw new Error(`Recording exceeded the ${fmtBytes(MAX_RECORDING_BYTES)} limit`)
}
if (total) downloadPercent = Math.round((downloadedBytes / total) * 100)
}
}
text = await new Blob(chunks as BlobPart[]).text()
} else {
text = await res.text()
if (text.length > MAX_RECORDING_BYTES) {
throw new Error(`Recording exceeded the ${fmtBytes(MAX_RECORDING_BYTES)} limit`)
}
}
if (!loadRecordingFromText(text)) {
const data = await fetchRecording(url, (bytes, total) => {
downloadedBytes = bytes
downloadPercent = total ? Math.round((bytes / total) * 100) : undefined
})
if (!accept(data)) {
downloadError = 'The downloaded file is not a valid recording.'
}
} catch (err) {
@@ -197,11 +69,6 @@
if (src) loadFromUrl(src)
})
function quit() {
setActiveReplay(undefined)
reset()
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
@@ -209,88 +76,37 @@
}
</script>
<!-- Shown by every player's boundary when a malformed recording crashes on
render/effect (the load-time validation and JobLoader guards cover the rest). -->
{#snippet replayFailed()}
<div class="flex flex-col items-center justify-center h-full gap-2 text-center">
<TriangleAlert class="text-red-500" size={28} />
<p class="max-w-md text-sm text-secondary">
This recording could not be replayed — it may be malformed or from an incompatible version.
</p>
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
Load another recording
</Button>
{#if loaded}
<RecordingPlayer {loaded} onreset={() => (loaded = undefined)} class="px-4 py-4" />
{:else if downloading}
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
<div class="flex flex-col items-center gap-3 max-w-md w-full">
<Loader2 class="animate-spin text-blue-500" size={28} />
<h2 class="text-lg font-semibold text-emphasis">Downloading recording…</h2>
{#if downloadPercent !== undefined}
<div class="w-full h-2 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full bg-blue-500 transition-all" style="width: {downloadPercent}%"></div>
</div>
<p class="text-2xs text-tertiary">{downloadPercent}% · {fmtBytes(downloadedBytes)}</p>
{:else}
<p class="text-2xs text-tertiary">{fmtBytes(downloadedBytes)}</p>
{/if}
</div>
</div>
{/snippet}
<!-- The pipeline player fills the viewport (graph left, detail right, like the
pipeline editor); the flow/script players keep the centered scrolling page. -->
<div
class={pipelineRecording
? 'flex flex-col h-full w-full px-4 py-4 min-h-0'
: 'max-w-7xl mx-auto px-4 py-8 w-full'}
>
{#if flowRecording}
<div class="flex justify-end mb-4">
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
Load another recording
</Button>
{:else}
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
<div class="flex flex-col items-center gap-2 max-w-md w-full">
<h2 class="text-lg font-semibold text-emphasis">Replay a recording</h2>
<p class="text-xs text-secondary mb-2">
Upload a recording JSON file to replay a flow, script or data-pipeline execution — or a
raw-app session — offline.
</p>
{#if downloadError}
<p class="text-xs text-red-600 dark:text-red-400 mb-1 text-center">{downloadError}</p>
{/if}
<FileInput accept=".json" convertTo="text" class="w-full" on:change={handleFileChange}>
Drag and drop a recording file
</FileInput>
</div>
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
<FlowRecordingReplay recording={flowRecording} />
{#snippet failed()}{@render replayFailed()}{/snippet}
</svelte:boundary>
{:else if scriptRecording}
<div class="flex justify-end mb-4">
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
Load another recording
</Button>
</div>
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
<ScriptRecordingReplay recording={scriptRecording} />
{#snippet failed()}{@render replayFailed()}{/snippet}
</svelte:boundary>
{:else if pipelineRecording}
<div class="flex justify-end mb-2 shrink-0">
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
Load another recording
</Button>
</div>
<div class="flex-1 min-h-0">
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
<PipelineRecordingReplay recording={pipelineRecording} />
{#snippet failed()}{@render replayFailed()}{/snippet}
</svelte:boundary>
</div>
{:else if downloading}
<div class="flex flex-col items-center justify-center min-h-[60vh]">
<div class="flex flex-col items-center gap-3 max-w-md w-full">
<Loader2 class="animate-spin text-blue-500" size={28} />
<h2 class="text-lg font-semibold text-emphasis">Downloading recording…</h2>
{#if downloadPercent !== undefined}
<div class="w-full h-2 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full bg-blue-500 transition-all" style="width: {downloadPercent}%"></div>
</div>
<p class="text-2xs text-tertiary">{downloadPercent}% · {fmtBytes(downloadedBytes)}</p>
{:else}
<p class="text-2xs text-tertiary">{fmtBytes(downloadedBytes)}</p>
{/if}
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center min-h-[60vh]">
<div class="flex flex-col items-center gap-2 max-w-md w-full">
<h2 class="text-lg font-semibold text-emphasis">Replay a recording</h2>
<p class="text-xs text-secondary mb-2">
Upload a recording JSON file to replay a flow, script or data-pipeline execution offline.
</p>
{#if downloadError}
<p class="text-xs text-red-600 dark:text-red-400 mb-1 text-center">{downloadError}</p>
{/if}
<FileInput accept=".json" convertTo="text" class="w-full" on:change={handleFileChange}>
Drag and drop a recording file
</FileInput>
</div>
</div>
{/if}
</div>
</div>
{/if}
@@ -1,9 +0,0 @@
import { redirect } from '@sveltejs/kit'
import { base } from '$app/paths'
// The replay page moved to /pipeline_replay (it now replays data-pipeline
// recordings in addition to flow/script ones). Redirect the old path in `load`
// so existing /replay links and bookmarks still resolve instead of 404-ing.
export function load({ url }: { url: URL }) {
redirect(307, `${base}/pipeline_replay${url.search}`)
}
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
/**
* Public, chrome-less player for a Windmill recording — no login, and
* embeddable in an iframe. Windmill stores nothing: the recording is either a
* file the visitor opens locally or a URL they host themselves (`?src=`), so a
* demo can live next to the docs or README that links it. Every kind of
* recording (app session, flow, script, pipeline run) plays here, offline.
*/
import RecordingPlayer from '$lib/components/recording/RecordingPlayer.svelte'
import { setOfflineReplay } from '$lib/components/recording/offlineReplay.svelte'
import {
fetchRecording,
MAX_RECORDING_BYTES,
parseRecording
} from '$lib/components/recording/rawAppRecordingLoad'
import type { LoadedRecording } from '$lib/components/recording/rawAppRecordingLoad'
import { Loader2, TriangleAlert, Upload } from 'lucide-svelte'
import { onDestroy, onMount } from 'svelte'
let loaded = $state<LoadedRecording | undefined>(undefined)
let loading = $state(false)
let error = $state<string | undefined>(undefined)
let progress = $state<number | undefined>(undefined)
function accept(data: unknown): boolean {
const res = parseRecording(data)
if (!res.ok) {
error = res.error
return false
}
error = undefined
loaded = res.loaded
return true
}
async function loadFromUrl(src: string) {
loading = true
error = undefined
try {
accept(
await fetchRecording(src, (bytes, total) => {
progress = total ? Math.round((bytes / total) * 100) : undefined
})
)
} catch (e) {
error = `Could not load the recording: ${e instanceof Error ? e.message : e}`
} finally {
loading = false
}
}
async function onFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Cleared so picking the same file again after an error still fires `change`.
input.value = ''
if (!file) return
// Same cap the `?src=` fetch enforces: reading a multi-hundred-MB file into a
// string is enough to take the tab down before validation gets a say.
if (file.size > MAX_RECORDING_BYTES) {
error = `That file is too large to replay (${file.size} bytes).`
return
}
try {
accept(JSON.parse(await file.text()))
} catch (err) {
error = `Could not read the file: ${err instanceof Error ? err.message : err}`
}
}
onMount(() => {
setOfflineReplay(true)
const src = new URL(window.location.href).searchParams.get('src')
if (src) loadFromUrl(src)
})
onDestroy(() => setOfflineReplay(false))
let title = $derived.by(() => {
if (!loaded) return 'Replay'
switch (loaded.kind) {
case 'app':
return `Replay — ${loaded.recording.app_path}`
case 'script':
return `Replay — ${loaded.recording.script_path}`
case 'pipeline':
return `Replay — ${loaded.recording.folder}`
case 'flow':
return `Replay — ${loaded.recording.flow_path}`
}
})
</script>
<svelte:head><title>{title}</title></svelte:head>
<div class="h-screen w-screen p-3 bg-surface overflow-auto">
{#if loaded}
<RecordingPlayer {loaded} hideHeader onreset={() => (loaded = undefined)} />
{:else if loading}
<div class="h-full flex flex-col items-center justify-center gap-2 text-sm text-secondary">
<Loader2 size={24} class="animate-spin text-blue-500" />
Downloading the recording{progress !== undefined ? ` — ${progress}%` : ''}
</div>
{:else}
<div class="h-full flex flex-col items-center justify-center gap-3 text-center px-4">
<h1 class="text-lg font-semibold text-emphasis">Replay a recording</h1>
<p class="text-xs text-secondary max-w-lg">
Open a recording file, or point this page at one you host yourself with
<span class="font-mono">?src=&lt;url&gt;</span>. Nothing is uploaded to Windmill — the
recording is read in your browser and replayed from it alone.
</p>
{#if error}
<p class="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<TriangleAlert size={14} />
{error}
</p>
{/if}
<label
class="inline-flex items-center gap-2 text-xs border rounded-md px-3 py-2 cursor-pointer hover:bg-surface-hover"
>
<Upload size={14} /> Choose a recording file
<input type="file" accept=".json" class="hidden" onchange={onFile} />
</label>
</div>
{/if}
</div>
+12 -1
View File
@@ -138,9 +138,20 @@ const config = {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}', 'src/**/*.dom.{test,spec}.{js,ts}'],
setupFiles: ['src/lib/test-setup.ts']
}
},
{
// `*.dom.test.ts` — for the pure DOM utilities (snapshot serialization,
// replay sanitization) whose contracts can only be asserted against a
// real document.
extends: './vite.config.js',
test: {
name: 'dom',
environment: 'jsdom',
include: ['src/**/*.dom.{test,spec}.{js,ts}']
}
}
]
}
+13
View File
@@ -647,6 +647,18 @@ const user = await backend.get_user({ user_id: '123' });
The frontend cannot reach datatables, workspace items, or external services on its own it goes through \`backend.<key>(args)\` for everything server-side.
### Keeping data out of recorded demos
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
\`\`\`tsx
<label data-wm-no-record>
Customer SSN <input value={ssn} onChange={onSsn} />
</label>
\`\`\`
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
## Backend runnables
Each runnable has a unique key (used to call it from the frontend) and one of four types:
@@ -749,6 +761,7 @@ def main(user_id: str):
3. **Keep runnables focused** one function per runnable; small surface area.
4. **Use descriptive keys** \`get_user\`, not \`a\`.
5. **Always whitelist tables** adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
`;
export const PIPELINE_BASE = `# Data pipeline authoring
@@ -284,6 +284,18 @@ const user = await backend.get_user({ user_id: '123' });
The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.<key>(args)` for everything server-side.
### Keeping data out of recorded demos
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
```tsx
<label data-wm-no-record>
Customer SSN <input value={ssn} onChange={onSsn} />
</label>
```
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
## Backend runnables
Each runnable has a unique key (used to call it from the frontend) and one of four types:
@@ -386,3 +398,4 @@ def main(user_id: str):
3. **Keep runnables focused** — one function per runnable; small surface area.
4. **Use descriptive keys**`get_user`, not `a`.
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.
6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
+13
View File
@@ -49,6 +49,18 @@ const user = await backend.get_user({ user_id: '123' });
The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.<key>(args)` for everything server-side.
### Keeping data out of recorded demos
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
```tsx
<label data-wm-no-record>
Customer SSN <input value={ssn} onChange={onSsn} />
</label>
```
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
## Backend runnables
Each runnable has a unique key (used to call it from the frontend) and one of four types:
@@ -151,3 +163,4 @@ def main(user_id: str):
3. **Keep runnables focused** — one function per runnable; small surface area.
4. **Use descriptive keys**`get_user`, not `a`.
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.
6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.