@erclx/canon 5.18.0 → 5.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
68
68
  | Standards | Authoring conventions for commits, branches, plans, tasks, and markdown | Opened by name, read by name with `canon standards <name>` |
69
69
  | Tooling stacks | Golden configs, seeds, and a reference per framework | Laid down by `canon init`, reconciled by `canon tooling sync` |
70
70
  | Design system | Ships a `DESIGN.md` token format, a skill that drafts one from an existing project or from scratch, and a render command | `canon design render` |
71
- | Slides | A `SLIDES.md` source format with a layout catalog | `canon slides render` writes PowerPoint |
71
+ | Slides | A `SLIDES.md` source format with a layout catalog, or a folder of HTML slides rebuilt as editable shapes | `canon slides render` writes PowerPoint |
72
72
  | Teach | Lessons, sources, and a glossary in one workspace, rendered to a small site | `canon teach` |
73
73
  | Canvas | Pages of HTML frames on a local pan and zoom surface, each frame drawn with the project's tokens | `canon canvas serve` |
74
74
  | Transcripts | A YouTube transcript with metadata frontmatter | `canon transcripts <url>` writes it into any repo |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.18.0",
4
+ "version": "5.19.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -129,6 +129,7 @@ Subcommands no other page describes:
129
129
  - `claude skills rank` scores either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`).
130
130
  - `design regen` rewrites this repository's `canon/DESIGN.md` from `src/design/tokens.ts` and `src/design/base.css` from `src/design/neutral.ts`. `design css` emits the tokens and components as CSS on stdout, with `--no-components` for properties alone, and `design render` declares each vendored face the typography table names under `--embed-fonts`.
131
131
  - `slides render` renders a `.claude/SLIDES.md` source into a PowerPoint deck and reports any unrecognized layout name on stderr, and `slides list --json` emits the layout catalog.
132
+ - `slides render --source <folder>` lays out each `.html` file in filename order in Chromium at 1280 by 720 and rebuilds it as editable shapes. Each element drawn as a picture prints one `✗` line on stderr naming the slide, the selector, and the properties that forced it. A link left out, being a `#slide-N` past the deck's end or any target that is neither that nor an absolute URL, prints one too, and the exit stays 0.
132
133
  - `canvas`, with its verbs, content format, and write routes, is described in `canvas.md`.
133
134
  - `labels scan` fails a pull request or a posted review whose title, body, or review comment carries a phase label, a label a code span quotes, a gitignored record path, a session link, a title word no dictionary holds, or a title breaking `standards/pr.md`'s format, casing, or length rule (`--event`, `--body-file`, `--json`).
134
135
  - `repo metadata propose` compares a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing, and `repo metadata apply` writes an explicitly supplied set through `gh repo edit` (`--description`, `--homepage`, `--topics`).
@@ -126,9 +126,15 @@ The link opens the named workspace's contents page, or the root listing when no
126
126
 
127
127
  The JSON record is one line carrying the `nav` fields, then `served`, the absolute folder, and the server's `host`, `port`, `entry`, `url`, and `entryExists`. A lesson `nav` skips lands in `skipped` and the server still starts, since a partial refresh is still worth viewing. A `nav` refusal starts no server.
128
128
 
129
+ While it runs, `up` watches the teach root. A change there, such as a lesson, a workspace, a glossary term, or a reference page added by hand or by another `teach` verb, runs the `nav` pass again, and every open page reloads once that pass changes what is served. Passes run one at a time, so a burst of saves costs one more pass rather than several at once. A pass `nav` refuses, such as one over a workspace deleted while it was served, is reported on stderr, the server keeps running, and no page reloads.
130
+
131
+ Each refresh is logged to stderr, so stdout stays the one record line. Dotfiles and editor backups ending in `~` start no pass.
132
+
133
+ The reload script is added to each page as the server sends it and never written to disk, so a page saved from the browser, a promoted lesson, and a committed fixture carry no trace of it. The script listens on `/__live`, which answers only a request naming the server by its loopback address or `localhost`.
134
+
129
135
  Viewing writes. Every page and generated stylesheet `nav` owns is rewritten first, which on a committed fixture shows as a diff. To serve without rewriting anything, `canon teach list <topic>` ends its human output with a `canon serve` line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it. The JSON record of `list` carries no such field.
130
136
 
131
- `canon serve` itself stays general, since nothing about serving a directory is specific to a learning workspace and the same verb carries a slide render and a design preview. What `up` adds in front of it is the refresh, which is teach-specific, and it serves through the same server rather than a second one.
137
+ `canon serve` itself stays general, since nothing about serving a directory is specific to a learning workspace and the same verb carries a slide render and a design preview. What `up` adds in front of it is the refresh and the watch that repeats it, both teach-specific, and it serves through the same server rather than a second one.
132
138
 
133
139
  Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
134
140
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.18.0",
4
+ "version": "5.19.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -56,6 +56,7 @@
56
56
  "commander": "^13.1.0",
57
57
  "execa": "^9.5.2",
58
58
  "highlight.js": "^11.12.0",
59
+ "jszip": "^3.10.1",
59
60
  "playwright-core": "1.59.1",
60
61
  "pptxgenjs": "^4.0.1",
61
62
  "preact": "^11.0.0"
@@ -1,6 +1,7 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { INSTALL_BROWSER } from '@/browser/engine'
4
5
  import { creationRel, SCRATCH } from '@/record-root'
5
6
  import { LAYOUTS } from '@/slides/layouts'
6
7
  import { openDeck } from '@/slides/open'
@@ -17,8 +18,14 @@ export function register(program: Command): void {
17
18
  slides
18
19
  .command('render')
19
20
  .helpOption('-h, --help', 'Show this help message')
20
- .description('Render a SLIDES.md source into a PowerPoint deck')
21
- .option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
21
+ .description(
22
+ 'Render a SLIDES.md source, or a folder of HTML slides, into a PowerPoint deck',
23
+ )
24
+ .option(
25
+ '-s, --source <path>',
26
+ 'Source SLIDES.md file, or a folder of .html slides read in filename order',
27
+ '.claude/SLIDES.md',
28
+ )
22
29
  .option(
23
30
  '-o, --out <path>',
24
31
  'Output directory',
@@ -44,8 +51,15 @@ export function register(program: Command): void {
44
51
  if (!existsSync(sourcePath)) {
45
52
  fail(`${opts.source} not found`)
46
53
  }
47
- const variant = parseVariant(opts.variant)
48
54
  const mirror = resolveMirror(opts.mirror)
55
+ if (statSync(sourcePath).isDirectory()) {
56
+ if (opts.variant !== undefined) {
57
+ fail('--variant applies to a SLIDES.md source, not a folder')
58
+ }
59
+ await renderHtmlFolder(sourcePath, outDir, mirror, opts.open)
60
+ return
61
+ }
62
+ const variant = parseVariant(opts.variant)
49
63
  const { GREEN, GREY, NC, RED } = palette(process.stderr)
50
64
  intro('Render slides')
51
65
  const result = await renderSlidesDoc(sourcePath, outDir, {
@@ -103,6 +117,61 @@ export function register(program: Command): void {
103
117
  })
104
118
  }
105
119
 
120
+ /**
121
+ * The converter loads on demand, so no other command resolves pptxgenjs,
122
+ * jszip, or the token module at startup. A fallback or a refused link reports
123
+ * one line each and keeps the exit at 0, as an unrecognized layout does on the
124
+ * markdown path, since the deck is written either way.
125
+ */
126
+ async function renderHtmlFolder(
127
+ sourceDir: string,
128
+ outDir: string,
129
+ mirror: string | undefined,
130
+ shouldOpen: boolean | undefined,
131
+ ): Promise<void> {
132
+ const { exportHtmlDeck } = await import('@/slides/convert/export')
133
+ const { GREEN, GREY, NC, RED } = palette(process.stderr)
134
+ const result = await exportHtmlDeck(sourceDir, outDir, {
135
+ root: process.cwd(),
136
+ mirror,
137
+ })
138
+ if (result.status === 'refused') {
139
+ fail(
140
+ result.reason === 'browser-missing'
141
+ ? `Chromium is not installed. Run ${INSTALL_BROWSER}`
142
+ : result.message,
143
+ )
144
+ }
145
+ intro('Render slides')
146
+ process.stderr.write(
147
+ `${GREY}│${NC} ${GREEN}✓${NC} ${result.slideCount} slides\n${GREY}│${NC} ${GREEN}✓${NC} ${result.pptxPath}\n`,
148
+ )
149
+ for (const { slide, selector, properties } of result.fallbacks) {
150
+ process.stderr.write(
151
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} drawn as a picture for ${properties.join(', ')}\n`,
152
+ )
153
+ }
154
+ for (const { slide, selector, href, reason } of result.refusedLinks) {
155
+ process.stderr.write(
156
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} link "${href}" left out: ${reason}\n`,
157
+ )
158
+ }
159
+ if (result.mirrorPath) {
160
+ process.stderr.write(
161
+ `${GREY}│${NC} ${GREEN}✓${NC} mirrored to ${result.mirrorPath}\n`,
162
+ )
163
+ }
164
+ if (shouldOpen) {
165
+ const target = result.mirrorPath ?? result.pptxPath
166
+ const opened = await openDeck(target)
167
+ const mark = opened ? `${GREEN}✓${NC}` : `${RED}✗${NC}`
168
+ process.stderr.write(
169
+ `${GREY}│${NC} ${mark} ${opened ? 'opened' : 'could not open'} ${target}\n`,
170
+ )
171
+ }
172
+ outro()
173
+ }
174
+
106
175
  function parseVariant(value: string | undefined): Variant | undefined {
107
176
  if (value === undefined) return undefined
108
177
  if (value === 'light' || value === 'dark') return value
@@ -1,12 +1,16 @@
1
- import { isAbsolute, relative, resolve, sep } from 'node:path'
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
2
3
  import type { Command } from 'commander'
4
+ import { LiveStream } from '@/serve/live'
3
5
  import {
4
6
  logServing,
5
7
  parsePort,
6
8
  serveFields,
7
9
  waitForInterrupt,
8
10
  } from '@/serve/report'
9
- import { DEFAULT_PORT, startServer } from '@/serve/static'
11
+ import { DEFAULT_PORT, LIVE_EVENTS_PATH, startServer } from '@/serve/static'
12
+ import { compileScript } from '@/teach/browser/compile'
13
+ import { reload } from '@/teach/browser/reload'
10
14
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
11
15
  import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
12
16
  import type { RenderOutcome } from '@/teach/render'
@@ -942,8 +946,17 @@ async function runUp(
942
946
  ? 'index.html'
943
947
  : relative(dir, resolve(root, contents)).split(sep).join('/')
944
948
 
945
- const served = startServer(dir, { port, entry })
949
+ const stream = refreshOnChange(root, dir, topic, emitJson)
950
+ const served = startServer(dir, {
951
+ port,
952
+ entry,
953
+ live: {
954
+ stream,
955
+ script: compileScript(reload, [LIVE_EVENTS_PATH]),
956
+ },
957
+ })
946
958
  if (!served.ok) {
959
+ stream.close()
947
960
  // A serve refusal carries a reason the teach refusals do not list, so it
948
961
  // is written here in the teach record's shape rather than through one.
949
962
  if (emitJson) {
@@ -970,10 +983,105 @@ async function runUp(
970
983
  logServing(served)
971
984
  }
972
985
 
973
- await waitForInterrupt(served.stop)
986
+ await waitForInterrupt(async () => {
987
+ stream.close()
988
+ await served.stop()
989
+ })
974
990
  return 0
975
991
  }
976
992
 
993
+ /**
994
+ * An editor's swap or backup file, or anything under a dot folder. It changes
995
+ * nothing a page shows, so it neither starts a pass nor counts as served.
996
+ */
997
+ function isScratch(path: string): boolean {
998
+ return path
999
+ .split(/[\\/]/)
1000
+ .some((segment) => segment.startsWith('.') || segment.endsWith('~'))
1001
+ }
1002
+
1003
+ /**
1004
+ * What the served folder holds, as each file's path, size, and mtime. A pass
1005
+ * whose `nav` wrote nothing leaves it unchanged, which is how the echo of a
1006
+ * pass's own writes settles without a second reload.
1007
+ */
1008
+ function servedState(dir: string): string {
1009
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
1010
+ .filter((entry) => entry.isFile())
1011
+ .map((entry) => relative(dir, join(entry.parentPath, entry.name)))
1012
+ .filter((path) => !isScratch(path))
1013
+ .sort()
1014
+ .map((path) => {
1015
+ const stat = statSync(join(dir, path), { throwIfNoEntry: false })
1016
+ return `${path}:${stat?.size}:${stat?.mtimeMs}`
1017
+ })
1018
+ .join('\n')
1019
+ }
1020
+
1021
+ /**
1022
+ * Re-runs `nav` on each settled change under the teach folder and tells every
1023
+ * open page to reload once a pass changed what is served. Passes run one at a
1024
+ * time, since `nav` is not reentrant over the files it rewrites, and a change
1025
+ * arriving mid-pass is coalesced into exactly one more. A refused or failed
1026
+ * pass sends no reload, so a page never reloads onto a half-written state.
1027
+ */
1028
+ function refreshOnChange(
1029
+ root: string,
1030
+ dir: string,
1031
+ topic: string | undefined,
1032
+ emitJson: boolean,
1033
+ ): LiveStream<string> {
1034
+ let isRunning = false
1035
+ let isDirty = false
1036
+ let state = servedState(dir)
1037
+
1038
+ const report = (message: string, isWarning: boolean) => {
1039
+ if (emitJson) process.stderr.write(`${message}\n`)
1040
+ else if (isWarning) logWarn(message)
1041
+ else logInfo(message)
1042
+ }
1043
+
1044
+ const pass = async (): Promise<void> => {
1045
+ const nav = await generateNav(root, topic)
1046
+ if (!nav.ok) {
1047
+ report(`Refresh refused: ${nav.message}`, true)
1048
+ return
1049
+ }
1050
+ const next = servedState(dir)
1051
+ if (next === state) return
1052
+ state = next
1053
+ stream.send({ reload: true })
1054
+ report(`Refreshed ${nav.lessons} lessons, reloading open pages`, false)
1055
+ }
1056
+
1057
+ const schedule = async (): Promise<void> => {
1058
+ if (isRunning) {
1059
+ isDirty = true
1060
+ return
1061
+ }
1062
+ isRunning = true
1063
+ do {
1064
+ isDirty = false
1065
+ // A throw, such as the teach folder removed mid-pass, is reported and
1066
+ // the loop carries on, since a rejection left here would end the
1067
+ // process and a stuck flag would end every later refresh.
1068
+ try {
1069
+ await pass()
1070
+ } catch (error) {
1071
+ const code = (error as NodeJS.ErrnoException).code ?? String(error)
1072
+ report(`Refresh failed: ${code}`, true)
1073
+ }
1074
+ } while (isDirty)
1075
+ isRunning = false
1076
+ }
1077
+
1078
+ const stream = new LiveStream<string>(dir, {
1079
+ map: (path) => (isScratch(path) ? undefined : path),
1080
+ onChange: () => void schedule(),
1081
+ })
1082
+ return stream
1083
+ }
1084
+
977
1085
  function reportRefusal(
978
1086
  title: string,
979
1087
  refused: TeachRefused,
@@ -0,0 +1,113 @@
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
+ }
@@ -1,5 +1,6 @@
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'
3
4
 
4
5
  /**
5
6
  * The loopback interface, never a wildcard bind. A preview serves whatever
@@ -78,11 +79,25 @@ export interface ServeStarted {
78
79
 
79
80
  export type ServeOutcome = ServeStarted | ServeRefused
80
81
 
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
+
81
94
  export interface ServeOptions {
82
95
  readonly port?: number
83
96
  readonly entry?: string
84
97
  /** Answers a directory request with no index page by listing it. Off by default. */
85
98
  readonly index?: boolean
99
+ /** Answers the events route and splices a script into each page. Off by default. */
100
+ readonly live?: LiveServe
86
101
  }
87
102
 
88
103
  function escapeHtml(text: string): string {
@@ -253,16 +268,59 @@ export function bindFirstFree(
253
268
  return undefined
254
269
  }
255
270
 
271
+ /**
272
+ * Splices the script in before the last closing body tag of a page. A page
273
+ * carrying none goes out as the bytes on disk, since guessing a place for it
274
+ * risks breaking markup the author wrote. The splice happens per response and
275
+ * never on disk, so a page the reader saves or commits carries no hook.
276
+ */
277
+ export async function injectLive(
278
+ response: Response,
279
+ script: string,
280
+ ): Promise<Response> {
281
+ const type = response.headers.get('content-type') ?? ''
282
+ if (response.status !== 200 || !type.startsWith('text/html')) return response
283
+
284
+ const bytes = new Uint8Array(await response.arrayBuffer())
285
+ const html = new TextDecoder().decode(bytes)
286
+ /*
287
+ * Searched in the original rather than a lowercased copy, since lowercasing
288
+ * changes the length of some characters, such as İ, and an index read off
289
+ * the copy would cut the original inside the tag.
290
+ */
291
+ const at = [...html.matchAll(/<\/body>/gi)].at(-1)?.index
292
+ const init = { status: response.status, headers: response.headers }
293
+ if (at === undefined) return new Response(bytes, init)
294
+ return new Response(`${html.slice(0, at)}${script}${html.slice(at)}`, init)
295
+ }
296
+
256
297
  function listen(
257
298
  root: string,
258
299
  first: number,
259
300
  index: boolean,
301
+ live: LiveServe | undefined,
260
302
  ): { server: BoundServer; port: number } | undefined {
303
+ if (!live) {
304
+ return bindFirstFree(first, (port) =>
305
+ Bun.serve({
306
+ hostname: SERVE_HOST,
307
+ port,
308
+ fetch: (request) => respond(root, request, index),
309
+ }),
310
+ )
311
+ }
261
312
  return bindFirstFree(first, (port) =>
262
313
  Bun.serve({
263
314
  hostname: SERVE_HOST,
264
315
  port,
265
- fetch: (request) => respond(root, request, index),
316
+ /* The event stream stays open for as long as the page does. */
317
+ idleTimeout: 0,
318
+ fetch: async (request, server) => {
319
+ if (new URL(request.url).pathname === LIVE_EVENTS_PATH) {
320
+ return live.stream.answer(request, server.port ?? 0)
321
+ }
322
+ return injectLive(await respond(root, request, index), live.script)
323
+ },
266
324
  }),
267
325
  )
268
326
  }
@@ -378,7 +436,7 @@ export function startServer(
378
436
  */
379
437
  let bound: ReturnType<typeof listen>
380
438
  try {
381
- bound = listen(root, first, options.index ?? false)
439
+ bound = listen(root, first, options.index ?? false, options.live)
382
440
  } catch (error) {
383
441
  const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
384
442
  return refuse(