@abide/abide 0.121.1 → 0.123.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.
@@ -246,27 +246,22 @@ export async function readsIn(
246
246
  )
247
247
  }),
248
248
  )
249
+ const owned = await ownMembers(checker, nodes.flat(), (node) =>
250
+ valueTypeOf(checker, node),
251
+ )
249
252
  const holding: Promise<void>[] = []
250
253
  const located: { node: Node; read: Read }[] = []
251
254
  for (let index = 0; index < nodes.length; index += 1)
252
255
  for (const node of nodes[index] as (Node | undefined)[]) {
253
256
  if (node === undefined) continue
254
- const read = classifyRead(node)
257
+ const read = classifyRead(node, owned.has(node))
255
258
  if (read === undefined) continue
256
259
  read.byValue = byValue[index] as boolean
257
260
  read.local = index >= topLevel
258
261
  reads.push(read)
259
262
  located.push({ node, read })
260
263
  if (read.shape === 'value')
261
- holding.push(
262
- declaredType(checker, node).then(async (type) => {
263
- if (
264
- type !== undefined &&
265
- (await isReactiveType(checker, abide, type, true))
266
- )
267
- read.shape = 'asWritten'
268
- }),
269
- )
264
+ holding.push(holdIn(checker, abide, node, read))
270
265
  }
271
266
  await Promise.all(holding)
272
267
  await props
@@ -392,7 +387,7 @@ async function bindingsOfReads(
392
387
  * The tracking and type warnings a file's reactive reads earn.
393
388
  *
394
389
  * The syntax alone decides 14.14 to 14.16 (`trackingWarnings`);
395
- * 14.17 and 31.13 ask what a read hands back, which is the return of the `Reactive`'s call.
390
+ * 14.17 asks what a read hands back, which is the return of the `Reactive`'s call.
396
391
  */
397
392
  async function warningsFor(
398
393
  checker: Checker,
@@ -424,45 +419,16 @@ async function warningsFor(
424
419
  isBinaryExpression(parent) &&
425
420
  parent.left === node &&
426
421
  parent.operatorToken.kind === SyntaxKind.QuestionQuestionToken
427
- const member =
428
- isPropertyAccessExpression(parent) &&
429
- parent.expression === node &&
430
- isIdentifier(parent.name) &&
431
- REACTIVE_MEMBERS.has(parent.name.text)
432
- ? parent.name.text
433
- : undefined
434
- if (!nullish && member === undefined) continue
422
+ if (!nullish) continue
435
423
  typed.push(
436
424
  valueTypeOf(checker, node).then(async (value) => {
437
- if (value === undefined) return
438
425
  // 14.17 — the fallback of a read that is never nullish is never taken: an unlanded
439
426
  // read is a hole the position fills, not an `undefined` (2.2).
440
- if (nullish && !(await admitsNullish(value)))
427
+ if (value !== undefined && !(await admitsNullish(value)))
441
428
  warnings.push({
442
429
  at: read.start,
443
430
  message: `abide: \`${name} ?? …\` never takes its fallback — a read of \`${name}\` is not undefined while it loads, and its type says it is not undefined after. Ask \`${name}.ready()\` to tell the two apart. RULEBOOK 14.17.`,
444
431
  })
445
- if (member === undefined) return
446
- // Per member, because a union's properties are only those every member shares, and
447
- // `undefined` shares none — so `HTMLVideoElement | undefined` would declare no `error`.
448
- for (const held of await unionMembers(value)) {
449
- if ((held.flags & NULLISH) !== 0) continue
450
- for (const property of await checker.getPropertiesOfType(
451
- held,
452
- ))
453
- if (property.name === member) {
454
- const dot = (
455
- parent as { questionDotToken?: unknown }
456
- ).questionDotToken
457
- ? '?.'
458
- : '.'
459
- warnings.push({
460
- at: read.start,
461
- message: `abide: \`${name}${dot}${member}\` reaches the Reactive's own \`${member}\`, and the value it holds has one too. Write \`${name}()${dot}${member}\` for the value's. RULEBOOK 31.13.`,
462
- })
463
- return
464
- }
465
- }
466
432
  }),
467
433
  )
468
434
  }
@@ -779,7 +745,7 @@ function valueLeaves(node: Node, leaves: Node[]): void {
779
745
  const ROUTE_MEMBERS = new Set(['url', 'params', 'name', 'navigating'])
780
746
 
781
747
  /**
782
- * 31.16 — `route`'s four values, each read by name as a name bound to a `Reactive` is.
748
+ * 31.15 — `route`'s four values, each read by name as a name bound to a `Reactive` is.
783
749
  *
784
750
  * `route.params.id` reads `route.params().id`, and the explicit call keeps compiling (31.7). Told
785
751
  * by the symbol, as `props` is, so an app's own `route` is not abide's.
@@ -1142,13 +1108,18 @@ async function propsReads(
1142
1108
  ),
1143
1109
  ),
1144
1110
  )
1111
+ const owned = await ownMembers(checker, found.flat(), (node) =>
1112
+ checker.getTypeAtLocation(node),
1113
+ )
1114
+ // 33.27 — each prop asked whether its position holds the `Reactive`, all at once.
1115
+ const holding: Promise<void>[] = []
1145
1116
  for (let at = 0; at < found.length; at += 1) {
1146
1117
  const binding = bindings[at] as (typeof bindings)[number]
1147
1118
  if (binding.rest) {
1148
1119
  restReads(found[at] as (Node | undefined)[], reads)
1149
1120
  continue
1150
1121
  }
1151
- const uses: Read[] = []
1122
+ const uses: { node: Node; read: Read }[] = []
1152
1123
  let written = false
1153
1124
  for (const node of found[at] as (Node | undefined)[]) {
1154
1125
  // The binding itself is the pattern's, and not a use of the name.
@@ -1157,15 +1128,16 @@ async function propsReads(
1157
1128
  node.parent?.kind === SyntaxKind.BindingElement
1158
1129
  )
1159
1130
  continue
1160
- const read = classifyRead(node)
1131
+ const read = classifyRead(node, owned.has(node))
1161
1132
  if (read === undefined) continue
1162
1133
  if (
1163
1134
  read.shape === 'write' ||
1164
1135
  read.shape === 'update' ||
1165
- isBindOperand(positions, node)
1136
+ isBindOperand(positions, node) ||
1137
+ writesThrough(node, owned)
1166
1138
  )
1167
1139
  written = true
1168
- uses.push(read)
1140
+ uses.push({ node, read })
1169
1141
  }
1170
1142
  if (!written) {
1171
1143
  for (const node of found[at] as (Node | undefined)[]) {
@@ -1173,18 +1145,141 @@ async function propsReads(
1173
1145
  const read = classifyPropRead(node)
1174
1146
  if (read === undefined) continue
1175
1147
  read.local = binding.local
1176
- reads.push(read)
1148
+ if (read.shape !== 'value') {
1149
+ reads.push(read)
1150
+ continue
1151
+ }
1152
+ holding.push(
1153
+ holdsProp(checker, abide, node, owned.has(node)).then(
1154
+ (held) => {
1155
+ if (!held) return void reads.push(read)
1156
+ // Classified as a page's own name is, reaching a cell over the accessor.
1157
+ const asHeld = classifyRead(node) as Read
1158
+ if (asHeld.shape === 'value')
1159
+ asHeld.shape = 'asWritten'
1160
+ asHeld.held = true
1161
+ asHeld.local = binding.local
1162
+ reads.push(asHeld)
1163
+ },
1164
+ ),
1165
+ )
1177
1166
  }
1178
1167
  continue
1179
1168
  }
1180
1169
  binding.call.path.push({ name: binding.key, start: 0, end: 0 })
1181
1170
  for (const read of binding.defaults) read.through = true
1182
- for (const read of uses) {
1171
+ // A written prop's cell is already a `Reactive`, and a position due one holds it.
1172
+ for (const { node, read } of uses) {
1183
1173
  read.through = true
1184
1174
  read.local = binding.local
1185
1175
  reads.push(read)
1176
+ if (read.shape === 'value')
1177
+ holding.push(holdIn(checker, abide, node, read))
1186
1178
  }
1187
1179
  }
1180
+ await Promise.all(holding)
1181
+ }
1182
+
1183
+ /**
1184
+ * 31.5 — the `Reactive`'s member a property access reaches by name, or `undefined` where the name
1185
+ * is not one — `n.ready`, `n.set`. Whether it is the `Reactive`'s is the value's type's to say
1186
+ * (`ownMembers`).
1187
+ */
1188
+ function memberNamed(node: Node): string | undefined {
1189
+ const parent = node.parent
1190
+ return isPropertyAccessExpression(parent) &&
1191
+ parent.expression === node &&
1192
+ isIdentifier(parent.name) &&
1193
+ REACTIVE_MEMBERS.has(parent.name.text)
1194
+ ? parent.name.text
1195
+ : undefined
1196
+ }
1197
+
1198
+ /**
1199
+ * 31.5 — the occurrences among `nodes` reaching a member the VALUE's own type declares, where the
1200
+ * access is the value's rather than the `Reactive`'s — a `Map`'s `set` is the map's. Asked before
1201
+ * any is classified, so each is classified once.
1202
+ *
1203
+ * Per union member, because a union's properties are only those every member shares, and
1204
+ * `undefined` shares none — so `HTMLVideoElement | undefined` would declare no `error`. A value
1205
+ * typed `any` or `unknown` declares none: its type says nothing of what it holds.
1206
+ */
1207
+ async function ownMembers(
1208
+ checker: Checker,
1209
+ nodes: readonly (Node | undefined)[],
1210
+ valueOf: (node: Node) => Promise<Type | undefined>,
1211
+ ): Promise<Set<Node>> {
1212
+ const owned = new Set<Node>()
1213
+ const asked: Promise<void>[] = []
1214
+ for (const node of nodes) {
1215
+ if (node === undefined) continue
1216
+ const member = memberNamed(node)
1217
+ if (member === undefined) continue
1218
+ asked.push(
1219
+ valueOf(node).then(async (value) => {
1220
+ if (value === undefined) return
1221
+ for (const held of await unionMembers(value))
1222
+ if (
1223
+ (held.flags & NULLISH) === 0 &&
1224
+ (await checker.getPropertyOfType(held, member)) !==
1225
+ undefined
1226
+ )
1227
+ return void owned.add(node)
1228
+ }),
1229
+ )
1230
+ }
1231
+ await Promise.all(asked)
1232
+ return owned
1233
+ }
1234
+
1235
+ /**
1236
+ * 31.7, 33.6, 33.7 — `n.set(v)` and `n.patch(…)` are writes by name, called or handed on, where the
1237
+ * value's own type declares no member of that name — a `Map`'s `set` writes the map, not the prop.
1238
+ */
1239
+ function writesThrough(node: Node, owned: ReadonlySet<Node>): boolean {
1240
+ const member = memberNamed(node)
1241
+ return (member === 'set' || member === 'patch') && !owned.has(node)
1242
+ }
1243
+
1244
+ /**
1245
+ * 33.27 — whether a prop the child only reads stands where its `Reactive` is due, as a page's own
1246
+ * name would: an `await` operand (31.3), a `Reactive`'s member its value does not declare (31.5),
1247
+ * or a position typed as a `Reactive` or a `Selection` (31.3, 31.13).
1248
+ */
1249
+ async function holdsProp(
1250
+ checker: Checker,
1251
+ abide: Abide,
1252
+ node: Node,
1253
+ ownsMember: boolean,
1254
+ ): Promise<boolean> {
1255
+ if (node.parent.kind === SyntaxKind.AwaitExpression) return true
1256
+ if (memberNamed(node) !== undefined) return !ownsMember
1257
+ return typedReactive(checker, abide, node)
1258
+ }
1259
+
1260
+ /**
1261
+ * 31.3, 31.13 — `read` holds the `Reactive` where `node` stands in a position whose declared type
1262
+ * is a `Reactive` or a `Selection`.
1263
+ */
1264
+ async function holdIn(
1265
+ checker: Checker,
1266
+ abide: Abide,
1267
+ node: Node,
1268
+ read: Read,
1269
+ ): Promise<void> {
1270
+ if (await typedReactive(checker, abide, node)) read.shape = 'asWritten'
1271
+ }
1272
+
1273
+ /** 31.3, 31.13 — whether `node`'s position is declared a `Reactive` or a `Selection`. */
1274
+ async function typedReactive(
1275
+ checker: Checker,
1276
+ abide: Abide,
1277
+ node: Node,
1278
+ ): Promise<boolean> {
1279
+ const type = await declaredType(checker, node)
1280
+ return (
1281
+ type !== undefined && (await isReactiveType(checker, abide, type, true))
1282
+ )
1188
1283
  }
1189
1284
 
1190
1285
  /**
@@ -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.4.0'
3
+ export const BRIDGE_VERSION = '0.5.1'
@@ -10,6 +10,7 @@ import { coversRoute, parseRoutePattern } from '#shared/routePattern.ts'
10
10
 
11
11
  import type { AppSources } from './answerRequest.ts'
12
12
  import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
13
+ import { prefers } from './prefers.ts'
13
14
  import { boundPage, renderDocument } from './render.ts'
14
15
  import { beginRender, currentRequestScope } from './requestScope.ts'
15
16
 
@@ -100,3 +101,18 @@ export async function answerFailure(
100
101
  headers.set('content-type', 'text/plain; charset=utf-8')
101
102
  return new Response(failure.message, { status, headers })
102
103
  }
104
+
105
+ /**
106
+ * A failure at an address whose lane only the request can say: a page's, painted, where it prefers
107
+ * a document, and a handler's otherwise. 15.22.
108
+ */
109
+ export function laneFailure(
110
+ app: ErrorPages,
111
+ pathname: string,
112
+ failure: Failed<string, unknown>,
113
+ request: Request,
114
+ ): Response | Promise<Response> {
115
+ return prefers(request, 'text/html', 'application/json')
116
+ ? answerFailure(app, pathname, failure)
117
+ : answered(failure)
118
+ }
@@ -22,7 +22,7 @@ import { DEBUG_META } from '#shared/DEBUG_META.ts'
22
22
  import { decodedOrKept } from '#shared/decodedOrKept.ts'
23
23
  import { ERROR_META } from '#shared/ERROR_META.ts'
24
24
  import { escapeAttribute } from '#shared/escapeText.ts'
25
- import { type Failed, isFailed } from '#shared/Failed.ts'
25
+ import { isFailed } from '#shared/Failed.ts'
26
26
  import { HandlerRoute } from '#shared/HandlerRoute.ts'
27
27
  import { health } from '#shared/health.ts'
28
28
  import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
@@ -45,7 +45,7 @@ import {
45
45
  import type { Method, Rpc } from '#shared/rpc.ts'
46
46
  import { rpcRequest } from '#shared/rpcRequest.ts'
47
47
 
48
- import { answered, answerFailure } from './answerFailure.ts'
48
+ import { answerFailure, laneFailure } from './answerFailure.ts'
49
49
  import {
50
50
  answerRpc,
51
51
  type Mounted,
@@ -64,12 +64,11 @@ import { settingOn } from './config.ts'
64
64
  import { noteCallInRender } from './deferredBuilds.ts'
65
65
  import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
66
66
  import { answerMcp, openApi } from './generatedSurfaces.ts'
67
- import { type AppRung, failureOf, rungsOver } from './hooks.ts'
67
+ import { failureOf, type LaneRung, rungsOver } from './hooks.ts'
68
68
  import { chunksOf, type Manifest } from './manifestOf.ts'
69
69
  import { muxAdmission } from './muxUpgrade.ts'
70
70
  import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
71
71
  import { plainAnswer } from './plainAnswer.ts'
72
- import { prefers } from './prefers.ts'
73
72
  import { PRINCIPAL_MOUNT } from './principal.ts'
74
73
  import {
75
74
  boundPage,
@@ -625,7 +624,18 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
625
624
  if (row.page !== undefined)
626
625
  row.answers.set(
627
626
  row.page,
628
- answerInside(sources, row.page, rungsOver(row.pattern)),
627
+ answerInside(
628
+ sources,
629
+ row.page,
630
+ // 37.14 — a page's lane paints a refusal as its error page does (35.2).
631
+ rungsOver(row.pattern, (failure, request) =>
632
+ answerFailure(
633
+ sources,
634
+ new URL(request.url).pathname,
635
+ failure,
636
+ ),
637
+ ),
638
+ ),
629
639
  )
630
640
  for (const handler of row.handlers.values())
631
641
  row.answers.set(
@@ -640,7 +650,7 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
640
650
  function answerInside(
641
651
  app: AppSources,
642
652
  found: Ranked | Mounted,
643
- rungs: AppRung[],
653
+ rungs: LaneRung[],
644
654
  ): (ctx: { request: Request }) => Promise<Response> {
645
655
  return compose(rungs, ({ request }) => answerMatched(app, request, found))
646
656
  }
@@ -774,21 +784,6 @@ async function methodRefused(
774
784
  return answer
775
785
  }
776
786
 
777
- /**
778
- * A failure at an address whose lane only the request can say: a page's, painted, where it prefers
779
- * a document, and a handler's otherwise. 15.22.
780
- */
781
- function laneFailure(
782
- app: AppSources,
783
- pathname: string,
784
- failure: Failed<string, unknown>,
785
- request: Request,
786
- ): Response | Promise<Response> {
787
- return prefers(request, 'text/html', 'application/json')
788
- ? answerFailure(app, pathname, failure)
789
- : answered(failure)
790
- }
791
-
792
787
  /**
793
788
  * A public file's answer, conditional and range-aware off the etag read at boot.
794
789
  *
@@ -44,7 +44,7 @@ import {
44
44
  crossingOrigin,
45
45
  crossingRefusal,
46
46
  } from './crossingOrigin.ts'
47
- import { type AppRung, failureOf, rungsOver } from './hooks.ts'
47
+ import { failureOf, type LaneRung, rungsOver } from './hooks.ts'
48
48
  import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
49
49
  import { prefers } from './prefers.ts'
50
50
  import {
@@ -120,7 +120,7 @@ export type Mounted = Mount & {
120
120
  * The rungs filed over `scope`, read once at mount (16.14) — what an http call and a socket
121
121
  * subscription to it both pass through (37.2, 18.26).
122
122
  */
123
- rungs: AppRung[]
123
+ rungs: LaneRung[]
124
124
  handler: Rpc<unknown, unknown> & Gated<unknown> & HeldToAnnotation
125
125
  /**
126
126
  * The args' JSON Schema, probed once at mount.
@@ -203,7 +203,8 @@ export async function mountHandlers(
203
203
  ...mount,
204
204
  // 37.1, 16.72 — the route its file's hooks are filed at, which the build read off
205
205
  // where the file answers (23.19's mount for a package's), never its place on disk.
206
- rungs: rungsOver(mount.scope),
206
+ // 37.14 — a handler's lane answers a refusal as Problem Details (15.1).
207
+ rungs: rungsOver(mount.scope, answered),
207
208
  handler,
208
209
  argsDocument: declared ?? (mount.args as Document | undefined),
209
210
  })
@@ -122,37 +122,58 @@ export async function* assistant(
122
122
  options.mcp === true ? appSurface() : NO_SURFACE,
123
123
  askerOf(request()),
124
124
  )
125
+ const signal = request().signal
125
126
  const events = provider.run(turns, tools, {
126
127
  maxTurns: options.maxTurns ?? MAX_TURNS,
127
- signal: request().signal,
128
+ signal,
128
129
  })
129
130
  // 28.7 — a closed namespace runs the provider's stream untouched.
130
- yield* ASSISTANT_LOG.enabled() ? traced(events, messages.length) : events
131
+ yield* ASSISTANT_LOG.enabled()
132
+ ? traced(events, messages.length, signal)
133
+ : events
131
134
  }
132
135
 
133
- /** `events`, and one line on `abide:assistant` once they end, however they end. 44.31. */
136
+ /**
137
+ * `events`, and one line on `abide:assistant` once they end, however they end. 44.31.
138
+ *
139
+ * AN UNFINISHED CONVERSATION SAYS WHY, an abort read ahead of a throw: an aborted provider may throw
140
+ * as it unwinds, and the abort is what ended it.
141
+ */
134
142
  async function* traced(
135
143
  events: AsyncGenerator<AssistantEvent>,
136
144
  messageCount: number,
145
+ signal: AbortSignal,
137
146
  ): AsyncGenerator<AssistantEvent> {
138
147
  const started = performance.now()
139
148
  let calls = 0
140
149
  let done: Extract<AssistantEvent, { type: 'done' }> | undefined
150
+ let failure: string | undefined
141
151
  try {
142
152
  for await (const event of events) {
143
153
  if (event.type === 'tool') calls += 1
144
154
  else if (event.type === 'done') done = event
145
155
  yield event
146
156
  }
157
+ } catch (thrown) {
158
+ failure = thrown instanceof Error ? thrown.message : String(thrown)
159
+ throw thrown
147
160
  } finally {
161
+ const ending =
162
+ done?.stop ??
163
+ (signal.aborted
164
+ ? 'unfinished aborted'
165
+ : failure === undefined
166
+ ? 'unfinished stopped'
167
+ : 'unfinished failed')
148
168
  ASSISTANT_LOG.debug(
149
- done === undefined ? 'unfinished' : done.stop,
169
+ ending,
150
170
  `${messageCount} messages`,
151
171
  `${calls} tool calls`,
152
172
  done === undefined
153
173
  ? 'no usage'
154
174
  : `${done.usage.input}/${done.usage.output} tokens`,
155
175
  `${(performance.now() - started).toFixed(1)}ms`,
176
+ ...(ending === 'unfinished failed' ? [failure] : []),
156
177
  )
157
178
  }
158
179
  }
@@ -17,6 +17,7 @@ import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
17
17
  import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
18
18
 
19
19
  import { servedTable } from './abideModules.ts'
20
+ import { laneFailure } from './answerFailure.ts'
20
21
  import { answerRequest, type AppSources } from './answerRequest.ts'
21
22
  import type { Mount } from './answerRpc.ts'
22
23
  import { Mux, type MuxConnection } from './answerSocket.ts'
@@ -278,7 +279,10 @@ export async function bootEntered(
278
279
  // 16.14, 37.3 — THE APP LANE'S ONION, composed once over the whole request path: the root
279
280
  // route's rungs, the ones below it running after routing (37.2, `answerRequest`). 21.20 — the
280
281
  // center takes its own time back out of what `fetch` counts for the whole onion.
281
- const rungs = rootRungs()
282
+ // 37.14 — before routing, the lane is the request's to say (15.22).
283
+ const rungs = rootRungs((failure, request) =>
284
+ laneFailure(booted, new URL(request.url).pathname, failure, request),
285
+ )
282
286
  const answer = compose(rungs, async ({ request }) => {
283
287
  const began = performance.now()
284
288
  const answered = await answerRequest(booted, request)
package/src/server/csp.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // THE NONCE IS MINTED BY WHICHEVER ASKS FIRST — a page reading `csp.nonce` while it renders, or this
6
6
  // rung writing a document's header — so the two agree, and an answer that is not a document mints none.
7
7
 
8
- import type { Middleware } from '#shared/Middleware.ts'
8
+ import type { Rung } from '#shared/Middleware.ts'
9
9
 
10
10
  import { randomToken } from './randomToken.ts'
11
11
  import { EARLY_CALL_HASH } from './render.ts'
@@ -64,7 +64,7 @@ function policy(
64
64
 
65
65
  export function cspRung(
66
66
  sources: Record<string, string[]> | undefined,
67
- ): Middleware<{ request: Request }, Response> {
67
+ ): Rung<{ request: Request }, Response> {
68
68
  const written = policy(sources)
69
69
  return async (next) => {
70
70
  const answer = await next()
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { type Failed, isFailed } from '#shared/Failed.ts'
15
15
  import { namedLogger } from '#shared/log.ts'
16
- import type { Middleware } from '#shared/Middleware.ts'
16
+ import type { Rung } from '#shared/Middleware.ts'
17
17
  import { aborted, refuse } from '#shared/refuse.ts'
18
18
  import {
19
19
  coversRoute,
@@ -26,7 +26,25 @@ import { config } from './config.ts'
26
26
  import type { LauncherOptions } from './launcherOptions.ts'
27
27
  import { currentRequestScope } from './requestScope.ts'
28
28
 
29
- export type AppRung = Middleware<{ request: Request }, Response>
29
+ type AppContext = { request: Request }
30
+
31
+ /** A rung `middleware` files: what `next` answered passed on, or a refusal in its place (37.14). */
32
+ export type AppRung = (
33
+ next: (ctx?: AppContext) => Promise<Response>,
34
+ ctx: AppContext,
35
+ ) =>
36
+ | Response
37
+ | Failed<string, unknown>
38
+ | Promise<Response | Failed<string, unknown>>
39
+
40
+ /** An app rung as its lane composes it, a refusal it returned already answered (37.14). */
41
+ export type LaneRung = Rung<AppContext, Response>
42
+
43
+ /** How a lane answers a refusal one of its rungs returned, in that lane's representation (37.14). */
44
+ export type RefusalAnswer = (
45
+ failure: Failed<string, unknown>,
46
+ request: Request,
47
+ ) => Response | Promise<Response>
30
48
 
31
49
  export type Env = Record<string, string | undefined>
32
50
  export type ConfigDefaults = (env: Env) => unknown
@@ -106,11 +124,24 @@ export function inOrder<T>(list: readonly Filed<T>[]): T[] {
106
124
  return items
107
125
  }
108
126
 
127
+ /**
128
+ * `rung` with a refusal it returns answered by `answer`.
129
+ *
130
+ * 37.14, 37.15 — PER LINK, not at the onion's edge: a rung outside this one is handed the answer
131
+ * from `next`, so one that reads a `Response` (29.2's) reads one whichever rung inside it refused.
132
+ */
133
+ function answering(rung: AppRung, answer: RefusalAnswer): LaneRung {
134
+ return async (next, ctx) => {
135
+ const result = await rung(next, ctx)
136
+ return isFailed(result) ? answer(result, ctx.request) : result
137
+ }
138
+ }
139
+
109
140
  /** The rungs filed at the root route, which run before routing. 37.3. */
110
- export function rootRungs(): AppRung[] {
111
- const rungs: AppRung[] = []
141
+ export function rootRungs(answer: RefusalAnswer): LaneRung[] {
142
+ const rungs: LaneRung[] = []
112
143
  for (const entry of HOOKS.rungs)
113
- if (entry.route === ROOT) rungs.push(entry.item)
144
+ if (entry.route === ROOT) rungs.push(answering(entry.item, answer))
114
145
  return rungs
115
146
  }
116
147
 
@@ -120,12 +151,12 @@ export function rootRungs(): AppRung[] {
120
151
  * 37.2 — every rung filed at `route` or at a route that is its front, the root's aside: those ran
121
152
  * before routing (37.3).
122
153
  */
123
- export function rungsOver(route: string): AppRung[] {
154
+ export function rungsOver(route: string, answer: RefusalAnswer): LaneRung[] {
124
155
  const segments = parseRoutePattern(route)
125
- const rungs: AppRung[] = []
156
+ const rungs: LaneRung[] = []
126
157
  for (const entry of HOOKS.rungs)
127
158
  if (entry.route !== ROOT && coversRoute(entry.segments, segments))
128
- rungs.push(entry.item)
159
+ rungs.push(answering(entry.item, answer))
129
160
  return rungs
130
161
  }
131
162
 
@@ -1,7 +1,27 @@
1
1
  // ONE RUNG OF AN ONION, and the onion. The app lane's `middleware` (37.3) and a handler's rungs
2
2
  // (16.11) are the same shape over a different `Ctx`, so they compose through the one function.
3
3
 
4
- export type Middleware<Ctx, Result> = (
4
+ import type { Failed } from './Failed.ts'
5
+
6
+ /**
7
+ * A rung written once for every lane whose context carries `Extra`: generic over the context it is
8
+ * handed and over what `next` answers, so it passes `next()`'s answer on, refuses in its place, or
9
+ * answers with an `Answer` of its own.
10
+ */
11
+ export type Middleware<Answer = never, Extra extends object = object> = <
12
+ Given extends { request: Request } & Extra,
13
+ Result,
14
+ >(
15
+ next: (ctx?: Given) => Promise<Result>,
16
+ ctx: Given,
17
+ ) =>
18
+ | Result
19
+ | Answer
20
+ | Failed<string, unknown>
21
+ | Promise<Result | Answer | Failed<string, unknown>>
22
+
23
+ /** A rung as one lane composes it, over that lane's context and what its center answers. */
24
+ export type Rung<Ctx, Result> = (
5
25
  next: (ctx?: Ctx) => Promise<Result>,
6
26
  ctx: Ctx,
7
27
  ) => Result | Promise<Result>
@@ -17,17 +37,17 @@ export type Middleware<Ctx, Result> = (
17
37
  * `next()` with no argument hands the inner link the ctx this rung was given.
18
38
  */
19
39
  export function compose<Ctx, Result>(
20
- rungs: readonly Middleware<Ctx, Result>[],
40
+ rungs: readonly Rung<Ctx, Result>[],
21
41
  terminal: (ctx: Ctx) => Promise<Result>,
22
42
  ): (ctx: Ctx) => Promise<Result> {
23
43
  let link = terminal
24
44
  for (let at = rungs.length - 1; at >= 0; at -= 1)
25
- link = linkOf(rungs[at] as Middleware<Ctx, Result>, link)
45
+ link = linkOf(rungs[at] as Rung<Ctx, Result>, link)
26
46
  return link
27
47
  }
28
48
 
29
49
  function linkOf<Ctx, Result>(
30
- rung: Middleware<Ctx, Result>,
50
+ rung: Rung<Ctx, Result>,
31
51
  inner: (ctx: Ctx) => Promise<Result>,
32
52
  ): (ctx: Ctx) => Promise<Result> {
33
53
  return async (ctx) => rung((given) => inner(given ?? ctx), ctx)
package/src/shared/csp.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  // mint one for, so `csp.nonce` answers the one its document was served with, which is what a
4
4
  // binding that wrote it on the server hydrates to.
5
5
 
6
- import type { Middleware } from './Middleware.ts'
6
+ import type { Rung as LaneRung } from './Middleware.ts'
7
7
 
8
- type Rung = Middleware<{ request: Request }, Response>
8
+ type Rung = LaneRung<{ request: Request }, Response>
9
9
 
10
10
  /**
11
11
  * The server's content-security-policy half, as slots `#server/installServer.ts` fills from