@abide/abide 0.124.1 → 0.125.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 (71) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +66 -40
  5. package/docs/RULEBOOK.md +202 -18
  6. package/docs/abide.md +391 -67
  7. package/package.json +1 -1
  8. package/src/compiler/appMap.ts +14 -1
  9. package/src/compiler/declaredExports.ts +41 -0
  10. package/src/compiler/handlersUnder.ts +1 -0
  11. package/src/compiler/hookCalls.ts +1 -0
  12. package/src/compiler/projectShadow.ts +52 -2
  13. package/src/compiler/readsIn.ts +39 -34
  14. package/src/server/PageRender.ts +7 -8
  15. package/src/server/abideModules.ts +6 -0
  16. package/src/server/answerRequest.ts +115 -52
  17. package/src/server/answerSocket.ts +6 -5
  18. package/src/server/answerWorker.ts +34 -23
  19. package/src/server/assistant.ts +141 -35
  20. package/src/server/banner.ts +9 -5
  21. package/src/server/bridgeRelay.ts +3 -3
  22. package/src/server/callerOf.ts +5 -0
  23. package/src/server/claudeCode.ts +20 -11
  24. package/src/server/commands.ts +14 -7
  25. package/src/server/createApp.ts +65 -12
  26. package/src/server/devWorker.ts +18 -16
  27. package/src/server/generatedSurfaces.ts +6 -5
  28. package/src/server/handlerFileMount.ts +2 -0
  29. package/src/server/hooks.ts +54 -8
  30. package/src/server/index.ts +18 -4
  31. package/src/server/invokeHandler.ts +1 -1
  32. package/src/server/mountHandlers.ts +6 -0
  33. package/src/server/principal.ts +3 -2
  34. package/src/server/render.ts +11 -2
  35. package/src/server/requestScope.ts +61 -1
  36. package/src/server/responseRelay.ts +9 -3
  37. package/src/server/serveDev.ts +18 -3
  38. package/src/server/sqliteStore.ts +65 -0
  39. package/src/server/testMount.ts +1 -0
  40. package/src/shared/AssistantEvent.ts +40 -0
  41. package/src/shared/BrowserHooks.ts +73 -0
  42. package/src/shared/CURRENT_ROUTER.ts +3 -1
  43. package/src/shared/Issues.ts +11 -3
  44. package/src/shared/Middleware.ts +9 -9
  45. package/src/shared/UNEXPECTED.ts +1 -1
  46. package/src/shared/answerOf.ts +9 -4
  47. package/src/shared/imagePiece.ts +85 -0
  48. package/src/shared/index.ts +17 -0
  49. package/src/shared/isServer.ts +15 -0
  50. package/src/shared/navigate.ts +10 -3
  51. package/src/shared/onError.ts +51 -0
  52. package/src/shared/onRefusal.ts +47 -0
  53. package/src/shared/online.ts +5 -1
  54. package/src/shared/reactive/Keyed.ts +12 -1
  55. package/src/shared/reactive/Reactive.ts +53 -25
  56. package/src/shared/reactive/cellShape.ts +4 -3
  57. package/src/shared/reactive/poll.ts +49 -0
  58. package/src/shared/reactive/scoped.ts +10 -1
  59. package/src/shared/reactive/scoping.ts +14 -0
  60. package/src/shared/reactive/watch.ts +19 -8
  61. package/src/shared/resultsOf.ts +21 -0
  62. package/src/shared/route.ts +18 -4
  63. package/src/shared/rpc.ts +112 -26
  64. package/src/shared/seededDocument.ts +5 -6
  65. package/src/shared/storedValue.ts +32 -0
  66. package/src/shared/webStore.ts +54 -0
  67. package/src/ui/Router.ts +42 -9
  68. package/src/ui/outboxQueue.ts +1 -1
  69. package/src/ui/wire.ts +4 -4
  70. package/src/shared/KEEP_OPENED.ts +0 -7
  71. /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.124.1",
3
+ "version": "0.125.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -16,6 +16,8 @@ type MappedRoute = {
16
16
  kind: 'page' | 'handler' | 'private'
17
17
  /** 23.26 — each matcher its address names, to the anchored pattern it takes a part by. */
18
18
  matchers: Record<string, string>
19
+ /** 38.135 — the rungs a handler's `middleware` names, `[]` for none, `null` where unread. */
20
+ middleware: string[] | null
19
21
  }
20
22
 
21
23
  /** One hook call, the route it is filed at, and the file making it. */
@@ -42,6 +44,8 @@ export async function appMap(
42
44
  file: route.source,
43
45
  kind: 'page',
44
46
  matchers: route.matchers,
47
+ // A page declares no rungs of its own: what runs over it is filed by a hook (37.2).
48
+ middleware: [],
45
49
  })
46
50
  for (const mount of app.mounts)
47
51
  routes.push({
@@ -50,6 +54,7 @@ export async function appMap(
50
54
  file: mount.source,
51
55
  kind: mount.private ? 'private' : 'handler',
52
56
  matchers: mount.matchers,
57
+ middleware: mount.middleware,
53
58
  })
54
59
  routes.sort((one, two) =>
55
60
  one.address === two.address
@@ -81,7 +86,7 @@ export function mapText(map: Awaited<ReturnType<typeof appMap>>): string {
81
86
  const addresses = width(map.routes.map((route) => route.address))
82
87
  for (const route of map.routes)
83
88
  lines.push(
84
- `${route.method.padEnd(methods)} ${route.address.padEnd(addresses)} ${route.file}${route.kind === 'private' ? ' (private)' : ''}`,
89
+ `${route.method.padEnd(methods)} ${route.address.padEnd(addresses)} ${route.file}${route.kind === 'private' ? ' (private)' : ''}${rungsText(route.middleware)}`,
85
90
  )
86
91
  // 23.26 — each matcher once, whichever routes name it: what a part must match to be taken.
87
92
  const matchers = new Map<string, string>()
@@ -105,3 +110,11 @@ export function mapText(map: Awaited<ReturnType<typeof appMap>>): string {
105
110
  }
106
111
  return lines.join('\n')
107
112
  }
113
+
114
+ /** 38.135 — a handler's rungs as the text map prints them after its file, or nothing for none. */
115
+ function rungsText(middleware: string[] | null): string {
116
+ if (middleware === null) return ' middleware: unread'
117
+ return middleware.length === 0
118
+ ? ''
119
+ : ` middleware: ${middleware.join(', ')}`
120
+ }
@@ -10,6 +10,7 @@ import {
10
10
  isObjectLiteralExpression,
11
11
  isPropertyAccessExpression,
12
12
  isPropertyAssignment,
13
+ isSpreadElement,
13
14
  isStringLiteral,
14
15
  isVariableDeclaration,
15
16
  } from 'typescript/unstable/ast/is'
@@ -179,6 +180,10 @@ async function exportsIn(
179
180
  cli: declared?.cli ?? true,
180
181
  mcp: declared?.mcp ?? true,
181
182
  description: declared?.description,
183
+ middleware:
184
+ declared === undefined
185
+ ? []
186
+ : declared.middleware,
182
187
  copy: declared?.copy,
183
188
  defaults: declared?.defaults,
184
189
  args:
@@ -228,6 +233,7 @@ type DeclaredExport = Pick<
228
233
  | 'args'
229
234
  | 'result'
230
235
  | 'description'
236
+ | 'middleware'
231
237
  > & {
232
238
  method: Declared | undefined
233
239
  /** 18.15 — what its copy's options declare and the build could not read. */
@@ -249,6 +255,7 @@ async function declaringMethod(
249
255
  cli: boolean
250
256
  mcp: boolean
251
257
  description: string | undefined
258
+ middleware: string[] | null
252
259
  copy: CopyOptions | undefined
253
260
  unread: Node[]
254
261
  defaulted?: string[]
@@ -321,6 +328,7 @@ async function declaringMethod(
321
328
  cli: declaredFor(call, 'cli') ?? !answersResponse,
322
329
  mcp: declaredFor(call, 'mcp') ?? !answersResponse,
323
330
  description,
331
+ middleware: rungsNamed(call),
324
332
  ...declared,
325
333
  defaulted: defaultedMembers(body),
326
334
  defaults: literalDefaults(body) ?? undefined,
@@ -602,6 +610,39 @@ function describedAs(
602
610
  : undefined
603
611
  }
604
612
 
613
+ /**
614
+ * The rung each entry of a handler call's inline `middleware` names, or `null` where its options are
615
+ * not an object written inline.
616
+ *
617
+ * 38.135 — AS WRITTEN, which is what the build can read without running it: an entry is named by
618
+ * the function it calls or the name it refers to, a spread by what it spreads, and an inline function
619
+ * `<inline>`. A value that is not an array is read as one entry, so `middleware: homeAccess(…)` names
620
+ * `homeAccess`.
621
+ */
622
+ function rungsNamed(
623
+ call: Node & { arguments?: readonly Node[] },
624
+ ): string[] | null {
625
+ const options = call.arguments?.[1]
626
+ if (options === undefined) return []
627
+ if (!isObjectLiteralExpression(options)) return null
628
+ const written = optionNamed(options, 'middleware')
629
+ if (written === undefined) return []
630
+ const entries = isArrayLiteralExpression(written)
631
+ ? written.elements
632
+ : [written]
633
+ const names: string[] = []
634
+ for (const entry of entries)
635
+ names.push(rungName(isSpreadElement(entry) ? entry.expression : entry))
636
+ return names
637
+ }
638
+
639
+ function rungName(entry: Node): string {
640
+ const named = isCallExpression(entry) ? entry.expression : entry
641
+ return isIdentifier(named) || isPropertyAccessExpression(named)
642
+ ? named.getText()
643
+ : '<inline>'
644
+ }
645
+
605
646
  /**
606
647
  * What a handler call's inline `clients` declares for `surface`, or `undefined` where it does not.
607
648
  *
@@ -128,6 +128,7 @@ export async function handlersUnder(
128
128
  cli: false,
129
129
  mcp: false,
130
130
  description: each.description,
131
+ middleware: each.middleware,
131
132
  copy: each.copy,
132
133
  defaults: each.defaults,
133
134
  args: each.args,
@@ -18,6 +18,7 @@ export const HOOK_NAMES: ReadonlySet<string> = new Set([
18
18
  'onPrincipal',
19
19
  'onLauncher',
20
20
  'onMenu',
21
+ 'onRefusal',
21
22
  ])
22
23
 
23
24
  /** One call of one of abide's hooks. */
@@ -20,6 +20,7 @@ import {
20
20
  SOURCE_SUFFIX,
21
21
  } from './splitImports.ts'
22
22
  import type {
23
+ Attribute,
23
24
  Body,
24
25
  Branch,
25
26
  ElementNode,
@@ -489,7 +490,10 @@ function project(file: AbideFile): Projection {
489
490
  const [kind, open] = attribute.lowerName.startsWith(
490
491
  'bind:',
491
492
  )
492
- ? (['elementBind', `${BIND}(`] as const)
493
+ ? ([
494
+ 'elementBind',
495
+ bindOpenerOf(node, attribute.lowerName),
496
+ ] as const)
493
497
  : openerOf(node.tag, attribute.lowerName)
494
498
  slots.set(
495
499
  attribute,
@@ -636,7 +640,7 @@ function project(file: AbideFile): Projection {
636
640
  * implicit `any`; an attribute on a component whose tag is no name a call can spell (`invocation`
637
641
  * has the rest) is typed by nothing the shadow can see, and says so with `any` rather than with an
638
642
  * error the author did not make. A `bind:` operand is not asked about here: the caller opens it
639
- * with `BIND`, which types it as `any` too.
643
+ * with `bindOpenerOf`.
640
644
  */
641
645
  function openerOf(
642
646
  tag: string,
@@ -648,6 +652,44 @@ function openerOf(
648
652
  return ['attribute', '(']
649
653
  }
650
654
 
655
+ /**
656
+ * What a `bind:` operand on an element is opened with: the bind's `set` held to what the element
657
+ * writes back (32.78), or `BIND` where the element's write is no type the shadow knows.
658
+ *
659
+ * A `type` or a `multiple` written as an expression, or one a spread may write, is read as each it
660
+ * could be, so the `set` is held to the union of their writes.
661
+ */
662
+ function bindOpenerOf(node: ElementNode, lowerName: string): string {
663
+ const kind = writesOf(node, lowerName)
664
+ return kind === undefined
665
+ ? `${BIND}(`
666
+ : `${BIND_WRITES}(${JSON.stringify(kind)})(`
667
+ }
668
+
669
+ /** Which write a `bind:` on an element stands for, as `BIND_WRITES` names its kinds. */
670
+ function writesOf(node: ElementNode, lowerName: string): string | undefined {
671
+ if (lowerName === 'bind:checked' || lowerName === 'bind:open')
672
+ return 'boolean'
673
+ if (lowerName === 'bind:files') return 'files'
674
+ if (lowerName !== 'bind:value') return undefined
675
+ if (node.lowerTag === 'textarea') return 'text'
676
+ if (node.lowerTag !== 'input' && node.lowerTag !== 'select')
677
+ return undefined
678
+ const named = node.lowerTag === 'input' ? 'type' : 'multiple'
679
+ let written: Attribute | undefined
680
+ let spread = false
681
+ for (const attribute of node.attributes) {
682
+ if (attribute.lowerName === named) written = attribute
683
+ else if (attribute.name === '...') spread = true
684
+ }
685
+ if (written?.expression || (written === undefined && spread))
686
+ return node.lowerTag === 'input' ? 'input' : 'select'
687
+ if (node.lowerTag === 'select')
688
+ return written === undefined ? 'option' : 'options'
689
+ const type = written?.value.trim().toLowerCase()
690
+ return type === 'number' || type === 'range' ? 'number' : 'text'
691
+ }
692
+
651
693
  const PROP = '__prop'
652
694
  // What an inline component's function names the record it is called with, and abide's `props` as
653
695
  // its head calls it (32.40) — bound here, since the author imports `props` only where they call it.
@@ -669,6 +711,8 @@ const ON = '__on'
669
711
  const ANY = '__any'
670
712
  /** 31.3 — what a `bind:` operand is wrapped in on an element and on a component. */
671
713
  const BIND = '__bind'
714
+ /** 32.78 — what an element's `bind:` is opened with where the shadow knows what the element writes. */
715
+ const BIND_WRITES = '__bindWrites'
672
716
  const BOUND = '__bound'
673
717
 
674
718
  /** 33.6 — the type a written prop's cell is checked as, which is the framework's own. */
@@ -696,6 +740,12 @@ declare function ${ANY}(value: any): void
696
740
  declare function ${PROP}<Value>(value: Value): Value
697
741
  declare function ${BIND}<Value>(value: { get(): Value; set(value: Value): unknown }): void
698
742
  declare function ${BIND}(value: any): void
743
+ type ${BIND_WRITES}Number<Value> = [Extract<Value, number>] extends [never] ? never : number | undefined
744
+ type ${BIND_WRITES}Text<Value> = [Extract<Value, number>] extends [never] ? string : [Exclude<Value, number>] extends [never] ? number | undefined : string | number | undefined
745
+ type ${BIND_WRITES}Option<Value> = [Extract<Value, string>] extends [never] ? ${BIND_WRITES}Text<Value> : Extract<Value, string> | ${BIND_WRITES}Number<Value>
746
+ type ${BIND_WRITES}Options<Value> = Value extends readonly (infer Item)[] ? [Extract<Item, string>] extends [never] ? string[] : Extract<Item, string>[] : string[]
747
+ type ${BIND_WRITES}Of<Kind, Value> = Kind extends 'boolean' ? boolean : Kind extends 'files' ? File[] : Kind extends 'number' ? number | undefined : Kind extends 'input' ? string | number | undefined : Kind extends 'option' ? ${BIND_WRITES}Option<Value> : Kind extends 'options' ? ${BIND_WRITES}Options<Value> : Kind extends 'select' ? ${BIND_WRITES}Option<Value> | ${BIND_WRITES}Options<Value> : ${BIND_WRITES}Text<Value>
748
+ declare function ${BIND_WRITES}<Kind extends string>(kind: Kind): <Value>(value: { (): Value; set: (value: ${BIND_WRITES}Of<Kind, Value>) => unknown } | { get(): Value; set: (value: ${BIND_WRITES}Of<Kind, Value>) => unknown }) => void
699
749
  declare function ${BOUND}<Value>(value: ${REACTIVE}<Value, any, any, any>): Value
700
750
  declare function ${BOUND}<Value>(value: { get(): Value; set(value: Value): unknown }): Value
701
751
  declare function __restValues<Accessors>(record: Accessors): { [Key in keyof Accessors]: NonNullable<Accessors[Key]> extends () => infer Value ? Value : never }
@@ -447,6 +447,14 @@ const NULLISH =
447
447
  TypeFlags.Any |
448
448
  TypeFlags.Unknown
449
449
 
450
+ /** A union's members, or the type alone, with every `NULLISH` one aside. */
451
+ async function presentMembers(type: Type): Promise<Type[]> {
452
+ const present: Type[] = []
453
+ for (const held of await unionMembers(type))
454
+ if ((held.flags & NULLISH) === 0) present.push(held)
455
+ return present
456
+ }
457
+
450
458
  async function admitsNullish(type: Type): Promise<boolean> {
451
459
  for (const member of await unionMembers(type))
452
460
  if ((member.flags & NULLISH) !== 0) return true
@@ -737,11 +745,11 @@ function valueLeaves(node: Node, leaves: Node[]): void {
737
745
  }
738
746
  }
739
747
 
740
- // 23.1 — the four values `route` holds, each a `Reactive` of its own.
741
- const ROUTE_MEMBERS = new Set(['url', 'params', 'name', 'navigating'])
748
+ // 23.1 — the five values `route` holds, each a `Reactive` of its own.
749
+ const ROUTE_MEMBERS = new Set(['url', 'params', 'name', 'index', 'navigating'])
742
750
 
743
751
  /**
744
- * 31.15 — `route`'s four values, each read by name as a name bound to a `Reactive` is.
752
+ * 31.15 — `route`'s five values, each read by name as a name bound to a `Reactive` is.
745
753
  *
746
754
  * `route.params.id` reads `route.params().id`, and the explicit call keeps compiling (31.7). Told
747
755
  * by the symbol, as `props` is, so an app's own `route` is not abide's.
@@ -1224,11 +1232,10 @@ async function ownMembers(
1224
1232
  asked.push(
1225
1233
  valueOf(node).then(async (value) => {
1226
1234
  if (value === undefined) return
1227
- for (const held of await unionMembers(value))
1235
+ for (const held of await presentMembers(value))
1228
1236
  if (
1229
- (held.flags & NULLISH) === 0 &&
1230
1237
  (await checker.getPropertyOfType(held, member)) !==
1231
- undefined
1238
+ undefined
1232
1239
  )
1233
1240
  return void owned.add(node)
1234
1241
  }),
@@ -1282,7 +1289,9 @@ async function liftedMutators(
1282
1289
  asked.push(
1283
1290
  valueOf(node).then(async (value) => {
1284
1291
  if (value === undefined) return
1285
- let types: Type[] = [value]
1292
+ // `null` and `undefined` aside at the root as at every step down: `drafts?.push(d)`
1293
+ // over a `Reactive<Draft[] | undefined>` is a mutator on an array.
1294
+ let types = await presentMembers(value)
1286
1295
  for (const step of steps)
1287
1296
  types = await stepTypes(checker, types, step)
1288
1297
  if (types.length === 0) return
@@ -1310,34 +1319,30 @@ async function stepTypes(
1310
1319
  step: string | undefined,
1311
1320
  ): Promise<Type[]> {
1312
1321
  const next: Type[] = []
1313
- for (const type of types)
1314
- for (const held of await unionMembers(type)) {
1315
- if ((held.flags & NULLISH) !== 0) continue
1316
- if (step !== undefined) {
1317
- const member = await checker.getPropertyOfType(held, step)
1318
- const stepped =
1319
- member === undefined
1320
- ? undefined
1321
- : await checker.getTypeOfSymbol(member)
1322
- if (stepped !== undefined)
1323
- next.push(...(await unionMembers(stepped)))
1324
- continue
1325
- }
1326
- if (await checker.isArrayType(held)) {
1327
- const [element] = await checker.getTypeArguments(
1328
- held as TypeReference,
1329
- )
1330
- if (element !== undefined)
1331
- next.push(...(await unionMembers(element)))
1332
- continue
1333
- }
1334
- for (const index of await checker.getIndexInfosOfType(held))
1335
- next.push(...(await unionMembers(index.valueType)))
1322
+ // `types` is present members already: the root's, or this walk's own.
1323
+ for (const held of types) {
1324
+ if (step !== undefined) {
1325
+ const member = await checker.getPropertyOfType(held, step)
1326
+ const stepped =
1327
+ member === undefined
1328
+ ? undefined
1329
+ : await checker.getTypeOfSymbol(member)
1330
+ if (stepped !== undefined)
1331
+ next.push(...(await presentMembers(stepped)))
1332
+ continue
1336
1333
  }
1337
- const present: Type[] = []
1338
- for (const type of next)
1339
- if ((type.flags & NULLISH) === 0) present.push(type)
1340
- return present
1334
+ if (await checker.isArrayType(held)) {
1335
+ const [element] = await checker.getTypeArguments(
1336
+ held as TypeReference,
1337
+ )
1338
+ if (element !== undefined)
1339
+ next.push(...(await presentMembers(element)))
1340
+ continue
1341
+ }
1342
+ for (const index of await checker.getIndexInfosOfType(held))
1343
+ next.push(...(await presentMembers(index.valueType)))
1344
+ }
1345
+ return next
1341
1346
  }
1342
1347
 
1343
1348
  /**
@@ -11,7 +11,6 @@
11
11
  import { EARLY_CALLS } from '#shared/EARLY_CALLS.ts'
12
12
  import { HandlerRoute } from '#shared/HandlerRoute.ts'
13
13
  import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
14
- import { KEEP_OPENED } from '#shared/KEEP_OPENED.ts'
15
14
  import type { Rpc } from '#shared/rpc.ts'
16
15
  import { type RpcRequest, rpcRequest } from '#shared/rpcRequest.ts'
17
16
 
@@ -141,18 +140,18 @@ export class PageRender {
141
140
  * one `principal.caller` reads for the rest of the request. 41.13 — every call the render makes is
142
141
  * left for the browser's own call to be answered from. 41.29 — BUT NOT A PAGE A SCRIPT FETCHED, which
143
142
  * nothing hydrates: its entries would answer the next call this caller makes with what the render
144
- * saw, a sign-in since included — save the one the worker fetched for the navigation it answers,
145
- * which is the page that hydrates (45.23). 41.30 — SAVE ONE THE WORKER KEEPS, whose early calls are
146
- * what a later open of it starts.
143
+ * saw, a sign-in since included. The worker asks for the navigation it answers as that navigation
144
+ * (45.25), so the page that hydrates is never one a script fetched. 41.30 — SAVE ONE THE WORKER
145
+ * KEEPS, whose early calls are what a later open of it starts.
147
146
  */
148
147
  export function beginPageRender(request: Request): PageRender {
149
148
  const caller = callerOrMint()
150
149
  const keep = request.headers.get(KEEP_HEADER)
151
- const hydrated =
152
- keep === KEEP_OPENED ||
153
- request.headers.get('sec-fetch-dest') !== 'empty'
154
150
  const page = new PageRender(
155
- hydrated
151
+ // A WORKER'S NAVIGATION IS STILL ONE: forwarded unchanged (45.25) it arrives `navigate` with
152
+ // its destination `empty`, and no script's fetch can ask in that mode.
153
+ request.headers.get('sec-fetch-dest') !== 'empty' ||
154
+ request.headers.get('sec-fetch-mode') === 'navigate'
156
155
  ? seedFor(caller)
157
156
  : keep !== null
158
157
  ? callsOnly()
@@ -489,11 +489,17 @@ function compiledAt(served: Held, path: string): CompiledFile {
489
489
  // what an app's own module calls is a function of plain props answering a `Component` (35.4), which
490
490
  // `render` invokes with nothing. The component is the module's one export (34.17).
491
491
  // 38.119 — and it carries what `mount` hydrates it with: the same accessors, and its browser module.
492
+ // 38.134 — a prop written as a getter is read through it on every read, as a parent's would be.
492
493
  function boundModule(path: string): string {
493
494
  return `import component from ${JSON.stringify(path)}
494
495
  export default (props = {}) => {
495
496
  const accessors = {}
496
497
  for (const key in props) {
498
+ const live = Object.getOwnPropertyDescriptor(props, key)?.get
499
+ if (live !== undefined) {
500
+ accessors[key] = () => live.call(props)
501
+ continue
502
+ }
497
503
  const value = props[key]
498
504
  accessors[key] = () => value
499
505
  }
@@ -33,7 +33,12 @@ import { answerBridge } from './bridgeRelay.ts'
33
33
  import { compressAnswer } from './compressAnswer.ts'
34
34
  import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
35
35
  import { answerMcp, openApi } from './generatedSurfaces.ts'
36
- import { failureOf, type LaneRung } from './hooks.ts'
36
+ import {
37
+ type AppContext,
38
+ failureOf,
39
+ type Lane,
40
+ type LaneRung,
41
+ } from './hooks.ts'
37
42
  import { type Mounted, mountedOwn } from './mountHandlers.ts'
38
43
  import { muxAdmission } from './muxUpgrade.ts'
39
44
  import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
@@ -95,10 +100,7 @@ export type RouteRow = {
95
100
  * 16.14 — composed once with the table. Every server block is imported before it is built
96
101
  * (37.1), so these are the rungs the boot found, as the root's are.
97
102
  */
98
- answers: Map<
99
- Ranked | Mounted,
100
- (ctx: { request: Request }) => Promise<Response>
101
- >
103
+ answers: Map<Ranked | Mounted, (ctx: AppContext) => Promise<Response>>
102
104
  }
103
105
 
104
106
  /** A public file, read once at boot: where the bytes are, the etag off them, and its type. */
@@ -171,7 +173,7 @@ export async function answerRequest(
171
173
  // 20.5, 20.9, 36.7 — a surface of abide's own, or a 404 where it answers nothing.
172
174
  const surface = SURFACES.get(asked)
173
175
  if (surface !== undefined) {
174
- const answer = await surface(app, request)
176
+ const answer = await surface.answer(app, request)
175
177
  if (answer === undefined) return notFound()
176
178
  // 21.4 — an answer about the app's surfaces is never cached.
177
179
  return answerValue(
@@ -187,11 +189,7 @@ export async function answerRequest(
187
189
  // 37.3 — THE MUX'S UPGRADE PASSES THE APP LANE as every request does (`muxUpgrade`).
188
190
  const admitted = muxAdmission(asked, request, app)
189
191
  if (admitted !== undefined) return admitted
190
- // 16.70 — a private handler is answered by the route table, below abide's own namespace.
191
- if (
192
- asked.startsWith(`${ABIDE_ADDRESS}/`) &&
193
- !asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
194
- ) {
192
+ if (isFrameworkAddress(asked)) {
195
193
  const answer = app.framework(asked, request)
196
194
  if (answer === undefined) return notFound()
197
195
  return readsOnly(request) ? answer : methodRefused(app, asked, READS)
@@ -217,38 +215,7 @@ export async function answerRequest(
217
215
  refuse(400, 'The address is not a valid path.'),
218
216
  )
219
217
 
220
- // 19.20 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
221
- // its preflight names (16.24). A route that matches and answers some other method is held, so
222
- // an address nothing answers the method at is a 405 naming what is answered there (15.17).
223
- const preflight = request.method === 'OPTIONS'
224
- // `null` for an `OPTIONS` that names no method, which is no preflight (Fetch's CORS-preflight
225
- // request carries one) and so no handler's: the route answers 15.17's 405 with what it allows.
226
- const asking = preflight
227
- ? request.headers.get('access-control-request-method')
228
- : method
229
- let allowed: Set<string> | undefined
230
- let found:
231
- | { route: RouteRow; params: Params; handler: Mounted | undefined }
232
- | undefined
233
- for (const route of app.routes) {
234
- const params = matchOf(route.segments, parts)
235
- if (params === undefined) continue
236
- const handler =
237
- asking === null ? undefined : route.handlers.get(asking as Method)
238
- if (
239
- handler !== undefined ||
240
- (!preflight && asking === 'GET' && route.page !== undefined)
241
- ) {
242
- found = { route, params, handler }
243
- break
244
- }
245
- allowed ??= new Set()
246
- if (route.page !== undefined) allowed.add('GET').add('HEAD')
247
- for (const method of route.handlers.keys()) {
248
- allowed.add(method)
249
- if (method === 'GET') allowed.add('HEAD')
250
- }
251
- }
218
+ const { found, allowed } = routed(app, parts, request)
252
219
  if (found === undefined) {
253
220
  if (allowed !== undefined)
254
221
  return methodRefused(app, asked, [...allowed], request)
@@ -270,8 +237,90 @@ export async function answerRequest(
270
237
  scope.params = found.params
271
238
  const answer = found.route.answers.get(
272
239
  found.handler ?? (found.route.page as Ranked),
273
- ) as (ctx: { request: Request }) => Promise<Response>
274
- return answer({ request })
240
+ ) as (ctx: AppContext) => Promise<Response>
241
+ return answer({
242
+ request,
243
+ lane: found.handler === undefined ? 'page' : 'handler',
244
+ })
245
+ }
246
+
247
+ /**
248
+ * What `answerRequest` would answer `request` with, by the arm of its dispatch it takes. 37.19.
249
+ *
250
+ * ASKED IN THE DISPATCH'S OWN ORDER, over the same tables, so a gate reading it before routing
251
+ * (37.3) is told what routing will choose.
252
+ */
253
+ export function laneOf(app: AppSources, request: Request): Lane {
254
+ const asked = new URL(request.url).pathname
255
+ if (OWN_HANDLERS.has(asked)) return 'handler'
256
+ const surface = SURFACES.get(asked)
257
+ if (surface !== undefined) return surface.lane
258
+ if (muxAdmission(asked, request, app) !== undefined) return 'socket'
259
+ if (isFrameworkAddress(asked)) return 'abide'
260
+ if (app.published.has(decodedOrKept(asked))) return 'file'
261
+ const parts = partsOf(asked)
262
+ if (parts === undefined) return 'page'
263
+ return routed(app, parts, request).found?.handler === undefined
264
+ ? 'page'
265
+ : 'handler'
266
+ }
267
+
268
+ /**
269
+ * Whether `asked` is an address abide answers out of the command's own files.
270
+ *
271
+ * 16.70 — a private handler is answered by the route table, below abide's own namespace.
272
+ */
273
+ function isFrameworkAddress(asked: string): boolean {
274
+ return (
275
+ asked.startsWith(`${ABIDE_ADDRESS}/`) &&
276
+ !asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
277
+ )
278
+ }
279
+
280
+ /**
281
+ * The route answering `request` at `parts`, or the methods answered there where none answers its own.
282
+ *
283
+ * 19.20 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
284
+ * its preflight names (16.24). A route that matches and answers some other method is held, so
285
+ * an address nothing answers the method at is a 405 naming what is answered there (15.17).
286
+ */
287
+ function routed(
288
+ app: AppSources,
289
+ parts: string[],
290
+ request: Request,
291
+ ): {
292
+ found:
293
+ | { route: RouteRow; params: Params; handler: Mounted | undefined }
294
+ | undefined
295
+ allowed: Set<string> | undefined
296
+ } {
297
+ const preflight = request.method === 'OPTIONS'
298
+ // `null` for an `OPTIONS` that names no method, which is no preflight (Fetch's CORS-preflight
299
+ // request carries one) and so no handler's: the route answers 15.17's 405 with what it allows.
300
+ const asking = preflight
301
+ ? request.headers.get('access-control-request-method')
302
+ : request.method === 'HEAD'
303
+ ? 'GET'
304
+ : request.method
305
+ let allowed: Set<string> | undefined
306
+ for (const route of app.routes) {
307
+ const params = matchOf(route.segments, parts)
308
+ if (params === undefined) continue
309
+ const handler =
310
+ asking === null ? undefined : route.handlers.get(asking as Method)
311
+ if (
312
+ handler !== undefined ||
313
+ (!preflight && asking === 'GET' && route.page !== undefined)
314
+ )
315
+ return { found: { route, params, handler }, allowed: undefined }
316
+ allowed ??= new Set()
317
+ if (route.page !== undefined) allowed.add('GET').add('HEAD')
318
+ for (const method of route.handlers.keys()) {
319
+ allowed.add(method)
320
+ if (method === 'GET') allowed.add('HEAD')
321
+ }
322
+ }
323
+ return { found: undefined, allowed }
275
324
  }
276
325
 
277
326
  /**
@@ -332,7 +381,8 @@ async function answerMatched(
332
381
  // would otherwise render one address and hydrate against another.
333
382
  const at = new URL(request.url)
334
383
  at.pathname = mounted(at.pathname)
335
- arrive(at.href, page.pattern, params)
384
+ // 23.29 — a request is a document load, which arrives at an entry of its own.
385
+ arrive(at.href, page.pattern, params, 0)
336
386
  // 11.32 — the page renders in a scope of its own, so what its values hold is this request's —
337
387
  // and 41.13, every call it makes is recorded into its render.
338
388
  beginPageRender(request)
@@ -369,18 +419,31 @@ const OWN_HANDLERS = new Map([
369
419
 
370
420
  // THE ADDRESSES ABIDE ANSWERS ITSELF, looked up once rather than asked in turn: each to its value,
371
421
  // a `Response` of its own, or `undefined` for a 404.
422
+ // 37.19 — each with the lane a gate is told it is.
372
423
  const SURFACES = new Map<
373
424
  string,
374
- (app: AppSources, request: Request) => unknown
425
+ {
426
+ lane: Lane
427
+ answer: (app: AppSources, request: Request) => unknown
428
+ }
375
429
  >([
376
- [OPENAPI_ADDRESS, (app) => openApi(app.handlers)],
430
+ [
431
+ OPENAPI_ADDRESS,
432
+ { lane: 'openapi', answer: (app) => openApi(app.handlers) },
433
+ ],
377
434
  // Filled by the app answering this request, so never `undefined` here.
378
435
  [
379
436
  MCP_ADDRESS,
380
- (_app, request) =>
381
- answerMcp((within('mcp').app as AnsweringApp).mcp, request),
437
+ {
438
+ lane: 'mcp',
439
+ answer: (_app, request) =>
440
+ answerMcp((within('mcp').app as AnsweringApp).mcp, request),
441
+ },
442
+ ],
443
+ [
444
+ BRIDGE_ADDRESS,
445
+ { lane: 'bridge', answer: (_app, request) => answerBridge(request) },
382
446
  ],
383
- [BRIDGE_ADDRESS, (_app, request) => answerBridge(request)],
384
447
  ])
385
448
 
386
449
  /**
@@ -395,7 +458,7 @@ export function answerInside(
395
458
  app: AppSources,
396
459
  found: Ranked | Mounted,
397
460
  rungs: LaneRung[],
398
- ): (ctx: { request: Request }) => Promise<Response> {
461
+ ): (ctx: AppContext) => Promise<Response> {
399
462
  return compose(rungs, ({ request }) => answerMatched(app, request, found))
400
463
  }
401
464