@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
@@ -17,7 +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 { laneFailure } from './answerFailure.ts'
20
- import { answerRequest, type AppSources } from './answerRequest.ts'
20
+ import { answerRequest, type AppSources, laneOf } from './answerRequest.ts'
21
21
  import { Mux, type MuxConnection } from './answerSocket.ts'
22
22
  import {
23
23
  type Builder,
@@ -26,6 +26,7 @@ import {
26
26
  spawnedBuild,
27
27
  } from './appBuild.ts'
28
28
  import { holdsApp } from './appTree.ts'
29
+ import { presumeCaller } from './callerOf.ts'
29
30
  import {
30
31
  APP_PACKAGE,
31
32
  type AppPackage,
@@ -35,7 +36,14 @@ import {
35
36
  } from './config.ts'
36
37
  import { Followers } from './Followers.ts'
37
38
  import { McpSurface } from './generatedSurfaces.ts'
38
- import { HOOKS, inOrder, reportUnexpected, rootRungs } from './hooks.ts'
39
+ import {
40
+ type AppContext,
41
+ HOOKS,
42
+ inOrder,
43
+ type Lane,
44
+ reportUnexpected,
45
+ rootRungs,
46
+ } from './hooks.ts'
39
47
  import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
40
48
  import { installServer } from './installServer.ts'
41
49
  import { followedIn, type Mount } from './mountHandlers.ts'
@@ -51,6 +59,7 @@ import {
51
59
  holdScope,
52
60
  inReleasedScope,
53
61
  LISTENING,
62
+ RECEIVED_FROM,
54
63
  rungsTook,
55
64
  serverTiming,
56
65
  } from './requestScope.ts'
@@ -65,12 +74,15 @@ const REQUEST_LOG = namedLogger('abide:request')
65
74
  // by it.
66
75
  const STOP_TIMEOUT = 5000
67
76
 
77
+ /** Who a scope `App.run` or `App.fetch` opens is answered as, over what its request's cookies say. */
78
+ type Presumed = { principal?: Record<string, unknown>; caller?: string }
79
+
68
80
  /** The app object: what a host, a test or a script holds the app as. */
69
81
  export interface App {
70
- fetch(request: Request): Promise<Response>
82
+ fetch(request: Request, options?: Presumed): Promise<Response>
71
83
  run<Result>(
72
84
  fn: () => Result | Promise<Result>,
73
- options?: { request?: Request; principal?: Record<string, unknown> },
85
+ options?: { request?: Request } & Presumed,
74
86
  ): Promise<Result>
75
87
  listen(options?: {
76
88
  port?: number
@@ -247,8 +259,15 @@ export async function bootEntered(
247
259
  // route's rungs, the ones below it running after routing (37.2, `answerRequest`). 21.20 — the
248
260
  // center takes its own time back out of what `fetch` counts for the whole onion.
249
261
  // 37.14 — before routing, the lane is the request's to say (15.22).
250
- const rungs = rootRungs((failure, request) =>
251
- laneFailure(booted, new URL(request.url).pathname, failure, request),
262
+ const rungs = rootRungs(
263
+ (failure, request) =>
264
+ laneFailure(
265
+ booted,
266
+ new URL(request.url).pathname,
267
+ failure,
268
+ request,
269
+ ),
270
+ (request) => laneOf(booted, request),
252
271
  )
253
272
  const answer = compose(rungs, async ({ request }) => {
254
273
  const began = performance.now()
@@ -265,23 +284,32 @@ export async function bootEntered(
265
284
  let stopping = false
266
285
  let bound: Bun.Server<unknown> | undefined
267
286
 
287
+ // 42.19, 42.20, 42.21 — inside the scope, ahead of anything that reads either.
288
+ const presume = (options: Presumed | undefined): void => {
289
+ if (options?.principal !== undefined)
290
+ presumePrincipal(options.principal)
291
+ if (options?.caller !== undefined) presumeCaller(options.caller)
292
+ }
293
+
268
294
  const run: App['run'] = async (fn, options) => {
269
- const claims = options?.principal
270
- // 42.19 — a principal is a request's, so one given alone is given a request to be.
295
+ // 42.19, 42.21 — a principal or a caller is a request's, so one given alone is given a request
296
+ // to be.
271
297
  const request =
272
298
  options?.request ??
273
- (claims === undefined ? undefined : new Request(IN_PROCESS_ORIGIN))
299
+ (options?.principal === undefined && options?.caller === undefined
300
+ ? undefined
301
+ : new Request(IN_PROCESS_ORIGIN))
274
302
  return inReleasedScope(
275
303
  request,
276
304
  async () => {
277
- if (claims !== undefined) presumePrincipal(claims)
305
+ presume(options)
278
306
  return await fn()
279
307
  },
280
308
  answering,
281
309
  )
282
310
  }
283
311
 
284
- const fetch = async (given: Request): Promise<Response> => {
312
+ const fetch: App['fetch'] = async (given, options) => {
285
313
  if (stopping) return plainAnswer('the app is stopping', 503)
286
314
  const request = belowMount(given)
287
315
  if (request instanceof Response) return request
@@ -294,9 +322,12 @@ export async function bootEntered(
294
322
  return await inReleasedScope(
295
323
  request,
296
324
  async () => {
325
+ presume(options)
297
326
  let answered: Response
298
327
  try {
299
- answered = await answer({ request })
328
+ answered = await answer(
329
+ new RootContext(booted, request),
330
+ )
300
331
  if (rungs.length > 0)
301
332
  rungsTook(performance.now() - started)
302
333
  } catch (thrown) {
@@ -431,6 +462,8 @@ export async function bootEntered(
431
462
  maxRequestBodySize: Number.MAX_SAFE_INTEGER,
432
463
  // 18.1, 37.3 — THE ONE MUX, upgraded where the app lane admitted it.
433
464
  fetch: async (request, server) => {
465
+ // 22.12 — where it came from, for `requestIP` to answer.
466
+ RECEIVED_FROM.set(request, server.requestIP(request))
434
467
  const answered = await fetch(request)
435
468
  return admitsMux(request, answered, booted)
436
469
  ? upgraded(server, request, {
@@ -465,6 +498,26 @@ export async function bootEntered(
465
498
  }
466
499
  }
467
500
 
501
+ /**
502
+ * What a root rung is handed: the request, and what answers it, asked on the first read.
503
+ *
504
+ * 37.19 — LAZILY, because routing a request twice is a cost only an app whose root rungs read the
505
+ * lane pays: the dispatch routes it again after them, and an app with none reads nothing here.
506
+ */
507
+ class RootContext implements AppContext {
508
+ readonly request: Request
509
+ #sources: AppSources
510
+ #lane: Lane | undefined = undefined
511
+ constructor(sources: AppSources, request: Request) {
512
+ this.#sources = sources
513
+ this.request = request
514
+ }
515
+ get lane(): Lane {
516
+ this.#lane ??= laneOf(this.#sources, this.request)
517
+ return this.#lane
518
+ }
519
+ }
520
+
468
521
  /**
469
522
  * The answer with the headers every answer carries, and what was written through `response`.
470
523
  *
@@ -35,7 +35,7 @@ import { bootApp, type BootedApp } from './createApp.ts'
35
35
  import { keepServing } from './faultPolicy.ts'
36
36
  import { type Manifest, NO_MANIFEST } from './manifestOf.ts'
37
37
  import { admitsMux, muxSocketOptions } from './muxUpgrade.ts'
38
- import { LISTENING } from './requestScope.ts'
38
+ import { LISTENING, RECEIVED_FROM, type ReceivedFrom } from './requestScope.ts'
39
39
  import {
40
40
  describe,
41
41
  type FromRelay,
@@ -70,6 +70,7 @@ export type ToWorker =
70
70
  connection: number
71
71
  url: string
72
72
  headers: [string, string][]
73
+ from: ReceivedFrom
73
74
  }
74
75
  | { kind: 'socket-message'; connection: number; text: string }
75
76
  | { kind: 'socket-close'; connection: number }
@@ -113,7 +114,9 @@ let failure = ''
113
114
  const relay = serveOver(
114
115
  { postMessage: toListener },
115
116
  {
116
- async fetch(request) {
117
+ async fetch(request, from) {
118
+ // 22.12 — where the listener received it from, which is not this process.
119
+ RECEIVED_FROM.set(request, from)
117
120
  await opening
118
121
  return app === undefined
119
122
  ? failedDocument(failure)
@@ -161,21 +164,20 @@ async function receive(message: ToWorker): Promise<void> {
161
164
  await opening
162
165
  if (message.kind === 'socket-open') {
163
166
  const numbered = message.connection
164
- const connection = mux?.connect(
165
- new Request(message.url, { headers: message.headers }),
166
- {
167
- send(text: string) {
168
- toListener({
169
- kind: 'socket-send',
170
- connection: numbered,
171
- text,
172
- })
173
- },
174
- // NOTHING TO CLOSE: the listener holds the browser's socket, and it outlives this
175
- // worker (38.14). Nothing here stops the mux, either.
176
- close() {},
167
+ const upgrade = new Request(message.url, { headers: message.headers })
168
+ RECEIVED_FROM.set(upgrade, message.from)
169
+ const connection = mux?.connect(upgrade, {
170
+ send(text: string) {
171
+ toListener({
172
+ kind: 'socket-send',
173
+ connection: numbered,
174
+ text,
175
+ })
177
176
  },
178
- )
177
+ // NOTHING TO CLOSE: the listener holds the browser's socket, and it outlives this
178
+ // worker (38.14). Nothing here stops the mux, either.
179
+ close() {},
180
+ })
179
181
  if (connection !== undefined) connections.set(numbered, connection)
180
182
  return
181
183
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { isFailed } from '#shared/Failed.ts'
17
17
  import { essenceOf } from '#shared/framing.ts'
18
+ import { IMAGE_TYPES } from '#shared/IMAGE_TYPES.ts'
18
19
  import { namedLogger } from '#shared/log.ts'
19
20
  import { mounted } from '#shared/mount.ts'
20
21
  import { type Reactive, reactive } from '#shared/reactive/index.ts'
@@ -26,7 +27,6 @@ import { bodyWithin } from './answerRpc.ts'
26
27
  import type { ToolAnswer, ToolDefinition } from './assistant.ts'
27
28
  import { appIdentity } from './config.ts'
28
29
  import { crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
29
- import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
30
30
  import { MCP_PROTOCOL_VERSION } from './MCP_PROTOCOL_VERSION.ts'
31
31
  import type { Mounted } from './mountHandlers.ts'
32
32
  import { type Listed, surfaceOf } from './surfaceOf.ts'
@@ -326,7 +326,7 @@ export class McpSurface {
326
326
 
327
327
  /** What calling the tool `name` as `asker` answered, a refusal or a missing tool a failed one. */
328
328
  call(name: string, args: unknown, asker: Request): Promise<ToolAnswer> {
329
- const tool = this.#tool(name)
329
+ const tool = this.tool(name)
330
330
  if (tool === undefined) {
331
331
  logCall(String(name), 404, undefined)
332
332
  return Promise.resolve({
@@ -337,12 +337,13 @@ export class McpSurface {
337
337
  return this.#called(tool, args as Record<string, unknown>, asker)
338
338
  }
339
339
 
340
- #tool(name: unknown): Published | undefined {
340
+ /** The tool it lists as `name`, or `undefined` where it lists none. */
341
+ tool(name: unknown): Listed | undefined {
341
342
  return this.#published().find((one) => one.name === name)
342
343
  }
343
344
 
344
345
  async #called(
345
- one: Published,
346
+ one: Listed,
346
347
  args: Record<string, unknown> | undefined,
347
348
  asker: Request,
348
349
  ): Promise<ToolAnswer> {
@@ -416,7 +417,7 @@ export class McpSurface {
416
417
  case 'tools/list':
417
418
  return { result: { tools: this.list() } }
418
419
  case 'tools/call': {
419
- const tool = this.#tool(params?.name)
420
+ const tool = this.tool(params?.name)
420
421
  if (tool === undefined) {
421
422
  logCall(String(params?.name), 404, undefined)
422
423
  return mcpFailure(
@@ -13,6 +13,7 @@ export function handlerFileMount(
13
13
  | 'cli'
14
14
  | 'mcp'
15
15
  | 'description'
16
+ | 'middleware'
16
17
  | 'copy'
17
18
  | 'defaults'
18
19
  | 'args'
@@ -32,6 +33,7 @@ export function handlerFileMount(
32
33
  cli: declared.cli,
33
34
  mcp: declared.mcp,
34
35
  description: declared.description,
36
+ middleware: declared.middleware,
35
37
  copy: declared.copy,
36
38
  defaults: declared.defaults,
37
39
  args: declared.args,
@@ -26,11 +26,29 @@ import { config } from './config.ts'
26
26
  import type { LauncherOptions } from './launcherOptions.ts'
27
27
  import { currentRequestScope } from './requestScope.ts'
28
28
 
29
- type AppContext = { request: Request }
29
+ /**
30
+ * What answers a request, by the lane the app sends it down. 37.19.
31
+ *
32
+ * One per arm of `answerRequest`'s dispatch: a page (and every address no route answers), a
33
+ * handler, a public file, the three surfaces of abide's own a gate decides about one by one, the
34
+ * socket mux's upgrade, and the rest of `/__abide`.
35
+ */
36
+ export type Lane =
37
+ | 'page'
38
+ | 'handler'
39
+ | 'file'
40
+ | 'openapi'
41
+ | 'mcp'
42
+ | 'bridge'
43
+ | 'socket'
44
+ | 'abide'
45
+
46
+ /** What an app rung is handed: the request, and what answers it (37.19). */
47
+ export type AppContext = { request: Request; lane: Lane }
30
48
 
31
49
  /** A rung `middleware` files: what `next` answered passed on, or a refusal in its place (37.14). */
32
50
  export type AppRung = (
33
- next: (ctx?: AppContext) => Promise<Response>,
51
+ next: (ctx?: { request: Request }) => Promise<Response>,
34
52
  ctx: AppContext,
35
53
  ) =>
36
54
  | Response
@@ -130,18 +148,46 @@ export function inOrder<T>(list: readonly Filed<T>[]): T[] {
130
148
  * 37.14, 37.15 — PER LINK, not at the onion's edge: a rung outside this one is handed the answer
131
149
  * from `next`, so one that reads a `Response` (29.2's) reads one whichever rung inside it refused.
132
150
  */
133
- function answering(rung: AppRung, answer: RefusalAnswer): LaneRung {
151
+ function answering(
152
+ rung: AppRung,
153
+ answer: RefusalAnswer,
154
+ laneOf: ((request: Request) => Lane) | undefined,
155
+ ): LaneRung {
134
156
  return async (next, ctx) => {
135
- const result = await rung(next, ctx)
157
+ // 37.19 — A REQUEST A RUNG HANDS `next` IS ANSWERED BY WHAT ITS OWN ADDRESS ROUTES TO, asked
158
+ // again before routing (`laneOf`) and kept after it, where routing has already chosen.
159
+ const result = await rung(
160
+ (given) =>
161
+ next(
162
+ given === undefined || given.request === ctx.request
163
+ ? ctx
164
+ : {
165
+ request: given.request,
166
+ lane:
167
+ laneOf === undefined
168
+ ? ctx.lane
169
+ : laneOf(given.request),
170
+ },
171
+ ),
172
+ ctx,
173
+ )
136
174
  return isFailed(result) ? answer(result, ctx.request) : result
137
175
  }
138
176
  }
139
177
 
140
- /** The rungs filed at the root route, which run before routing. 37.3. */
141
- export function rootRungs(answer: RefusalAnswer): LaneRung[] {
178
+ /**
179
+ * The rungs filed at the root route, which run before routing. 37.3.
180
+ *
181
+ * `laneOf` is what the dispatch would answer a request with (37.19), asked of one a rung hands on.
182
+ */
183
+ export function rootRungs(
184
+ answer: RefusalAnswer,
185
+ laneOf: (request: Request) => Lane,
186
+ ): LaneRung[] {
142
187
  const rungs: LaneRung[] = []
143
188
  for (const entry of HOOKS.rungs)
144
- if (entry.route === ROOT) rungs.push(answering(entry.item, answer))
189
+ if (entry.route === ROOT)
190
+ rungs.push(answering(entry.item, answer, laneOf))
145
191
  return rungs
146
192
  }
147
193
 
@@ -156,7 +202,7 @@ export function rungsOver(route: string, answer: RefusalAnswer): LaneRung[] {
156
202
  const rungs: LaneRung[] = []
157
203
  for (const entry of HOOKS.rungs)
158
204
  if (entry.route !== ROOT && coversRoute(entry.segments, segments))
159
- rungs.push(answering(entry.item, answer))
205
+ rungs.push(answering(entry.item, answer, undefined))
160
206
  return rungs
161
207
  }
162
208
 
@@ -26,13 +26,17 @@ export {
26
26
  type RpcOptions,
27
27
  } from '#shared/rpc.ts'
28
28
  // 45 — a conversation with a model, piped through a handler, and the two providers abide ships.
29
+ // 44 — what a page sends and is handed back, which the isomorphic entry carries too.
30
+ export type {
31
+ AssistantContent,
32
+ AssistantEvent,
33
+ AssistantMessage,
34
+ } from '#shared/AssistantEvent.ts'
29
35
  export {
30
- type AssistantContent,
31
- type AssistantEvent,
32
- type AssistantMessage,
33
36
  type AssistantOptions,
34
37
  assistant,
35
38
  type Provider,
39
+ type ProviderEvent,
36
40
  type ToolAnswer,
37
41
  type Toolbox,
38
42
  type ToolDefinition,
@@ -62,6 +66,7 @@ export {
62
66
  type ConfigOptions,
63
67
  type Env,
64
68
  fileRoute,
69
+ type Lane,
65
70
  middleware,
66
71
  onConfig,
67
72
  onError,
@@ -80,7 +85,14 @@ export { type Component, render } from './render.ts'
80
85
  // child's markup one thing for the client to walk to.
81
86
  export { renderComponent } from './renderComponent.ts'
82
87
  // 22.1 — the request being served, the response being built for it, and the listening server.
83
- export { request, response, server } from './requestScope.ts'
88
+ // 22.12 — and where it came from.
89
+ export {
90
+ type ReceivedFrom,
91
+ request,
92
+ requestIP,
93
+ response,
94
+ server,
95
+ } from './requestScope.ts'
84
96
  // 17 — the response helpers, each a write through `response`.
85
97
  export {
86
98
  bytes,
@@ -92,6 +104,8 @@ export {
92
104
  sse,
93
105
  type Values,
94
106
  } from './responseHelpers.ts'
107
+ // 8.25 — a `Store` in a `bun:sqlite` table.
108
+ export { sqliteStore } from './sqliteStore.ts'
95
109
  // 24 — the trace this work belongs to.
96
110
  export { trace } from './trace.ts'
97
111
 
@@ -222,7 +222,7 @@ async function routeAdmits(
222
222
  const answered = await compose(mounted.rungs, async () => {
223
223
  reached = true
224
224
  return new Response(null, { status: 204 })
225
- })({ request })
225
+ })({ request, lane: 'handler' })
226
226
  return reached && answered.ok
227
227
  } catch {
228
228
  return false
@@ -44,6 +44,11 @@ export type Mount = {
44
44
  mcp: boolean
45
45
  /** Its `description`, where the declaration writes one as a literal the build can read. */
46
46
  description: string | undefined
47
+ /**
48
+ * The rung each entry of its declaration's `middleware` names, as written; `null` where the
49
+ * options are not written where the build can read them. 38.135.
50
+ */
51
+ middleware: string[] | null
47
52
  /**
48
53
  * What a `GET`'s value declares that the browser's copy behaves by, or `undefined`.
49
54
  *
@@ -134,6 +139,7 @@ export function mountedOwn(
134
139
  cli: false,
135
140
  mcp: false,
136
141
  description: undefined,
142
+ middleware: [],
137
143
  copy: undefined,
138
144
  defaults: undefined,
139
145
  args: undefined,
@@ -198,13 +198,14 @@ function resolvePrincipal(): Promise<Principal> {
198
198
  /**
199
199
  * This request's principal, resolved from `claims` as a sealed cookie's are, with no cookie written.
200
200
  *
201
- * 42.19 — `App.run`'s `principal`, which is why it throws where `principal.set` does.
201
+ * 42.19, 42.20 — `App.run`'s and `App.fetch`'s `principal`, which is why it throws where
202
+ * `principal.set` does.
202
203
  */
203
204
  export function presumePrincipal(claims: Record<string, unknown>): void {
204
205
  const scope = within('App.run principal')
205
206
  if (!isRecord(claims))
206
207
  throw new Error(
207
- 'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
208
+ 'abide: App.run and App.fetch take a principal of claims, as principal.set does. RULEBOOK 26.11.',
208
209
  )
209
210
  const expires = Date.now() + ttl()
210
211
  scope.principal = documentOf(claims, expires)
@@ -11,7 +11,7 @@ import type { ShareScope } from '#shared/reactive/share.ts'
11
11
  import { setUpLevels } from '#shared/setUpLevels.ts'
12
12
 
13
13
  import { buildDeferred } from './deferredBuilds.ts'
14
- import { currentRequestScope } from './requestScope.ts'
14
+ import { currentRequestScope, inReleasedRender } from './requestScope.ts'
15
15
 
16
16
  /**
17
17
  * A compiled component, opaque to an app.
@@ -314,7 +314,16 @@ export function render(component: Component): Promise<Uint8Array<ArrayBuffer>> {
314
314
  * `render` into `given`: abide's own document with what the server adds to it — a page's client
315
315
  * tags, the launcher page's stylesheet. NOT PUBLIC SURFACE — `server/index.ts` exports `render`.
316
316
  */
317
- export async function renderDocument(
317
+ export function renderDocument(
318
+ component: Component,
319
+ given: string,
320
+ ): Promise<Uint8Array<ArrayBuffer>> {
321
+ // 22.11 — a server's render whether or not a request is being served; 12.5 — and torn down once
322
+ // written where no request is.
323
+ return inReleasedRender(() => documentOf(component, given))
324
+ }
325
+
326
+ async function documentOf(
318
327
  component: Component,
319
328
  given: string,
320
329
  ): Promise<Uint8Array<ArrayBuffer>> {
@@ -125,8 +125,20 @@ const STORAGE = new AsyncLocalStorage<RequestScope | undefined>()
125
125
  // value is filed per request (11.32), and a server module builds values as it loads — the
126
126
  // principal's document here, an app's handlers ahead of `bootApp` in a compiled entry. Anything
127
127
  // that can enter a scope imports this module, so none is built before the provider is in.
128
+ // 22.11 — A RENDER OUTSIDE EVERY REQUEST IS STILL A SERVER'S: `render` called bare, and `mount`'s
129
+ // first half, which a test process runs beside a DOM and the browser half that hydrates it. Marked
130
+ // apart from STORAGE so the render's values stay the ones 11.32 files outside a request. What it
131
+ // holds is the render's stops (12.5): a request's are its scope's.
132
+ const RENDERING = new AsyncLocalStorage<(() => void)[]>()
133
+
134
+ /** The stops of the request or the render current, or `undefined` where neither is. */
135
+ function currentStops(): (() => void)[] | undefined {
136
+ return STORAGE.getStore()?.stops ?? RENDERING.getStore()
137
+ }
138
+
128
139
  SCOPING.provider = {
129
140
  current: (): Scope | undefined => STORAGE.getStore(),
141
+ serverStops: currentStops,
130
142
  run: (scope, body, given) =>
131
143
  STORAGE.run(scope as RequestScope | undefined, body, given),
132
144
  }
@@ -179,6 +191,21 @@ export function inScope<Result>(
179
191
  return STORAGE.run(scopeFor(given, given, undefined), body)
180
192
  }
181
193
 
194
+ /**
195
+ * Runs `body` as a server's render, stopping every watch it opened once it has settled.
196
+ *
197
+ * 12.5 — A RENDER OUTSIDE EVERY REQUEST IS TORN DOWN as a request's scope is, once its document is
198
+ * written. Inside a request or a render already open, the watches are that one's to stop.
199
+ */
200
+ export function inReleasedRender<Result>(
201
+ body: () => Promise<Result>,
202
+ ): Promise<Result> {
203
+ // A REQUEST'S PAGE PAYS NOTHING HERE — no wrapping promise, no tick — as its scope stops them.
204
+ if (currentStops() !== undefined) return body()
205
+ const stops: (() => void)[] = []
206
+ return RENDERING.run(stops, body).finally(() => stopAll(stops))
207
+ }
208
+
182
209
  /** Runs `body` in `scope`, which `scopeFor` opened, and returns what it returned. */
183
210
  export function inOpenScope<Result>(
184
211
  scope: RequestScope,
@@ -261,7 +288,11 @@ export function releaseScope(scope: RequestScope): void {
261
288
  scope.unreaped = false
262
289
  LISTENING.timeout(scope.received as Request, IDLE_TIMEOUT_SECONDS)
263
290
  }
264
- const stops = scope.stops
291
+ stopAll(scope.stops)
292
+ }
293
+
294
+ /** Runs and drops every stop in `stops`. 12.5. */
295
+ function stopAll(stops: (() => void)[]): void {
265
296
  for (let at = 0; at < stops.length; at += 1) (stops[at] as () => void)()
266
297
  stops.length = 0
267
298
  }
@@ -433,6 +464,35 @@ export function response(): { status: number; headers: Headers } {
433
464
  return scope.response
434
465
  }
435
466
 
467
+ /** Where a request came from: a peer's address, `null` where the listener names none. */
468
+ export type ReceivedFrom = {
469
+ address: string
470
+ family: 'IPv4' | 'IPv6'
471
+ port: number
472
+ } | null
473
+
474
+ /**
475
+ * The peer each request a listener received came from, keyed by the request it handed the app.
476
+ *
477
+ * 22.12 — FILED BY THE LISTENER: `App.listen` files what bun names, and `abide dev`'s worker what its
478
+ * listener sent beside the request (38.14). A request nobody filed was made in process.
479
+ */
480
+ export const RECEIVED_FROM = new WeakMap<Request, ReceivedFrom>()
481
+
482
+ /**
483
+ * The address the request being served came from, or `'in-process'` for one no listener received.
484
+ *
485
+ * 22.12, 22.13 — the same under `abide start`, `abide dev` and `App.listen`; `null` where the
486
+ * listener names no peer, as a unix socket does. A socket mux frame answers its upgrade's.
487
+ */
488
+ export function requestIP(): ReceivedFrom | 'in-process' {
489
+ const scope = within('requestIP')
490
+ const asked = (scope.received ?? scope.request) as Request
491
+ return RECEIVED_FROM.has(asked)
492
+ ? (RECEIVED_FROM.get(asked) as ReceivedFrom)
493
+ : 'in-process'
494
+ }
495
+
436
496
  /**
437
497
  * The listening server.
438
498
  *
@@ -6,6 +6,7 @@
6
6
  // What `serveDev` and `devWorker` keep is the processes and the sockets.
7
7
 
8
8
  import { plainAnswer } from './plainAnswer.ts'
9
+ import type { ReceivedFrom } from './requestScope.ts'
9
10
 
10
11
  /** One direction of the line between the halves: bun's IPC in `abide dev`, a pair of queues in a test. */
11
12
  export type Channel<Message> = { postMessage(message: Message): void }
@@ -17,6 +18,8 @@ export type Ask = {
17
18
  url: string
18
19
  headers: [string, string][]
19
20
  body: ArrayBuffer | null
21
+ /** 22.12 — the peer the listener received it from. */
22
+ from: ReceivedFrom
20
23
  }
21
24
 
22
25
  /** What the listener's half sends: a request, and a pull or cancel of its body. */
@@ -62,7 +65,7 @@ export function describe(thrown: unknown): string {
62
65
  export function serveOver(
63
66
  channel: Channel<FromRelay>,
64
67
  answering: {
65
- fetch(request: Request): Promise<Response>
68
+ fetch(request: Request, from: ReceivedFrom): Promise<Response>
66
69
  /** 37.3 — whether the answer admits the request to the mux. */
67
70
  admits(request: Request, response: Response): boolean
68
71
  /** The answer to a throw out of `fetch`, which is an answer and not a dropped message. */
@@ -107,7 +110,7 @@ export function serveOver(
107
110
  asks.set(request, ask.id)
108
111
  let response: Response
109
112
  try {
110
- response = await answering.fetch(request)
113
+ response = await answering.fetch(request, ask.from)
111
114
  } catch (thrown) {
112
115
  response = answering.failed(describe(thrown))
113
116
  }
@@ -207,6 +210,7 @@ type InFlight = {
207
210
  request: Request
208
211
  headers: [string, string][]
209
212
  body: ArrayBuffer | null
213
+ from: ReceivedFrom
210
214
  settle: (response: Response) => void
211
215
  asked: Channel<ToRelay> | undefined
212
216
  stream: ReadableStreamDefaultController<Uint8Array> | null
@@ -252,6 +256,7 @@ export function askOver(
252
256
  url: waiting.request.url,
253
257
  headers: waiting.headers,
254
258
  body: waiting.body,
259
+ from: waiting.from,
255
260
  })
256
261
  }
257
262
 
@@ -310,7 +315,7 @@ export function askOver(
310
315
  * what the worker rebuilds one from — and the body is collected ONCE, HERE, which is what lets
311
316
  * it be asked again of a replacement (38.24): a body can only be read off a `Request` once.
312
317
  */
313
- async ask(request: Request): Promise<Response> {
318
+ async ask(request: Request, from: ReceivedFrom): Promise<Response> {
314
319
  const body =
315
320
  request.body === null ? null : await request.arrayBuffer()
316
321
  return await new Promise<Response>((settle) => {
@@ -318,6 +323,7 @@ export function askOver(
318
323
  request,
319
324
  headers: identityOnly(request.headers),
320
325
  body,
326
+ from,
321
327
  settle,
322
328
  asked: undefined,
323
329
  stream: null,