@erclx/canon 5.54.0 → 5.56.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 (102) hide show
  1. package/CHANGELOG.md +16 -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/context-fold/references/classify.md +17 -4
  11. package/claude/skills/create-rule/references/rule.md +4 -0
  12. package/claude/skills/create-skill/SKILL.md +3 -3
  13. package/claude/skills/deploy-app/SKILL.md +2 -0
  14. package/claude/skills/deprecation-migration/SKILL.md +7 -9
  15. package/claude/skills/document-health/SKILL.md +6 -6
  16. package/claude/skills/document-health/references/axes.md +1 -1
  17. package/claude/skills/document-repair/SKILL.md +5 -5
  18. package/claude/skills/git-followup/SKILL.md +16 -5
  19. package/claude/skills/git-issue/references/issue.md +9 -11
  20. package/claude/skills/git-pr/references/publish.md +4 -5
  21. package/claude/skills/git-split/SKILL.md +11 -4
  22. package/claude/skills/markdown-craft/SKILL.md +1 -1
  23. package/claude/skills/markdown-craft/references/markdown.md +2 -2
  24. package/claude/skills/markdown-craft/references/tables.md +9 -7
  25. package/claude/skills/plan-feature/SKILL.md +2 -0
  26. package/claude/skills/plan-feature/references/plan.md +2 -0
  27. package/claude/skills/review-address/SKILL.md +9 -7
  28. package/claude/skills/review-craft/SKILL.md +7 -9
  29. package/claude/skills/review-craft/references/adopted.md +10 -8
  30. package/claude/skills/review-pr/SKILL.md +2 -0
  31. package/claude/skills/review-pr/references/key-changes.md +8 -6
  32. package/claude/skills/review-ui/references/driving.md +10 -8
  33. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
  34. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
  35. package/claude/skills/role-reviewer/SKILL.md +5 -5
  36. package/claude/skills/search-craft/SKILL.md +5 -7
  37. package/claude/skills/session-worktree/SKILL.md +2 -0
  38. package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
  39. package/claude/skills/systematic-debugging/SKILL.md +9 -9
  40. package/claude/skills/systematic-debugging/references/adopted.md +1 -1
  41. package/claude/skills/target-check/SKILL.md +6 -8
  42. package/claude/skills/target-check/references/domains.md +1 -4
  43. package/claude/skills/target-setup/SKILL.md +8 -8
  44. package/claude/skills/target-update/SKILL.md +5 -5
  45. package/claude/skills/task-board/SKILL.md +2 -4
  46. package/claude/skills/task-board/references/tasks.md +5 -5
  47. package/claude/skills/task-complete/references/archive.md +5 -2
  48. package/claude/skills/test-craft/SKILL.md +2 -0
  49. package/claude/skills/test-craft/references/adopted.md +2 -0
  50. package/claude/skills/ui-checklist/SKILL.md +8 -9
  51. package/claude/skills/write-doc/SKILL.md +8 -8
  52. package/claude/skills/write-doc/references/readme.md +2 -0
  53. package/claude/skills/write-doc/references/wiki-kind.md +11 -4
  54. package/docs/agents/context-audit.md +16 -14
  55. package/docs/agents/install-and-sync.md +2 -2
  56. package/docs/agents/markdown-audit.md +11 -4
  57. package/docs/agents/migrate.md +15 -13
  58. package/docs/agents/output-shape.md +13 -10
  59. package/docs/target/migrations.md +3 -3
  60. package/docs/target/sync.md +1 -1
  61. package/docs/workflow/operating-model.md +2 -0
  62. package/package.json +1 -1
  63. package/scripts/core/install-check.sh +1 -1
  64. package/scripts/lib/ui.sh +12 -65
  65. package/src/audits/run.ts +1 -1
  66. package/src/claude/routing.ts +1 -1
  67. package/src/cli/run.ts +5 -6
  68. package/src/cli/ui.ts +87 -28
  69. package/src/cli.ts +6 -0
  70. package/src/commands/claude.ts +2 -3
  71. package/src/commands/design.ts +8 -13
  72. package/src/commands/gate.ts +5 -3
  73. package/src/commands/gov.ts +11 -21
  74. package/src/commands/labels.ts +1 -1
  75. package/src/commands/markdown.ts +25 -29
  76. package/src/commands/pr.ts +1 -1
  77. package/src/commands/sync.ts +1 -1
  78. package/src/commands/tooling.ts +5 -8
  79. package/src/commands/transcripts.ts +6 -11
  80. package/src/gate/sequencer.ts +1 -1
  81. package/src/gate/stages.ts +0 -10
  82. package/src/hooks/post-merge.ts +2 -5
  83. package/src/labels/density.ts +5 -46
  84. package/src/markdown/density.ts +41 -6
  85. package/src/markdown/scope.ts +6 -1
  86. package/src/markdown/structure.ts +18 -9
  87. package/src/markdown/tables.ts +32 -18
  88. package/src/process/harness.ts +11 -4
  89. package/src/process/seeded-package.ts +21 -0
  90. package/src/sandbox/equivalence.ts +0 -1
  91. package/src/sandbox/provision.ts +8 -13
  92. package/src/sandbox/scenario.ts +6 -12
  93. package/src/serve/static.ts +2 -60
  94. package/src/sync/engine.ts +3 -8
  95. package/src/sync/workflow.ts +3 -5
  96. package/src/tooling/verify-stack.ts +1 -1
  97. package/tooling/base/configs/.husky/post-merge +6 -10
  98. package/tooling/base/reference.md +7 -6
  99. package/tooling/claude/reference.md +7 -5
  100. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
  101. package/scripts/core/check-color-source.sh +0 -51
  102. package/src/serve/live.ts +0 -113
@@ -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(
@@ -26,7 +26,7 @@ import {
26
26
  logStep,
27
27
  logWarn,
28
28
  outro,
29
- palette,
29
+ outroSuccess,
30
30
  select,
31
31
  } from '@/cli/ui'
32
32
  import { compareVersions, parseVersion } from '@/version/compare'
@@ -436,7 +436,6 @@ export async function runDomainSync(
436
436
  }
437
437
 
438
438
  const plan = planSync(adapter, resolved)
439
- const { GREEN, GREY, NC } = palette(process.stderr)
440
439
 
441
440
  if (
442
441
  !existsSync(adapter.installedRoot(resolved)) &&
@@ -453,8 +452,7 @@ export async function runDomainSync(
453
452
  if (count === 0) {
454
453
  await adapter.onComplete?.(resolved)
455
454
  await recordStamp(adapter, resolved, new Date())
456
- outro()
457
- process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
455
+ outroSuccess('Everything up to date')
458
456
  return 0
459
457
  }
460
458
 
@@ -489,10 +487,7 @@ export async function runDomainSync(
489
487
  await adapter.onComplete?.(resolved)
490
488
  await recordStamp(adapter, resolved, new Date())
491
489
 
492
- outro()
493
- process.stderr.write(
494
- `${GREEN}✓ Sync complete${NC} ${GREY}(${count} ${adapter.unit})${NC}\n`,
495
- )
490
+ outroSuccess('Sync complete', `(${count} ${adapter.unit})`)
496
491
  return 0
497
492
  }
498
493
 
@@ -19,7 +19,7 @@ import {
19
19
  logStep,
20
20
  logWarn,
21
21
  outro,
22
- palette,
22
+ outroSuccess,
23
23
  pipeOutput,
24
24
  select,
25
25
  } from '@/cli/ui'
@@ -75,7 +75,7 @@ export async function collectChanges(git: GitRunner): Promise<DomainChange[]> {
75
75
  *
76
76
  * This is the only toolkit code that writes to a git remote, which is why a
77
77
  * headless run refuses it outright. `select_option` resolved to its first
78
- * option under `CANON_NON_INTERACTIVE=1`, and the first option here was
78
+ * option with no terminal to answer it, and the first option here was
79
79
  * `Commit and open PR` whenever `gh` was installed, so an agent following the
80
80
  * documented non-interactive path pushed a branch and opened a pull request on
81
81
  * someone's repository without being asked.
@@ -193,8 +193,6 @@ function promptChoice(canOpenPullRequest: boolean): Promise<WorkflowChoice> {
193
193
  }
194
194
 
195
195
  function succeed(message: string): number {
196
- const { GREEN, NC } = palette(process.stderr)
197
- outro()
198
- process.stderr.write(`\n${GREEN}✓ ${message}${NC}\n`)
196
+ outroSuccess(message)
199
197
  return 0
200
198
  }
@@ -124,7 +124,7 @@ async function run({
124
124
  await phase(state, 'Sync', () =>
125
125
  execa('bun', [cli, 'tooling', 'sync', stack, '.', '--write'], {
126
126
  ...captured(state.dir),
127
- env: { CI: '1', CANON_NON_INTERACTIVE: '1' },
127
+ env: { CI: '1' },
128
128
  }),
129
129
  )
130
130
 
@@ -27,20 +27,16 @@ if ! command -v canon >/dev/null 2>&1; then
27
27
  exit 0
28
28
  fi
29
29
 
30
- # A global binary older than the verb cannot run it, and the verb carries the
31
- # only step that would update it, so reinstall here and probe again. The probe
32
- # reads the verb's own usage line rather than the exit, since an older binary
33
- # answers an unknown command's `--help` with its root help and exits zero.
30
+ # A global binary older than the verb cannot run it, and its owner upgrades it
31
+ # by hand. The probe reads the verb's own usage line rather than the exit,
32
+ # since an older binary answers an unknown command's `--help` with its root
33
+ # help and exits zero.
34
34
  has_verb() {
35
35
  canon hooks post-merge --help 2>/dev/null | grep -q '^Usage: canon hooks post-merge'
36
36
  }
37
37
 
38
- if ! has_verb; then
39
- if [ -z "$CANON_SKIP_UPGRADE" ]; then
40
- CANON_NON_INTERACTIVE=1 canon upgrade >/dev/null 2>&1 || true
41
- fi
42
- fi
43
-
44
38
  if has_verb; then
45
39
  canon hooks post-merge --root "$root" || true
40
+ else
41
+ echo 'canon is too old to run the post-merge hook. Run `canon upgrade`.' >&2
46
42
  fi
@@ -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
 
@@ -1,51 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
-
8
- SCRIPTS_ROOT="$PROJECT_ROOT/scripts"
9
- SANDBOX_ROOT="$PROJECT_ROOT/sandbox"
10
- PALETTE_SOURCE="$SCRIPTS_ROOT/lib/ui.sh"
11
-
12
- # `find` writes to stderr and returns non-zero for a missing root, inside a
13
- # process substitution whose status nothing reads. Without this the walk covers
14
- # nothing and the check reports the tree clean having never measured it.
15
- if [ ! -f "$PALETTE_SOURCE" ]; then
16
- echo "No palette source at ${PALETTE_SOURCE#"$PROJECT_ROOT/"}, color sourcing unverifiable." >&2
17
- exit 1
18
- fi
19
-
20
- # The same silence follows a root that moved, so a walked root that is gone
21
- # fails here rather than shrinking the corpus with no message.
22
- for root in "$SCRIPTS_ROOT" "$SANDBOX_ROOT"; do
23
- if [ ! -d "$root" ]; then
24
- echo "No shell root at ${root#"$PROJECT_ROOT/"}, color sourcing unverifiable." >&2
25
- exit 1
26
- fi
27
- done
28
-
29
- # Any SGR sequence in either spelling. The cursor and erase sequences an
30
- # interactive prompt writes end in a letter instead, and they run only where a
31
- # terminal already exists, so they are not what this measures.
32
- SGR='\\(033|e|x1b)\[[0-9;]*m'
33
-
34
- # Sixteen copies of one decision is what made color impossible to turn off on
35
- # the TypeScript side. A rule nothing enforces rebuilds them the next time a
36
- # script wants grey, so the escape lives in the shared library and every other
37
- # script reads the palette that library derives for its stream.
38
- offenders=""
39
- while IFS= read -r file; do
40
- [ "$file" = "$PALETTE_SOURCE" ] && continue
41
- if grep -qE "$SGR" "$file"; then
42
- offenders="$offenders ${file#"$PROJECT_ROOT/"}"$'\n'
43
- fi
44
- done < <(find "$SCRIPTS_ROOT" "$SANDBOX_ROOT" -type f -name '*.sh')
45
-
46
- if [ -n "$offenders" ]; then
47
- echo "A color escape is defined outside the shared library:" >&2
48
- printf '%s' "$offenders" >&2
49
- echo "Source scripts/lib/ui.sh and read its palette, so one answer covers every writer." >&2
50
- exit 1
51
- fi
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
- }