opencode-codeops 2.2.0 → 2.3.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.
@@ -0,0 +1,755 @@
1
+ /**
2
+ * Discovery listener and HTTP API.
3
+ *
4
+ * One `node:http` listener per OpenCode process is shared by every opted-in
5
+ * project. It serves the browser app, the JSON API, live events, and the
6
+ * delivery hook. The runtime lives in a `globalThis` slot so every plugin
7
+ * instance in the process reaches the same listener.
8
+ *
9
+ * Security is enforced before routing: only a loopback `Host` is accepted,
10
+ * writes must be JSON and same-origin, route slugs are validated before any
11
+ * store call, bodies are capped, and every response carries `nosniff`,
12
+ * `no-referrer`, and a nonce-based CSP. Error bodies never contain paths,
13
+ * stack traces, or stored content.
14
+ *
15
+ * @module discovery-server
16
+ */
17
+
18
+ import { randomBytes, randomUUID } from "node:crypto"
19
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"
20
+ import { createServer } from "node:http"
21
+ import { extname, join, resolve, sep } from "node:path"
22
+
23
+ import { KEY_PATTERN, SLUG_PATTERN } from "./discovery-rules.mjs"
24
+ import { DiscoveryDataError } from "./discovery-store.mjs"
25
+
26
+ /** The process-wide runtime slot. */
27
+ const RUNTIME_KEY = Symbol.for("opencode-codeops.discovery")
28
+
29
+ /** Request body cap in bytes. */
30
+ const BODY_LIMIT = 256 * 1024
31
+
32
+ /** The default port when the environment names none. */
33
+ const DEFAULT_PORT = 4747
34
+
35
+ /** The service name reported by the health endpoint. */
36
+ const SERVICE_NAME = "codeops-discovery"
37
+
38
+ /** MIME types served from the asset directory. */
39
+ const MIME_TYPES = {
40
+ ".js": "text/javascript; charset=utf-8",
41
+ ".css": "text/css; charset=utf-8",
42
+ ".svg": "image/svg+xml",
43
+ }
44
+
45
+ /** The static page shown when the UI bundle has not been built yet. */
46
+ const NOT_BUILT_HTML = `<!doctype html>
47
+ <html><head><meta charset="utf-8"><title>Discovery</title></head>
48
+ <body><h1>Discovery UI is not built</h1>
49
+ <p>Run <code>npm run build:discovery</code> in the CodeOps checkout.</p></body></html>`
50
+
51
+ /**
52
+ * Resolve the listener port from the environment.
53
+ *
54
+ * `CODEOPS_DISCOVERY_PORT` must be a plain integer in 1–65535; anything else
55
+ * falls back to 4747.
56
+ *
57
+ * @param {Record<string, string | undefined>} [env] - Environment variables.
58
+ * @returns {number} The configured port, or 4747.
59
+ */
60
+ export function resolvePort(env = process.env) {
61
+ const raw = String(env?.CODEOPS_DISCOVERY_PORT ?? "").trim()
62
+ if (!/^\d+$/.test(raw)) return DEFAULT_PORT
63
+ const port = Number(raw)
64
+ return port >= 1 && port <= 65535 ? port : DEFAULT_PORT
65
+ }
66
+
67
+ /** Canonicalize a checkout directory so registration and lookup agree. */
68
+ function canonicalDirectory(directory) {
69
+ const resolved = resolve(String(directory))
70
+ try {
71
+ return realpathSync(resolved)
72
+ } catch {
73
+ return resolved
74
+ }
75
+ }
76
+
77
+ /** A fresh base64 CSP nonce. */
78
+ function newNonce() {
79
+ return randomBytes(16).toString("base64")
80
+ }
81
+
82
+ /** The CSP header for one nonce. */
83
+ function csp(nonce) {
84
+ return (
85
+ "default-src 'none'; script-src 'self'; " +
86
+ `style-src 'self' 'nonce-${nonce}'; ` +
87
+ "connect-src 'self'; img-src 'self' data:; base-uri 'none'; " +
88
+ "form-action 'none'; frame-ancestors 'none'"
89
+ )
90
+ }
91
+
92
+ /** Base security headers shared by every response. */
93
+ function securityHeaders(nonce) {
94
+ return {
95
+ "X-Content-Type-Options": "nosniff",
96
+ "Referrer-Policy": "no-referrer",
97
+ "Content-Security-Policy": csp(nonce),
98
+ }
99
+ }
100
+
101
+ /** Build the runtime object for one process. */
102
+ function buildRuntime(options) {
103
+ const store = options.store
104
+ const assetsDir = options.assetsDir
105
+ const version = String(options.version ?? "0.0.0")
106
+ const log = typeof options.log === "function" ? options.log : () => {}
107
+ const requestedPort = options.port === undefined ? resolvePort(process.env) : options.port
108
+
109
+ /** Registered projects, keyed by checkout directory. */
110
+ const projects = new Map()
111
+ /** Open event streams, keyed by "<project>/<plan>". */
112
+ const viewers = new Map()
113
+ /** Live sockets, used for a prompt shutdown. */
114
+ const sockets = new Set()
115
+
116
+ let server = null
117
+ let listening = null
118
+ let boundPort = 0
119
+ let fallback = false
120
+ let instanceId = null
121
+ let indexTemplate = null
122
+ let assets = new Map()
123
+
124
+ /** Send one JSON response with the shared security headers. */
125
+ function sendJson(response, status, payload) {
126
+ if (response.writableEnded) return
127
+ const nonce = newNonce()
128
+ response.writeHead(status, {
129
+ ...securityHeaders(nonce),
130
+ "Content-Type": "application/json; charset=utf-8",
131
+ "Cache-Control": "no-store",
132
+ })
133
+ response.end(JSON.stringify(payload))
134
+ }
135
+
136
+ /** Send one error envelope. */
137
+ function sendError(response, status, code, message = "") {
138
+ sendJson(response, status, { error: code, message })
139
+ }
140
+
141
+ /** Send one HTML response whose CSP nonce matches its body. */
142
+ function sendHtml(response, status, template) {
143
+ if (response.writableEnded) return
144
+ const nonce = newNonce()
145
+ response.writeHead(status, {
146
+ ...securityHeaders(nonce),
147
+ "Content-Type": "text/html; charset=utf-8",
148
+ })
149
+ response.end(template.split("__CSP_NONCE__").join(nonce))
150
+ }
151
+
152
+ /** Read the index template and the asset allow-list from disk. */
153
+ function refreshAssets() {
154
+ if (!existsSync(assetsDir)) return false
155
+ const indexPath = join(assetsDir, "index.html")
156
+ if (!existsSync(indexPath)) return false
157
+ try {
158
+ indexTemplate = readFileSync(indexPath, "utf8")
159
+ assets = new Map()
160
+ const directory = join(assetsDir, "assets")
161
+ if (existsSync(directory)) {
162
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
163
+ if (!entry.isFile()) continue
164
+ const file = join(directory, entry.name)
165
+ if (!file.startsWith(directory + sep)) continue
166
+ assets.set(entry.name, file)
167
+ }
168
+ }
169
+ return true
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
175
+ /** Decode and validate a project or plan slug. */
176
+ function slugParameter(value) {
177
+ try {
178
+ const decoded = decodeURIComponent(value)
179
+ return SLUG_PATTERN.test(decoded) ? decoded : null
180
+ } catch {
181
+ return null
182
+ }
183
+ }
184
+
185
+ /** Decode and validate a question key. */
186
+ function keyParameter(value) {
187
+ try {
188
+ const decoded = decodeURIComponent(value)
189
+ return KEY_PATTERN.test(decoded) ? decoded : null
190
+ } catch {
191
+ return null
192
+ }
193
+ }
194
+
195
+ /** Read a JSON body within the cap, or send the matching error. */
196
+ async function readBody(request, response) {
197
+ const chunks = []
198
+ let size = 0
199
+ let tooLarge = false
200
+ for await (const chunk of request) {
201
+ size += chunk.length
202
+ if (size > BODY_LIMIT) {
203
+ tooLarge = true
204
+ continue
205
+ }
206
+ chunks.push(chunk)
207
+ }
208
+ if (tooLarge) {
209
+ sendError(response, 413, "too-large")
210
+ return undefined
211
+ }
212
+ try {
213
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"))
214
+ } catch {
215
+ sendError(response, 400, "bad-request")
216
+ return undefined
217
+ }
218
+ }
219
+
220
+ /** True when the Host header names this listener. */
221
+ function hostAllowed(host) {
222
+ return host === `127.0.0.1:${boundPort}` || host === `localhost:${boundPort}`
223
+ }
224
+
225
+ /** True when the Origin header, when present, is this listener. */
226
+ function originAllowed(origin) {
227
+ if (origin === undefined) return true
228
+ return origin === `http://127.0.0.1:${boundPort}` || origin === `http://localhost:${boundPort}`
229
+ }
230
+
231
+ /** Serve the single-page app or the not-built page. */
232
+ function serveIndex(response) {
233
+ if (indexTemplate === null || assets.size === 0) refreshAssets()
234
+ if (indexTemplate === null) {
235
+ sendHtml(response, 200, NOT_BUILT_HTML)
236
+ return
237
+ }
238
+ sendHtml(response, 200, indexTemplate)
239
+ }
240
+
241
+ /** Serve one allow-listed asset. */
242
+ function serveAsset(response, name) {
243
+ if (indexTemplate === null) refreshAssets()
244
+ const file = assets.get(name)
245
+ if (!file) {
246
+ sendError(response, 404, "not-found")
247
+ return
248
+ }
249
+ const extension = extname(name)
250
+ const type = MIME_TYPES[extension]
251
+ if (!type) {
252
+ sendError(response, 404, "not-found")
253
+ return
254
+ }
255
+ try {
256
+ const content = readFileSync(file)
257
+ const nonce = newNonce()
258
+ response.writeHead(200, {
259
+ ...securityHeaders(nonce),
260
+ "Content-Type": type,
261
+ })
262
+ response.end(content)
263
+ } catch {
264
+ sendError(response, 404, "not-found")
265
+ }
266
+ }
267
+
268
+ /** Summarize a plan's submissions for the read endpoint. */
269
+ function summarize(plan) {
270
+ return {
271
+ ...plan,
272
+ submissions: (plan.submissions ?? []).map((submission) => ({
273
+ id: submission.id,
274
+ at: submission.at,
275
+ groups: submission.groups,
276
+ deliveredAt: submission.deliveredAt,
277
+ deliveredTo: submission.deliveredTo,
278
+ })),
279
+ }
280
+ }
281
+
282
+ /** Map a store refusal to an HTTP response. */
283
+ function storeFailure(response, reason, detail) {
284
+ if (reason === "not-found") {
285
+ sendError(response, 404, "not-found")
286
+ return
287
+ }
288
+ if (reason === "invalid") {
289
+ sendError(response, 400, "invalid", detail?.message ?? "")
290
+ return
291
+ }
292
+ sendError(response, 409, reason, detail?.message ?? "")
293
+ }
294
+
295
+ /** Broadcast one live event to the streams of a plan. */
296
+ function notify(projectSlug, planSlug, event, data) {
297
+ const streams = viewers.get(`${projectSlug}/${planSlug}`)
298
+ if (!streams) return
299
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
300
+ for (const response of streams) {
301
+ if (response.writableEnded) continue
302
+ try {
303
+ response.write(payload)
304
+ } catch {
305
+ // A broken stream is removed by its own close handler.
306
+ }
307
+ }
308
+ }
309
+
310
+ /** The request handler. */
311
+ async function handle(request, response) {
312
+ const host = request.headers.host ?? ""
313
+ if (!hostAllowed(host)) {
314
+ sendError(response, 421, "forbidden-host")
315
+ return
316
+ }
317
+ const method = request.method ?? "GET"
318
+ const path = new URL(request.url ?? "/", `http://${host}`).pathname
319
+
320
+ if (method === "GET" && path.startsWith("/assets/")) {
321
+ let name = ""
322
+ try {
323
+ name = decodeURIComponent(path.slice("/assets/".length))
324
+ } catch {
325
+ sendError(response, 404, "not-found")
326
+ return
327
+ }
328
+ if (name.length === 0 || name.includes("/") || name.includes("\\") || name.includes("..")) {
329
+ sendError(response, 404, "not-found")
330
+ return
331
+ }
332
+ serveAsset(response, name)
333
+ return
334
+ }
335
+ if (method === "GET" && path === "/") {
336
+ serveIndex(response)
337
+ return
338
+ }
339
+
340
+ // Route parameters are validated before the write checks and before any
341
+ // store call, so a malformed slug can never reach a file path.
342
+ const segments = path.split("/")
343
+ if (segments[1] === "api" && segments[2] === "projects") {
344
+ if (segments[3] !== undefined && slugParameter(segments[3]) === null) {
345
+ sendError(response, 404, "not-found")
346
+ return
347
+ }
348
+ if (segments[5] !== undefined && slugParameter(segments[5]) === null) {
349
+ sendError(response, 404, "not-found")
350
+ return
351
+ }
352
+ if (segments[7] !== undefined && keyParameter(segments[7]) === null) {
353
+ sendError(response, 404, "not-found")
354
+ return
355
+ }
356
+ }
357
+
358
+ const isWrite = method === "PUT" || method === "POST" || method === "DELETE"
359
+ let writeBody
360
+ if (isWrite) {
361
+ if (!String(request.headers["content-type"] ?? "").startsWith("application/json")) {
362
+ sendError(response, 415, "unsupported-media-type")
363
+ return
364
+ }
365
+ if (!originAllowed(request.headers.origin)) {
366
+ sendError(response, 403, "forbidden-origin")
367
+ return
368
+ }
369
+ // The body is validated before routing, so an oversized or malformed
370
+ // body never reaches a store call.
371
+ writeBody = await readBody(request, response)
372
+ if (writeBody === undefined) return
373
+ }
374
+
375
+ if (method === "GET" && path === "/api/health") {
376
+ sendJson(response, 200, { name: SERVICE_NAME, instanceId, version })
377
+ return
378
+ }
379
+ if (method === "GET" && path === "/api/projects") {
380
+ const summaries = await store.listProjects()
381
+ const repositories = new Map()
382
+ for (const project of summaries) {
383
+ const canonical = project.repository?.canonical ?? project.directory
384
+ if (!repositories.has(canonical)) {
385
+ repositories.set(canonical, {
386
+ name: project.repository?.name ?? canonical,
387
+ canonical,
388
+ projects: [],
389
+ })
390
+ }
391
+ repositories.get(canonical).projects.push({
392
+ slug: project.slug,
393
+ name: project.name,
394
+ branch: project.branch,
395
+ directory: project.directory,
396
+ counts: project.counts,
397
+ live: projects.has(project.directory),
398
+ })
399
+ }
400
+ sendJson(response, 200, { repositories: [...repositories.values()] })
401
+ return
402
+ }
403
+
404
+ let match = /^\/api\/projects\/([^/]+)$/.exec(path)
405
+ if (method === "GET" && match) {
406
+ const projectSlug = slugParameter(match[1])
407
+ if (!projectSlug) {
408
+ sendError(response, 404, "not-found")
409
+ return
410
+ }
411
+ const project = await store.getProject(projectSlug)
412
+ if (!project) {
413
+ sendError(response, 404, "not-found")
414
+ return
415
+ }
416
+ const plans = await store.listPlans(projectSlug)
417
+ sendJson(response, 200, { project, plans })
418
+ return
419
+ }
420
+
421
+ match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/questions\/([^/]+)$/.exec(path)
422
+ if (method === "PUT" && match) {
423
+ const projectSlug = slugParameter(match[1])
424
+ const planSlug = slugParameter(match[2])
425
+ const key = keyParameter(match[3])
426
+ if (!projectSlug || !planSlug || !key) {
427
+ sendError(response, 404, "not-found")
428
+ return
429
+ }
430
+ const body = writeBody
431
+ if (!body || typeof body.baseRevision !== "number" || typeof body.patch !== "object") {
432
+ sendError(response, 400, "invalid", "expected baseRevision and patch")
433
+ return
434
+ }
435
+ const result = await store.saveAnswer(projectSlug, planSlug, key, body.patch, body.baseRevision)
436
+ if (!result.ok) {
437
+ storeFailure(response, result.reason, result)
438
+ return
439
+ }
440
+ notify(projectSlug, planSlug, "changed", { revision: result.plan.revision })
441
+ sendJson(response, 200, { plan: summarize(result.plan) })
442
+ return
443
+ }
444
+
445
+ match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/submit$/.exec(path)
446
+ if (method === "POST" && match) {
447
+ const projectSlug = slugParameter(match[1])
448
+ const planSlug = slugParameter(match[2])
449
+ if (!projectSlug || !planSlug) {
450
+ sendError(response, 404, "not-found")
451
+ return
452
+ }
453
+ const project = await store.getProject(projectSlug)
454
+ if (!project) {
455
+ sendError(response, 404, "not-found")
456
+ return
457
+ }
458
+ const body = writeBody
459
+ const group = body?.group ?? null
460
+ const baseRevision = body?.baseRevision ?? null
461
+ if (group !== null && typeof group !== "string") {
462
+ sendError(response, 400, "invalid", "group must be a key or null")
463
+ return
464
+ }
465
+ if (baseRevision !== null && typeof baseRevision !== "number") {
466
+ sendError(response, 400, "invalid", "baseRevision must be a number or null")
467
+ return
468
+ }
469
+ const result = await store.submit(projectSlug, planSlug, group, baseRevision)
470
+ if (!result.ok) {
471
+ sendJson(response, result.reason === "not-found" ? 404 : 409, {
472
+ error: result.reason,
473
+ message: "",
474
+ ...(result.missing === undefined ? {} : { missing: result.missing }),
475
+ })
476
+ return
477
+ }
478
+ const updated = await store.getPlan(projectSlug, planSlug)
479
+ notify(projectSlug, planSlug, "changed", { revision: updated?.revision ?? 0 })
480
+ let delivery = "pending"
481
+ const registered = projects.get(project.directory)
482
+ if (registered) {
483
+ try {
484
+ const outcome = await registered.deliver(planSlug)
485
+ if (outcome === "woken" || outcome === "pending" || outcome === "failed") {
486
+ delivery = outcome === "failed" ? "pending" : outcome
487
+ }
488
+ } catch {
489
+ delivery = "pending"
490
+ }
491
+ }
492
+ if (delivery === "woken") {
493
+ notify(projectSlug, planSlug, "delivered", { submissionId: result.submission.id })
494
+ }
495
+ sendJson(response, 200, {
496
+ submission: {
497
+ id: result.submission.id,
498
+ groups: result.submission.groups,
499
+ count: result.submission.questions.length,
500
+ },
501
+ skipped: result.skipped,
502
+ delivery,
503
+ })
504
+ return
505
+ }
506
+
507
+ match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/archive$/.exec(path)
508
+ if (method === "POST" && match) {
509
+ const projectSlug = slugParameter(match[1])
510
+ const planSlug = slugParameter(match[2])
511
+ if (!projectSlug || !planSlug) {
512
+ sendError(response, 404, "not-found")
513
+ return
514
+ }
515
+ const body = writeBody
516
+ if (typeof body?.archived !== "boolean") {
517
+ sendError(response, 400, "invalid", "archived must be a boolean")
518
+ return
519
+ }
520
+ const changed = await store.setArchived(projectSlug, planSlug, body.archived)
521
+ if (!changed) {
522
+ sendError(response, 404, "not-found")
523
+ return
524
+ }
525
+ const updated = await store.getPlan(projectSlug, planSlug)
526
+ notify(projectSlug, planSlug, "changed", { revision: updated?.revision ?? 0 })
527
+ sendJson(response, 200, { archived: body.archived })
528
+ return
529
+ }
530
+
531
+ match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)$/.exec(path)
532
+ if (method === "GET" && match) {
533
+ const projectSlug = slugParameter(match[1])
534
+ const planSlug = slugParameter(match[2])
535
+ if (!projectSlug || !planSlug) {
536
+ sendError(response, 404, "not-found")
537
+ return
538
+ }
539
+ const project = await store.getProject(projectSlug)
540
+ if (!project) {
541
+ sendError(response, 404, "not-found")
542
+ return
543
+ }
544
+ const plan = await store.getPlan(projectSlug, planSlug)
545
+ if (!plan) {
546
+ sendError(response, 404, "not-found")
547
+ return
548
+ }
549
+ sendJson(response, 200, { project, plan: summarize(plan) })
550
+ return
551
+ }
552
+
553
+ match = /^\/api\/projects\/([^/]+)\/plans\/([^/]+)\/events$/.exec(path)
554
+ if (method === "GET" && match) {
555
+ const projectSlug = slugParameter(match[1])
556
+ const planSlug = slugParameter(match[2])
557
+ if (!projectSlug || !planSlug) {
558
+ sendError(response, 404, "not-found")
559
+ return
560
+ }
561
+ const plan = await store.getPlan(projectSlug, planSlug)
562
+ if (!plan) {
563
+ sendError(response, 404, "not-found")
564
+ return
565
+ }
566
+ const nonce = newNonce()
567
+ response.writeHead(200, {
568
+ ...securityHeaders(nonce),
569
+ "Content-Type": "text/event-stream",
570
+ "Cache-Control": "no-store",
571
+ Connection: "keep-alive",
572
+ })
573
+ response.write(
574
+ `event: hello\ndata: ${JSON.stringify({ instanceId, revision: plan.revision })}\n\n`,
575
+ )
576
+ const key = `${projectSlug}/${planSlug}`
577
+ const streams = viewers.get(key) ?? new Set()
578
+ streams.add(response)
579
+ viewers.set(key, streams)
580
+ response.on("close", () => {
581
+ streams.delete(response)
582
+ if (streams.size === 0) viewers.delete(key)
583
+ })
584
+ return
585
+ }
586
+
587
+ if (method === "DELETE" && /^\/api\/projects\/[^/]+\/plans\/[^/]+$/.test(path)) {
588
+ const parts = path.split("/")
589
+ const projectSlug = slugParameter(parts[3])
590
+ const planSlug = slugParameter(parts[5])
591
+ if (!projectSlug || !planSlug) {
592
+ sendError(response, 404, "not-found")
593
+ return
594
+ }
595
+ const body = writeBody
596
+ if (body?.confirm !== true) {
597
+ sendError(response, 400, "bad-request", "confirm is required")
598
+ return
599
+ }
600
+ const deleted = await store.deletePlan(projectSlug, planSlug)
601
+ if (!deleted) {
602
+ sendError(response, 404, "not-found")
603
+ return
604
+ }
605
+ notify(projectSlug, planSlug, "changed", { revision: 0 })
606
+ sendJson(response, 200, { deleted: true })
607
+ return
608
+ }
609
+
610
+ sendError(response, 404, "not-found")
611
+ }
612
+
613
+ const runtime = {
614
+ /** See the module contract: start the listener on demand. */
615
+ ensureListening() {
616
+ if (server) return Promise.resolve({ url: `http://127.0.0.1:${boundPort}`, fallback })
617
+ if (listening) return listening
618
+ listening = new Promise((resolve) => {
619
+ const attempt = (port, isFallback) => {
620
+ const candidate = createServer((request, response) => {
621
+ handle(request, response).catch((error) => {
622
+ if (error instanceof DiscoveryDataError) {
623
+ sendJson(response, 409, { error: error.code, message: error.message })
624
+ return
625
+ }
626
+ log("Discovery request failed")
627
+ sendError(response, 500, "internal")
628
+ })
629
+ })
630
+ candidate.on("connection", (socket) => {
631
+ sockets.add(socket)
632
+ socket.on("close", () => sockets.delete(socket))
633
+ })
634
+ candidate.once("error", (error) => {
635
+ if (!isFallback && error?.code === "EADDRINUSE") {
636
+ attempt(0, true)
637
+ return
638
+ }
639
+ log("Discovery listener could not start")
640
+ resolve(null)
641
+ })
642
+ candidate.listen(port, "127.0.0.1", () => {
643
+ server = candidate
644
+ boundPort = candidate.address().port
645
+ fallback = isFallback
646
+ instanceId = randomUUID()
647
+ resolve({ url: `http://127.0.0.1:${boundPort}`, fallback })
648
+ })
649
+ }
650
+ attempt(Number.isInteger(requestedPort) ? requestedPort : DEFAULT_PORT, false)
651
+ })
652
+ return listening
653
+ },
654
+
655
+ /** Register one project's delivery callback. */
656
+ registerProject(directory, slug, deliver) {
657
+ const token = Symbol("discovery-project")
658
+ const key = canonicalDirectory(directory)
659
+ projects.set(key, { slug, deliver, token })
660
+ return () => {
661
+ const current = projects.get(key)
662
+ if (current && current.token === token) projects.delete(key)
663
+ }
664
+ },
665
+
666
+ /** True when an open page shows this plan. */
667
+ hasViewer(projectSlug, planSlug) {
668
+ return (viewers.get(`${projectSlug}/${planSlug}`)?.size ?? 0) > 0
669
+ },
670
+
671
+ /** Broadcast one event to the plan's open pages. */
672
+ notify,
673
+
674
+ /** End event streams, close connections, and stop when idle. */
675
+ async closeIfIdle() {
676
+ if (listening && server === null) {
677
+ // A start is in flight; let it finish, then re-check idleness.
678
+ try {
679
+ await listening
680
+ } catch {
681
+ // A failed start leaves nothing to close.
682
+ }
683
+ }
684
+ if (projects.size > 0 || server === null) return
685
+ for (const streams of viewers.values()) {
686
+ for (const response of streams) {
687
+ try {
688
+ response.end()
689
+ } catch {
690
+ // Already closed.
691
+ }
692
+ }
693
+ }
694
+ viewers.clear()
695
+ for (const socket of sockets) {
696
+ try {
697
+ socket.destroy()
698
+ } catch {
699
+ // Already closed.
700
+ }
701
+ }
702
+ sockets.clear()
703
+ const closing = server
704
+ server = null
705
+ listening = null
706
+ boundPort = 0
707
+ fallback = false
708
+ instanceId = null
709
+ indexTemplate = null
710
+ assets = new Map()
711
+ await new Promise((resolve) => closing.close(() => resolve()))
712
+ },
713
+
714
+ /** The absolute URL of a plan page, or null before the listener starts. */
715
+ planUrl(projectSlug, planSlug) {
716
+ if (boundPort === 0) return null
717
+ return `http://127.0.0.1:${boundPort}/#/p/${projectSlug}/${planSlug}`
718
+ },
719
+
720
+ /** The one store every handler uses. */
721
+ store,
722
+ }
723
+
724
+ return runtime
725
+ }
726
+
727
+ /**
728
+ * Create a runtime with its own store, assets, and listener.
729
+ *
730
+ * When the process-wide slot is empty, the new runtime is installed there, so
731
+ * a later {@link getDiscoveryRuntime} call returns the same instance. Tests
732
+ * use this directly to start a listener on port 0.
733
+ *
734
+ * @param {{ store: object, assetsDir: string, version: string, port?: number,
735
+ * log?: (message: string) => void }} options - Runtime dependencies.
736
+ * @returns {object} The runtime.
737
+ */
738
+ export function createDiscoveryRuntime(options) {
739
+ const runtime = buildRuntime(options)
740
+ if (globalThis[RUNTIME_KEY] === undefined) globalThis[RUNTIME_KEY] = runtime
741
+ return runtime
742
+ }
743
+
744
+ /**
745
+ * The process-wide runtime, created on first use and returned unchanged
746
+ * afterwards.
747
+ *
748
+ * @param {{ store: object, assetsDir: string, version: string, port?: number,
749
+ * log?: (message: string) => void }} options - Runtime dependencies, used only
750
+ * when the slot is empty.
751
+ * @returns {object} The runtime.
752
+ */
753
+ export function getDiscoveryRuntime(options) {
754
+ return globalThis[RUNTIME_KEY] ?? createDiscoveryRuntime(options)
755
+ }