@abide/abide 0.110.4 → 0.112.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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/REGISTRY.md +30 -29
  3. package/docs/RULEBOOK.md +98 -22
  4. package/docs/abide.md +46 -32
  5. package/package.json +1 -1
  6. package/src/compiler/Lowering.ts +5 -0
  7. package/src/compiler/classifyTemplate.ts +129 -34
  8. package/src/compiler/compileApp.ts +2 -1
  9. package/src/compiler/emitClient.ts +128 -14
  10. package/src/compiler/renderMarkup.ts +14 -8
  11. package/src/server/BRIDGE_VERSION.ts +1 -1
  12. package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
  13. package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
  14. package/src/server/answerRequest.ts +44 -3
  15. package/src/server/answerRpc.ts +14 -41
  16. package/src/server/answerSocket.ts +2 -2
  17. package/src/server/answerValue.ts +15 -3
  18. package/src/server/answerWorker.ts +195 -0
  19. package/src/server/appManifest.ts +48 -0
  20. package/src/server/assistant.ts +1 -1
  21. package/src/server/bridgeRelay.ts +4 -50
  22. package/src/server/claudeApi.ts +1 -1
  23. package/src/server/claudeCode.ts +3 -3
  24. package/src/server/clientBundle.ts +7 -0
  25. package/src/server/config.ts +14 -0
  26. package/src/server/createApp.ts +28 -4
  27. package/src/server/devWorker.ts +4 -1
  28. package/src/server/render.ts +2 -2
  29. package/src/server/requestScope.ts +31 -11
  30. package/src/server/responseRelay.ts +7 -7
  31. package/src/server/serveBuild.ts +2 -0
  32. package/src/server/serveDev.ts +4 -1
  33. package/src/server/streamEvent.ts +60 -1
  34. package/src/shared/ADDRESSES.ts +11 -0
  35. package/src/shared/ASCII_WHITESPACE.ts +6 -0
  36. package/src/shared/LONGEST_DELAY.ts +2 -0
  37. package/src/shared/OFFLINE_CACHES.ts +12 -0
  38. package/src/shared/OFFLINE_META.ts +7 -0
  39. package/src/shared/answerOf.ts +29 -4
  40. package/src/shared/attributeText.ts +1 -1
  41. package/src/shared/reactive/Expiry.ts +13 -1
  42. package/src/shared/reactive/Reactive.ts +13 -3
  43. package/src/shared/safeUrl.ts +8 -1
  44. package/src/ui/Level.ts +43 -95
  45. package/src/ui/Router.ts +37 -13
  46. package/src/ui/bindGroup.ts +41 -0
  47. package/src/ui/bindProperty.ts +1 -21
  48. package/src/ui/index.ts +5 -3
  49. package/src/ui/reconcileHead.ts +215 -0
  50. package/src/ui/registerWorker.ts +34 -0
  51. package/src/ui/routing.ts +1 -0
  52. package/src/ui/setClassGroup.ts +79 -0
  53. package/src/ui/setStyleGroup.ts +130 -0
  54. package/src/ui/socketWire.ts +33 -2
  55. package/src/ui/applyHead.ts +0 -214
@@ -21,6 +21,7 @@ import {
21
21
  classifyAttributes,
22
22
  isBound,
23
23
  isSingleRooted,
24
+ type MergeGroup,
24
25
  isSlot,
25
26
  } from './classifyTemplate.ts'
26
27
  import { decodeCharacterReferences } from './decodeCharacterReferences.ts'
@@ -220,9 +221,7 @@ export function emitClient(
220
221
  const registered = heads
221
222
  ? `if (${ROUTED}) ${ROUTED}.setHead(() => ${headMarkup})\n`
222
223
  : ''
223
- const applied = heads
224
- ? `if (${ROUTED}) ${lowering.use('applyHead')}(${ROUTED}.headMarkup()); `
225
- : ''
224
+ const applied = heads ? `if (${ROUTED}) ${ROUTED}.reconcile(); ` : ''
226
225
  // Applied with the fill either way, so the watch's first run only subscribes: what it would
227
226
  // write is already there, from the server or from the line above.
228
227
  if (liveHeads) lines.push(`if (${ROUTED}) ${ROUTED}.followHead(${SCOPE})`)
@@ -767,6 +766,17 @@ function elementUnits(
767
766
  )
768
767
  // 32.5, 32.7, 32.8 — THE PROPERTY AND THE VALUE, IN STEP BOTH WAYS: a watch on this
769
768
  // region's scope writes the property, and a listener writes it back.
769
+ // 32.8 — a group compares against the input's own value, read where the value is.
770
+ else if (one.kind === 'bound' && one.bind === 'group')
771
+ binds.push(
772
+ `${lowering.use('bindGroup')}(${element}, ${lowering.reference(one.attribute)}, () => (${
773
+ one.own === undefined
774
+ ? "'on'"
775
+ : one.own.expression
776
+ ? lowering.binding(one.own)
777
+ : JSON.stringify(one.own.value)
778
+ }), ${SCOPE})`,
779
+ )
770
780
  else if (one.kind === 'bound')
771
781
  binds.push(
772
782
  `${lowering.use('bindProperty')}(${element}, ${JSON.stringify(one.bind)}, ${lowering.reference(one.attribute)}, ${SCOPE})`,
@@ -798,16 +808,32 @@ if (__r${slot} !== undefined) ${SCOPE}.stops.push(__r${slot})`,
798
808
  write: 'attribute' as const,
799
809
  name: one.attribute.name,
800
810
  }
811
+ // 32.71 — A SHARED GROUP IS ONE WATCH over its whole value, which wakes for any member:
812
+ // a directive cannot keep what the plain attribute holds from a watch of its own.
813
+ const group =
814
+ one.kind === 'merged' && writesGroup(write)
815
+ ? one.group
816
+ : undefined
801
817
  // 32.67 — one reading a row's item is one of the row's item watches, on a name of its
802
818
  // own because it is installed apart from the element's other attributes.
803
- if (lowering.readsOwnItem(one.attribute))
804
- itemWrites.push(
805
- `${SCOPE}.itemWatch(() => ${lowering.use(WRITES[write])}(__i${slot}, ${JSON.stringify(name)}, ${lowering.binding(one.attribute)}))`,
806
- )
807
- else
808
- writes.push(
809
- `${SCOPE}.watch(() => ${lowering.use(WRITES[write])}(__a${slot}, ${JSON.stringify(name)}, ${lowering.binding(one.attribute)}))`,
810
- )
819
+ const asItem =
820
+ group === undefined
821
+ ? lowering.readsOwnItem(one.attribute)
822
+ : groupReadsOwnItem(group, lowering)
823
+ const call = writeCall(
824
+ write,
825
+ asItem ? `__i${slot}` : `__a${slot}`,
826
+ name,
827
+ group === undefined
828
+ ? lowering.binding(one.attribute)
829
+ : groupArguments(group, (attribute) =>
830
+ lowering.binding(attribute),
831
+ ),
832
+ lowering,
833
+ )
834
+ ;(asItem ? itemWrites : writes).push(
835
+ `${SCOPE}.${asItem ? 'itemWatch' : 'watch'}(() => ${call})`,
836
+ )
811
837
  }
812
838
  }
813
839
  if (itemWrites.length > 0)
@@ -825,14 +851,27 @@ if (__r${slot} !== undefined) ${SCOPE}.stops.push(__r${slot})`,
825
851
  const SCOPE = '__scope'
826
852
 
827
853
  // Which runtime write each kind of attribute binding is handed to. A record rather than a chain
828
- // of ternaries, so a fourth kind is a row here and the call site stays one line — all three take
829
- // the same three arguments, which is what makes them one site at all.
854
+ // of ternaries, so a sixth kind is a row here and the call site stays one line — all take the
855
+ // same three arguments, which is what makes them one site at all.
830
856
  const WRITES = {
831
857
  attribute: 'setAttribute',
832
858
  class: 'setClass',
833
859
  style: 'setStyleProperty',
860
+ classGroup: 'setClassGroup',
861
+ styleGroup: 'setStyleGroup',
834
862
  } as const
835
863
 
864
+ /** One attribute binding's write: the element, the one name it owns, and the value. */
865
+ function writeCall(
866
+ write: keyof typeof WRITES,
867
+ element: string,
868
+ name: string,
869
+ value: string,
870
+ lowering: Lowering,
871
+ ): string {
872
+ return `${lowering.use(WRITES[write])}(${element}, ${JSON.stringify(name)}, ${value})`
873
+ }
874
+
836
875
  // The share scope an instance captures for its deferred mounts (10.5), named apart from the one an
837
876
  // instance OPENS (`shareScope`): a page that shares nothing still carries this for a child that does.
838
877
  const SHARE_AT = '__shareAt'
@@ -1059,6 +1098,41 @@ function buildRender(lowering: Lowering): ValueRender {
1059
1098
  }
1060
1099
  }
1061
1100
 
1101
+ /** 32.71 — whether a write is a shared group's whole value. */
1102
+ function writesGroup(write: keyof typeof WRITES): boolean {
1103
+ return write === 'classGroup' || write === 'styleGroup'
1104
+ }
1105
+
1106
+ /** 32.71 — whether a shared group is one of a row's item watches: any member is. */
1107
+ function groupReadsOwnItem(group: MergeGroup, lowering: Lowering): boolean {
1108
+ for (const member of group.members)
1109
+ if (member.expression && lowering.readsOwnItem(member)) return true
1110
+ return false
1111
+ }
1112
+
1113
+ /**
1114
+ * 32.71 — a shared group's write arguments: the plain attribute's value, then every directive's
1115
+ * condition or value in the order the group names them, each read by `read`. A directive with no
1116
+ * expression is a class that is always on, or a property's own bytes.
1117
+ */
1118
+ function groupArguments(
1119
+ group: MergeGroup,
1120
+ read: (attribute: Attribute) => string,
1121
+ ): string {
1122
+ const values: string[] = []
1123
+ for (const directive of group.directives)
1124
+ values.push(
1125
+ directive.expression
1126
+ ? read(directive)
1127
+ : group.name === 'class'
1128
+ ? 'true'
1129
+ : JSON.stringify(
1130
+ decodeCharacterReferences(directive.value, 'attribute'),
1131
+ ),
1132
+ )
1133
+ return `${read(group.plain as Attribute)}, [${values.join(', ')}]`
1134
+ }
1135
+
1062
1136
  /**
1063
1137
  * Emits a region's `_build` and `_mount` pair into `helpers`, for a region nobody painted.
1064
1138
  *
@@ -1565,6 +1639,40 @@ function rowUpdate(
1565
1639
  position.holder as ElementNode,
1566
1640
  lowering,
1567
1641
  )) {
1642
+ // 32.71 — a shared group is written whole where a member moves with the item — and
1643
+ // where none is live, as for any attribute: a live group's watch reads the item too.
1644
+ if (
1645
+ one.kind === 'merged' &&
1646
+ one.group !== undefined &&
1647
+ one.toggles !== undefined &&
1648
+ writesGroup(one.toggles.write)
1649
+ ) {
1650
+ if (one.live) continue
1651
+ for (const member of one.group.members)
1652
+ if (
1653
+ member.expression &&
1654
+ !lowering.isLive(member) &&
1655
+ lowering.readsItem(member, block)
1656
+ ) {
1657
+ own.push(
1658
+ writeCall(
1659
+ one.toggles.write,
1660
+ `__a${slot}`,
1661
+ one.toggles.name,
1662
+ groupArguments(one.group, (attribute) =>
1663
+ unlandable(
1664
+ lowering,
1665
+ attribute,
1666
+ lowering.expression(attribute),
1667
+ ),
1668
+ ),
1669
+ lowering,
1670
+ ),
1671
+ )
1672
+ break
1673
+ }
1674
+ continue
1675
+ }
1568
1676
  if (!one.attribute.expression || one.live) continue
1569
1677
  if (!lowering.readsItem(one.attribute, block)) continue
1570
1678
  const toggles =
@@ -1578,7 +1686,13 @@ function rowUpdate(
1578
1686
  : undefined
1579
1687
  if (toggles === undefined) continue
1580
1688
  own.push(
1581
- `${lowering.use(WRITES[toggles.write])}(__a${slot}, ${JSON.stringify(toggles.name)}, ${lowering.expression(one.attribute)})`,
1689
+ writeCall(
1690
+ toggles.write,
1691
+ `__a${slot}`,
1692
+ toggles.name,
1693
+ lowering.expression(one.attribute),
1694
+ lowering,
1695
+ ),
1582
1696
  )
1583
1697
  }
1584
1698
  if (own.length === 0) continue
@@ -354,13 +354,9 @@ function renderPosition(
354
354
  // `<br>`, and a close tag written for one is markup no browser accepts.
355
355
  if (node.children.length === 0 && VOID_ELEMENTS.has(node.lowerTag))
356
356
  return parts.join(' + ')
357
- // 32.61 — THE VALUE IN PLACE OF THE AUTHOR'S TEXT, led by the newline the parser drops after
358
- // `<textarea>` so a value opening on one keeps it.
359
- if (
360
- within !== undefined &&
361
- node.lowerTag === 'textarea' &&
362
- node.children.length === 0
363
- )
357
+ // 32.61 — THE VALUE AS THE TEXT, led by the newline the parser drops after `<textarea>` so a
358
+ // value opening on one keeps it. The author wrote none: 32.72 refuses it beside the bind.
359
+ if (within !== undefined && node.lowerTag === 'textarea')
364
360
  return [
365
361
  ...parts,
366
362
  `'\\n' + ${lowering.use('escapeAttribute')}(${within})`,
@@ -614,7 +610,17 @@ function boundMarkup(
614
610
  return `' value="' + ${lowering.use('escapeAttribute')}(${read}) + '"'`
615
611
  const on =
616
612
  written.bind === 'group'
617
- ? `${lowering.use('groupHolds')}(${read}, ${JSON.stringify(written.own)})`
613
+ ? `${lowering.use('groupHolds')}(${read}, ${
614
+ written.own === undefined
615
+ ? "'on'"
616
+ : written.own.expression
617
+ ? unlandable(
618
+ lowering,
619
+ written.own,
620
+ lowering.expression(written.own),
621
+ )
622
+ : JSON.stringify(written.own.value)
623
+ })`
618
624
  : read
619
625
  return `(${on} ? ' ${written.reflects}' : '')`
620
626
  }
@@ -1,3 +1,3 @@
1
1
  // The `abide-bridge` release a pairing command names, so a visitor's `bunx` runs the relay this
2
2
  // server speaks. `packages/abide/tests/claudeCode.test.ts` holds it to the bridge package's own.
3
- export const BRIDGE_VERSION = '0.3.2'
3
+ export const BRIDGE_VERSION = '0.3.3'
@@ -0,0 +1,2 @@
1
+ /** How often a held-open event stream carries a comment while it has nothing else to send. 16.57. */
2
+ export const HEARTBEAT_MILLISECONDS = 15_000
@@ -0,0 +1,6 @@
1
+ /**
2
+ * How long a connection `App.listen` or `abide dev` binds may go without a byte while nothing over it
3
+ * is being answered (16.56): a body still arriving, or a kept-alive connection between requests.
4
+ * `Bun.serve`'s own default, named so the bound a request lifts can be put back.
5
+ */
6
+ export const IDLE_TIMEOUT_SECONDS = 10
@@ -8,15 +8,19 @@
8
8
 
9
9
  import {
10
10
  ABIDE_ADDRESS,
11
+ APP_MANIFEST_ADDRESS,
11
12
  BRIDGE_ADDRESS,
12
13
  CLIENT_ADDRESS,
13
14
  HEALTH_ADDRESS,
14
15
  MCP_ADDRESS,
15
16
  OPENAPI_ADDRESS,
16
17
  PRIVATE_HANDLERS_ADDRESS,
18
+ WORKER_ADDRESS,
17
19
  } from '#shared/ADDRESSES.ts'
18
20
  import { APP_META } from '#shared/APP_META.ts'
19
21
  import { DEBUG_META } from '#shared/DEBUG_META.ts'
22
+ import { ICON_ADDRESSES } from '#shared/LAUNCHER_FILES.ts'
23
+ import { OFFLINE_META } from '#shared/OFFLINE_META.ts'
20
24
  import { decodedOrKept } from '#shared/decodedOrKept.ts'
21
25
  import { ERROR_META } from '#shared/ERROR_META.ts'
22
26
  import { escapeAttribute } from '#shared/escapeText.ts'
@@ -49,7 +53,10 @@ import {
49
53
  import { answerBlobTagged, answerValue, contentEtag } from './answerValue.ts'
50
54
  import { answerBridge } from './bridgeRelay.ts'
51
55
  import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
56
+ import { appManifest } from './appManifest.ts'
57
+ import { answerWorker } from './answerWorker.ts'
52
58
  import { callerOrMint } from './callerOf.ts'
59
+ import { settingOn } from './config.ts'
53
60
  import { compressAnswer } from './compressAnswer.ts'
54
61
  import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
55
62
  import { noteCallInRender } from './deferredBuilds.ts'
@@ -72,6 +79,7 @@ import {
72
79
  adoptRequest,
73
80
  beginRender,
74
81
  currentRequestScope,
82
+ type PageRender,
75
83
  type RequestScope,
76
84
  within,
77
85
  writtenStatus,
@@ -162,6 +170,9 @@ export type AppSources = {
162
170
  explained: ((thrown: unknown) => Response) | undefined
163
171
  }
164
172
 
173
+ // 41.29 — the seed sink of a render no page hydrates: every call is left to run when it is made.
174
+ const SEEDS_NOTHING: PageRender['seed'] = () => false
175
+
165
176
  /** The `Response` to one request: a handler, a surface of abide's own, a file, or a page. */
166
177
  export async function answerRequest(
167
178
  app: AppSources,
@@ -338,7 +349,10 @@ async function answerMatched(
338
349
  arrive(at.href, page.pattern, params)
339
350
  // 11.32 — the page renders in a scope of its own, so what its values hold is this request's —
340
351
  // and 41.13, every call it makes is left for the browser's own call to be answered from.
341
- beginRender({ seed: seedFor(caller), calls: [] })
352
+ // 41.29 — BUT NOT A PAGE A SCRIPT FETCHED, which nothing hydrates: its entries would answer the
353
+ // next call this caller makes with what the render saw, a sign-in since included.
354
+ const hydrated = request.headers.get('sec-fetch-dest') !== 'empty'
355
+ beginRender({ seed: hydrated ? seedFor(caller) : SEEDS_NOTHING, calls: [] })
342
356
  // 41.1, 35.2 — THE DOCUMENT IS THE BUILT PAGE, read whole before there is a `Response` to commit a
343
357
  // status to: a refusal the page read, or a throw, is answered by the nearest error page at its own
344
358
  // status rather than as a 200 the stream then abandons.
@@ -457,6 +471,11 @@ type ClientLane = {
457
471
  manifest: Manifest
458
472
  framework: AppSources['framework']
459
473
  head: string
474
+ /**
475
+ * 45.1, 45.9 — whether the worker saves `manifest`'s bundle and the pages register it: `abide
476
+ * build`'s, unless `ABIDE_OFFLINE` is off. Where not, the worker address answers the removing one.
477
+ */
478
+ offline: boolean
460
479
  }
461
480
 
462
481
  /**
@@ -476,10 +495,19 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
476
495
  ])
477
496
  // 35.6 — every page is served in abide's own document; what it carries beyond that, the root
478
497
  // `layout.abide` writes (35.15).
498
+ // 45.15 — installable where the pages are kept offline and the app has an icon to install by.
499
+ const icon = published.get(ICON_ADDRESSES.png)
500
+ const install =
501
+ icon !== undefined && settingOn('ABIDE_OFFLINE', true)
502
+ ? await appManifest(icon.path)
503
+ : undefined
479
504
  const documentOf = (modules: string[]): string =>
480
505
  intoHead(
481
506
  DEFAULT_DOCUMENT,
482
- clientTags(client.manifest, modules) + client.head,
507
+ clientTags(client.manifest, modules) +
508
+ (client.offline ? `<meta name="${OFFLINE_META}">` : '') +
509
+ (install?.tags ?? '') +
510
+ client.head,
483
511
  )
484
512
  const pages: Omit<Ranked, 'segments'>[] = []
485
513
  for (const route of parts.table.routes)
@@ -517,7 +545,20 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
517
545
  errors,
518
546
  // 35.18 — marked, so the browser hydrates no page over what an error page painted.
519
547
  errorDocument: intoHead(documentOf([]), `<meta name="${ERROR_META}">`),
520
- framework: client.framework,
548
+ framework: (pathname, request) => {
549
+ if (pathname === WORKER_ADDRESS)
550
+ return answerWorker(
551
+ client.offline ? client.manifest : undefined,
552
+ )
553
+ if (pathname === APP_MANIFEST_ADDRESS && install !== undefined)
554
+ return new Response(install.body, {
555
+ headers: {
556
+ 'content-type': 'application/manifest+json',
557
+ 'cache-control': 'no-cache',
558
+ },
559
+ })
560
+ return client.framework(pathname, request)
561
+ },
521
562
  published,
522
563
  explained: parts.explained,
523
564
  ...mounted,
@@ -3,7 +3,7 @@
3
3
  // args off the request, and the answer onto a response.
4
4
  //
5
5
  // WIRE-ONLY, per 16.13: the `Origin` gate on a mutation, `maxBodySize`, the `crossOrigin` preflight,
6
- // raising the request's idle timeout (16.22), and 19.7's coercion of the text a URL carried.
6
+ // and 19.7's coercion of the text a URL carried.
7
7
 
8
8
  import type { CopyOptions } from '#shared/CopyOptions.ts'
9
9
  import { type Failed, isFailed } from '#shared/Failed.ts'
@@ -47,9 +47,9 @@ import { type AppRung, failureOf, rungsOver } from './hooks.ts'
47
47
  import { prefers } from './prefers.ts'
48
48
  import {
49
49
  currentRequestScope,
50
- LISTENING,
51
50
  rungsTook,
52
51
  timed,
52
+ unreap,
53
53
  writtenStatus,
54
54
  } from './requestScope.ts'
55
55
  import { type Outcome, outcomeOf, takeSeed } from './seedBuffer.ts'
@@ -265,9 +265,6 @@ export async function answerRpc(
265
265
  if (isFailed(refused)) return answered(refused)
266
266
  throw refused
267
267
  }
268
- // 16.56 — where neither names one, nothing is raised and the server's own idle timeout stands.
269
- const timeout = handler.timeout ?? RPC_SERVER.timeout()
270
- if (timeout !== undefined) raiseTimeout(timeout)
271
268
  // 41.7 — the call a hydrating page makes for what its render already reached is answered from
272
269
  // that, and the handler is not run again (41.13).
273
270
  // 16.51 — a `HEAD` leaves the seed for the `GET` it stands in front of.
@@ -499,23 +496,25 @@ function withParameters(
499
496
  }
500
497
 
501
498
  /**
502
- * The request's body under the ceiling `maxBodySize` sets, or `ABIDE_MAX_REQUEST_BODY_SIZE` where it
503
- * is unset, or a 413 refusal past it — the request itself where neither names one.
499
+ * The request's body, received whole, under the ceiling `maxBodySize` sets, or
500
+ * `ABIDE_MAX_REQUEST_BODY_SIZE` where it is unset, or a 413 refusal past it.
504
501
  *
505
502
  * 16.54 — NO CEILING BY DEFAULT. 16.55 — read off the declared length before a byte of the body is.
506
503
  * 16.78 — EVERY BODY ABIDE READS, a handler's and `/__abide/mcp`'s and `/__abide/bridge`'s alike.
504
+ * 16.23 — and the request received whole once it is read, which is where its idle timeout goes off.
507
505
  */
508
506
  export async function bodyWithin(
509
507
  request: Request,
510
508
  maxBodySize: number | undefined,
511
- ): Promise<Request | Response | Failed<'HttpError', undefined>> {
509
+ ): Promise<Response | Failed<'HttpError', undefined>> {
512
510
  const ceiling =
513
511
  maxBodySize ??
514
512
  positiveSetting('ABIDE_MAX_REQUEST_BODY_SIZE', Number.POSITIVE_INFINITY)
515
- if (!Number.isFinite(ceiling)) return request
516
513
  if (Number(request.headers.get('content-length') ?? 0) > ceiling)
517
514
  return overCeiling(ceiling)
518
- return bodyUnder(request, ceiling)
515
+ const body = await bodyUnder(request, ceiling)
516
+ if (!isFailed(body)) unreap(currentRequestScope())
517
+ return body
519
518
  }
520
519
 
521
520
  /**
@@ -530,6 +529,11 @@ async function bodyUnder(
530
529
  request: Request,
531
530
  ceiling: number,
532
531
  ): Promise<Response | Failed<'HttpError', undefined>> {
532
+ if (!Number.isFinite(ceiling))
533
+ // A blob, which a `Response` shares where it copies an `ArrayBuffer`.
534
+ return new Response(await request.blob(), {
535
+ headers: request.headers,
536
+ })
533
537
  const chunks: Uint8Array[] = []
534
538
  let size = 0
535
539
  if (request.body !== null)
@@ -560,37 +564,6 @@ function preflight(found: Mounted, origin: string, request: Request): Response {
560
564
  return new Response(null, { status: 204, headers })
561
565
  }
562
566
 
563
- /**
564
- * Raises the request's idle timeout to cover `milliseconds`, never lowering it.
565
- *
566
- * 16.22, 16.23 — the floor is the longest any handler in the request asked for. Where no socket is
567
- * bound there is no idle timeout to raise.
568
- *
569
- * 16.57 — `Infinity` is never reaped, which bun spells 0; any finite bound is capped at the 255
570
- * seconds bun's idle timeout can hold.
571
- *
572
- * FOUR SECONDS PAST THE TIMEOUT, NOT AT IT: a load that goes its timeout is refused at that moment,
573
- * and a stream's refusal is a frame written then (16.68). bun's sockets check an idle timeout on a
574
- * tick four seconds wide, so an idle timeout of the same second — or one past it — fired anywhere in
575
- * the tick before it and reaped the connection ahead of the frame, one stream in two under `abide
576
- * dev`. Four is the tick, so the refusal always goes out first.
577
- *
578
- * ON THE REQUEST THE LISTENER RECEIVED, never the one being answered: under a mount, or past a
579
- * rung that handed `next` its own, that is a copy, and bun's `timeout` does nothing for a request
580
- * it was not handed itself.
581
- */
582
- export function raiseTimeout(milliseconds: number): void {
583
- const scope = currentRequestScope()
584
- const received = scope?.received
585
- if (scope === undefined || received === undefined) return
586
- const seconds = Number.isFinite(milliseconds)
587
- ? Math.min(255, Math.ceil(milliseconds / 1000) + 4)
588
- : Number.POSITIVE_INFINITY
589
- if (seconds <= scope.timeout) return
590
- scope.timeout = seconds
591
- LISTENING.timeout(received, Number.isFinite(seconds) ? seconds : 0)
592
- }
593
-
594
567
  function isForm(request: Request): boolean {
595
568
  const type = request.headers.get('content-type') ?? ''
596
569
  return (
@@ -315,7 +315,7 @@ export class MuxConnection {
315
315
  upgrade.headers.get('origin'),
316
316
  upgrade.url,
317
317
  )
318
- // NO `received`: an upgraded connection has no http idle timeout for 16.22 to raise.
318
+ // NO `received`: an upgraded connection has no http idle timeout for 16.23 to set.
319
319
  this.scope = scopeFor(
320
320
  new Request(upgrade, { signal: this.hangUp.signal }),
321
321
  undefined,
@@ -562,7 +562,7 @@ export function followAsEvents(
562
562
  let ended = false
563
563
  return new ReadableStream({
564
564
  start(controller) {
565
- controller.enqueue(ENCODER.encode(EVENT_STREAM_OPENER))
565
+ controller.enqueue(EVENT_STREAM_OPENER)
566
566
  // Ended by the app's stop as any body it holds open is, or that stop waits on it.
567
567
  unhold = heldByApp(() => {
568
568
  if (ended) return
@@ -14,6 +14,7 @@ import {
14
14
  } from './requestScope.ts'
15
15
  import {
16
16
  EVENT_STREAM_OPENER,
17
+ heldBody,
17
18
  refusalFrame,
18
19
  streamEvent,
19
20
  } from './streamEvent.ts'
@@ -38,7 +39,7 @@ export async function answerValue(
38
39
  headers: Headers,
39
40
  status: number,
40
41
  ): Promise<Response> {
41
- if (value instanceof Response) return value
42
+ if (value instanceof Response) return heldUntilSent(value)
42
43
  const framing = currentRequestScope()?.framing
43
44
  // 17.9, 17.10 — what `json` fixed: the standing value, whatever it is, and `null` for nothing.
44
45
  if (framing === 'json') {
@@ -223,8 +224,7 @@ export function bodyOf(
223
224
  const unhold = heldByApp(() => stopped())
224
225
  return new ReadableStream<Uint8Array>({
225
226
  start(controller) {
226
- if (framing === 'sse')
227
- controller.enqueue(ENCODER.encode(EVENT_STREAM_OPENER))
227
+ if (framing === 'sse') controller.enqueue(EVENT_STREAM_OPENER)
228
228
  stopped = () => {
229
229
  if (ended) return
230
230
  release()
@@ -290,6 +290,18 @@ export function bodyOf(
290
290
  })
291
291
  }
292
292
 
293
+ /**
294
+ * `answer`, holding its request's scope until its body has been read to the end.
295
+ *
296
+ * 12.5, 16.23 — A `Response` A HANDLER BUILT MAY BE A STREAM, and nothing tells a stream from a
297
+ * string or a blob once it is one: `body` is a `ReadableStream` either way. So every body is held,
298
+ * at the price of a buffered one going out chunked rather than under a `Content-Length`.
299
+ */
300
+ function heldUntilSent(answer: Response): Response {
301
+ if (answer.body === null) return answer
302
+ return new Response(heldBody(answer.body, holdScope(), false), answer)
303
+ }
304
+
293
305
  /**
294
306
  * A blob answered at its own type under the given etag, range-aware.
295
307
  *