@abide/abide 0.110.4 → 0.111.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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/docs/REGISTRY.md +22 -21
  3. package/docs/RULEBOOK.md +85 -19
  4. package/docs/abide.md +38 -24
  5. package/package.json +1 -1
  6. package/src/compiler/compileApp.ts +2 -1
  7. package/src/server/BRIDGE_VERSION.ts +1 -1
  8. package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
  9. package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
  10. package/src/server/answerRequest.ts +44 -3
  11. package/src/server/answerRpc.ts +14 -41
  12. package/src/server/answerSocket.ts +2 -2
  13. package/src/server/answerValue.ts +15 -3
  14. package/src/server/answerWorker.ts +195 -0
  15. package/src/server/appManifest.ts +48 -0
  16. package/src/server/assistant.ts +1 -1
  17. package/src/server/bridgeRelay.ts +4 -50
  18. package/src/server/claudeApi.ts +1 -1
  19. package/src/server/claudeCode.ts +3 -3
  20. package/src/server/clientBundle.ts +7 -0
  21. package/src/server/config.ts +14 -0
  22. package/src/server/createApp.ts +28 -4
  23. package/src/server/devWorker.ts +4 -1
  24. package/src/server/requestScope.ts +31 -11
  25. package/src/server/responseRelay.ts +7 -7
  26. package/src/server/serveBuild.ts +2 -0
  27. package/src/server/serveDev.ts +4 -1
  28. package/src/server/streamEvent.ts +60 -1
  29. package/src/shared/ADDRESSES.ts +11 -0
  30. package/src/shared/LONGEST_DELAY.ts +2 -0
  31. package/src/shared/OFFLINE_CACHES.ts +12 -0
  32. package/src/shared/OFFLINE_META.ts +7 -0
  33. package/src/shared/answerOf.ts +29 -4
  34. package/src/shared/attributeText.ts +1 -1
  35. package/src/shared/reactive/Expiry.ts +13 -1
  36. package/src/shared/reactive/Reactive.ts +13 -3
  37. package/src/shared/safeUrl.ts +8 -1
  38. package/src/ui/Router.ts +25 -0
  39. package/src/ui/registerWorker.ts +34 -0
  40. package/src/ui/routing.ts +1 -0
  41. package/src/ui/socketWire.ts +33 -2
@@ -0,0 +1,195 @@
1
+ // SECTION 45 — THE SERVICE WORKER, written by the server rather than bundled: what it saves is the
2
+ // build's own file list, which the server holds (38.5), and a script whose bytes change with the
3
+ // build is how a browser learns there is a new one to install.
4
+ //
5
+ // WRITTEN AS TEXT, NOT AS A FUNCTION'S SOURCE: an executable `abide compile` writes carries its code
6
+ // as bytecode (38.93), where a function has no source to read back.
7
+
8
+ import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
9
+ import { MOUNT, mounted } from '#shared/mount.ts'
10
+ import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
11
+ import {
12
+ OFFLINE_BUNDLE,
13
+ OFFLINE_CACHE_PREFIX,
14
+ OFFLINE_SAVED,
15
+ } from '#shared/OFFLINE_CACHES.ts'
16
+ import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
17
+ import type { Manifest } from './manifestOf.ts'
18
+
19
+ // What `framingOf` reads as a framed stream (16.32), and a binary's bytes (16.34), which may be one —
20
+ // spelled again as a list because `framingOf`, in every page's client closure, reads them as branches.
21
+ const STREAMED_TYPES = [
22
+ 'text/event-stream',
23
+ 'application/jsonl',
24
+ 'application/x-ndjson',
25
+ 'application/octet-stream',
26
+ ]
27
+
28
+ const CACHES = `const OURS = ${JSON.stringify(OFFLINE_CACHE_PREFIX)}
29
+ const SAVED = ${JSON.stringify(OFFLINE_SAVED)}
30
+ const BUNDLE = ${JSON.stringify(OFFLINE_BUNDLE)}
31
+
32
+ async function dropOurs(keeping) {
33
+ for (const name of await caches.keys())
34
+ if (name.startsWith(OURS) && name !== keeping) await caches.delete(name)
35
+ }
36
+ `
37
+
38
+ // 45.3 through 45.8, and 45.13.
39
+ const SAVING = `
40
+ self.addEventListener('install', (event) => {
41
+ event.waitUntil(
42
+ caches
43
+ .open(BUNDLE)
44
+ .then((cache) => cache.addAll([...FILES]))
45
+ .then(() => self.skipWaiting()),
46
+ )
47
+ })
48
+
49
+ self.addEventListener('activate', (event) => {
50
+ event.waitUntil(
51
+ (async () => {
52
+ await dropOurs(BUNDLE)
53
+ const cache = await caches.open(BUNDLE)
54
+ for (const request of await cache.keys())
55
+ if (!FILES.has(request.url)) await cache.delete(request)
56
+ await self.registration.navigationPreload?.enable()
57
+ await self.clients.claim()
58
+ for (const client of await self.clients.matchAll({ type: 'window' }))
59
+ await fetch(client.url).then(
60
+ (response) =>
61
+ kept(response)
62
+ ? keep(new Request(client.url), response)
63
+ : response.body?.cancel(),
64
+ () => {},
65
+ )
66
+ })(),
67
+ )
68
+ })
69
+
70
+ self.addEventListener('message', (event) => {
71
+ if (event.data === 'rotated') event.waitUntil(caches.delete(SAVED))
72
+ })
73
+
74
+ self.addEventListener('fetch', (event) => {
75
+ const request = event.request
76
+ if (request.mode === 'navigate') {
77
+ if (request.method === 'GET') event.respondWith(answer(event, true))
78
+ else event.waitUntil(caches.delete(SAVED))
79
+ return
80
+ }
81
+ if (request.method !== 'GET') return
82
+ if (FILES.has(request.url)) {
83
+ event.respondWith(
84
+ caches
85
+ .match(request, { cacheName: BUNDLE })
86
+ .then((saved) => saved ?? fetch(request)),
87
+ )
88
+ return
89
+ }
90
+ if (new URL(request.url).origin !== self.location.origin) return
91
+ if (request.headers.has(NAVIGATION) || request.headers.has('range')) return
92
+ if ((request.headers.get('accept') ?? '').includes('text/event-stream'))
93
+ return
94
+ event.respondWith(answer(event, false))
95
+ })
96
+
97
+ async function answer(event, page) {
98
+ let response
99
+ try {
100
+ response =
101
+ (page ? await event.preloadResponse : undefined) ??
102
+ (await fetch(event.request))
103
+ } catch {
104
+ const saved = await caches.match(event.request, {
105
+ cacheName: SAVED,
106
+ ignoreVary: true,
107
+ })
108
+ return saved ?? (page ? offline() : Response.error())
109
+ }
110
+ // A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
111
+ const copy = kept(response) ? response.clone() : undefined
112
+ const rotated = response.headers.has(PRINCIPAL)
113
+ event.waitUntil(
114
+ (rotated ? caches.delete(SAVED) : Promise.resolve()).then(() =>
115
+ copy === undefined ? undefined : keep(event.request, copy),
116
+ ),
117
+ )
118
+ return response
119
+ }
120
+
121
+ function kept(response) {
122
+ if (response.status !== 200) return false
123
+ const type = response.headers.get('content-type') ?? ''
124
+ return !STREAMED.some((streamed) => type.startsWith(streamed))
125
+ }
126
+
127
+ async function keep(request, response) {
128
+ const cache = await caches.open(SAVED)
129
+ await cache.put(request, response)
130
+ }
131
+
132
+ function offline() {
133
+ return new Response(OFFLINE, {
134
+ status: 503,
135
+ headers: { 'content-type': 'text/html; charset=utf-8' },
136
+ })
137
+ }
138
+ `
139
+
140
+ // 45.11 — the page a navigation with no network and no saved copy is answered with.
141
+ const OFFLINE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Offline</title></head><body><p>You're offline, and this page wasn't saved on this device. It loads once you're back online.</p></body></html>`
142
+
143
+ // 45.9 — THE WORKER THAT REMOVES ITSELF, for a browser still holding one an earlier build registered.
144
+ const REMOVING = `${CACHES}
145
+ self.addEventListener('install', () => self.skipWaiting())
146
+
147
+ self.addEventListener('activate', (event) => {
148
+ event.waitUntil(
149
+ (async () => {
150
+ await dropOurs(undefined)
151
+ await self.registration.unregister()
152
+ })(),
153
+ )
154
+ })
155
+ `
156
+
157
+ /** Every file of the build's bundle, by name: the entry, its stylesheet and each route's chunks. */
158
+ function bundleFiles(manifest: Manifest): Set<string> {
159
+ const files = new Set<string>()
160
+ if (manifest.entry !== undefined) files.add(manifest.entry)
161
+ if (manifest.stylesheet !== undefined) files.add(manifest.stylesheet)
162
+ for (const chunks of Object.values(manifest.chunks))
163
+ for (const chunk of chunks) files.add(chunk)
164
+ return files
165
+ }
166
+
167
+ /**
168
+ * The service worker's script: the one saving the build `manifest` names, or, with none, the one that
169
+ * removes itself.
170
+ *
171
+ * 45.2 — ALLOWED THE MOUNT'S WHOLE SCOPE from below `/__abide`, where a worker's own directory would
172
+ * be all it could reach.
173
+ */
174
+ export function answerWorker(manifest: Manifest | undefined): Response {
175
+ let script = REMOVING
176
+ if (manifest !== undefined) {
177
+ const paths: string[] = []
178
+ for (const name of bundleFiles(manifest))
179
+ paths.push(mounted(`${CLIENT_ADDRESS}/${name}`))
180
+ script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
181
+ const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
182
+ // 45.4 — the types a stream 16.32 frames, which has no end to save.
183
+ const STREAMED = ${JSON.stringify(STREAMED_TYPES)}
184
+ const OFFLINE = ${JSON.stringify(OFFLINE)}
185
+ const FILES = new Set(${JSON.stringify(paths)}.map((path) => new URL(path, self.location).href))
186
+ ${SAVING}`
187
+ }
188
+ return new Response(script, {
189
+ headers: {
190
+ 'content-type': 'text/javascript; charset=utf-8',
191
+ 'cache-control': 'no-cache',
192
+ 'service-worker-allowed': `${MOUNT.current()}/`,
193
+ },
194
+ })
195
+ }
@@ -0,0 +1,48 @@
1
+ // 45.15 — THE WEB APP MANIFEST, which makes the app one a phone can put on its home screen: where the
2
+ // browser keeps what the service worker saved for as long as the app stays installed, rather than
3
+ // clearing it after a spell unvisited.
4
+
5
+ import { APP_MANIFEST_ADDRESS } from '#shared/ADDRESSES.ts'
6
+ import { ICON_ADDRESSES } from '#shared/LAUNCHER_FILES.ts'
7
+ import { appName } from '#shared/log.ts'
8
+ import { MOUNT, mounted } from '#shared/mount.ts'
9
+
10
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
11
+
12
+ /**
13
+ * The tags a document links the manifest for the icon at `iconPath` by, and the manifest itself, or
14
+ * `undefined` where it is not a PNG.
15
+ *
16
+ * DECLARED AT ITS OWN SIZE, read off the PNG's header: nothing on a server draws it at another
17
+ * (`abide bundle` has `sips`, and a Linux host does not).
18
+ */
19
+ export async function appManifest(
20
+ iconPath: string,
21
+ ): Promise<{ tags: string; body: string } | undefined> {
22
+ const head = await Bun.file(iconPath).slice(0, 24).bytes()
23
+ // A file cut off before the header's width and height is no PNG to size.
24
+ if (head.length < 24) return undefined
25
+ for (let at = 0; at < PNG_SIGNATURE.length; at += 1)
26
+ if (head[at] !== PNG_SIGNATURE[at]) return undefined
27
+ const header = new DataView(head.buffer, head.byteOffset, head.byteLength)
28
+ const icon = mounted(ICON_ADDRESSES.png)
29
+ const scope = `${MOUNT.current()}/`
30
+ const name = appName()
31
+ return {
32
+ tags: `<link rel="manifest" href="${mounted(APP_MANIFEST_ADDRESS)}"><link rel="apple-touch-icon" href="${icon}">`,
33
+ body: JSON.stringify({
34
+ name,
35
+ short_name: name,
36
+ start_url: scope,
37
+ scope,
38
+ display: 'standalone',
39
+ icons: [
40
+ {
41
+ src: icon,
42
+ sizes: `${header.getUint32(16)}x${header.getUint32(20)}`,
43
+ type: 'image/png',
44
+ },
45
+ ],
46
+ }),
47
+ }
48
+ }
@@ -93,7 +93,7 @@ export async function* assistant(
93
93
  yield* ASSISTANT_LOG.enabled() ? traced(events, messages.length) : events
94
94
  }
95
95
 
96
- /** `events`, and one line on `abide:assistant` once they end, however they end. 44.32. */
96
+ /** `events`, and one line on `abide:assistant` once they end, however they end. 44.31. */
97
97
  async function* traced(
98
98
  events: AsyncGenerator<AssistantEvent>,
99
99
  messageCount: number,
@@ -15,11 +15,10 @@
15
15
  import { isFailed } from '#shared/Failed.ts'
16
16
  import { refuse } from '#shared/refuse.ts'
17
17
  import { answered } from './answerFailure.ts'
18
- import { bodyWithin, raiseTimeout } from './answerRpc.ts'
18
+ import { bodyWithin } from './answerRpc.ts'
19
19
  import { framed } from './answerValue.ts'
20
20
  import { Inbox } from './Inbox.ts'
21
21
  import { randomToken } from './randomToken.ts'
22
- import { EVENT_STREAM_OPENER } from './streamEvent.ts'
23
22
 
24
23
  /**
25
24
  * What a turn hears from the bridge, in the order the bridge posted it: an event, an MCP request, the
@@ -53,9 +52,6 @@ const CODE_LIFETIME = 10 * 60_000
53
52
  const IDLE_GRACE = 30_000
54
53
  /** How long a pairing whose stream dropped waits to be resumed. 44.26. */
55
54
  export const RESUME_MILLISECONDS = 60_000
56
- /** How often a pairing's stream carries a comment while it has nothing else to send. 44.27. */
57
- export const HEARTBEAT_MILLISECONDS = 15_000
58
- const HEARTBEAT = new TextEncoder().encode(EVENT_STREAM_OPENER)
59
55
 
60
56
  const CODES = new Map<string, { caller: string; expires: number }>()
61
57
  const BY_CALLER = new Map<string, Bridge>()
@@ -246,56 +242,14 @@ function resume(request: Request): Response {
246
242
  /** `bridge`'s frames as this request's stream, held for `RESUME_MILLISECONDS` once it drops. */
247
243
  function attach(bridge: Bridge, request: Request): Response {
248
244
  const frames = bridge.frames
249
- // 16.57 — a pairing stays open for as long as the bridge holds it.
250
- raiseTimeout(Number.POSITIVE_INFINITY)
251
245
  request.signal.addEventListener('abort', () => {
252
246
  if (bridge.frames !== frames || !BY_TOKEN.has(bridge.token)) return
253
247
  bridge.frames = frames.handOff()
254
248
  bridge.held = setTimeout(() => dropBridge(bridge), RESUME_MILLISECONDS)
255
249
  })
256
- const answer = framed(
257
- frames[Symbol.asyncIterator](),
258
- 'sse',
259
- new Headers(),
260
- 200,
261
- )
262
- return new Response(
263
- withHeartbeat(answer.body as ReadableStream<Uint8Array>),
264
- answer,
265
- )
266
- }
267
-
268
- // 44.27 — A COMMENT EVERY HEARTBEAT, so a bridge can tell a connection that went silent without
269
- // ending from a quiet one. A comment rather than a frame: a bridge reads only `data:` lines, and a
270
- // frame would sit in the pairing's inbox through a drop.
271
- function withHeartbeat(
272
- body: ReadableStream<Uint8Array>,
273
- ): ReadableStream<Uint8Array> {
274
- const reader = body.getReader()
275
- let beat: ReturnType<typeof setInterval> | undefined
276
- return new ReadableStream<Uint8Array>({
277
- start(controller) {
278
- beat = setInterval(
279
- () => controller.enqueue(HEARTBEAT),
280
- HEARTBEAT_MILLISECONDS,
281
- )
282
- },
283
- async pull(controller) {
284
- try {
285
- const next = await reader.read()
286
- if (!next.done) return controller.enqueue(next.value)
287
- clearInterval(beat)
288
- controller.close()
289
- } catch (thrown) {
290
- clearInterval(beat)
291
- controller.error(thrown)
292
- }
293
- },
294
- cancel(reason) {
295
- clearInterval(beat)
296
- return reader.cancel(reason)
297
- },
298
- })
250
+ // 16.57 — `App.fetch` beats it, which is how a bridge tells a connection
251
+ // gone silent from a quiet one (44.27).
252
+ return framed(frames[Symbol.asyncIterator](), 'sse', new Headers(), 200)
299
253
  }
300
254
 
301
255
  type BridgeReport = {
@@ -122,7 +122,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
122
122
  const block = event.content_block
123
123
  blocks[event.index] = block
124
124
  partials[event.index] = ''
125
- // 44.29 — announced as the model starts writing it.
125
+ // 44.28 — announced as the model starts writing it.
126
126
  if (block.type === 'tool_use')
127
127
  yield {
128
128
  type: 'tool',
@@ -6,7 +6,7 @@
6
6
  // CONFIGURED BY CLAUDE CODE'S OWN OPTION NAMES, which the bridge lays on as their flags, and only the ones
7
7
  // that shape the answer. What would give a site power over the visitor's machine — permissions,
8
8
  // directory, settings — is not an option here at all, and its built-in `tools` are a request the visitor
9
- // grants or does not: the bridge decides (44.15, 44.16, 44.30).
9
+ // grants or does not: the bridge decides (44.15, 44.16, 44.29).
10
10
 
11
11
  import { mounted } from '#shared/mount.ts'
12
12
  import { refuse } from '#shared/refuse.ts'
@@ -42,7 +42,7 @@ export type ClaudeCodeConfig = {
42
42
  tools?: string[]
43
43
  }
44
44
 
45
- /** The built-in tools a pairing command grants, as the visitor would after its `--`. 44.33. */
45
+ /** The built-in tools a pairing command grants, as the visitor would after its `--`. 44.32. */
46
46
  type PairingOptions = Pick<ClaudeCodeConfig, 'tools'>
47
47
 
48
48
  /** Whether this browser's bridge is connected, and the command that pairs one where it is not. */
@@ -128,7 +128,7 @@ function askingCaller(): string {
128
128
 
129
129
  /**
130
130
  * The command that pairs a bridge with this browser, and when its code stops standing. 44.14. The
131
- * `tools` it is given are written after its `--`, so the visitor running it grants them (44.30, 44.33).
131
+ * `tools` it is given are written after its `--`, so the visitor running it grants them (44.29, 44.32).
132
132
  */
133
133
  function bridgeCommand({ tools }: PairingOptions = {}): {
134
134
  command: string
@@ -71,6 +71,13 @@ export async function clientBundle(
71
71
  // app serves is still worth a build error.
72
72
  external: [...published.values()],
73
73
  throw: false,
74
+ // 36.16 — THE MODE, NOT THE ENVIRONMENT: the bundler writes in the `NODE_ENV` this process
75
+ // STARTED under, so the `production` `abide build` assigns once running never reached it.
76
+ define: {
77
+ 'process.env.NODE_ENV': JSON.stringify(
78
+ mode === 'build' ? 'production' : 'development',
79
+ ),
80
+ },
74
81
  ...(mode === 'build'
75
82
  ? {
76
83
  // 38.5 — MINIFIED, and EVERY NAME HASHED, the entry's included: chunks and assets are
@@ -156,6 +156,20 @@ export function positiveSetting<Fallback extends number | undefined>(
156
156
  return Number.isFinite(declared) && declared > 0 ? declared : fallback
157
157
  }
158
158
 
159
+ /**
160
+ * Whether the switch `name` is on: `true` or `false`, or text reading `true` or `1`, `false` or `0`
161
+ * in any case, as 27.8 coerces one over a boolean default — and `fallback` where it is unset or reads
162
+ * as neither.
163
+ *
164
+ * 45.14.
165
+ */
166
+ export function settingOn(name: string, fallback: boolean): boolean {
167
+ const declared = config.peek()?.[name]
168
+ if (typeof declared === 'boolean') return declared
169
+ if (typeof declared !== 'string') return fallback
170
+ return (coerce(declared, fallback) as boolean | undefined) ?? fallback
171
+ }
172
+
159
173
  // 36.2 — `APP_URL`, parsed once per text it holds, and `undefined` where there is none or it will not
160
174
  // parse. Read through the configuration, so an environment that moves it moves every address.
161
175
  let appUrlFor: string | undefined
@@ -6,6 +6,8 @@
6
6
  import { existsSync } from 'node:fs' // bun exposes no sync existence check of its own
7
7
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
8
8
  import { FAILURE_STATUS_TEXT } from '#shared/FAILURE_STATUS_TEXT.ts'
9
+ import { framingOf } from '#shared/framing.ts'
10
+ import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
9
11
  import { namedLogger } from '#shared/log.ts'
10
12
  import { compose } from '#shared/Middleware.ts'
11
13
  import { MOUNT, unmounted } from '#shared/mount.ts'
@@ -26,6 +28,7 @@ import {
26
28
  } from './config.ts'
27
29
  import { McpSurface } from './generatedSurfaces.ts'
28
30
  import { HOOKS, inOrder, reportUnexpected, rootRungs } from './hooks.ts'
31
+ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
29
32
  import { installServer } from './installServer.ts'
30
33
  import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
31
34
  import { plainAnswer } from './plainAnswer.ts'
@@ -34,11 +37,13 @@ import {
34
37
  type AnsweringApp,
35
38
  applyWritten,
36
39
  currentRequestScope,
40
+ holdScope,
37
41
  inReleasedScope,
38
42
  LISTENING,
39
43
  rungsTook,
40
44
  serverTiming,
41
45
  } from './requestScope.ts'
46
+ import { heldBody } from './streamEvent.ts'
42
47
  import { testBuild } from './testBuild.ts'
43
48
  import { traceResponse } from './trace.ts'
44
49
 
@@ -299,7 +304,7 @@ export async function bootEntered(
299
304
  try {
300
305
  // 42.6 — `run`, with dispatch over it — and the headers every answer carries stamped
301
306
  // inside it, where the trace the request was answered under is still the current one.
302
- // 16.22 — the scope keeps `given` too, the one request bun will raise a timeout on.
307
+ // 16.23 — the scope keeps `given` too, the one request bun will set a timeout on.
303
308
  return await inReleasedScope(
304
309
  request,
305
310
  async () => {
@@ -312,7 +317,7 @@ export async function bootEntered(
312
317
  reportUnexpected(thrown)
313
318
  answered = plainAnswer('internal server error', 500)
314
319
  }
315
- const stamped = stamp(answered)
320
+ const stamped = beating(stamp(answered))
316
321
  // 28.7 — a closed namespace does not pay for the line it would have written.
317
322
  if (REQUEST_LOG.enabled())
318
323
  REQUEST_LOG.debug(
@@ -388,10 +393,9 @@ export async function bootEntered(
388
393
  new Promise<false>((done) => {
389
394
  deadline = setTimeout(
390
395
  () => done(false),
391
- // setTimeout reads anything past a signed 32-bit count as 1 ms.
392
396
  Math.min(
393
397
  positiveSetting('ABIDE_STOP_TIMEOUT', STOP_TIMEOUT),
394
- 2 ** 31 - 1,
398
+ LONGEST_DELAY,
395
399
  ),
396
400
  )
397
401
  }),
@@ -423,6 +427,9 @@ export async function bootEntered(
423
427
  ...(options?.unix !== undefined
424
428
  ? { unix: options.unix }
425
429
  : {
430
+ // 16.56 — the bound a request received whole lifts and its answer puts
431
+ // back (16.23). A unix socket takes bun's own.
432
+ idleTimeout: IDLE_TIMEOUT_SECONDS,
426
433
  ...(port === undefined ? {} : { port }),
427
434
  ...(options?.hostname === undefined
428
435
  ? {}
@@ -505,6 +512,23 @@ function stamp(answered: Response): Response {
505
512
  return stamped
506
513
  }
507
514
 
515
+ /**
516
+ * `answered`, beating where it is an event stream, and holding its request's scope until it ends.
517
+ *
518
+ * 16.57 — EVERY `text/event-stream`, whoever wrote it: an sse body, a followed value, a pairing's
519
+ * stream, a `Response` a handler built itself. 16.23 — and the hold is what keeps the last of those
520
+ * from being reaped once `fetch` returns, where nothing else outlives it.
521
+ */
522
+ function beating(answered: Response): Response {
523
+ // The type first: reading `body` turns a string answer into a stream.
524
+ if (
525
+ framingOf(answered.headers.get('content-type')) !== 'sse' ||
526
+ answered.body === null
527
+ )
528
+ return answered
529
+ return new Response(heldBody(answered.body, holdScope(), true), answered)
530
+ }
531
+
508
532
  /**
509
533
  * The request with the mount taken off its address, where it is under the mount — or the redirect
510
534
  * to the mount's root, where it asked for the mount with a `/` after it.
@@ -128,7 +128,7 @@ const relay = serveOver(
128
128
  },
129
129
  )
130
130
 
131
- // 16.22 — THE LISTENER HOLDS THE CONNECTION, so the idle timeout a call raises is raised there, on
131
+ // 16.23 — THE LISTENER HOLDS THE CONNECTION, so a request's idle timeout is set there, on
132
132
  // the request bun handed it.
133
133
  LISTENING.timeout = relay.timeout
134
134
 
@@ -239,6 +239,9 @@ async function open(
239
239
  client: bundling.then((bundle) => ({
240
240
  manifest: bundle.manifest,
241
241
  head: `<script type="module" src="${RELOAD_CLIENT_ADDRESS}"></script>`,
242
+ // 45.9 — a worker `abide start` registered on this origin is removed rather than
243
+ // left answering a dev server from what it saved.
244
+ offline: false,
242
245
  framework: (pathname, request) =>
243
246
  pathname === RELOAD_CLIENT_ADDRESS
244
247
  ? new Response(RELOAD_CLIENT, {
@@ -17,6 +17,7 @@ import type { Principal } from '#shared/principal.ts'
17
17
  import { SCOPING, type Scope } from '#shared/reactive/scoping.ts'
18
18
  import type { RpcRequest } from '#shared/rpcRequest.ts'
19
19
  import type { McpSurface } from './generatedSurfaces.ts'
20
+ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
20
21
 
21
22
  /** The three framings a stream is answered in. 17.6. */
22
23
  export type Framing = Framed | 'bytes'
@@ -37,12 +38,8 @@ export type RequestScope = Scope & {
37
38
  caller: string | undefined
38
39
  /** This request's one resolve of the principal. 26.14. */
39
40
  principal: Promise<Principal> | undefined
40
- /**
41
- * The longest `timeout` any handler that ran in this request asked for, in seconds.
42
- *
43
- * 16.23 — and `Infinity` for one that is never reaped (16.57).
44
- */
45
- timeout: number
41
+ /** 16.23 — whether the received request's idle timeout is off until the answer is sent. */
42
+ unreaped: boolean
46
43
  /** This response's nonce, minted by the `csp` rung or on the first ask. 29.3. */
47
44
  nonce: string | undefined
48
45
  /** This request's cookie jar, built on the first ask. 22.2. */
@@ -71,7 +68,7 @@ export type RequestScope = Scope & {
71
68
  /**
72
69
  * The request the listener was handed, before a mount or a rung made another.
73
70
  *
74
- * 16.22 — bun raises the idle timeout only on the one it received itself.
71
+ * 16.23 — bun sets the idle timeout only on the one it received itself.
75
72
  */
76
73
  received: Request | undefined
77
74
  /** What a write through `response` is collected onto, for whoever answers to apply. 22.9. */
@@ -168,7 +165,7 @@ export function scopeFor(
168
165
  cookies: undefined,
169
166
  nonce: undefined,
170
167
  framing: undefined,
171
- timeout: 0,
168
+ unreaped: false,
172
169
  caller: undefined,
173
170
  principal: undefined,
174
171
  applied: false,
@@ -216,6 +213,8 @@ export function inReleasedScope<Result>(
216
213
  received: Request | undefined = given,
217
214
  ): Promise<Result> {
218
215
  const scope = scopeFor(given, received, app)
216
+ // 16.23 — a request with no body is received whole as it arrives.
217
+ if (received?.body === null) unreap(scope)
219
218
  return STORAGE.run(scope, async () => {
220
219
  try {
221
220
  return await body()
@@ -269,6 +268,12 @@ export function heldByApp(end: () => void): () => void {
269
268
  export function releaseScope(scope: RequestScope): void {
270
269
  scope.holds -= 1
271
270
  if (scope.holds > 0) return
271
+ // 16.56 — answered, the connection is reaped once idle again: set before bun writes an answer
272
+ // that was not streamed, and as the last chunk of one that was goes out.
273
+ if (scope.unreaped) {
274
+ scope.unreaped = false
275
+ LISTENING.timeout(scope.received as Request, IDLE_TIMEOUT_SECONDS)
276
+ }
272
277
  const stops = scope.stops
273
278
  for (let at = 0; at < stops.length; at += 1) (stops[at] as () => void)()
274
279
  stops.length = 0
@@ -285,10 +290,25 @@ export function adoptRequest(given: Request): void {
285
290
  }
286
291
 
287
292
  /**
288
- * What `App.listen` bound, and how a call raises the idle timeout on the received request.
293
+ * Turns the received request's idle timeout off until its answer is sent (16.23), for a request
294
+ * received whole. Where no request was received there is no connection to keep.
295
+ *
296
+ * WHOLE, NOT ARRIVED: a body still arriving is reaped as any idle connection is (16.56), so a caller
297
+ * that stops sending partway is not held open for good.
298
+ */
299
+ export function unreap(scope: RequestScope | undefined): void {
300
+ if (scope === undefined || scope.received === undefined || scope.unreaped)
301
+ return
302
+ scope.unreaped = true
303
+ // bun spells "never reaped" 0.
304
+ LISTENING.timeout(scope.received, 0)
305
+ }
306
+
307
+ /**
308
+ * What `App.listen` bound, and how the idle timeout is set on the received request.
289
309
  *
290
- * 42.9 — the server is for `server` to hand back. 16.22 — the timeout is raised on the server
291
- * bound here, or where the listener is in another process (`abide dev`), by a message to it.
310
+ * 42.9 — the server is for `server` to hand back. 16.23 — the timeout is set on the server bound
311
+ * here, or where the listener is in another process (`abide dev`), by a message to it.
292
312
  */
293
313
  export const LISTENING: {
294
314
  server: Bun.Server<unknown> | undefined
@@ -25,7 +25,7 @@ export type ToRelay =
25
25
  | { kind: 'pull'; id: number }
26
26
  | { kind: 'cancel'; id: number }
27
27
 
28
- /** What the worker's half sends: a request's idle timeout raised, and its head, each chunk, and its end. */
28
+ /** What the worker's half sends: a request's idle timeout set, and its head, each chunk, and its end. */
29
29
  export type FromRelay =
30
30
  | { kind: 'timeout'; id: number; seconds: number }
31
31
  | {
@@ -56,8 +56,8 @@ export function describe(thrown: unknown): string {
56
56
 
57
57
  /**
58
58
  * The worker's half: answers each request the listener sends with `fetch`, and its body a chunk per
59
- * pull. `take` is handed every `ToRelay` message; `timeout` raises a request's idle timeout where the
60
- * listener holds its connection (16.22); `hangUpAll` aborts every request still being answered.
59
+ * pull. `take` is handed every `ToRelay` message; `timeout` sets a request's idle timeout where the
60
+ * listener holds its connection (16.23); `hangUpAll` aborts every request still being answered.
61
61
  */
62
62
  export function serveOver(
63
63
  channel: Channel<FromRelay>,
@@ -80,7 +80,7 @@ export function serveOver(
80
80
  number,
81
81
  { reader: ReadableStreamDefaultReader<Uint8Array>; html: boolean }
82
82
  >()
83
- // 16.22 — each request built here, known by the ask it was built from.
83
+ // 16.23 — each request built here, known by the ask it was built from.
84
84
  const asks = new WeakMap<Request, number>()
85
85
 
86
86
  const ended = (id: number): void => {
@@ -203,7 +203,7 @@ export function serveOver(
203
203
  // and, once its head has come back, the stream the body is fed into and the pull waiting on the
204
204
  // worker's next chunk (38.35).
205
205
  type InFlight = {
206
- // 16.22 — the request bun handed the listener, which is the one its idle timeout is raised on.
206
+ // 16.23 — the request bun handed the listener, which is the one its idle timeout is set on.
207
207
  request: Request
208
208
  headers: [string, string][]
209
209
  body: ArrayBuffer | null
@@ -223,7 +223,7 @@ type InFlight = {
223
223
  * compiles; `endAll` ends what the worker that is gone was answering.
224
224
  */
225
225
  export function askOver(
226
- raiseTimeout: (request: Request, seconds: number) => void,
226
+ setIdleTimeout: (request: Request, seconds: number) => void,
227
227
  ) {
228
228
  let channel: Channel<ToRelay> | undefined
229
229
  const inFlight = new Map<number, InFlight>()
@@ -334,7 +334,7 @@ export function askOver(
334
334
  const waiting = inFlight.get(message.id)
335
335
  if (waiting === undefined) return
336
336
  if (message.kind === 'timeout') {
337
- raiseTimeout(waiting.request, message.seconds)
337
+ setIdleTimeout(waiting.request, message.seconds)
338
338
  return
339
339
  }
340
340
  if (message.kind === 'head') {