@sorb/seed 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @sorb/seed
2
2
 
3
- Storybook Figma capture for Sorb. This package holds the **heavy**
4
- pieces (esbuild now; Playwright later) so the bridge (`@sorb/juice`)
5
- and `@sorb/leaf` stay lean.
3
+ Storybook→Figma capture for Sorb™, the design-token bridge for your running app. (Seed.)
4
+
5
+ This package holds the **heavy** pieces (esbuild now; Playwright later)
6
+ so the bridge (`@sorb/juice`) and `@sorb/leaf` stay lean.
6
7
 
7
8
  The full design lives in the team's internal spec (kept out of the repo).
8
9
 
@@ -13,7 +14,7 @@ This package is **private / not published to npm yet**, so there's no
13
14
 
14
15
  ```bash
15
16
  # 1. install this package's deps (from this directory)
16
- cd packages/seed
17
+ cd sorb-seed
17
18
  npm install # pulls esbuild (Playwright is optional — see capture)
18
19
 
19
20
  # 2. expose the `sorb-seed` bin on your PATH
@@ -27,9 +28,14 @@ later: `npm unlink -g @sorb/seed` (or `npm rm -g @sorb/seed`).
27
28
  directly from the consuming app:
28
29
 
29
30
  ```bash
30
- node /abs/path/to/sorb/packages/seed/src/cli.js resolve
31
+ node /abs/path/to/sorb-seed/src/cli.js resolve
31
32
  ```
32
33
 
34
+ The CLI has exactly two commands — **`resolve`** and **`capture`** — plus
35
+ `sorb-seed --help` / `-h` (usage) and `sorb-seed --version` / `-v`. (There is
36
+ **no** `annotate` command: `annotateTree`/`annotateTokens` is the internal
37
+ binder `capture` calls, not a CLI verb.)
38
+
33
39
  > **Where you run it matters.** `sorb-seed` reads `sorb.config.json`,
34
40
  > `sd.config.js`, and `tokens/` from the **current working directory** — i.e.
35
41
  > your *app* (e.g. `example/`), **not** this package directory. Run the commands
@@ -122,3 +128,7 @@ ranking (component > semantic > primitive).
122
128
  Planned: the **plugin materializer** (turns each `LayerNode` into a Figma
123
129
  component bound to Variables via `setBoundVariable`); pseudo-elements and
124
130
  forced interaction states; component-set assembly from per-story captures.
131
+
132
+ ---
133
+
134
+ **Sorb™** is a trademark of Metatoy LLC.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sorb/seed",
3
- "version": "0.1.1",
4
- "description": "Storybook Figma capture for Sorb: headless capture + the resolved bindable token map. Heavy deps (esbuild, later Playwright) live here so the bridge stays lean.",
3
+ "version": "0.2.0",
4
+ "description": "Storybook→Figma capture for Sorb, the design-token bridge for your running app. (Seed.)",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "sorb",
@@ -21,6 +21,9 @@
21
21
  },
22
22
  "type": "module",
23
23
  "main": "src/index.js",
24
+ "scripts": {
25
+ "test": "node --test src/"
26
+ },
24
27
  "bin": {
25
28
  "sorb-seed": "src/cli.js"
26
29
  },
@@ -28,7 +31,7 @@
28
31
  "src"
29
32
  ],
30
33
  "dependencies": {
31
- "@sorb/core": "^0.1.0",
34
+ "@sorb/core": "^0.1.1",
32
35
  "esbuild": "^0.21.0"
33
36
  },
34
37
  "peerDependencies": {
package/src/captureCli.js CHANGED
@@ -27,6 +27,27 @@ const loadChromium = async () => {
27
27
  }
28
28
  }
29
29
 
30
+ // The `playwright` PACKAGE can be installed while its Chromium BROWSER binary is
31
+ // not (that's a separate `npx playwright install chromium` step). Launching then
32
+ // throws a raw "Executable doesn't exist" error — turn it into the same
33
+ // actionable guidance the missing-package path already gives.
34
+ export const launchChromium = async (chromium) => {
35
+ try {
36
+ return await chromium.launch()
37
+ } catch (e) {
38
+ const msg = e && e.message ? e.message : String(e)
39
+ if (/Executable doesn't exist|playwright install|browserType\.launch/i.test(msg)) {
40
+ console.error(
41
+ '✗ `sorb-seed capture` found Playwright but its Chromium browser is not installed.\n' +
42
+ ' Install the browser where you run capture:\n' +
43
+ ' npx playwright install chromium',
44
+ )
45
+ process.exit(1)
46
+ }
47
+ throw e
48
+ }
49
+ }
50
+
30
51
  const cwd = process.cwd()
31
52
 
32
53
  const loadConfig = () => {
@@ -127,7 +148,7 @@ export const runCapture = async (opts) => {
127
148
  // 2. Browser setup + walker injection
128
149
  const chromium = await loadChromium()
129
150
  const walker = await buildWalkerBundle()
130
- const browser = await chromium.launch()
151
+ const browser = await launchChromium(chromium)
131
152
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
132
153
  await ctx.addInitScript({ content: walker })
133
154
 
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, existsSync } from 'fs'
3
- import { resolve } from 'path'
3
+ import { resolve, dirname } from 'path'
4
4
  import { execSync } from 'child_process'
5
5
 
6
6
  // sorb-seed — Storybook → Figma capture tooling.
@@ -25,7 +25,60 @@ const loadConfig = () => {
25
25
 
26
26
  const cmd = process.argv[2] || 'resolve'
27
27
 
28
- if (cmd === 'resolve') {
28
+ const pkgVersion = () => {
29
+ try {
30
+ const pkgPath = resolve(dirname(new URL(import.meta.url).pathname), '..', 'package.json')
31
+ return JSON.parse(readFileSync(pkgPath, 'utf-8')).version
32
+ } catch (e) {
33
+ return '0.0.0'
34
+ }
35
+ }
36
+
37
+ const HELP = `sorb-seed — Storybook → Figma token capture for Sorb.
38
+
39
+ Usage: sorb-seed <command> [options]
40
+
41
+ Commands:
42
+ resolve Build .sorb/resolved.json from your DTCG token sets via
43
+ Style Dictionary (the default when no command is given).
44
+ capture Visit each Storybook story with Playwright, capture the
45
+ rendered tree, and annotate it against the resolved token
46
+ map → per-component *.sorb.json + .sorb/index.json.
47
+ render-worker Hosted-capture on-demand render worker (Mode B / E3):
48
+ reads RenderJobInput job(s) as NDJSON on stdin (or
49
+ --job=/--job-file=), renders each against a URL + token
50
+ map, emits RenderJobResult NDJSON on stdout. See
51
+ src/render/worker.js.
52
+ variant <add|deprecate>
53
+ Add or deprecate a component variant in the DTCG source,
54
+ bump $version, and rebuild. See \`variant\` usage below.
55
+
56
+ variant options:
57
+ variant add <id> --from <base> [--tokens-dir <dir>] [--sd-dir <dir>]
58
+ variant deprecate <id> --replaced-by <replacement> [--tokens-dir <dir>] [--sd-dir <dir>]
59
+
60
+ capture options:
61
+ --changed Only re-capture stories whose rendered hash changed.
62
+ --only=<pattern> Capture only stories matching the glob/regex (matched
63
+ against importPath, title, and id).
64
+ --storybook-url=<url>
65
+ Storybook base URL (default: sorb.config.json seed.storybookUrl
66
+ or http://localhost:6006).
67
+
68
+ Global:
69
+ -h, --help Show this help and exit.
70
+ -v, --version Print the sorb-seed version and exit.
71
+
72
+ capture needs Playwright + its Chromium browser:
73
+ npm install playwright && npx playwright install chromium`
74
+
75
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
76
+ console.log(HELP)
77
+ process.exit(0)
78
+ } else if (cmd === '--version' || cmd === '-v') {
79
+ console.log(pkgVersion())
80
+ process.exit(0)
81
+ } else if (cmd === 'resolve') {
29
82
  const config = loadConfig()
30
83
  const sdConfig = config.styleDictionaryConfig || 'sd.config.js'
31
84
  const abs = resolve(cwd, sdConfig)
@@ -53,7 +106,136 @@ if (cmd === 'resolve') {
53
106
  else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
54
107
  }
55
108
  await runCapture(opts)
109
+ } else if (cmd === 'render-worker') {
110
+ const { main: renderWorkerMain } = await import('./render/cli.js')
111
+ await renderWorkerMain(process.argv)
112
+ } else if (cmd === 'variant') {
113
+ const sub = process.argv[3]
114
+
115
+ // ── shared arg parser ──────────────────────────────────────────────────────
116
+ const args = process.argv.slice(4)
117
+ const flag = (name) => {
118
+ const prefix = `--${name}=`
119
+ const eq = args.find((a) => a.startsWith(prefix))
120
+ if (eq) return eq.slice(prefix.length)
121
+ const idx = args.indexOf(`--${name}`)
122
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) return args[idx + 1]
123
+ return null
124
+ }
125
+
126
+ // ── auto-detect sorb-demo paths ────────────────────────────────────────────
127
+ // Walks up from cwd looking for sorb-demo/tokens/component.json and
128
+ // sorb-demo/sd.config.js. Falls back to cwd-relative guesses.
129
+ const autoDetect = () => {
130
+ // Try workspace-sibling layout: cwd may be sorb-seed or any sibling
131
+ const candidates = [
132
+ resolve(cwd, '../sorb-demo'),
133
+ resolve(cwd, 'sorb-demo'),
134
+ cwd,
135
+ ]
136
+ for (const base of candidates) {
137
+ const tokens = resolve(base, 'tokens/component.json')
138
+ const sd = resolve(base, 'sd.config.js')
139
+ if (existsSync(tokens) && existsSync(sd)) return { tokensDir: resolve(base, 'tokens'), sdDir: base }
140
+ }
141
+ return null
142
+ }
143
+
144
+ if (sub === 'add') {
145
+ const variantId = args[0] && !args[0].startsWith('--') ? args[0] : null
146
+ const fromRaw = flag('from')
147
+ const tokensDirFlag = flag('tokens-dir')
148
+ const sdDirFlag = flag('sd-dir')
149
+
150
+ if (!variantId) {
151
+ console.error('✗ Usage: sorb-seed variant add <variantId> --from <fromVariant> [--tokens-dir <dir>] [--sd-dir <dir>]')
152
+ process.exit(1)
153
+ }
154
+ if (!fromRaw) {
155
+ console.error('✗ --from is required (e.g. --from primary or --from button.primary)')
156
+ process.exit(1)
157
+ }
158
+
159
+ // Normalise --from: if it has no dot, prepend the component from variantId
160
+ const componentId = variantId.split('.')[0]
161
+ const fromVariant = fromRaw.includes('.') ? fromRaw : `${componentId}.${fromRaw}`
162
+
163
+ // Resolve paths
164
+ let tokensDir = tokensDirFlag ? resolve(cwd, tokensDirFlag) : null
165
+ let sdDir = sdDirFlag ? resolve(cwd, sdDirFlag) : null
166
+ if (!tokensDir || !sdDir) {
167
+ const detected = autoDetect()
168
+ if (!tokensDir) tokensDir = detected ? detected.tokensDir : resolve(cwd, 'tokens')
169
+ if (!sdDir) sdDir = detected ? detected.sdDir : cwd
170
+ }
171
+ const componentJsonPath = resolve(tokensDir, 'component.json')
172
+
173
+ if (!existsSync(componentJsonPath)) {
174
+ console.error(`✗ component.json not found at ${componentJsonPath}`)
175
+ console.error(' Use --tokens-dir to point at your tokens directory.')
176
+ process.exit(1)
177
+ }
178
+
179
+ const { addVariant } = await import('./variants.js')
180
+ try {
181
+ const changeset = await addVariant({ componentJsonPath, variantId, fromVariant, sdConfigDir: sdDir })
182
+ console.log(`✓ Added variant ${changeset.variantId} (${changeset.tokenIds.length} tokens) — $version bumped to ${changeset.newVersion}`)
183
+ } catch (e) {
184
+ console.error('✗', e.message)
185
+ process.exit(1)
186
+ }
187
+
188
+ } else if (sub === 'deprecate') {
189
+ const variantId = args[0] && !args[0].startsWith('--') ? args[0] : null
190
+ const replacedBy = flag('replaced-by')
191
+ const tokensDirFlag = flag('tokens-dir')
192
+ const sdDirFlag = flag('sd-dir')
193
+
194
+ if (!variantId) {
195
+ console.error('✗ Usage: sorb-seed variant deprecate <variantId> --replaced-by <replacedBy> [--tokens-dir <dir>] [--sd-dir <dir>]')
196
+ process.exit(1)
197
+ }
198
+ if (!replacedBy) {
199
+ console.error('✗ --replaced-by is required (e.g. --replaced-by button.error)')
200
+ process.exit(1)
201
+ }
202
+
203
+ // Resolve paths
204
+ let tokensDir = tokensDirFlag ? resolve(cwd, tokensDirFlag) : null
205
+ let sdDir = sdDirFlag ? resolve(cwd, sdDirFlag) : null
206
+ if (!tokensDir || !sdDir) {
207
+ const detected = autoDetect()
208
+ if (!tokensDir) tokensDir = detected ? detected.tokensDir : resolve(cwd, 'tokens')
209
+ if (!sdDir) sdDir = detected ? detected.sdDir : cwd
210
+ }
211
+ const componentJsonPath = resolve(tokensDir, 'component.json')
212
+
213
+ if (!existsSync(componentJsonPath)) {
214
+ console.error(`✗ component.json not found at ${componentJsonPath}`)
215
+ console.error(' Use --tokens-dir to point at your tokens directory.')
216
+ process.exit(1)
217
+ }
218
+
219
+ // Normalise replacedBy: if no dot, prepend the component from variantId
220
+ const componentId = variantId.split('.')[0]
221
+ const replacedByFull = replacedBy.includes('.') ? replacedBy : `${componentId}.${replacedBy}`
222
+
223
+ const { deprecateVariant } = await import('./variants.js')
224
+ try {
225
+ const changeset = await deprecateVariant({ componentJsonPath, variantId, replacedBy: replacedByFull, sdConfigDir: sdDir })
226
+ console.log(`✓ Deprecated variant ${changeset.variantId} → ${replacedByFull} (${changeset.tokenIds.length} tokens marked)`)
227
+ } catch (e) {
228
+ console.error('✗', e.message)
229
+ process.exit(1)
230
+ }
231
+
232
+ } else {
233
+ console.error(`✗ Unknown variant subcommand: ${sub || '(none)'}`)
234
+ console.error(' Usage: sorb-seed variant <add|deprecate> ...')
235
+ process.exit(1)
236
+ }
237
+
56
238
  } else {
57
- console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture> [options]`)
239
+ console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture|render-worker|variant> [options]\nRun \`sorb-seed --help\` for details.`)
58
240
  process.exit(1)
59
241
  }
@@ -0,0 +1,51 @@
1
+ // Smoke tests for the `sorb-seed` CLI entrypoint — --help / --version / unknown
2
+ // command dispatch (GFP RC1 Part 2 · D4). Spawns the real bin so exit codes and
3
+ // stdout/stderr are exercised end-to-end. Run: `node --test src/cli.test.js`.
4
+
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { readFileSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { dirname, resolve } from 'node:path'
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url))
13
+ const CLI = resolve(here, 'cli.js')
14
+ const pkgVersion = JSON.parse(
15
+ readFileSync(resolve(here, '..', 'package.json'), 'utf-8'),
16
+ ).version
17
+
18
+ const run = (args) => spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf-8' })
19
+
20
+ test('--help prints usage, lists resolve+capture, exits 0', () => {
21
+ const r = run(['--help'])
22
+ assert.equal(r.status, 0)
23
+ assert.match(r.stdout, /Usage: sorb-seed/)
24
+ assert.match(r.stdout, /\bresolve\b/)
25
+ assert.match(r.stdout, /\bcapture\b/)
26
+ })
27
+
28
+ test('-h is an alias for --help', () => {
29
+ const r = run(['-h'])
30
+ assert.equal(r.status, 0)
31
+ assert.match(r.stdout, /Usage: sorb-seed/)
32
+ })
33
+
34
+ test('--version prints the package.json version, exits 0', () => {
35
+ const r = run(['--version'])
36
+ assert.equal(r.status, 0)
37
+ assert.equal(r.stdout.trim(), pkgVersion)
38
+ })
39
+
40
+ test('-v is an alias for --version', () => {
41
+ const r = run(['-v'])
42
+ assert.equal(r.status, 0)
43
+ assert.equal(r.stdout.trim(), pkgVersion)
44
+ })
45
+
46
+ test('unknown command exits 1 and points at --help', () => {
47
+ const r = run(['bogus'])
48
+ assert.equal(r.status, 1)
49
+ assert.match(r.stderr, /Unknown command: bogus/)
50
+ assert.match(r.stderr, /--help/)
51
+ })
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // `sorb-seed render-worker` — E3 hosted-capture on-demand render worker entry.
3
+ // hosted-bridge-modes-exploration-plan.md §3 E3.
4
+ //
5
+ // This is the thin callable seam a cloud off-box runner (queue dispatcher)
6
+ // invokes to run render jobs — mirrors sorb-cloud's `runnerEntry.mjs`
7
+ // NDJSON-over-stdout child-runner contract so the pattern is consistent
8
+ // across the two repos:
9
+ //
10
+ // Input: NDJSON on stdin — one `RenderJobInput` JSON object per line
11
+ // (see worker.js JSDoc). For a one-shot call from a shell, pass
12
+ // `--job='<json>'` or `--job-file=<path>` instead.
13
+ // Output: NDJSON on stdout — one line per job:
14
+ // {"t":"result","data":<RenderJobResult>}
15
+ // {"t":"error","message":"...","job":<the input job>}
16
+ // Diagnostics/logs go to STDERR only — stdout is reserved for the
17
+ // protocol so a queue can pipe it straight into JSON.parse per line.
18
+ //
19
+ // The Playwright page pool persists across jobs read from stdin, so a queue
20
+ // that batches multiple jobs for the SAME url into one process invocation
21
+ // gets pagePool.js's navigation-skip reuse. It is closed once stdin ends (or
22
+ // after the single --job/--job-file run), so the process exits cleanly.
23
+
24
+ import { readFileSync } from 'node:fs'
25
+ import { renderJob } from './worker.js'
26
+ import { createPagePool } from './pagePool.js'
27
+
28
+ const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n')
29
+
30
+ async function runOneJob(job, pool) {
31
+ try {
32
+ const data = await renderJob(job, { pagePool: async () => pool })
33
+ emit({ t: 'result', data })
34
+ } catch (e) {
35
+ emit({ t: 'error', message: e && e.message ? e.message : String(e), job })
36
+ }
37
+ }
38
+
39
+ async function readStdinJobs() {
40
+ let buf = ''
41
+ const jobs = []
42
+ process.stdin.setEncoding('utf8')
43
+ for await (const chunk of process.stdin) {
44
+ buf += chunk
45
+ let nl
46
+ while ((nl = buf.indexOf('\n')) >= 0) {
47
+ const line = buf.slice(0, nl)
48
+ buf = buf.slice(nl + 1)
49
+ if (line.trim()) jobs.push(JSON.parse(line))
50
+ }
51
+ }
52
+ if (buf.trim()) jobs.push(JSON.parse(buf))
53
+ return jobs
54
+ }
55
+
56
+ export async function main(argv = process.argv) {
57
+ const pool = createPagePool()
58
+ try {
59
+ const jobFileArg = argv.find((a) => a.startsWith('--job-file='))
60
+ const jobArg = argv.find((a) => a.startsWith('--job='))
61
+
62
+ if (jobFileArg || jobArg) {
63
+ const raw = jobFileArg
64
+ ? readFileSync(jobFileArg.slice('--job-file='.length), 'utf-8')
65
+ : jobArg.slice('--job='.length)
66
+ await runOneJob(JSON.parse(raw), pool)
67
+ return
68
+ }
69
+
70
+ const jobs = await readStdinJobs()
71
+ for (const job of jobs) await runOneJob(job, pool)
72
+ } finally {
73
+ await pool.closeAll()
74
+ }
75
+ }
76
+
77
+ if (import.meta.url === `file://${process.argv[1]}`) {
78
+ main().catch((e) => {
79
+ process.stderr.write((e && e.stack ? e.stack : String(e)) + '\n')
80
+ process.exit(1)
81
+ })
82
+ }
@@ -0,0 +1,47 @@
1
+ // cli.js wiring test. Deliberately does NOT exercise a real render (that would
2
+ // launch a real browser) — it drives the `--job=` path with an invalid job
3
+ // (missing `url`), which renderJob rejects BEFORE ever touching the page pool
4
+ // (see worker.js: the url check runs first). That proves the CLI's NDJSON
5
+ // error-emission + pool-lifecycle wiring without needing Playwright/a browser.
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { main } from './cli.js'
9
+
10
+ function captureStdout(fn) {
11
+ const lines = []
12
+ const original = process.stdout.write.bind(process.stdout)
13
+ process.stdout.write = (chunk) => {
14
+ lines.push(String(chunk))
15
+ return true
16
+ }
17
+ return fn().finally(() => {
18
+ process.stdout.write = original
19
+ }).then(() => lines.join(''))
20
+ }
21
+
22
+ test('cli main(): --job= with a missing url emits a {t:"error"} NDJSON line, not a throw', async () => {
23
+ const output = await captureStdout(() => main(['node', 'cli.js', `--job=${JSON.stringify({})}`]))
24
+ const lines = output.trim().split('\n').filter(Boolean)
25
+ assert.equal(lines.length, 1)
26
+ const msg = JSON.parse(lines[0])
27
+ assert.equal(msg.t, 'error')
28
+ assert.match(msg.message, /url is required/)
29
+ assert.deepEqual(msg.job, {})
30
+ })
31
+
32
+ test('cli main(): --job-file= reads the job from disk', async () => {
33
+ const { writeFile, rm } = await import('node:fs/promises')
34
+ const { join } = await import('node:path')
35
+ const { tmpdir } = await import('node:os')
36
+ const { randomUUID } = await import('node:crypto')
37
+ const path = join(tmpdir(), `sorb-cli-test-${randomUUID()}.json`)
38
+ await writeFile(path, JSON.stringify({}))
39
+ try {
40
+ const output = await captureStdout(() => main(['node', 'cli.js', `--job-file=${path}`]))
41
+ const msg = JSON.parse(output.trim())
42
+ assert.equal(msg.t, 'error')
43
+ assert.match(msg.message, /url is required/)
44
+ } finally {
45
+ await rm(path, { force: true })
46
+ }
47
+ })
@@ -0,0 +1,152 @@
1
+ // E3 (hosted-capture render worker) — diff-only cache spike.
2
+ //
3
+ // Two independent wins, both real (not stubbed), scoped honestly:
4
+ // 1. EXACT-MATCH short-circuit: if a (url, tokenMap) pair was rendered before
5
+ // and the token map is byte-identical, skip the render entirely and return
6
+ // the cached result. This is the biggest, cheapest win during the credit
7
+ // window (repeat previews of an unchanged proposal).
8
+ // 2. CHANGED-SUBTREE REPORT: when the token map differs from the last render
9
+ // of the SAME url, we still do a full page recapture (Playwright reads the
10
+ // whole rendered DOM in one pass — there is no cheap partial DOM read), but
11
+ // we diff the new capture's per-node hashes against the previous one and
12
+ // report which subtrees actually changed. That's real, useful signal for a
13
+ // caller (cloud telemetry / a future incremental-repaint UI) even though the
14
+ // RENDER itself is not selectively re-executed.
15
+ //
16
+ // TRUE selective re-render (only re-rendering the changed subtrees inside the
17
+ // browser, skipping layout for the rest) is NOT implemented — it would require
18
+ // either a persistent, patchable page (partial `page.evaluate` reflow, which
19
+ // Chromium doesn't expose cleanly) or a virtual-DOM diffing shim injected into
20
+ // the target app (out of our control since Mode B targets arbitrary apps). This
21
+ // is the honest boundary called out in hosted-bridge-modes-exploration-plan.md
22
+ // §3 E3 ("keep it behind a flag/option; correctness first, optimization second
23
+ // — leave a documented stub"). What IS implemented (page reuse — skip
24
+ // navigation on a cache-miss-but-same-url render) lives in `pagePool.js`.
25
+
26
+ import { createHash } from 'node:crypto'
27
+
28
+ /** Stable JSON stringify (sorted keys) so token-map key order never changes the hash. */
29
+ export const stableStringify = (value) => {
30
+ const sortKeys = (v) => {
31
+ if (Array.isArray(v)) return v.map(sortKeys)
32
+ if (v && typeof v === 'object') {
33
+ return Object.keys(v)
34
+ .sort()
35
+ .reduce((acc, k) => {
36
+ acc[k] = sortKeys(v[k])
37
+ return acc
38
+ }, {})
39
+ }
40
+ return v
41
+ }
42
+ return JSON.stringify(sortKeys(value))
43
+ }
44
+
45
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex')
46
+
47
+ /** Hash a token map (cssVar -> value), order-independent. */
48
+ export const hashTokenMap = (tokenMap) => 'sha256:' + sha256(stableStringify(tokenMap || {}))
49
+
50
+ /** The cache key for a render: (url, tokenMap-hash). */
51
+ export const makeCacheKey = (url, tokenMap) => `${url}::${hashTokenMap(tokenMap)}`
52
+
53
+ /**
54
+ * Per-node hash map keyed by a stable structural path ("0.2.1" = root's 3rd
55
+ * child's 2nd child), so two trees of the same shape can be compared node-by-
56
+ * node even though DOM nodes have no stable id. Hash excludes nothing — the
57
+ * whole node's own fields (not descendants) plus a summary of children count,
58
+ * so a change anywhere down a branch bubbles up as a changed hash at every
59
+ * ancestor on that branch (cheap "which top-level regions changed" signal).
60
+ * @param {object} tree LayerNode-shaped tree (root, with .children[])
61
+ * @returns {Map<string,string>} path -> sha256 hash
62
+ */
63
+ export const hashNodePaths = (tree) => {
64
+ const out = new Map()
65
+ const walk = (node, path) => {
66
+ if (!node) return
67
+ const { children, ...ownFields } = node
68
+ const own = sha256(stableStringify(ownFields))
69
+ const childHashes = (children || []).map((c, i) => walk(c, path ? `${path}.${i}` : String(i)))
70
+ const combined = sha256(own + '|' + childHashes.join(','))
71
+ out.set(path || '0', combined)
72
+ return combined
73
+ }
74
+ walk(tree, '0')
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * Diff two node-path hash maps.
80
+ * @returns {{changed:string[], added:string[], removed:string[]}} paths present
81
+ * in both but with a different hash / only in `next` / only in `prev`.
82
+ */
83
+ export const diffNodeHashes = (prevHashes, nextHashes) => {
84
+ const changed = []
85
+ const added = []
86
+ const removed = []
87
+ for (const [path, hash] of nextHashes) {
88
+ if (!prevHashes.has(path)) added.push(path)
89
+ else if (prevHashes.get(path) !== hash) changed.push(path)
90
+ }
91
+ for (const path of prevHashes.keys()) {
92
+ if (!nextHashes.has(path)) removed.push(path)
93
+ }
94
+ return { changed, added, removed }
95
+ }
96
+
97
+ /**
98
+ * In-memory diff-cache. One process/worker instance's lifetime — not persisted.
99
+ * `capacity` bounds memory (LRU-by-insertion via Map iteration order); default
100
+ * is generous since a render result's dom tree is the only heavy field kept.
101
+ */
102
+ export class DiffCache {
103
+ constructor({ capacity = 200 } = {}) {
104
+ this.capacity = capacity
105
+ /** exact (url,tokenMap-hash) -> full render result */
106
+ this.exact = new Map()
107
+ /** url -> { tokenMap, hashes: Map<path,hash> } — last render, ANY token map */
108
+ this.byUrl = new Map()
109
+ }
110
+
111
+ /** Exact-match lookup: same url + byte-identical token map. */
112
+ getExact(url, tokenMap) {
113
+ return this.exact.get(makeCacheKey(url, tokenMap))
114
+ }
115
+
116
+ /** Record an exact-match entry (called after every successful render). */
117
+ putExact(url, tokenMap, result) {
118
+ const key = makeCacheKey(url, tokenMap)
119
+ this.exact.set(key, result)
120
+ this._evictIfNeeded(this.exact)
121
+ }
122
+
123
+ /**
124
+ * Diff a freshly-captured tree against the last capture for this URL
125
+ * (regardless of token map), returning which node paths changed. Also
126
+ * records this capture as the new "last" for the url. Returns `null` when
127
+ * there is no prior capture to diff against (first render for this url).
128
+ */
129
+ diffAgainstLastForUrl(url, tokenMap, tree) {
130
+ const prev = this.byUrl.get(url)
131
+ const nextHashes = hashNodePaths(tree)
132
+ let diff = null
133
+ if (prev) {
134
+ diff = diffNodeHashes(prev.hashes, nextHashes)
135
+ }
136
+ this.byUrl.set(url, { tokenMap, hashes: nextHashes })
137
+ this._evictIfNeeded(this.byUrl)
138
+ return diff
139
+ }
140
+
141
+ _evictIfNeeded(map) {
142
+ while (map.size > this.capacity) {
143
+ const oldestKey = map.keys().next().value
144
+ map.delete(oldestKey)
145
+ }
146
+ }
147
+
148
+ clear() {
149
+ this.exact.clear()
150
+ this.byUrl.clear()
151
+ }
152
+ }