@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 +15 -5
- package/package.json +6 -3
- package/src/captureCli.js +22 -1
- package/src/cli.js +185 -3
- package/src/cli.test.js +51 -0
- package/src/render/cli.js +82 -0
- package/src/render/cli.test.js +47 -0
- package/src/render/diffCache.js +152 -0
- package/src/render/diffCache.test.js +110 -0
- package/src/render/pagePool.js +82 -0
- package/src/render/pagePool.test.js +23 -0
- package/src/render/tokenInject.js +53 -0
- package/src/render/tokenInject.test.js +59 -0
- package/src/render/walkerBundle.js +24 -0
- package/src/render/walkerBundle.test.js +12 -0
- package/src/render/worker.js +193 -0
- package/src/render/worker.test.js +233 -0
- package/src/variants.js +279 -0
- package/src/variants.test.js +129 -0
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @sorb/seed
|
|
2
2
|
|
|
3
|
-
Storybook
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "Storybook
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/cli.test.js
ADDED
|
@@ -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
|
+
}
|