@erclx/canon 5.66.0 → 5.68.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/plan-feature/references/plan.md +1 -1
  11. package/claude/skills/plan-intake/references/intake.md +12 -2
  12. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
  13. package/claude/skills/pr-evidence/SKILL.md +7 -1
  14. package/claude/skills/pr-evidence/references/evidence.md +33 -3
  15. package/claude/skills/review-craft/references/ui.md +30 -10
  16. package/claude/skills/review-ui/SKILL.md +5 -4
  17. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  18. package/claude/skills/review-ui/references/driving.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  20. package/claude/skills/role-planner/SKILL.md +2 -2
  21. package/claude/skills/role-worker/SKILL.md +4 -6
  22. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  23. package/claude/skills/session-map/SKILL.md +8 -1
  24. package/claude/skills/session-map/references/session.md +19 -5
  25. package/claude/skills/ui-record/REQUIREMENT.md +36 -0
  26. package/claude/skills/ui-record/SKILL.md +114 -0
  27. package/claude/skills/ui-record/references/config.md +40 -0
  28. package/claude/skills/ui-record/references/handoff.md +67 -0
  29. package/claude/skills/ui-record/references/pr-clip.md +84 -0
  30. package/claude/skills/ui-record/references/take-craft.md +99 -0
  31. package/docs/agents/records.md +2 -0
  32. package/docs/workflow/parallel-features.md +1 -1
  33. package/docs/workflow/skill-map-on-request.md +1 -0
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/docs/workflow/visual-design-workflow.md +2 -1
  36. package/package.json +5 -2
  37. package/src/audits/catalog.ts +5 -1
  38. package/src/browser/agent-cli.ts +68 -0
  39. package/src/claude/cases/workflow.ts +20 -0
  40. package/src/cli/help.ts +3 -0
  41. package/src/cli.ts +4 -0
  42. package/src/commands/browser.ts +102 -0
  43. package/src/commands/intake.ts +181 -1
  44. package/src/commands/pr.ts +252 -85
  45. package/src/commands/records.ts +18 -1
  46. package/src/commands/take.ts +302 -0
  47. package/src/context/requirements.ts +1 -11
  48. package/src/git/status.ts +73 -0
  49. package/src/intake/drive-check.ts +355 -0
  50. package/src/intake/folder.ts +1 -1
  51. package/src/markdown/scan.ts +10 -0
  52. package/src/pr/drive-copy.ts +97 -84
  53. package/src/pr/evidence.ts +208 -78
  54. package/src/pr/frames.ts +42 -4
  55. package/src/pr/pixels.ts +44 -0
  56. package/src/records/backup.ts +28 -4
  57. package/src/records/session-map.ts +75 -0
  58. package/src/records/validate.ts +64 -8
  59. package/src/take/camera.ts +309 -0
  60. package/src/take/encode.ts +207 -0
  61. package/src/take/film.ts +211 -0
  62. package/src/take/fixtures/fade.html +49 -0
  63. package/src/take/fixtures/fetch.html +38 -0
  64. package/src/take/log.ts +109 -0
  65. package/src/take/perform.ts +200 -0
  66. package/src/take/plan.ts +193 -0
  67. package/src/take/pointer.ts +168 -0
  68. package/src/take/reel.ts +215 -0
  69. package/src/take/screencast.ts +97 -0
  70. package/src/take/shell.ts +287 -0
  71. package/src/take/stepper.ts +255 -0
  72. package/src/take/zoom/NOTICE.md +45 -0
  73. package/src/take/zoom/math-utils.ts +69 -0
  74. package/src/take/zoom/region-utils.ts +55 -0
  75. package/src/take/zoom/spring.ts +133 -0
  76. package/src/tasks/archive.ts +1 -3
  77. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { readFileSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import type { CameraTrack } from '@/take/camera'
5
+ import { finished } from '@/take/encode'
6
+ import type { FilmedBeat } from '@/take/film'
7
+ import { FPS } from '@/take/log'
8
+ import { type Frame, drawPointer } from '@/take/pointer'
9
+
10
+ /**
11
+ * Joins a take's beats into one short reel for a pull request: each beat's
12
+ * clip shrunk to one width, every third frame kept, the pointer drawn from its
13
+ * camera track, encoded as an animated WebP that GitHub plays inline. It
14
+ * draws the pointer and nothing else, since the zoom, captions, and cuts are
15
+ * the edit's work for a launch video.
16
+ */
17
+
18
+ export const REEL_WIDTH = 960
19
+
20
+ /** How many take frames each reel frame stands for, 60 fps down to 20. */
21
+ export const REEL_EVERY = 3
22
+
23
+ export const REEL_FPS = FPS / REEL_EVERY
24
+
25
+ /** WebP quality, measured at about 1.4 MB for a 9 s reel at this width. */
26
+ const QUALITY = 60
27
+
28
+ export interface ReelFrame extends Frame {
29
+ /** Which beat of the take, in plan order. */
30
+ readonly beat: number
31
+ /** The frame's place in its beat, which draws camera frame `index * REEL_EVERY`. */
32
+ readonly index: number
33
+ }
34
+
35
+ export interface Reel {
36
+ readonly file: string
37
+ readonly frames: number
38
+ readonly seconds: number
39
+ readonly bytes: number
40
+ }
41
+
42
+ /**
43
+ * Yields every reel frame in order with the pointer drawn on it. The decoder
44
+ * keeps frames by index rather than by timestamp, since an `fps` filter that
45
+ * rounds timestamps drifts the pointer off what it pressed.
46
+ */
47
+ export async function* reelFrames(
48
+ out: string,
49
+ beats: readonly FilmedBeat[],
50
+ ): AsyncGenerator<ReelFrame> {
51
+ for (const [beat, filmed] of beats.entries()) {
52
+ const camera = JSON.parse(
53
+ readFileSync(join(out, filmed.camera), 'utf8'),
54
+ ) as CameraTrack
55
+ const { width, height } = reelSize(camera)
56
+ // The camera is in CSS pixels of the page, and every route's clip shrinks
57
+ // to the same width, so one factor places the pointer on either.
58
+ const factor = width / camera.viewport.width
59
+
60
+ let index = 0
61
+ for await (const data of decode(join(out, filmed.clip), width, height)) {
62
+ const at =
63
+ camera.frames[Math.min(index * REEL_EVERY, camera.frames.length - 1)]
64
+ const frame = { data, width, height, beat, index }
65
+ if (at) {
66
+ drawPointer(
67
+ frame,
68
+ { x: at.cursor.x * factor, y: at.cursor.y * factor },
69
+ at.ring,
70
+ )
71
+ }
72
+ yield frame
73
+ index += 1
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Renders the reel of a filmed take to `file`, an animated WebP. */
79
+ export async function renderReel(
80
+ out: string,
81
+ beats: readonly FilmedBeat[],
82
+ file: string,
83
+ ): Promise<Reel> {
84
+ const first = beats[0]
85
+ if (!first) throw new Error('a reel needs at least one filmed beat')
86
+ const { width, height } = reelSize(
87
+ JSON.parse(readFileSync(join(out, first.camera), 'utf8')) as CameraTrack,
88
+ )
89
+
90
+ const proc = spawn(
91
+ 'ffmpeg',
92
+ [
93
+ '-y',
94
+ '-loglevel',
95
+ 'error',
96
+ '-f',
97
+ 'rawvideo',
98
+ '-pix_fmt',
99
+ 'rgba',
100
+ '-s',
101
+ `${width}x${height}`,
102
+ '-framerate',
103
+ String(REEL_FPS),
104
+ '-i',
105
+ '-',
106
+ '-c:v',
107
+ 'libwebp_anim',
108
+ '-quality',
109
+ String(QUALITY),
110
+ '-loop',
111
+ '0',
112
+ '-f',
113
+ 'webp',
114
+ file,
115
+ ],
116
+ { stdio: ['pipe', 'ignore', 'pipe'] },
117
+ )
118
+ const done = finished(proc)
119
+ // Held so an encoder that dies mid-stream surfaces through `done` rather than
120
+ // as an unhandled rejection or a broken pipe thrown out of the stream.
121
+ done.catch(() => {})
122
+ proc.stdin?.on('error', () => {})
123
+
124
+ let frames = 0
125
+ try {
126
+ for await (const frame of reelFrames(out, beats)) {
127
+ // Raced against the exit, since a write waiting on a drain from an
128
+ // encoder that already died would wait forever.
129
+ await Promise.race([write(proc.stdin, frame.data), done])
130
+ frames += 1
131
+ }
132
+ } catch (error) {
133
+ proc.kill('SIGKILL')
134
+ throw error
135
+ }
136
+ proc.stdin?.end()
137
+ await done
138
+
139
+ return {
140
+ file,
141
+ frames,
142
+ seconds: Number((frames / REEL_FPS).toFixed(2)),
143
+ bytes: statSync(file).size,
144
+ }
145
+ }
146
+
147
+ function reelSize(camera: CameraTrack): { width: number; height: number } {
148
+ const height =
149
+ Math.round(
150
+ (REEL_WIDTH * camera.viewport.height) / camera.viewport.width / 2,
151
+ ) * 2
152
+ return { width: REEL_WIDTH, height }
153
+ }
154
+
155
+ /** Decodes every `REEL_EVERY`th frame of a clip as RGBA at the reel's size. */
156
+ async function* decode(
157
+ clip: string,
158
+ width: number,
159
+ height: number,
160
+ ): AsyncGenerator<Uint8Array> {
161
+ const proc = spawn(
162
+ 'ffmpeg',
163
+ [
164
+ '-loglevel',
165
+ 'error',
166
+ '-i',
167
+ clip,
168
+ '-vf',
169
+ `select='not(mod(n\\,${REEL_EVERY}))',scale=${width}:${height}:flags=area`,
170
+ '-fps_mode',
171
+ 'passthrough',
172
+ '-f',
173
+ 'rawvideo',
174
+ '-pix_fmt',
175
+ 'rgba',
176
+ '-',
177
+ ],
178
+ { stdio: ['ignore', 'pipe', 'pipe'] },
179
+ )
180
+ const done = finished(proc)
181
+ done.catch(() => {})
182
+
183
+ const size = width * height * 4
184
+ let pending: Buffer = Buffer.alloc(0)
185
+ let drained = false
186
+ try {
187
+ for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
188
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk
189
+ while (pending.length >= size) {
190
+ // Copied out, since the frame is drawn on and outlives the chunk.
191
+ yield new Uint8Array(pending.subarray(0, size))
192
+ pending = pending.subarray(size)
193
+ }
194
+ }
195
+ drained = true
196
+ } finally {
197
+ // A reader that stopped early leaves FFmpeg decoding into a full pipe.
198
+ if (!drained) proc.kill('SIGKILL')
199
+ }
200
+ await done
201
+ }
202
+
203
+ function write(
204
+ stdin: NodeJS.WritableStream | null,
205
+ data: Uint8Array,
206
+ ): Promise<void> {
207
+ return new Promise((resolve, reject) => {
208
+ if (!stdin) {
209
+ reject(new Error('ffmpeg stopped reading frames'))
210
+ return
211
+ }
212
+ if (stdin.write(data)) resolve()
213
+ else stdin.once('drain', () => resolve())
214
+ })
215
+ }
@@ -0,0 +1,97 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { chromium } from 'playwright-core'
5
+ import { lastFrame, probeFrames, transcode } from '@/take/encode'
6
+ import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
+ import { type PointerInput, type Stage, performSteps } from '@/take/perform'
8
+ import type { Beat } from '@/take/plan'
9
+ import { HOLD_MS } from '@/take/stepper'
10
+
11
+ /**
12
+ * Films a beat in real time through Playwright's screencast, for a page frame
13
+ * stepping cannot drive. It runs at the viewport's own 1920x1080, so it goes
14
+ * soft under a zoom where a stepped take stays sharp, and its frames land when
15
+ * the page paints rather than one per step.
16
+ */
17
+
18
+ const FRAME_MS = 1000 / FPS
19
+
20
+ const SETTLE_MS = 2500
21
+
22
+ export interface ScreencastTake {
23
+ readonly frames: number
24
+ readonly events: readonly LogEvent[]
25
+ /** The clip's closing frame as a JPEG. */
26
+ readonly still: Buffer
27
+ }
28
+
29
+ export async function filmScreencast(request: {
30
+ readonly url: string
31
+ readonly beat: Beat
32
+ readonly clip: string
33
+ }): Promise<ScreencastTake> {
34
+ const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
35
+ const browser = await chromium.launch()
36
+ try {
37
+ const page = await browser.newPage({ viewport: VIEWPORT })
38
+ await page.goto(request.url, { waitUntil: 'load' })
39
+ await page.waitForTimeout(SETTLE_MS)
40
+
41
+ const stageOn = (clock: () => number): Stage => ({
42
+ now: clock,
43
+ hold: (ms) => page.waitForTimeout(ms),
44
+ pointer: async (input) => {
45
+ const sent = clock()
46
+ await send(page.mouse, input)
47
+ const spent = clock() - sent
48
+ if (spent < FRAME_MS) await page.waitForTimeout(FRAME_MS - spent)
49
+ },
50
+ key: async (text) => {
51
+ await page.keyboard.insertText(text)
52
+ await page.waitForTimeout(FRAME_MS)
53
+ },
54
+ evaluate: (expression) => page.evaluate(expression),
55
+ })
56
+
57
+ const live = stageOn(() => performance.now())
58
+ await performSteps(live, request.beat.setup, { pointer: START })
59
+ if (request.beat.setup.length > 0) await live.hold(1000)
60
+ await page.mouse.move(START.x, START.y)
61
+
62
+ const webm = join(scratch, 'take.webm')
63
+ const events: LogEvent[] = []
64
+ await page.screencast.start({ path: webm, size: VIEWPORT, fps: FPS })
65
+ const started = performance.now()
66
+ const recording = stageOn(() => performance.now() - started)
67
+ await recording.hold(HOLD_MS)
68
+ await performSteps(recording, request.beat.steps, {
69
+ pointer: START,
70
+ log: events,
71
+ })
72
+ await recording.hold(HOLD_MS)
73
+ await page.screencast.stop()
74
+
75
+ await transcode(webm, request.clip, FPS)
76
+ const frames = await probeFrames(request.clip)
77
+ return { frames, events, still: await lastFrame(request.clip, frames) }
78
+ } finally {
79
+ await browser.close()
80
+ rmSync(scratch, { recursive: true, force: true })
81
+ }
82
+ }
83
+
84
+ type Mouse = import('playwright-core').Mouse
85
+
86
+ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
87
+ switch (input.type) {
88
+ case 'move':
89
+ return mouse.move(input.x, input.y)
90
+ case 'down':
91
+ return mouse.down()
92
+ case 'up':
93
+ return mouse.up()
94
+ case 'wheel':
95
+ return mouse.wheel(0, input.dy)
96
+ }
97
+ }
@@ -0,0 +1,287 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { homedir, tmpdir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ /**
8
+ * Launches chrome-headless-shell under begin-frame control and speaks raw CDP
9
+ * to one page in it. Playwright cannot ask for begin-frame control on a page it
10
+ * opens, and only the headless shell honors the flag, so this bypasses the
11
+ * engine and finds the shell Playwright installed beside Chromium.
12
+ *
13
+ * `playwright-core` exposes no path for the shell, so the path is rebuilt from
14
+ * its `browsers.json` revision and its cache layout. A Playwright upgrade that
15
+ * moves that layout surfaces here as `browser-missing` rather than as a crash.
16
+ */
17
+
18
+ export class ShellMissing extends Error {}
19
+
20
+ /** Each platform's folder and binary inside the shell's revision folder. */
21
+ const SHELL_DIRS: Record<string, readonly [string, string]> = {
22
+ 'linux-x64': ['chrome-headless-shell-linux64', 'chrome-headless-shell'],
23
+ 'linux-arm64': ['chrome-headless-shell-linux-arm64', 'chrome-headless-shell'],
24
+ 'darwin-x64': ['chrome-headless-shell-mac-x64', 'chrome-headless-shell'],
25
+ 'darwin-arm64': ['chrome-headless-shell-mac-arm64', 'chrome-headless-shell'],
26
+ }
27
+
28
+ const FLAGS = [
29
+ '--enable-begin-frame-control',
30
+ '--run-all-compositor-stages-before-draw',
31
+ '--disable-threaded-animation',
32
+ '--disable-threaded-scrolling',
33
+ '--disable-checker-imaging',
34
+ '--hide-scrollbars',
35
+ '--force-device-scale-factor=2',
36
+ '--mute-audio',
37
+ '--no-first-run',
38
+ ]
39
+
40
+ /** Resolves the installed shell, throwing `ShellMissing` naming where it looked. */
41
+ export function findShell(env: NodeJS.ProcessEnv = process.env): string {
42
+ const require = createRequire(import.meta.url)
43
+ const engineRoot = dirname(require.resolve('playwright-core/package.json'))
44
+ const manifest = JSON.parse(
45
+ readFileSync(join(engineRoot, 'browsers.json'), 'utf8'),
46
+ ) as { browsers: { name: string; revision: string }[] }
47
+ const revision = manifest.browsers.find(
48
+ (browser) => browser.name === 'chromium-headless-shell',
49
+ )?.revision
50
+ const dirs = SHELL_DIRS[`${process.platform}-${process.arch}`]
51
+ if (!revision || !dirs) {
52
+ throw new ShellMissing(
53
+ `no chrome-headless-shell for ${process.platform}-${process.arch} in playwright-core's browsers.json`,
54
+ )
55
+ }
56
+
57
+ const path = join(
58
+ registryRoot(env, engineRoot),
59
+ `chromium_headless_shell-${revision}`,
60
+ ...dirs,
61
+ )
62
+ if (!existsSync(path)) {
63
+ throw new ShellMissing(
64
+ `chrome-headless-shell executable doesn't exist at ${path}`,
65
+ )
66
+ }
67
+ return path
68
+ }
69
+
70
+ /** Where Playwright keeps its browsers, read the way its own registry reads it. */
71
+ function registryRoot(env: NodeJS.ProcessEnv, engineRoot: string): string {
72
+ const pinned = env.PLAYWRIGHT_BROWSERS_PATH
73
+ if (pinned === '0') return join(engineRoot, '.local-browsers')
74
+ if (pinned) return pinned
75
+ if (process.platform === 'darwin') {
76
+ return join(homedir(), 'Library', 'Caches', 'ms-playwright')
77
+ }
78
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'ms-playwright')
79
+ }
80
+
81
+ type Listener = (params: Record<string, unknown>) => void
82
+
83
+ export interface ShellPage {
84
+ send<T = Record<string, unknown>>(
85
+ method: string,
86
+ params?: Record<string, unknown>,
87
+ ): Promise<T>
88
+ /** Calls `listener` on every event of `method` from this page. */
89
+ on(method: string, listener: Listener): void
90
+ /** Runs an expression in the page and returns its value. */
91
+ evaluate<T>(expression: string): Promise<T>
92
+ /** Kills the shell and removes its profile, safe to call more than once. */
93
+ close(): void
94
+ }
95
+
96
+ /**
97
+ * Opens one page at `width` by `height` with begin-frame control. The launch
98
+ * owns its profile folder and kills the shell on process exit, so a killed take
99
+ * leaves no shell running and no profile behind.
100
+ */
101
+ export async function openShell(
102
+ executable: string,
103
+ size: { readonly width: number; readonly height: number },
104
+ ): Promise<ShellPage> {
105
+ const profile = mkdtempSync(join(tmpdir(), 'canon-take-'))
106
+ const proc = spawn(
107
+ executable,
108
+ [
109
+ '--remote-debugging-port=0',
110
+ `--user-data-dir=${profile}`,
111
+ ...FLAGS,
112
+ 'about:blank',
113
+ ],
114
+ { stdio: ['ignore', 'ignore', 'pipe'] },
115
+ )
116
+
117
+ let closed = false
118
+ const close = (): void => {
119
+ if (closed) return
120
+ closed = true
121
+ process.off('exit', close)
122
+ process.off('SIGINT', interrupt)
123
+ process.off('SIGTERM', interrupt)
124
+ socket?.close()
125
+ proc.kill('SIGKILL')
126
+ rmSync(profile, { recursive: true, force: true })
127
+ }
128
+ // A signal skips the exit event, so it closes the shell and then re-raises
129
+ // itself with the default handler restored, ending the process as it would have.
130
+ const interrupt = (signal: NodeJS.Signals): void => {
131
+ close()
132
+ process.kill(process.pid, signal)
133
+ }
134
+ process.on('exit', close)
135
+ process.once('SIGINT', interrupt)
136
+ process.once('SIGTERM', interrupt)
137
+
138
+ let socket: WebSocket | undefined
139
+ try {
140
+ socket = new WebSocket(await devtoolsUrl(proc))
141
+ await new Promise((done, fail) => {
142
+ socket?.addEventListener('open', done, { once: true })
143
+ socket?.addEventListener('error', fail, { once: true })
144
+ })
145
+ return await attach(socket, size, close)
146
+ } catch (error) {
147
+ close()
148
+ throw error
149
+ }
150
+ }
151
+
152
+ function devtoolsUrl(proc: ChildProcess): Promise<string> {
153
+ return new Promise((done, fail) => {
154
+ let output = ''
155
+ proc.stderr?.on('data', (chunk: Buffer) => {
156
+ output += chunk.toString()
157
+ const match = output.match(/DevTools listening on (ws:\S+)/)
158
+ if (match?.[1]) done(match[1])
159
+ })
160
+ proc.on('error', fail)
161
+ proc.on('exit', (code) =>
162
+ fail(new Error(`the shell exited ${code}: ${output.slice(-400)}`)),
163
+ )
164
+ })
165
+ }
166
+
167
+ async function attach(
168
+ socket: WebSocket,
169
+ size: { readonly width: number; readonly height: number },
170
+ close: () => void,
171
+ ): Promise<ShellPage> {
172
+ let nextId = 0
173
+ const pending = new Map<
174
+ number,
175
+ { resolve: (value: never) => void; reject: (error: Error) => void }
176
+ >()
177
+ const listeners: { method: string; sessionId: string; listener: Listener }[] =
178
+ []
179
+
180
+ socket.addEventListener('message', (event) => {
181
+ const message = JSON.parse(String(event.data)) as {
182
+ id?: number
183
+ method?: string
184
+ sessionId?: string
185
+ params?: Record<string, unknown>
186
+ result?: never
187
+ error?: { message: string; data?: string }
188
+ }
189
+ if (message.id !== undefined) {
190
+ const waiter = pending.get(message.id)
191
+ if (!waiter) return
192
+ pending.delete(message.id)
193
+ if (message.error) {
194
+ waiter.reject(
195
+ new Error(
196
+ `${message.error.message} ${message.error.data ?? ''}`.trim(),
197
+ ),
198
+ )
199
+ } else {
200
+ waiter.resolve(message.result as never)
201
+ }
202
+ return
203
+ }
204
+ for (const entry of listeners) {
205
+ if (
206
+ entry.method === message.method &&
207
+ entry.sessionId === message.sessionId
208
+ ) {
209
+ entry.listener(message.params ?? {})
210
+ }
211
+ }
212
+ })
213
+ socket.addEventListener('close', () => {
214
+ for (const waiter of pending.values()) {
215
+ waiter.reject(new Error('the shell closed its connection'))
216
+ }
217
+ pending.clear()
218
+ })
219
+
220
+ const raw = <T>(
221
+ method: string,
222
+ params: Record<string, unknown> = {},
223
+ sessionId?: string,
224
+ ): Promise<T> =>
225
+ new Promise<T>((resolve, reject) => {
226
+ nextId += 1
227
+ pending.set(nextId, {
228
+ resolve: resolve as (value: never) => void,
229
+ reject,
230
+ })
231
+ socket.send(
232
+ JSON.stringify({
233
+ id: nextId,
234
+ method,
235
+ params,
236
+ ...(sessionId ? { sessionId } : {}),
237
+ }),
238
+ )
239
+ })
240
+
241
+ const { browserContextId } = await raw<{ browserContextId: string }>(
242
+ 'Target.createBrowserContext',
243
+ )
244
+ const { targetId } = await raw<{ targetId: string }>('Target.createTarget', {
245
+ url: 'about:blank',
246
+ browserContextId,
247
+ enableBeginFrameControl: true,
248
+ width: size.width,
249
+ height: size.height,
250
+ })
251
+ const { sessionId } = await raw<{ sessionId: string }>(
252
+ 'Target.attachToTarget',
253
+ { targetId, flatten: true },
254
+ )
255
+
256
+ const send = <T>(method: string, params?: Record<string, unknown>) =>
257
+ raw<T>(method, params, sessionId)
258
+
259
+ await send('Page.enable')
260
+ await send('Runtime.enable')
261
+
262
+ return {
263
+ send,
264
+ on: (method, listener) => listeners.push({ method, sessionId, listener }),
265
+ evaluate: async <T>(expression: string): Promise<T> => {
266
+ const reply = await send<{
267
+ result: { value: T }
268
+ exceptionDetails?: {
269
+ text: string
270
+ exception?: { description?: string }
271
+ }
272
+ }>('Runtime.evaluate', {
273
+ expression,
274
+ awaitPromise: true,
275
+ returnByValue: true,
276
+ })
277
+ if (reply.exceptionDetails) {
278
+ throw new Error(
279
+ reply.exceptionDetails.exception?.description ??
280
+ reply.exceptionDetails.text,
281
+ )
282
+ }
283
+ return reply.result.value
284
+ },
285
+ close,
286
+ }
287
+ }