@abide/abide 0.104.0 → 0.105.1

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +17 -17
  4. package/docs/RULEBOOK.md +50 -12
  5. package/docs/abide.md +9 -9
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +45 -86
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -20
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +11 -9
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +180 -512
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/readSources.ts +27 -42
  25. package/src/compiler/readsIn.ts +5 -16
  26. package/src/compiler/resolverIn.ts +77 -0
  27. package/src/server/abideModules.ts +92 -123
  28. package/src/server/answerRequest.ts +18 -4
  29. package/src/server/answerSocket.ts +136 -76
  30. package/src/server/answerValue.ts +28 -15
  31. package/src/server/appTree.ts +49 -71
  32. package/src/server/claudeApi.ts +2 -5
  33. package/src/server/clientBundle.ts +198 -0
  34. package/src/server/coerceArgs.ts +1 -1
  35. package/src/server/commands.ts +93 -57
  36. package/src/server/createApp.ts +90 -16
  37. package/src/server/deferredBuilds.ts +175 -0
  38. package/src/server/devWorker.ts +107 -319
  39. package/src/server/faceOf.ts +120 -0
  40. package/src/server/fenceServer.ts +10 -18
  41. package/src/server/index.ts +3 -0
  42. package/src/server/placement.ts +167 -0
  43. package/src/server/remote.ts +14 -42
  44. package/src/server/render.ts +25 -17
  45. package/src/server/renderComponent.ts +9 -5
  46. package/src/server/requestScope.ts +10 -4
  47. package/src/server/responseRelay.ts +389 -0
  48. package/src/server/runCompiled.ts +9 -12
  49. package/src/server/runDesktop.ts +6 -9
  50. package/src/server/serveBuild.ts +26 -73
  51. package/src/server/serveDev.ts +19 -176
  52. package/src/server/streamEvent.ts +29 -0
  53. package/src/server/withAbideSources.ts +2 -1
  54. package/src/shared/HandlerRoute.ts +68 -0
  55. package/src/shared/REFUSAL_KEY.ts +5 -0
  56. package/src/shared/answerOf.ts +105 -78
  57. package/src/shared/filledPath.ts +1 -9
  58. package/src/shared/framing.ts +127 -128
  59. package/src/shared/queryText.ts +15 -7
  60. package/src/shared/reactive/Cursor.ts +4 -2
  61. package/src/shared/reactive/Reactive.ts +285 -197
  62. package/src/shared/reactive/index.ts +11 -8
  63. package/src/shared/reactive/pairReactive.ts +1 -1
  64. package/src/shared/reactive/scoped.ts +4 -3
  65. package/src/shared/reactive/share.ts +11 -1
  66. package/src/shared/routePattern.ts +0 -123
  67. package/src/shared/rpc.ts +17 -24
  68. package/src/shared/rpcRequest.ts +18 -47
  69. package/src/shared/takeEarlyCall.ts +12 -37
  70. package/src/shared/url.ts +1 -1
  71. package/src/ui/Level.ts +276 -0
  72. package/src/ui/List.ts +17 -17
  73. package/src/ui/Router.ts +20 -87
  74. package/src/ui/applyHead.ts +92 -79
  75. package/src/ui/index.ts +4 -2
  76. package/src/ui/leave.ts +5 -0
  77. package/src/ui/reconcile.ts +23 -104
  78. package/src/ui/regions.ts +27 -2
  79. package/src/ui/rows.ts +123 -2
  80. package/src/ui/wire.ts +12 -27
  81. package/src/compiler/hooksCalled.ts +0 -35
  82. package/src/server/clientBuildOptions.ts +0 -54
  83. package/src/server/refuseAddressCollision.ts +0 -37
  84. package/src/shared/reactive/chunksOfStream.ts +0 -33
  85. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -28,12 +28,13 @@ import {
28
28
  } from '#compiler/compileApp.ts'
29
29
  import { formatDiagnosis } from '#compiler/formatDiagnosis.ts'
30
30
  import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
31
- import { placeOf } from '#shared/routePattern.ts'
32
31
  import type { Compilation } from './abideModules.ts'
33
32
  import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
34
- import type { Ask, FromWorker, ToWorker } from './devWorker.ts'
33
+ import type { FromWorker, ToWorker } from './devWorker.ts'
35
34
  import { upgraded } from './muxUpgrade.ts'
35
+ import { placeOf } from './placement.ts'
36
36
  import { plainAnswer } from './plainAnswer.ts'
37
+ import { askOver } from './responseRelay.ts'
37
38
  import { SETTLE_MILLISECONDS } from './SETTLE_MILLISECONDS.ts'
38
39
 
39
40
  /** What `abide dev` serves, and where. */
@@ -46,20 +47,6 @@ export type DevOptions = {
46
47
  port?: number | undefined
47
48
  }
48
49
 
49
- // A request in flight: the request itself — because a worker replaced before it answered answers
50
- // nothing, and the ask has to be put to the app that exists (38.24) — and, once its head has come
51
- // back, the stream the body is fed into and the pull waiting on the worker's next chunk (38.35).
52
- type InFlight = Omit<Ask, 'id' | 'method' | 'url'> & {
53
- // 16.22 — the request bun handed the listener, which is the one its idle timeout is raised on.
54
- request: Request
55
- settle: (response: Response) => void
56
- stream: ReadableStreamDefaultController<Uint8Array> | null
57
- pulled: (() => void) | null
58
- // A failure the worker sent behind a chunk still queued, raised on the pull after it — raising it
59
- // at once would discard the chunk, which is the markup saying what broke.
60
- failed: string | null
61
- }
62
-
63
50
  // The app's process, spoken to in its own protocol.
64
51
  type DevWorker = {
65
52
  postMessage(message: ToWorker): void
@@ -75,9 +62,6 @@ type Compiled = {
75
62
 
76
63
  const LAST_DEV_PORT = 3099
77
64
 
78
- // 37.3 — the answers the worker said `admitsMux`, which the listener upgrades on.
79
- const ADMITTED = new WeakSet<Response>()
80
-
81
65
  // An app-mux socket's data: the id the worker knows its connection by, and what `Mux.connect` reads
82
66
  // off the upgrade. `undefined` on the reload client's.
83
67
  type Carried =
@@ -92,7 +76,11 @@ type Carried =
92
76
  export async function serveDev(
93
77
  options: DevOptions,
94
78
  ): Promise<Bun.Server<undefined>> {
95
- const inFlight = new Map<number, InFlight>()
79
+ // 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
80
+ // 16.22 — a call's idle timeout raised on the request bun handed this listener.
81
+ const relay = askOver((request, seconds) =>
82
+ server.timeout(request, seconds),
83
+ )
96
84
  // Every socket this listener holds: the reload client's, with no data, and the app mux's, carrying
97
85
  // the connection id the worker knows it by.
98
86
  const sockets = new Set<Bun.ServerWebSocket<Carried>>()
@@ -100,12 +88,6 @@ export async function serveDev(
100
88
  let nextConnection = 0
101
89
  let worker: DevWorker | undefined
102
90
  let ready = false
103
- let nextId = 0
104
- // ASKED BEFORE THERE IS ANYONE TO ASK. A replacement terminates the worker and the next one
105
- // does not exist until its compile finishes, so a request arriving in that window has
106
- // nowhere to go — posted to the terminated worker it is never answered, and the reader's
107
- // tab hangs until they give up. It waits here instead.
108
- const queued: InFlight[] = []
109
91
 
110
92
  // ONE COMPILE AT A TIME, and the worker is started from the LAST one. A save landing while a
111
93
  // compile runs marks it stale rather than starting a second: two in flight each started a
@@ -222,11 +204,7 @@ export async function serveDev(
222
204
  if (message.kind === 'ready') ready = true
223
205
  else if (message.kind === 'socket-send')
224
206
  muxed.get(message.connection)?.send(message.text)
225
- else if (message.kind === 'timeout') {
226
- const asked = inFlight.get(message.id)?.request
227
- if (asked !== undefined)
228
- server.timeout(asked, message.seconds)
229
- } else answer(started, message)
207
+ else relay.take(message)
230
208
  },
231
209
  // 38.41 — AN APP THAT EXITS ON ITS OWN is replaced, and says so: a listener still
232
210
  // forwarding to it leaves every tab hanging.
@@ -235,7 +213,8 @@ export async function serveDev(
235
213
  console.error(
236
214
  'abide dev: the app exited, so it was restarted',
237
215
  )
238
- endInFlight(false, 'abide dev: the app exited')
216
+ // 38.41 — the request in hand may be what ended it, so none is asked again.
217
+ relay.endAll('abide dev: the app exited', false)
239
218
  replace()
240
219
  },
241
220
  },
@@ -261,25 +240,9 @@ export async function serveDev(
261
240
  published,
262
241
  failure,
263
242
  })
264
- // The worker holds every request behind its own compile, so what waited here only had
265
- // to wait for one to EXIST.
266
- const waiting = queued.splice(0)
267
- for (const again of waiting) forward(again)
268
- }
269
-
270
- // WHAT AN APP THAT IS GONE WAS HOLDING. A request whose head has not gone out is re-asked of the
271
- // replacement (38.24), or answered where the app ended itself — the request in hand may be what
272
- // ended it (38.41). 38.37 — one whose head has gone out cannot be answered again, and ends as
273
- // `abide start`'s does.
274
- function endInFlight(reask: boolean, why: string): void {
275
- for (const held of inFlight.values()) {
276
- if (held.stream !== null) {
277
- held.stream.error(new Error(why))
278
- held.pulled?.()
279
- } else if (reask) queued.push(held)
280
- else held.settle(plainAnswer(why, 500))
281
- }
282
- inFlight.clear()
243
+ // The worker holds every request behind its own compile, so what waited for one only had
244
+ // to wait for it to EXIST.
245
+ relay.replace(started)
283
246
  }
284
247
 
285
248
  // 38.15 — THE REPLACEMENT. The old worker is terminated, every socket it could still be
@@ -293,10 +256,11 @@ export async function serveDev(
293
256
  ready = false
294
257
  worker?.terminate()
295
258
  worker = undefined
259
+ relay.replace(undefined)
296
260
  for (const socket of sockets) socket.terminate()
297
261
  sockets.clear()
298
262
  muxed.clear()
299
- endInFlight(true, 'abide dev replaced the app mid-response')
263
+ relay.endAll('abide dev replaced the app mid-response', true)
300
264
  if (compiling !== undefined) stale = true
301
265
  else compiling = reopen()
302
266
  }
@@ -315,127 +279,6 @@ export async function serveDev(
315
279
  moved(filename === null ? undefined : `${options.root}/${filename}`),
316
280
  )
317
281
 
318
- // A REQUEST AS A MESSAGE. `Request` does not survive a structured clone, so what crosses is
319
- // the four things the worker rebuilds one from — and the request's body is collected here, which
320
- // is what lets it be asked again of a replacement (38.24). The answer's body streams (38.35).
321
- //
322
- // COLLECTED ONCE, HERE, rather than inside `forward`: a body can only be read off a
323
- // `Request` once, and a re-asked request is the second read that is not there.
324
- async function ask(request: Request): Promise<Response> {
325
- const body = request.body === null ? null : await request.arrayBuffer()
326
- return await new Promise<Response>((resolve) => {
327
- forward({
328
- request,
329
- headers: identityOnly(request.headers),
330
- body,
331
- settle: resolve,
332
- stream: null,
333
- pulled: null,
334
- failed: null,
335
- })
336
- })
337
- }
338
-
339
- // 38.38 — ASKED WITHOUT `accept-encoding`, so the app answers uncompressed. A failure after the
340
- // head is shown as markup appended to the stream (`pull` in the worker), and markup appended to a
341
- // compressed stream is a stream no browser can decode. Localhost buys nothing from the bytes saved.
342
- function identityOnly(headers: Headers): [string, string][] {
343
- const kept: [string, string][] = []
344
- for (const [name, value] of headers)
345
- if (name !== 'accept-encoding') kept.push([name, value])
346
- return kept
347
- }
348
-
349
- // 38.35, 38.36 — THE HEAD SETTLES THE RESPONSE, and its body pulls one chunk from the worker per
350
- // read the connection makes: a high-water mark of 0 asks for nothing ahead of the reader.
351
- function answer(
352
- from: DevWorker,
353
- message: Exclude<
354
- FromWorker,
355
- { kind: 'ready' | 'socket-send' | 'timeout' }
356
- >,
357
- ): void {
358
- const waiting = inFlight.get(message.id)
359
- if (waiting === undefined) return
360
- const { id } = message
361
- if (message.kind === 'head') {
362
- if (message.empty || message.whole !== undefined) {
363
- inFlight.delete(id)
364
- const answered = new Response(message.whole ?? null, {
365
- status: message.status,
366
- statusText: message.statusText,
367
- headers: message.headers,
368
- })
369
- // The admission has no body, so an admitted answer is always settled here.
370
- if (message.admitted) ADMITTED.add(answered)
371
- waiting.settle(answered)
372
- return
373
- }
374
- const body = new ReadableStream<Uint8Array>(
375
- {
376
- start(controller) {
377
- waiting.stream = controller
378
- },
379
- pull(controller) {
380
- if (waiting.failed !== null) {
381
- inFlight.delete(id)
382
- controller.error(new Error(waiting.failed))
383
- return
384
- }
385
- return new Promise<void>((done) => {
386
- waiting.pulled = done
387
- from.postMessage({ kind: 'pull', id })
388
- })
389
- },
390
- cancel() {
391
- inFlight.delete(id)
392
- from.postMessage({ kind: 'cancel', id })
393
- },
394
- },
395
- { highWaterMark: 0 },
396
- )
397
- waiting.settle(
398
- new Response(body, {
399
- status: message.status,
400
- statusText: message.statusText,
401
- headers: message.headers,
402
- }),
403
- )
404
- return
405
- }
406
- const stream = waiting.stream
407
- const pulled = waiting.pulled
408
- waiting.pulled = null
409
- if (stream === null) return
410
- if (message.kind === 'chunk')
411
- stream.enqueue(new Uint8Array(message.bytes))
412
- else if (message.kind === 'end') {
413
- inFlight.delete(id)
414
- stream.close()
415
- } else waiting.failed = message.message
416
- pulled?.()
417
- }
418
-
419
- // NOT TRANSFERRED. Handing the buffer over detaches it here, and a request re-asked of the
420
- // replacement worker would post a body that is no longer there.
421
- function forward(waiting: InFlight): void {
422
- if (worker === undefined) {
423
- queued.push(waiting)
424
- return
425
- }
426
- nextId += 1
427
- const id = nextId
428
- inFlight.set(id, waiting)
429
- worker.postMessage({
430
- kind: 'request',
431
- id,
432
- method: waiting.request.method,
433
- url: waiting.request.url,
434
- headers: waiting.headers,
435
- body: waiting.body,
436
- })
437
- }
438
-
439
282
  // THE CHECKER AND THE WORKER GO WITH THE COMMAND. Each is a process of its own, and an app with
440
283
  // no tsconfig holds a temporary project on disk for as long as the checker is open. The worker
441
284
  // is told directly rather than left to its `disconnect`, which a worker busy inside a build
@@ -461,11 +304,11 @@ export async function serveDev(
461
304
  const mux =
462
305
  asked !== DEV_ADDRESS &&
463
306
  request.headers.get('upgrade') === 'websocket'
464
- if (!mux && asked !== DEV_ADDRESS) return ask(request)
307
+ if (!mux && asked !== DEV_ADDRESS) return relay.ask(request)
465
308
  // 37.3 — THE MUX'S UPGRADE IS ASKED OF THE APP FIRST, whose lane runs in the worker.
466
309
  if (mux && ready) {
467
- const answered = await ask(request)
468
- if (!ADMITTED.has(answered)) return answered
310
+ const answered = await relay.ask(request)
311
+ if (!relay.admitted(answered)) return answered
469
312
  }
470
313
  // NOT WHILE THE APP IS COMPILING. The reload client and the browser's mux both read a
471
314
  // refusal as "still rebuilding" and retry, which is what keeps a reload from racing
@@ -0,0 +1,29 @@
1
+ // 16.32, 18.20 — AN EVENT STREAM, WRITTEN: the one writer of `text/event-stream` behind a streaming
2
+ // handler's sse body and a followed `GET` answered as events. `framing.ts`'s `frameReader` is the
3
+ // one reader.
4
+
5
+ import type { Problem } from '#shared/Failed.ts'
6
+ import type { Framed } from '#shared/framing.ts'
7
+ import { REFUSAL_KEY } from '#shared/REFUSAL_KEY.ts'
8
+
9
+ /** AN EVENT STREAM OPENS ON A COMMENT, so a proxy has bytes to pass before the first event. */
10
+ export const EVENT_STREAM_OPENER = ':\n\n'
11
+
12
+ /**
13
+ * One event: its `event` name and `id` where it has them, and `data` — one line, which JSON is as
14
+ * `JSON.stringify` writes it.
15
+ */
16
+ export function streamEvent(
17
+ name: string | undefined,
18
+ id: number | undefined,
19
+ data: string,
20
+ ): string {
21
+ return `${name === undefined ? '' : `event: ${name}\n`}${id === undefined ? '' : `id: ${id}\n`}data: ${data}\n\n`
22
+ }
23
+
24
+ /** A framed stream's last frame, as the server writes it for a refusal (16.68). */
25
+ export function refusalFrame(problem: Problem, framing: Framed): string {
26
+ return framing === 'jsonl'
27
+ ? `${JSON.stringify({ [REFUSAL_KEY]: problem })}\n`
28
+ : streamEvent('refusal', undefined, JSON.stringify(problem))
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { relative, resolve } from 'node:path' // bun exposes no path helpers of its own
2
- import { COMPONENT_NAMESPACE, type Compilation } from './abideModules.ts'
2
+ import type { Compilation } from './abideModules.ts'
3
+ import { COMPONENT_NAMESPACE } from './faceOf.ts'
3
4
  import { COMPONENT_QUERY } from './MODULE_QUERIES.ts'
4
5
  import {
5
6
  decodeMappings,
@@ -0,0 +1,68 @@
1
+ // 16.9, 16.66 — A HANDLER'S ROUTE, COMPILED ONCE: what takes a call's route parameters out of its
2
+ // args and into the path, keeping the rest for the query or the body. Built once per address by
3
+ // what holds the handler — an `Rpc`, a browser's `wire` — and read by every call through it: the
4
+ // request a call is (`rpcRequest`), the in-process call's early refusal, and `abide call`.
5
+
6
+ import { filledSegments } from './filledPath.ts'
7
+ import { validationError } from './refuse.ts'
8
+ import { parseRoutePattern, type Segment } from './routePattern.ts'
9
+
10
+ /** 15.8 — a parameter no address carries, refused as args a gate refused. */
11
+ function pathRefusal(name: string, message: string): unknown {
12
+ return validationError<Record<string, unknown>>({ [name]: [message] })
13
+ }
14
+
15
+ /** A handler's address, parsed once: its segments and the names its parameters take. */
16
+ export class HandlerRoute {
17
+ readonly address: string
18
+ /** The parsed route, or `undefined` where it has no parameters and the path is the address. */
19
+ readonly segments: Segment[] | undefined
20
+ readonly names: Set<string>
21
+
22
+ constructor(address: string) {
23
+ this.address = address
24
+ this.names = new Set()
25
+ if (!address.includes('[')) {
26
+ this.segments = undefined
27
+ return
28
+ }
29
+ this.segments = parseRoutePattern(address)
30
+ for (const segment of this.segments)
31
+ if (segment.kind !== 'static') this.names.add(segment.name)
32
+ }
33
+
34
+ /**
35
+ * The path with each parameter `params` names written in.
36
+ *
37
+ * 30.3, 30.19 — one no address carries is thrown: by default as args a gate refused, so every
38
+ * caller answers it as the wire answers a 422.
39
+ */
40
+ path(
41
+ params: Record<string, unknown> | undefined,
42
+ refused: (name: string, message: string) => unknown = pathRefusal,
43
+ ): string {
44
+ return this.segments === undefined
45
+ ? this.address
46
+ : filledSegments(this.segments, params ?? {}, this.address, refused)
47
+ }
48
+
49
+ /**
50
+ * `args` split: the path, and the members no parameter took — `undefined` where none is left —
51
+ * so the query or body never carries a parameter twice (16.66).
52
+ */
53
+ split(args: Record<string, unknown> | undefined): {
54
+ path: string
55
+ rest: Record<string, unknown> | undefined
56
+ } {
57
+ if (this.segments === undefined)
58
+ return { path: this.address, rest: args }
59
+ const path = this.path(args)
60
+ let rest: Record<string, unknown> | undefined
61
+ for (const name in args) {
62
+ if (this.names.has(name)) continue
63
+ rest ??= {}
64
+ rest[name] = args[name]
65
+ }
66
+ return { path, rest }
67
+ }
68
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The key a jsonl line carries a refusal under (16.68): the server writes it, a browser's wire
3
+ * reads it.
4
+ */
5
+ export const REFUSAL_KEY = '$refusal'
@@ -1,13 +1,52 @@
1
1
  // 41.21 — A CALL THE PAGE ALREADY STARTED IS TAKEN, NOT MADE AGAIN, and what a call answered is read
2
2
  // one way whoever made it: a handler's `wire`, and a handler abide mounts itself (`seededDocument`).
3
+ //
4
+ // ONE CLASSIFIER. The early-call script (41.20) records the `Response` and nothing it decides, so
5
+ // an answer the page started and one fetched here are read by `answerFrom` alike.
3
6
 
4
7
  import { failedFrom, isProblem } from './Failed.ts'
5
- import { carriesBytes, framedChunks, framingPrefix } from './framing.ts'
8
+ import {
9
+ carriesBytes,
10
+ type Framed,
11
+ framedChunks,
12
+ framingOf,
13
+ } from './framing.ts'
6
14
  import { principalRotated } from './principalRotated.ts'
7
15
  import { refuse } from './refuse.ts'
8
16
  import type { RpcRequest } from './rpcRequest.ts'
9
- import { type Answer, takeEarlyCall } from './takeEarlyCall.ts'
17
+ import { takeEarlyCall } from './takeEarlyCall.ts'
10
18
  import { VERSION_HEADER } from './VERSION_HEADER.ts'
19
+ import { versionInEtag } from './versionInEtag.ts'
20
+
21
+ /** What a call answered, classified once. */
22
+ export type Answer = {
23
+ status: number
24
+ statusText: string
25
+ /** A 2xx. */
26
+ ok: boolean
27
+ /** What precedes each chunk's JSON on its line (16.32), or `null` where the answer is not framed. */
28
+ framing: Framed | null
29
+ /**
30
+ * Whether a subscriber may follow it (18.24): a 2xx that is not a binary, whose bytes the mux's
31
+ * JSON frames cannot carry.
32
+ */
33
+ followable: boolean
34
+ /** The version its `ETag` named (18.5). */
35
+ version: number | undefined
36
+ /** The `max-age` its `cache-control` named, in ms (18.14, 21.8). */
37
+ life: number | undefined
38
+ text: string | undefined
39
+ /**
40
+ * A binary answer's body read to the end (16.34), set in place of `text` — a body read as text
41
+ * is not the bytes it carried.
42
+ */
43
+ bytes: Uint8Array | undefined
44
+ /**
45
+ * A streamed answer's body, left unread so its chunks are taken as they land (16.32): framed,
46
+ * or a binary's where `framing` is `null`.
47
+ */
48
+ body: ReadableStream<Uint8Array> | null | undefined
49
+ }
11
50
 
12
51
  /**
13
52
  * A call's answer: the early call's where the page started one, fetched where it did not.
@@ -26,87 +65,77 @@ export function answerOf(
26
65
  ): Answer | Promise<Answer> {
27
66
  const early = takeEarlyCall(request.key)
28
67
  if (early === undefined) return fetched(request, streams, signal)
29
- if (early.painted === 1) {
30
- const landed = landedAnswer(early)
31
- if (landed !== undefined) return landed
32
- }
33
- return early.promise.then((): Answer | Promise<Answer> => {
34
- const landed = landedAnswer(early)
35
- if (landed !== undefined) return landed
36
- // The early call failed as a request — or read a binary as text, which lost its bytes — so
37
- // this one is made again.
38
- if (early.body === undefined || early.body === null)
39
- return fetched(request, streams, signal)
40
- if (streams) return early
41
- const read = new Response(early.body)
42
- return carriesBytes(early.type) && succeeded(early.status)
43
- ? read
44
- .bytes()
45
- .then((bytes) => ({ ...early, bytes, body: undefined }))
46
- : read.text().then((text) => ({ ...early, text, body: undefined }))
47
- })
48
- }
49
-
50
- /**
51
- * An early call's answer where it has landed read: as text, or — for one the server painted, whose
52
- * body the runner reads to the end so hydration waits on it — as bytes, decoded where the answer is
53
- * not a binary.
54
- */
55
- function landedAnswer(early: Answer): Answer | undefined {
56
- if (readAsText(early)) return early
57
- if (early.bytes === undefined) return undefined
58
- return carriesBytes(early.type) && succeeded(early.status)
59
- ? early
60
- : {
61
- ...early,
62
- text: new TextDecoder().decode(early.bytes),
63
- bytes: undefined,
64
- }
65
- }
66
-
67
- /** Whether an early call's answer arrived as text that is what it carried — no binary read as one. */
68
- function readAsText(early: Answer): boolean {
69
- return (
70
- early.text !== undefined &&
71
- !(carriesBytes(early.type) && succeeded(early.status))
68
+ if (early.painted === 1 && early.response !== undefined)
69
+ return answerFrom(early.response, early.bytes, streams)
70
+ // An early call whose request failed is made again.
71
+ return early.promise.then(() =>
72
+ early.response === undefined
73
+ ? fetched(request, streams, signal)
74
+ : answerFrom(early.response, early.bytes, streams),
72
75
  )
73
76
  }
74
77
 
75
- function succeeded(status: number | undefined): boolean {
76
- return status !== undefined && status >= 200 && status < 300
77
- }
78
-
79
- /**
80
- * Fetches a call's answer; a framed answer to a streaming copy keeps its body unread (16.32).
81
- *
82
- * Anything else is read to its end: as bytes where it is a binary (16.34), as text otherwise.
83
- */
78
+ /** Fetches a call's answer. */
84
79
  async function fetched(
85
80
  request: RpcRequest,
86
81
  streams: boolean,
87
82
  signal: AbortSignal | undefined,
88
83
  ): Promise<Answer> {
89
- const answer = await fetch(
84
+ const response = await fetch(
90
85
  request.url,
91
86
  signal === undefined ? request.init : { ...request.init, signal },
92
87
  )
93
88
  // THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
94
- principalRotated(answer)
95
- const type = answer.headers.get('content-type')
96
- const binary = answer.ok && carriesBytes(type)
97
- // A streaming copy reads bytes as they land too (16.46), as it does a framed answer's lines.
98
- const unread =
99
- streams && answer.ok && (binary || framingPrefix(type) !== null)
100
- return {
101
- status: answer.status,
102
- statusText: answer.statusText,
103
- type,
104
- version: answer.headers.get(VERSION_HEADER),
105
- life: answer.headers.get('cache-control'),
106
- text: unread || binary ? undefined : await answer.text(),
107
- bytes: !unread && binary ? await answer.bytes() : undefined,
108
- body: unread ? answer.body : undefined,
89
+ principalRotated(response)
90
+ return answerFrom(response, undefined, streams)
91
+ }
92
+
93
+ /**
94
+ * `response`, classified: a body `read` to its end already (an early call the server painted)
95
+ * decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
96
+ * otherwise — or left unread for a `streams` caller where it is framed or a binary (16.32, 16.46).
97
+ */
98
+ function answerFrom(
99
+ response: Response,
100
+ read: ArrayBuffer | undefined,
101
+ streams: boolean,
102
+ ): Answer | Promise<Answer> {
103
+ const { status, statusText, headers } = response
104
+ const type = headers.get('content-type')
105
+ const ok = status >= 200 && status < 300
106
+ const framing = framingOf(type)
107
+ const binary = ok && carriesBytes(type)
108
+ const seconds = /max-age=(\d+)/.exec(headers.get('cache-control') ?? '')
109
+ const answer: Answer = {
110
+ status,
111
+ statusText,
112
+ ok,
113
+ framing,
114
+ followable: ok && !binary,
115
+ version: versionInEtag(headers.get(VERSION_HEADER)),
116
+ life: seconds === null ? undefined : Number(seconds[1]) * 1000,
117
+ text: undefined,
118
+ bytes: undefined,
119
+ body: undefined,
120
+ }
121
+ if (read !== undefined) {
122
+ if (binary) answer.bytes = new Uint8Array(read)
123
+ else answer.text = new TextDecoder().decode(read)
124
+ return answer
125
+ }
126
+ if (streams && ok && (binary || framing !== null)) {
127
+ answer.body = response.body
128
+ return answer
109
129
  }
130
+ return binary
131
+ ? response.arrayBuffer().then((buffer) => {
132
+ answer.bytes = new Uint8Array(buffer)
133
+ return answer
134
+ })
135
+ : response.text().then((text) => {
136
+ answer.text = text
137
+ return answer
138
+ })
110
139
  }
111
140
 
112
141
  /**
@@ -120,14 +149,12 @@ async function fetched(
120
149
  * value is one production. A marked one reads its body as it lands (`framedStream`).
121
150
  */
122
151
  export function answered(answer: Answer): unknown {
123
- const status = answer.status ?? 0
124
- if (status === 204) return undefined
125
- const ok = succeeded(status)
126
- if (ok) {
152
+ if (answer.status === 204) return undefined
153
+ if (answer.ok) {
127
154
  // 16.34 — a binary decodes back to the bytes it was.
128
155
  if (answer.bytes !== undefined) return answer.bytes
129
- const prefix = framingPrefix(answer.type)
130
- if (prefix !== null) return framedChunks(answer.text ?? '', prefix)
156
+ if (answer.framing !== null)
157
+ return framedChunks(answer.text ?? '', answer.framing)
131
158
  }
132
159
  let body: unknown
133
160
  try {
@@ -135,7 +162,7 @@ export function answered(answer: Answer): unknown {
135
162
  } catch {
136
163
  body = undefined
137
164
  }
138
- if (ok) return body
165
+ if (answer.ok) return body
139
166
  if (isProblem(body)) return failedFrom(body)
140
- return refuse(status, answer.statusText || undefined)
167
+ return refuse(answer.status, answer.statusText || undefined)
141
168
  }
@@ -18,7 +18,7 @@ export function filledPath(
18
18
  return filledSegments(parseRoutePattern(pattern), params, spelled, refused)
19
19
  }
20
20
 
21
- /** `filledPath` over a route already parsed, for a caller that fills the same route per call. */
21
+ /** `filledPath` over a route already parsed: a handler's, compiled once (`HandlerRoute`). */
22
22
  export function filledSegments(
23
23
  segments: Segment[],
24
24
  params: Record<string, unknown>,
@@ -50,14 +50,6 @@ export function filledSegments(
50
50
  return address === '' ? '/' : address
51
51
  }
52
52
 
53
- /** The names of `pattern`'s parameters — what a call takes out of its args and into the path (16.66). */
54
- export function parametersOf(pattern: string): Set<string> {
55
- const names = new Set<string>()
56
- for (const segment of parseRoutePattern(pattern))
57
- if (segment.kind !== 'static') names.add(segment.name)
58
- return names
59
- }
60
-
61
53
  /**
62
54
  * A param value encoded as one path segment; `.` and `..` are refused.
63
55
  *