@erclx/canon 5.55.0 → 5.57.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 (84) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +8 -10
  4. package/claude/skills/auto-ship/SKILL.md +4 -5
  5. package/claude/skills/auto-ship/references/failure-recovery.md +37 -17
  6. package/claude/skills/auto-ship/references/tier-failures.md +9 -7
  7. package/claude/skills/auto-ship/references/worktree-name.md +8 -5
  8. package/claude/skills/backlog-triage/SKILL.md +10 -10
  9. package/claude/skills/backlog-triage/references/apply.md +6 -6
  10. package/claude/skills/canon-feedback-triage/SKILL.md +7 -5
  11. package/claude/skills/context-fold/references/classify.md +17 -4
  12. package/claude/skills/create-rule/references/rule.md +4 -0
  13. package/claude/skills/create-skill/SKILL.md +2 -2
  14. package/claude/skills/deploy-app/SKILL.md +2 -0
  15. package/claude/skills/deprecation-migration/SKILL.md +7 -9
  16. package/claude/skills/document-health/references/axes.md +1 -1
  17. package/claude/skills/git-followup/REQUIREMENT.md +4 -4
  18. package/claude/skills/git-followup/SKILL.md +24 -7
  19. package/claude/skills/git-issue/references/issue.md +9 -11
  20. package/claude/skills/git-pr/references/closing-issues.md +8 -0
  21. package/claude/skills/git-pr/references/publish.md +4 -5
  22. package/claude/skills/git-split/SKILL.md +11 -4
  23. package/claude/skills/markdown-craft/SKILL.md +1 -1
  24. package/claude/skills/markdown-craft/references/markdown.md +2 -2
  25. package/claude/skills/markdown-craft/references/tables.md +9 -7
  26. package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
  27. package/claude/skills/plan-feature/SKILL.md +19 -1
  28. package/claude/skills/plan-feature/references/onboarding.md +51 -0
  29. package/claude/skills/plan-feature/references/plan.md +18 -0
  30. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  31. package/claude/skills/plan-intake/REQUIREMENT.md +3 -3
  32. package/claude/skills/plan-intake/SKILL.md +4 -0
  33. package/claude/skills/plan-intake/references/intake.md +10 -0
  34. package/claude/skills/review-address/SKILL.md +10 -8
  35. package/claude/skills/review-craft/SKILL.md +7 -9
  36. package/claude/skills/review-craft/references/adopted.md +10 -8
  37. package/claude/skills/review-pr/SKILL.md +2 -0
  38. package/claude/skills/review-pr/references/key-changes.md +8 -6
  39. package/claude/skills/review-ui/references/driving.md +10 -8
  40. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
  41. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
  42. package/claude/skills/role-reviewer/SKILL.md +5 -5
  43. package/claude/skills/search-craft/SKILL.md +5 -7
  44. package/claude/skills/session-worktree/SKILL.md +2 -0
  45. package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
  46. package/claude/skills/systematic-debugging/SKILL.md +9 -9
  47. package/claude/skills/systematic-debugging/references/adopted.md +1 -1
  48. package/claude/skills/target-update/REQUIREMENT.md +1 -0
  49. package/claude/skills/target-update/SKILL.md +22 -0
  50. package/claude/skills/task-board/SKILL.md +2 -4
  51. package/claude/skills/task-board/references/tasks.md +5 -5
  52. package/claude/skills/task-complete/references/archive.md +5 -2
  53. package/claude/skills/test-craft/SKILL.md +2 -0
  54. package/claude/skills/test-craft/references/adopted.md +2 -0
  55. package/claude/skills/ui-checklist/SKILL.md +8 -9
  56. package/claude/skills/write-doc/SKILL.md +8 -8
  57. package/claude/skills/write-doc/references/readme.md +2 -0
  58. package/claude/skills/write-doc/references/wiki-kind.md +11 -4
  59. package/docs/agents/context-audit.md +16 -14
  60. package/docs/agents/markdown-audit.md +11 -4
  61. package/docs/agents/migrate.md +15 -13
  62. package/docs/agents/output-shape.md +2 -0
  63. package/docs/target/migrations.md +3 -3
  64. package/docs/workflow/operating-model.md +2 -0
  65. package/package.json +1 -1
  66. package/src/commands/markdown.ts +25 -29
  67. package/src/commands/tasks.ts +10 -4
  68. package/src/labels/density.ts +5 -46
  69. package/src/markdown/density.ts +41 -6
  70. package/src/markdown/scope.ts +6 -1
  71. package/src/markdown/structure.ts +18 -9
  72. package/src/markdown/tables.ts +32 -18
  73. package/src/pr/bijection.ts +5 -3
  74. package/src/pr/paths.ts +32 -10
  75. package/src/process/harness.ts +8 -0
  76. package/src/process/seeded-package.ts +21 -0
  77. package/src/records/validate.ts +1 -0
  78. package/src/serve/static.ts +2 -60
  79. package/src/tasks/branch.ts +6 -1
  80. package/src/tasks/record.ts +44 -0
  81. package/tooling/base/reference.md +7 -6
  82. package/tooling/claude/reference.md +7 -5
  83. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
  84. package/src/serve/live.ts +0 -113
@@ -9,6 +9,13 @@ const CLI = join(PROJECT_ROOT, 'src/cli.ts')
9
9
  /** No case has ever needed longer, and a blocked verb should fail fast. */
10
10
  const DEFAULT_TIMEOUT_MS = 10_000
11
11
 
12
+ /**
13
+ * A spawn that reaches `bun add` fails at once against this unreachable
14
+ * address, so an unseeded install is a loud defect rather than a test that
15
+ * passes or fails with the network. `options.env` still wins.
16
+ */
17
+ const DEAD_REGISTRY = 'http://127.0.0.1:9'
18
+
12
19
  export interface ProcessRun {
13
20
  readonly status: number | null
14
21
  readonly stdout: string
@@ -127,6 +134,7 @@ export function runCli(
127
134
  ...inherited,
128
135
  CANON_STATE_DIR: join(options.cwd, '.canon-state'),
129
136
  CANON_SANDBOX_DIR: join(options.cwd, '.canon-state', 'sandbox'),
137
+ BUN_CONFIG_REGISTRY: DEAD_REGISTRY,
130
138
  ...options.env,
131
139
  },
132
140
  })
@@ -0,0 +1,21 @@
1
+ import { existsSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
+ import { resolveChain } from '@/tooling/manifest'
5
+ import { collectDeps, parseSpec } from '@/tooling/package'
6
+
7
+ // A write run into a folder with no package.json would run `bun add -D` against
8
+ // the registry. A package.json already naming the stack's packages leaves
9
+ // nothing to install, so a test not about the manifest stays offline.
10
+ export const seedPackage = (dir: string, stack: string): void => {
11
+ const path = join(dir, 'package.json')
12
+ if (existsSync(path)) return
13
+
14
+ const devDependencies = Object.fromEntries(
15
+ collectDeps(resolveChain(PROJECT_ROOT, stack), {}).map((dep) => [
16
+ dep.name,
17
+ parseSpec(dep.spec).pin || '*',
18
+ ]),
19
+ )
20
+ writeFileSync(path, JSON.stringify({ devDependencies }))
21
+ }
@@ -219,6 +219,7 @@ const QUESTION_ITEM = /^\d+[a-z]?\.[ \t]+\S/
219
219
 
220
220
  const PLAN_SECTIONS = [
221
221
  'Summary',
222
+ 'Current behavior',
222
223
  'Constraints',
223
224
  'Files to touch',
224
225
  'Design references',
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
2
  import { join, resolve, sep } from 'node:path'
3
- import type { LiveStream } from '@/serve/live'
4
3
 
5
4
  /**
6
5
  * The loopback interface, never a wildcard bind. A preview serves whatever
@@ -79,25 +78,11 @@ export interface ServeStarted {
79
78
 
80
79
  export type ServeOutcome = ServeStarted | ServeRefused
81
80
 
82
- /** The route a live page's script opens its event stream on. */
83
- export const LIVE_EVENTS_PATH = '/__live'
84
-
85
- export interface LiveServe {
86
- readonly stream: Pick<LiveStream<unknown>, 'answer'>
87
- /**
88
- * The `<script>` element spliced into every page served, built by the caller
89
- * so this module stays free of what a reload means to any one surface.
90
- */
91
- readonly script: string
92
- }
93
-
94
81
  export interface ServeOptions {
95
82
  readonly port?: number
96
83
  readonly entry?: string
97
84
  /** Answers a directory request with no index page by listing it. Off by default. */
98
85
  readonly index?: boolean
99
- /** Answers the events route and splices a script into each page. Off by default. */
100
- readonly live?: LiveServe
101
86
  }
102
87
 
103
88
  function escapeHtml(text: string): string {
@@ -267,59 +252,16 @@ export function bindFirstFree(
267
252
  return undefined
268
253
  }
269
254
 
270
- /**
271
- * Splices the script in before the last closing body tag of a page. A page
272
- * carrying none goes out as the bytes on disk, since guessing a place for it
273
- * risks breaking markup the author wrote. The splice happens per response and
274
- * never on disk, so a page the reader saves or commits carries no hook.
275
- */
276
- export async function injectLive(
277
- response: Response,
278
- script: string,
279
- ): Promise<Response> {
280
- const type = response.headers.get('content-type') ?? ''
281
- if (response.status !== 200 || !type.startsWith('text/html')) return response
282
-
283
- const bytes = new Uint8Array(await response.arrayBuffer())
284
- const html = new TextDecoder().decode(bytes)
285
- /*
286
- * Searched in the original rather than a lowercased copy, since lowercasing
287
- * changes the length of some characters, such as İ, and an index read off
288
- * the copy would cut the original inside the tag.
289
- */
290
- const at = [...html.matchAll(/<\/body>/gi)].at(-1)?.index
291
- const init = { status: response.status, headers: response.headers }
292
- if (at === undefined) return new Response(bytes, init)
293
- return new Response(`${html.slice(0, at)}${script}${html.slice(at)}`, init)
294
- }
295
-
296
255
  function listen(
297
256
  root: string,
298
257
  first: number,
299
258
  index: boolean,
300
- live: LiveServe | undefined,
301
259
  ): { server: BoundServer; port: number } | undefined {
302
- if (!live) {
303
- return bindFirstFree(first, (port) =>
304
- Bun.serve({
305
- hostname: SERVE_HOST,
306
- port,
307
- fetch: (request) => respond(root, request, index),
308
- }),
309
- )
310
- }
311
260
  return bindFirstFree(first, (port) =>
312
261
  Bun.serve({
313
262
  hostname: SERVE_HOST,
314
263
  port,
315
- /* The event stream stays open for as long as the page does. */
316
- idleTimeout: 0,
317
- fetch: async (request, server) => {
318
- if (new URL(request.url).pathname === LIVE_EVENTS_PATH) {
319
- return live.stream.answer(request, server.port ?? 0)
320
- }
321
- return injectLive(await respond(root, request, index), live.script)
322
- },
264
+ fetch: (request) => respond(root, request, index),
323
265
  }),
324
266
  )
325
267
  }
@@ -435,7 +377,7 @@ export function startServer(
435
377
  */
436
378
  let bound: ReturnType<typeof listen>
437
379
  try {
438
- bound = listen(root, first, options.index ?? false, options.live)
380
+ bound = listen(root, first, options.index ?? false)
439
381
  } catch (error) {
440
382
  const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
441
383
  return refuse(
@@ -43,6 +43,11 @@ function slugOf(path: string): string {
43
43
  return stem.startsWith(PLAN_PREFIX) ? stem.slice(PLAN_PREFIX.length) : stem
44
44
  }
45
45
 
46
+ /** The branch a plan file derives, from its filename alone. */
47
+ export function branchOfPlan(path: string): string {
48
+ return `${PLAN_BRANCH_TYPE}/${slugOf(path)}`
49
+ }
50
+
46
51
  /**
47
52
  * Derives the branch a dispatch checks and a worker takes, from the plan both
48
53
  * of them name. It is the one derivation, so the collision check and the
@@ -60,7 +65,7 @@ export function planBranch(root: string, reference: string): BranchOutcome {
60
65
  if (!resolved.ok) return resolved
61
66
 
62
67
  const slug = slugOf(resolved.path)
63
- const branch = `${PLAN_BRANCH_TYPE}/${slug}`
68
+ const branch = branchOfPlan(resolved.path)
64
69
  const words = slug.split('-').filter((word) => word.length > 0).length
65
70
 
66
71
  return {
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, relative } from 'node:path'
4
4
  import { planCandidates } from '@/tasks/answers'
5
+ import { branchOfPlan } from '@/tasks/branch'
5
6
  import {
6
7
  appendPlanLink,
7
8
  describeUnmatchedStem,
@@ -57,6 +58,7 @@ export interface RecordRefused {
57
58
  export type RecordSelector =
58
59
  | { readonly kind: 'stem'; readonly stem: string }
59
60
  | { readonly kind: 'plan'; readonly plan: string }
61
+ | { readonly kind: 'branch'; readonly branches: readonly string[] }
60
62
 
61
63
  export type LineAction = 'added' | 'appended' | 'corrected' | 'unchanged'
62
64
 
@@ -383,6 +385,25 @@ async function resolveStem(
383
385
  return selector.stem
384
386
  }
385
387
 
388
+ if (selector.kind === 'branch') {
389
+ const matched = await matchByBranch(dir, stems, selector.branches)
390
+ const label = selector.branches[0] ?? 'no branch'
391
+
392
+ if (matched.length === 0) {
393
+ return refuse('no-match', `No task's plan derives branch ${label}.`)
394
+ }
395
+
396
+ if (matched.length > 1) {
397
+ return refuse(
398
+ 'ambiguous',
399
+ `${matched.length} tasks derive branch ${label}. One task, one plan.`,
400
+ matched,
401
+ )
402
+ }
403
+
404
+ return matched[0]
405
+ }
406
+
386
407
  const matched = await matchByPlan(dir, stems, selector.plan)
387
408
 
388
409
  if (matched.length === 0) {
@@ -400,6 +421,29 @@ async function resolveStem(
400
421
  return matched[0]
401
422
  }
402
423
 
424
+ /**
425
+ * Matches a branch to the tasks whose plan derives it. A task with no `Plan:`
426
+ * line carries no target and so never matches.
427
+ */
428
+ async function matchByBranch(
429
+ dir: string,
430
+ stems: readonly string[],
431
+ branches: readonly string[],
432
+ ): Promise<string[]> {
433
+ const read = await Promise.all(
434
+ stems.map(async (stem) => ({
435
+ stem,
436
+ targets: readPlanTargets(await readFile(join(dir, `${stem}.md`), 'utf8')),
437
+ })),
438
+ )
439
+
440
+ return read
441
+ .filter((entry) =>
442
+ entry.targets.some((target) => branches.includes(branchOfPlan(target))),
443
+ )
444
+ .map(({ stem }) => stem)
445
+ }
446
+
403
447
  export async function openTask(
404
448
  root: string,
405
449
  selector: RecordSelector,
@@ -91,18 +91,19 @@ Negative knowledge that costs a failed run to relearn.
91
91
 
92
92
  ## CLI
93
93
 
94
- | Script | What it does |
95
- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
96
- | `bun run check` | Gates a checkout through `scripts/verify.ts`, writing nothing. `VERIFY_NESTED=true` suppresses timeline boundaries in a call. |
97
- | `bun run check:format` | Asserts oxfmt formatting without writing |
98
- | `bun run format` | Writes oxfmt formatting in place |
99
- | `bun run prepare` | Initializes husky hooks, run automatically on `bun install` |
94
+ | Script | What it does |
95
+ | ---------------------- | ------------------------------------------------------------- |
96
+ | `bun run check` | Gates a checkout through `scripts/verify.ts`, writing nothing |
97
+ | `bun run check:format` | Asserts oxfmt formatting without writing |
98
+ | `bun run format` | Writes oxfmt formatting in place |
99
+ | `bun run prepare` | Initializes husky hooks, run automatically on `bun install` |
100
100
 
101
101
  ### The check script
102
102
 
103
103
  Base writes `check` as a `[scripts.override]`, so a sync replaces whatever `check` a target declared, including one that chained further checks after the runner. Re-add a chained check as its own script after a sync.
104
104
 
105
105
  - `bun run check` writes nothing. Formatting drift fails it at `check:format`, and the failure line names `bun run format`, the one command that writes.
106
+ - `VERIFY_NESTED=true` suppresses timeline boundaries when `check` runs inside another call
106
107
  - Auto-fixing drift inside `check` rewrote files in a shared checkout on every push and left a gate that could not fail, so the writing pass stays in `bun run format`.
107
108
  - The pull request workflow calls `check:format` directly as well.
108
109
 
@@ -47,11 +47,13 @@ A project installed before the board became a folder still holds `.claude/TASKS.
47
47
 
48
48
  ## CLI
49
49
 
50
- | Command | What it does |
51
- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
- | `canon claude init` | Seeds `.claude/` workflow docs, updates `.gitignore` |
53
- | `canon claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status. |
54
- | `canon claude setup` | Installs user-level config to `~/.claude/` once per machine: statusline and the permissions allow and deny lists, and removes the empty attribution pair an earlier setup wrote. |
50
+ | Command | What it does |
51
+ | -------------------- | ------------------------------------------------------------------------- |
52
+ | `canon claude init` | Seeds `.claude/` workflow docs, updates `.gitignore` |
53
+ | `canon claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status. |
54
+ | `canon claude setup` | Installs user-level config to `~/.claude/` once per machine |
55
+
56
+ `canon claude setup` writes the statusline and the permissions allow and deny lists, and removes the empty attribution pair an earlier setup wrote.
55
57
 
56
58
  ## CLAUDE.md
57
59
 
@@ -79,12 +79,13 @@ colons=$(printf '%s' "$record" |
79
79
  # returns them for a path in its scope and a finding the push does not fail on
80
80
  # yet is the one a rewrite has to clear before its folder is gated.
81
81
  density=$(printf '%s' "$record" |
82
- jq -r '.entries[]?.density[]? | ":\(.line) \(.kind) " + (if .limit == null then "opens with neither a paragraph nor a table" else "\(.value) past \(.limit)" end) + (if .gating then " gating" else "" end)' 2>/dev/null)
82
+ jq -r '.entries[]?.density[]? | ":\(.line) \(.kind) " + (if .kind == "table-then-list" then "a list follows the table with no lead sentence" elif .limit == null then "opens with neither a paragraph nor a table" else "\(.value) past \(.limit)" end) + (if .gating then " gating" else "" end)' 2>/dev/null)
83
83
 
84
- # Table findings report and never gate, so each kind is handed back with the
85
- # fix its shape takes rather than the seam a density finding splits at.
84
+ # The row count and the wide token report and never gate, so each kind is handed
85
+ # back with the fix its shape takes rather than the seam a density finding
86
+ # splits at. The cell and the trailing list arrive as density findings.
86
87
  tables=$(printf '%s' "$record" |
87
- jq -r '.entries[]?.tables[]? | ":\(.line) \(.kind) " + (if .limit == null then "a list follows the table with no lead sentence" else "\(.value) past \(.limit)" end)' 2>/dev/null)
88
+ jq -r '.entries[]?.tables[]? | ":\(.line) \(.kind) " + "\(.value) past \(.limit)"' 2>/dev/null)
88
89
 
89
90
  # A page marked `kind: reference` carries reference shape findings, handed back
90
91
  # whole the same way, since a gated one fails the push like a density finding.
@@ -126,12 +127,12 @@ if [ -n "$colons" ]; then
126
127
  fi
127
128
 
128
129
  if [ -n "$density" ]; then
129
- found=$(printf 'Standards-audit: %s breaks the markdown density limits, being a 350-character paragraph, 2 paragraphs in a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a paragraph or table opening every H3. Split each block at its seam rather than trimming words to pass. A finding marked gating fails the push.\n%s' "$file" "$density")
130
+ found=$(printf 'Standards-audit: %s breaks the markdown density limits, being a 350-character paragraph, 2 paragraphs in a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a paragraph or table opening every H3, a 120-character table cell, and no list directly after a table. Split each block at its seam rather than trimming words to pass. A finding marked gating fails the push.\n%s' "$file" "$density")
130
131
  msg="${msg:+$msg$nl}$found"
131
132
  fi
132
133
 
133
134
  if [ -n "$tables" ]; then
134
- found=$(printf 'Standards-audit: %s breaks the markdown table limits, being a 120-character cell, 8 body rows, a list directly after a table, and a code span holding a run past 30 characters. These report and never fail a push. Move a long cell into a bullet under the table'"'"'s lead, split a long table into groups each under a lead sentence, give a trailing list its own lead sentence, and move a wide token out of the table.\n%s' "$file" "$tables")
135
+ found=$(printf 'Standards-audit: %s breaks the markdown table limits that report, being 8 body rows and a code span holding a run past 30 characters. These never fail a push. Split a long table into groups each under a lead sentence, and move a wide token out of the table.\n%s' "$file" "$tables")
135
136
  msg="${msg:+$msg$nl}$found"
136
137
  fi
137
138
 
package/src/serve/live.ts DELETED
@@ -1,113 +0,0 @@
1
- import { type FSWatcher, watch } from 'node:fs'
2
- import { SERVE_HOST } from '@/serve/static'
3
-
4
- /**
5
- * How long a burst of watch events for one path waits before it is sent. One
6
- * editor save raises several, and each would otherwise reach a reader again.
7
- */
8
- const SETTLE_MS = 40
9
-
10
- export interface LiveOptions<T> {
11
- /**
12
- * Turns a changed path, relative to the watched directory, into what a change
13
- * carries, or into undefined to drop it, such as a write's staging file.
14
- */
15
- readonly map: (path: string) => T | undefined
16
- /**
17
- * Takes each settled change in place of the clients. A caller that has work
18
- * to do before a reader may look, such as regenerating pages, sends its own
19
- * payload through `send` once that work lands.
20
- */
21
- readonly onChange?: (payload: T) => void
22
- }
23
-
24
- /**
25
- * Whether a request names this server by a loopback name and its own port.
26
- * The bind keeps other machines out, and this keeps out a page the operator
27
- * visits whose domain was rebound to 127.0.0.1, which reaches the socket with
28
- * its own name in the Host header.
29
- */
30
- export function isLoopbackHost(host: string | null, port: number): boolean {
31
- return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
32
- }
33
-
34
- /**
35
- * Watches a directory and fans each settled change out as a server-sent event
36
- * to every open client, or to `onChange` when the caller takes changes itself.
37
- */
38
- export class LiveStream<T> {
39
- private readonly clients = new Set<ReadableStreamDefaultController<string>>()
40
- private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
41
- private readonly watcher: FSWatcher
42
-
43
- constructor(
44
- dir: string,
45
- private readonly options: LiveOptions<T>,
46
- ) {
47
- this.watcher = watch(dir, { recursive: true }, (_event, filename) => {
48
- if (filename) this.queue(filename.toString())
49
- })
50
- }
51
-
52
- private queue(path: string): void {
53
- const payload = this.options.map(path)
54
- if (payload === undefined) return
55
- clearTimeout(this.pending.get(path))
56
- this.pending.set(
57
- path,
58
- setTimeout(() => {
59
- this.pending.delete(path)
60
- if (this.options.onChange) this.options.onChange(payload)
61
- else this.send(payload)
62
- }, SETTLE_MS),
63
- )
64
- }
65
-
66
- send(payload: unknown): void {
67
- const frame = `data: ${JSON.stringify(payload)}\n\n`
68
- for (const client of this.clients) client.enqueue(frame)
69
- }
70
-
71
- open(): Response {
72
- let self: ReadableStreamDefaultController<string> | undefined
73
- const stream = new ReadableStream<string>({
74
- start: (controller) => {
75
- self = controller
76
- this.clients.add(controller)
77
- controller.enqueue(': open\n\n')
78
- },
79
- cancel: () => {
80
- if (self) this.clients.delete(self)
81
- },
82
- })
83
- return new Response(stream, {
84
- headers: {
85
- 'content-type': 'text/event-stream',
86
- 'cache-control': 'no-store',
87
- },
88
- })
89
- }
90
-
91
- /** Opens a stream for a request naming this server, and refuses any other. */
92
- answer(request: Request, port: number): Response {
93
- if (isLoopbackHost(request.headers.get('host'), port)) return this.open()
94
- return new Response('Forbidden\n', {
95
- status: 403,
96
- headers: { 'content-type': 'text/plain; charset=utf-8' },
97
- })
98
- }
99
-
100
- close(): void {
101
- this.watcher.close()
102
- for (const timer of this.pending.values()) clearTimeout(timer)
103
- this.pending.clear()
104
- for (const client of this.clients) {
105
- try {
106
- client.close()
107
- } catch {
108
- /* Already closed by the browser going away. */
109
- }
110
- }
111
- this.clients.clear()
112
- }
113
- }