@abide/abide 0.101.0 → 0.102.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 (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +223 -164
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +5 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -59
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +102 -30
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +114 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +73 -37
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  107. package/src/shared/cookies.ts +2 -2
  108. package/src/shared/csp.ts +1 -1
  109. package/src/shared/escapeText.ts +1 -1
  110. package/src/shared/filledPath.ts +85 -0
  111. package/src/shared/framing.ts +79 -6
  112. package/src/shared/headKeyOf.ts +2 -0
  113. package/src/shared/index.ts +1 -1
  114. package/src/shared/log.ts +27 -13
  115. package/src/shared/matchRoute.ts +23 -4
  116. package/src/shared/menuItem.ts +3 -3
  117. package/src/shared/reactive/Keyed.ts +2 -2
  118. package/src/shared/reactive/NotLanded.ts +1 -1
  119. package/src/shared/reactive/Reactive.ts +43 -43
  120. package/src/shared/reactive/argsContract.ts +4 -4
  121. package/src/shared/reactive/canonicalKey.ts +4 -4
  122. package/src/shared/reactive/chunksOfStream.ts +6 -0
  123. package/src/shared/reactive/index.ts +1 -1
  124. package/src/shared/reactive/recordWrites.ts +103 -5
  125. package/src/shared/reactive/structural.ts +1 -1
  126. package/src/shared/reactive/tracking.ts +1 -1
  127. package/src/shared/reactive/watch.ts +3 -3
  128. package/src/shared/refuse.ts +1 -1
  129. package/src/shared/routePattern.ts +82 -46
  130. package/src/shared/rpc.ts +37 -8
  131. package/src/shared/rpcRequest.ts +27 -3
  132. package/src/shared/url.ts +4 -44
  133. package/src/shared/writePath.ts +1 -1
  134. package/src/ui/LAYER_NAMES.ts +1 -1
  135. package/src/ui/List.ts +3 -3
  136. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  137. package/src/ui/Mismatch.ts +1 -1
  138. package/src/ui/RawRun.ts +1 -1
  139. package/src/ui/Router.ts +38 -31
  140. package/src/ui/Scope.ts +1 -1
  141. package/src/ui/TryRegion.ts +2 -2
  142. package/src/ui/applyHead.ts +33 -0
  143. package/src/ui/index.ts +1 -1
  144. package/src/ui/menuBridge.ts +1 -1
  145. package/src/ui/moveNode.ts +1 -1
  146. package/src/ui/reconcile.ts +2 -2
  147. package/src/ui/reportMenu.ts +2 -2
  148. package/src/ui/routing.ts +2 -2
  149. package/src/ui/setAttribute.ts +1 -1
  150. package/src/ui/setText.ts +1 -1
  151. package/src/ui/socketWire.ts +9 -7
  152. package/src/ui/wire.ts +1 -1
  153. package/scaffold/src/server/app.ts +0 -6
  154. package/scaffold/src/shared/.gitkeep +0 -0
  155. package/scaffold/src/ui/app.html +0 -8
  156. package/src/server/componentImports.ts +0 -56
  157. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -1,6 +1,6 @@
1
- // SECTION 20 — WHAT IS GENERATED OFF THE ROUTE TABLE: the OpenAPI document, the MCP endpoint, and 36.7's
2
- // `GET` over `log.records`. Each is derived from the handlers the app mounted (20.1) and none is
3
- // authored beside them; each is closed until its `ABIDE_*` opens it, and a closed one is a 404 (36.7).
1
+ // SECTION 20 — WHAT IS GENERATED OFF THE ROUTE TABLE: the OpenAPI document and the MCP endpoint. Each
2
+ // is derived from the handlers the app mounted (20.1) and none is authored beside them; each is open
3
+ // exactly where the route table lists a handler for it, and a closed one is a 404 (20.7, 36.7).
4
4
  //
5
5
  // THE SURFACES ANSWER INSIDE THE APP'S OWN MIDDLEWARE (20.8), because they are answered from the one
6
6
  // request path everything under `/__abide` is — and a tool call is a request through it again (20.10).
@@ -13,15 +13,15 @@
13
13
  // `cli` to false for a handler returning a `Response`, 20.4's `ui: false`, and 20.11's type-derived
14
14
  // schema (19.9) as `argsDocument`.
15
15
 
16
- import { log, namedLogger } from '#shared/log.ts'
16
+ import { isFailed } from '#shared/Failed.ts'
17
+ import { namedLogger } from '#shared/log.ts'
17
18
  import { type Reactive, reactive } from '#shared/reactive/index.ts'
18
- import { refuse } from '#shared/refuse.ts'
19
- import { GET } from '#shared/rpc.ts'
19
+ import { parseRoutePattern } from '#shared/routePattern.ts'
20
20
  import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
21
21
  import { answered } from './answerFailure.ts'
22
22
  import type { Mounted } from './answerRpc.ts'
23
23
  import type { ToolDefinition } from './assistant.ts'
24
- import { appIdentity, config } from './config.ts'
24
+ import { appIdentity } from './config.ts'
25
25
  import { crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
26
26
  import { MCP_PROTOCOL_VERSION } from './MCP_PROTOCOL_VERSION.ts'
27
27
  import { type Listed, surfaceOf } from './surfaceOf.ts'
@@ -29,14 +29,8 @@ import { type Listed, surfaceOf } from './surfaceOf.ts'
29
29
  const OPENAPI_LOG = namedLogger('abide:openapi')
30
30
  const MCP_LOG = namedLogger('abide:mcp')
31
31
 
32
- /** Whether the `ABIDE_*` setting `name` opens its surface. 36.7. */
33
- function opened(name: string): boolean {
34
- return (config.peek()?.[name] as unknown) === true
35
- }
36
-
37
- // What MCP and OpenAPI add to a listing: whether the handler is keyed, and its name where only
38
- // letters, digits, `_` and `-` are admitted — a tool's, an operation's.
39
- type Published = Listed & { keyed: boolean; name: string }
32
+ // What MCP and OpenAPI add to a listing: whether the handler is keyed.
33
+ type Published = Listed & { keyed: boolean }
40
34
 
41
35
  /** Every handler `surface` lists (`surfaceOf`). */
42
36
  function listed(
@@ -53,7 +47,6 @@ function listed(
53
47
  found.push({
54
48
  ...one,
55
49
  keyed: mounted.handler.keyed,
56
- name: one.path.replace(/[^a-zA-Z0-9_-]/g, '_'),
57
50
  })
58
51
  }
59
52
  return found
@@ -76,8 +69,27 @@ const PROBLEM_SCHEMA = {
76
69
  },
77
70
  }
78
71
 
72
+ /** `schema` without the members the path carries, or `schema` itself where it carries none. */
73
+ function withoutMembers(
74
+ schema: { properties?: Record<string, unknown>; required?: string[] },
75
+ names: Set<string>,
76
+ ): unknown {
77
+ if (names.size === 0 || schema.properties === undefined) return schema
78
+ const properties: Record<string, unknown> = {}
79
+ for (const name in schema.properties)
80
+ if (!names.has(name)) properties[name] = schema.properties[name]
81
+ const required: string[] = []
82
+ for (const name of schema.required ?? [])
83
+ if (!names.has(name)) required.push(name)
84
+ return { ...schema, properties, required }
85
+ }
86
+
79
87
  /** The OpenAPI 3.1 document, each handler's `JsonSchema` published as it stands. 20.6. */
80
- function openApiOf(handlers: Map<string, Mounted>): unknown {
88
+ function openApiOf(handlers: Map<string, Mounted>): {
89
+ openapi: string
90
+ info: { title: string; version: string }
91
+ paths: Record<string, Record<string, unknown>>
92
+ } {
81
93
  const paths: Record<string, Record<string, unknown>> = {}
82
94
  for (const each of listed(handlers, 'openapi')) {
83
95
  const operation: Record<string, unknown> = {
@@ -102,36 +114,60 @@ function openApiOf(handlers: Map<string, Mounted>): unknown {
102
114
  OPENAPI_LOG.warning(
103
115
  `${each.address} has no JSON Schema to publish for its args.`,
104
116
  )
105
- if (each.schema !== undefined) {
106
- if (argsInURL(each.method)) {
107
- const properties =
108
- (
109
- each.schema as {
110
- properties?: Record<string, unknown>
111
- required?: string[]
112
- }
113
- ).properties ?? {}
114
- const required = new Set(
115
- (each.schema as { required?: string[] }).required ?? [],
116
- )
117
- operation.parameters = Object.entries(properties).map(
118
- ([name, schema]) => ({
117
+ // 16.65 — a route's parameters are the path's, whatever the method, published with or
118
+ // without a schema to type them. OpenAPI has no optional path parameter, so an optional or
119
+ // rest segment is published present and says it may be left out (30.4, 30.5).
120
+ const schema = each.schema as
121
+ | { properties?: Record<string, unknown>; required?: string[] }
122
+ | undefined
123
+ const inPath = new Set<string>()
124
+ const parameters: unknown[] = []
125
+ for (const segment of parseRoutePattern(each.address)) {
126
+ if (segment.kind === 'static') continue
127
+ inPath.add(segment.name)
128
+ const parameter: Record<string, unknown> = {
129
+ name: segment.name,
130
+ in: 'path',
131
+ required: true,
132
+ schema: schema?.properties?.[segment.name] ?? {
133
+ type: 'string',
134
+ },
135
+ }
136
+ if (segment.kind === 'optional')
137
+ parameter.description = 'May be left out, with its segment.'
138
+ else if (segment.kind === 'rest')
139
+ parameter.description =
140
+ 'The rest of the path, any number of segments, and may be left out with its segment.'
141
+ parameters.push(parameter)
142
+ }
143
+ const queried = schema !== undefined && argsInURL(each.method)
144
+ if (queried) {
145
+ const properties = schema.properties ?? {}
146
+ const required = new Set(schema.required ?? [])
147
+ for (const [name, member] of Object.entries(properties))
148
+ if (!inPath.has(name))
149
+ parameters.push({
119
150
  name,
120
151
  in: 'query',
121
152
  required: required.has(name),
122
- schema,
123
- }),
124
- )
125
- } else
126
- operation.requestBody = {
127
- required: true,
128
- content: { 'application/json': { schema: each.schema } },
129
- }
153
+ schema: member,
154
+ })
130
155
  }
131
- paths[each.address] ??= {}
132
- ;(paths[each.address] as Record<string, unknown>)[
133
- each.method.toLowerCase()
134
- ] = operation
156
+ if (parameters.length > 0) operation.parameters = parameters
157
+ if (schema !== undefined && !queried)
158
+ operation.requestBody = {
159
+ required: true,
160
+ content: {
161
+ 'application/json': {
162
+ schema: withoutMembers(schema, inPath),
163
+ },
164
+ },
165
+ }
166
+ // OpenAPI spells a parameter `{id}` where a route spells it `[id]`.
167
+ const path = each.address.replace(/\[+(?:\.\.\.)?([^\]]+)\]+/g, '{$1}')
168
+ paths[path] ??= {}
169
+ ;(paths[path] as Record<string, unknown>)[each.method.toLowerCase()] =
170
+ operation
135
171
  }
136
172
  const { name, version } = appIdentity()
137
173
  return {
@@ -163,15 +199,17 @@ function overTable<Built>(
163
199
  return found
164
200
  }
165
201
 
166
- const documents = new WeakMap<Map<string, Mounted>, Reactive<unknown>>()
202
+ const documents = new WeakMap<
203
+ Map<string, Mounted>,
204
+ Reactive<ReturnType<typeof openApiOf>>
205
+ >()
167
206
 
168
- /** The OpenAPI document over the route table, or `undefined` where `ABIDE_OPENAPI` is closed. */
207
+ /** The OpenAPI document over the route table, or `undefined` where it lists no handler. 20.7. */
169
208
  export function openApi(
170
209
  handlers: Map<string, Mounted>,
171
210
  ): Reactive<unknown> | undefined {
172
- return opened('ABIDE_OPENAPI')
173
- ? overTable(documents, handlers, openApiOf)
174
- : undefined
211
+ const document = overTable(documents, handlers, openApiOf)
212
+ return Object.keys(document().paths).length > 0 ? document : undefined
175
213
  }
176
214
 
177
215
  /** What the MCP surface publishes: a tool per call that takes args or writes, a resource per read. */
@@ -224,9 +262,10 @@ export class McpSurface {
224
262
  this.#answer = answer
225
263
  }
226
264
 
227
- /** Whether `ABIDE_MCP` has opened it. 20.7. */
265
+ /** Whether the route table publishes any handler to it. 20.7. */
228
266
  get opened(): boolean {
229
- return opened('ABIDE_MCP')
267
+ const { tools, resources } = this.#published()
268
+ return tools.length > 0 || resources.length > 0
230
269
  }
231
270
 
232
271
  #published(): Tools {
@@ -274,7 +313,14 @@ export class McpSurface {
274
313
  args: Record<string, unknown> | undefined,
275
314
  asker: Request,
276
315
  ): Promise<{ text: string; failed: boolean }> {
277
- const answer = await this.#answer(callOf(asker, one, args))
316
+ let answer: Response
317
+ try {
318
+ answer = await this.#answer(callOf(asker, one, args))
319
+ } catch (thrown) {
320
+ // 15.8 — a path parameter no address carries, refused as the wire's 422 is (`rpcRequest`).
321
+ if (!isFailed(thrown)) throw thrown
322
+ answer = answered(thrown)
323
+ }
278
324
  return { text: await answer.text(), failed: answer.status >= 400 }
279
325
  }
280
326
 
@@ -355,7 +401,7 @@ export class McpSurface {
355
401
  }
356
402
 
357
403
  /**
358
- * Answers one MCP JSON-RPC request over `surface`, or `undefined` where `ABIDE_MCP` is closed.
404
+ * Answers one MCP JSON-RPC request over `surface`, or `undefined` where it publishes nothing.
359
405
  *
360
406
  * 20.9 — THE STREAMABLE-HTTP MCP ENDPOINT, answering JSON-RPC over `POST`, as the request's own
361
407
  * caller: a handler that takes no args is a resource, read at its address; one that takes args, and
@@ -406,7 +452,7 @@ function callOf(
406
452
  )
407
453
  const headers = new Headers(asked.headers)
408
454
  headers.delete('content-length')
409
- // 38.69 — the answer is read here rather than sent on, and nothing in process decodes it.
455
+ // 38.68 — the answer is read here rather than sent on, and nothing in process decodes it.
410
456
  headers.delete('accept-encoding')
411
457
  if (call.body !== undefined) headers.set('content-type', 'application/json')
412
458
  return new Request(new URL(call.url, asked.url), {
@@ -415,16 +461,3 @@ function callOf(
415
461
  body: call.body ?? null,
416
462
  })
417
463
  }
418
-
419
- /**
420
- * The `GET` over `log.records`, mounted at `/__abide/logs`: closed until `ABIDE_LOGS` opens it.
421
- *
422
- * 36.7, 28.2 — A `GET` LIKE ANY OTHER, so it is followed as any `global` value is (18.20): the lines
423
- * held, then each line written as an `add` at `/-`. The rung is the gate, and makes it a guarded
424
- * `GET` — run once as a follow opens (18.8), and answering 404 while `ABIDE_LOGS` is closed.
425
- */
426
- export const LOGS_HANDLER = GET(log.records, {
427
- middleware: [
428
- (next, ctx) => (opened('ABIDE_LOGS') ? next(ctx) : refuse(404)),
429
- ],
430
- })
@@ -1,14 +1,25 @@
1
- // SECTION 37 — THE LIFECYCLE HOOKS, called from `src/server/app.ts` rather than exported from it
2
- // (37.1). Each call files its function here and hands back what unfiles it; `createApp` reads what
3
- // is filed once the app module has run.
1
+ // SECTION 37 — THE LIFECYCLE HOOKS, called from a page's or a layout's `<script server>` rather
2
+ // than exported from it (37.1). Each call files its function here, at the ROUTE of the file that
3
+ // called it, and hands back what unfiles it; `createApp` reads what is filed once every module
4
+ // holding a server block has run.
4
5
  //
5
- // PROCESS-WIDE, like the module that calls them: `app.ts` is evaluated once per process, so a
6
- // second `App` in it boots under the same hooks (42.12's sharing, from the other side).
6
+ // WHICH ROUTE IS ASKED OF THE MODULE BEING EVALUATED, not of the hook: the compiled server module
7
+ // calls `fileRoute` with its route before its server block's body and with `undefined` after it,
8
+ // and a server block cannot await at its top level (34.3), so the window is synchronous. A hook
9
+ // called outside every server block — a test, a script — files at the root.
10
+ //
11
+ // PROCESS-WIDE, like the modules that call them: each is evaluated once per process, so a second
12
+ // `App` in it boots under the same hooks (42.12's sharing, from the other side).
7
13
 
8
14
  import { type Failed, isFailed } from '#shared/Failed.ts'
9
15
  import { namedLogger } from '#shared/log.ts'
10
16
  import type { Middleware } from '#shared/Middleware.ts'
11
17
  import { aborted, refuse } from '#shared/refuse.ts'
18
+ import {
19
+ coversRoute,
20
+ parseRoutePattern,
21
+ type Segment,
22
+ } from '#shared/routePattern.ts'
12
23
  import type { Schema } from '#shared/Schema.ts'
13
24
  import { config } from './config.ts'
14
25
  import { currentRequestScope } from './requestScope.ts'
@@ -21,40 +32,103 @@ export type ConfigOptions = { schema?: Schema<unknown> }
21
32
 
22
33
  type Wrapper = (inner: () => Promise<void>) => void | Promise<void>
23
34
 
24
- /** Everything the lifecycle hooks filed, for `createApp` to read once the app module has run. */
35
+ /** One hook, at the route of the file that filed it. 37.1. */
36
+ type Filed<T> = {
37
+ route: string
38
+ segments: Segment[]
39
+ item: T
40
+ }
41
+
42
+ /** Everything the lifecycle hooks filed, for `createApp` to read once the app's modules have run. */
25
43
  export const HOOKS = {
26
- rungs: [] as AppRung[],
27
- starts: [] as Wrapper[],
28
- stops: [] as Wrapper[],
29
- errors: [] as ((error: unknown) => unknown)[],
30
- health: [] as (() => unknown)[],
31
- config: undefined as
32
- | {
33
- defaults: ConfigDefaults | null
34
- options: ConfigOptions | undefined
35
- }
36
- | undefined,
37
- principal: undefined as
38
- | ((claims: unknown) => unknown | Promise<unknown>)
39
- | undefined,
44
+ rungs: [] as Filed<AppRung>[],
45
+ starts: [] as Filed<Wrapper>[],
46
+ stops: [] as Filed<Wrapper>[],
47
+ errors: [] as Filed<(error: unknown) => unknown>[],
48
+ health: [] as Filed<() => unknown>[],
49
+ config: [] as Filed<{
50
+ defaults: ConfigDefaults | null
51
+ options: ConfigOptions | undefined
52
+ }>[],
53
+ principal: [] as Filed<(claims: unknown) => unknown | Promise<unknown>>[],
54
+ }
55
+
56
+ const ROOT = '/'
57
+
58
+ // The route a hook called now is filed at — the server block being evaluated, or the root.
59
+ let filing: string | undefined
60
+
61
+ /**
62
+ * Sets the route the hooks called next are filed at, or the root again for `undefined`.
63
+ *
64
+ * 37.1 — what a compiled server module calls around its `<script server>`; nothing an app writes.
65
+ */
66
+ export function fileRoute(route: string | undefined): void {
67
+ filing = route
40
68
  }
41
69
 
42
70
  const LIFECYCLE_LOG = namedLogger('abide:lifecycle')
43
- const CONFIG_LOG = namedLogger('abide:config')
44
- const PRINCIPAL_LOG = namedLogger('abide:principal')
45
71
 
46
- function filed<T>(list: T[], item: T): () => void {
47
- list.push(item)
72
+ function filed<T>(list: Filed<T>[], item: T): () => void {
73
+ const route = filing ?? ROOT
74
+ const entry: Filed<T> = {
75
+ route,
76
+ segments: parseRoutePattern(route),
77
+ item,
78
+ }
79
+ list.push(entry)
80
+ // 37.7 — by route depth, then by route, then in the order called: kept sorted as it is filed,
81
+ // so a reader walks it in order and sorts nothing per request. The sort is stable and the
82
+ // entry lands last, so a tie stays in the order called.
83
+ list.sort(byRoute)
48
84
  return () => {
49
- const at = list.indexOf(item)
85
+ const at = list.indexOf(entry)
50
86
  if (at !== -1) list.splice(at, 1)
51
87
  }
52
88
  }
53
89
 
90
+ function byRoute(one: Filed<unknown>, two: Filed<unknown>): number {
91
+ if (one.segments.length !== two.segments.length)
92
+ return one.segments.length - two.segments.length
93
+ if (one.route !== two.route) return one.route < two.route ? -1 : 1
94
+ return 0
95
+ }
96
+
97
+ /** What `list` filed, in 37.7's order. */
98
+ export function inOrder<T>(list: readonly Filed<T>[]): T[] {
99
+ const items: T[] = []
100
+ for (const entry of list) items.push(entry.item)
101
+ return items
102
+ }
103
+
104
+ /** The rungs filed at the root route, which run before routing. 37.3. */
105
+ export function rootRungs(): AppRung[] {
106
+ const rungs: AppRung[] = []
107
+ for (const entry of HOOKS.rungs)
108
+ if (entry.route === ROOT) rungs.push(entry.item)
109
+ return rungs
110
+ }
111
+
112
+ /**
113
+ * The rungs filed below the root over `route`, outermost first.
114
+ *
115
+ * 37.2 — every rung filed at `route` or at a route that is its front, the root's aside: those ran
116
+ * before routing (37.3).
117
+ */
118
+ export function rungsOver(route: string): AppRung[] {
119
+ const segments = parseRoutePattern(route)
120
+ const rungs: AppRung[] = []
121
+ for (const entry of HOOKS.rungs)
122
+ if (entry.route !== ROOT && coversRoute(entry.segments, segments))
123
+ rungs.push(entry.item)
124
+ return rungs
125
+ }
126
+
54
127
  /**
55
- * Files middleware rungs the app runs before routing; returns what unfiles them.
128
+ * Files middleware rungs over the calling file's route; returns what unfiles them.
56
129
  *
57
- * 37.3 — pre-routing, over the request and the response, in the order registered.
130
+ * 37.3 at the root — pre-routing, over the request and the response, in the order registered. 37.2
131
+ * below it — after routing, over what that route and those below it answer.
58
132
  */
59
133
  export function middleware(...rungs: AppRung[]): () => void {
60
134
  const unfile: (() => void)[] = []
@@ -87,7 +161,7 @@ export function onStop(
87
161
  }
88
162
 
89
163
  /**
90
- * Files a hook handed every unexpected error; returns what unfiles it.
164
+ * Files a hook handed every unexpected error its route answers; returns what unfiles it.
91
165
  *
92
166
  * 37.5.
93
167
  */
@@ -98,52 +172,40 @@ export function onError(fn: (error: unknown) => unknown): () => void {
98
172
  /**
99
173
  * Files a health reporter whose account is merged over the baseline; returns what unfiles it.
100
174
  *
101
- * 25.2.
175
+ * 25.2, 37.7.
102
176
  */
103
177
  export function onHealth(report: () => unknown | Promise<unknown>): () => void {
104
178
  return filed(HOOKS.health, report)
105
179
  }
106
180
 
107
181
  /**
108
- * Sets the app's config defaults and schema; returns what unsets them.
182
+ * Files config defaults and a schema, composed with every other; returns what unfiles them.
109
183
  *
110
- * 27.6, 37.7 — a second replaces the first, schema and all, and says so. 27.10 — the read after
111
- * either is resolved under what is filed now, not what was filed when `config` was last read.
184
+ * 27.6, 37.7 — merged in route order rather than replacing. 27.10 — the read after either is
185
+ * resolved under what is filed now, not what was filed when `config` was last read.
112
186
  */
113
187
  export function onConfig(
114
188
  fn: ConfigDefaults | null,
115
189
  options?: ConfigOptions,
116
190
  ): () => void {
117
- if (HOOKS.config !== undefined)
118
- CONFIG_LOG.warning(
119
- 'a second onConfig replaced the first, and its schema with it. RULEBOOK 27.6.',
120
- )
121
- const entry = { defaults: fn, options }
122
- HOOKS.config = entry
191
+ const unfile = filed(HOOKS.config, { defaults: fn, options })
123
192
  config.invalidate()
124
193
  return () => {
125
- if (HOOKS.config !== entry) return
126
- HOOKS.config = undefined
194
+ unfile()
127
195
  config.invalidate()
128
196
  }
129
197
  }
130
198
 
131
199
  /**
132
- * Sets the function resolving a principal from its claims; returns what unsets it.
200
+ * Files a function resolving a principal from its claims, composed with every other; returns what
201
+ * unfiles it.
133
202
  *
134
- * 37.7 — a second replaces the first, and says so.
203
+ * 37.7 — each is handed the claims, and what they answer is merged in route order.
135
204
  */
136
205
  export function onPrincipal(
137
206
  resolve: (claims: unknown) => unknown | Promise<unknown>,
138
207
  ): () => void {
139
- if (HOOKS.principal !== undefined)
140
- PRINCIPAL_LOG.warning(
141
- 'a second onPrincipal replaced the first. RULEBOOK 37.7.',
142
- )
143
- HOOKS.principal = resolve
144
- return () => {
145
- if (HOOKS.principal === resolve) HOOKS.principal = undefined
146
- }
208
+ return filed(HOOKS.principal, resolve)
147
209
  }
148
210
 
149
211
  /**
@@ -163,12 +225,22 @@ export function reportUnexpected(error: unknown): void {
163
225
  * 12.6 — the half of 37.5 a failure already warned on its own namespace is owed.
164
226
  */
165
227
  export function handOnError(error: unknown): void {
166
- for (const hook of HOOKS.errors)
228
+ // 37.5 — NEAREST ROUTE FIRST, over the route the request matched; outside every request, or
229
+ // before one matched, the root's alone.
230
+ const route = currentRequestScope()?.route
231
+ const segments = route === undefined ? undefined : parseRoutePattern(route)
232
+ for (let at = HOOKS.errors.length - 1; at >= 0; at -= 1) {
233
+ const entry = HOOKS.errors[at] as Filed<(error: unknown) => unknown>
234
+ const reaches =
235
+ entry.route === ROOT ||
236
+ (segments !== undefined && coversRoute(entry.segments, segments))
237
+ if (!reaches) continue
167
238
  try {
168
- hook(error)
239
+ entry.item(error)
169
240
  } catch (thrown) {
170
241
  LIFECYCLE_LOG.error('an onError hook threw', thrown)
171
242
  }
243
+ }
172
244
  }
173
245
 
174
246
  /**