@abide/abide 0.100.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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/abide.ts +227 -163
  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 +10 -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 -56
  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 +124 -39
  53. package/src/server/answerSocket.ts +141 -48
  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 +48 -14
  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 +144 -183
  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 +81 -39
  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/GLOBAL_GETS.ts +9 -3
  107. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  108. package/src/shared/RPC_SERVER.ts +2 -2
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +1 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/shared/menuItem.ts +3 -3
  119. package/src/shared/reactive/Cursor.ts +3 -9
  120. package/src/shared/reactive/Keyed.ts +4 -3
  121. package/src/shared/reactive/NotLanded.ts +1 -1
  122. package/src/shared/reactive/Reactive.ts +81 -60
  123. package/src/shared/reactive/argsContract.ts +4 -4
  124. package/src/shared/reactive/canonicalKey.ts +4 -4
  125. package/src/shared/reactive/chunksOfStream.ts +6 -0
  126. package/src/shared/reactive/index.ts +1 -1
  127. package/src/shared/reactive/recordWrites.ts +103 -5
  128. package/src/shared/reactive/structural.ts +1 -1
  129. package/src/shared/reactive/tracking.ts +155 -21
  130. package/src/shared/reactive/watch.ts +10 -6
  131. package/src/shared/refuse.ts +1 -1
  132. package/src/shared/routePattern.ts +82 -46
  133. package/src/shared/rpc.ts +47 -13
  134. package/src/shared/rpcRequest.ts +27 -3
  135. package/src/shared/url.ts +4 -44
  136. package/src/shared/writePath.ts +1 -1
  137. package/src/ui/LAYER_NAMES.ts +1 -1
  138. package/src/ui/List.ts +3 -3
  139. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  140. package/src/ui/Mismatch.ts +1 -1
  141. package/src/ui/RawRun.ts +1 -1
  142. package/src/ui/Router.ts +38 -31
  143. package/src/ui/Scope.ts +1 -1
  144. package/src/ui/TryRegion.ts +2 -2
  145. package/src/ui/applyHead.ts +33 -0
  146. package/src/ui/index.ts +1 -1
  147. package/src/ui/menuBridge.ts +1 -1
  148. package/src/ui/moveNode.ts +1 -1
  149. package/src/ui/reconcile.ts +2 -2
  150. package/src/ui/reportMenu.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -16,6 +16,7 @@ import type { Gated } from '#shared/reactive/argsContract.ts'
16
16
  import { deadlineOf, heldGenerationOf } from '#shared/reactive/index.ts'
17
17
  import type { Defaults } from '#shared/reactive/parameterDefaults.ts'
18
18
  import { notFound, refuse } from '#shared/refuse.ts'
19
+ import { routeOf } from '#shared/routePattern.ts'
19
20
  import type { Method, Rpc } from '#shared/rpc.ts'
20
21
  import { argsInURL } from '#shared/rpcRequest.ts'
21
22
  import { VERSION_HEADER } from '#shared/VERSION_HEADER.ts'
@@ -30,7 +31,12 @@ import { followAsEvents } from './answerSocket.ts'
30
31
  import { answerValue, namesEtag, sseHeaders } from './answerValue.ts'
31
32
  import { argsFor } from './argsFor.ts'
32
33
  import { callerOf } from './callerOf.ts'
33
- import { argsDocumentOf, collapsed, type Document } from './coerceArgs.ts'
34
+ import {
35
+ argsDocumentOf,
36
+ coerceArgs,
37
+ collapsed,
38
+ type Document,
39
+ } from './coerceArgs.ts'
34
40
  import { positiveSetting } from './config.ts'
35
41
  import {
36
42
  allows,
@@ -38,7 +44,7 @@ import {
38
44
  crossingOrigin,
39
45
  crossingRefusal,
40
46
  } from './crossingOrigin.ts'
41
- import { failureOf } from './hooks.ts'
47
+ import { type AppRung, failureOf, rungsOver } from './hooks.ts'
42
48
  import { prefers } from './prefers.ts'
43
49
  import {
44
50
  currentRequestScope,
@@ -51,7 +57,8 @@ import { type Outcome, outcomeOf, takeSeed } from './seedBuffer.ts'
51
57
  import { versionOf } from './versionOf.ts'
52
58
 
53
59
  /**
54
- * One row of the table the build writes to `<out>/rpc.ts`: where a handler answers, and its module.
60
+ * One row of the handler table the build writes into the server's table (`SERVER_TABLE`): where a
61
+ * handler answers, and its module.
55
62
  *
56
63
  * The module is relative to the app's root, which both commands that serve already know.
57
64
  */
@@ -62,7 +69,7 @@ export type Mount = {
62
69
  name: string
63
70
  /** Whether a browser is handed a wrapper for it. 20.4. */
64
71
  ui: boolean
65
- /** Whether the command line lists it — the shell's listing and completion, never whether `call` reaches it. 20.3, 38.63. */
72
+ /** Whether the command line lists it — the shell's listing and completion, never whether `call` reaches it. 20.3, 38.62. */
66
73
  cli: boolean
67
74
  /**
68
75
  * Whether MCP lists it where its runtime `clients` does not say.
@@ -88,9 +95,23 @@ export type Mount = {
88
95
  args: JsonValue | undefined
89
96
  }
90
97
 
98
+ /**
99
+ * A row of the table the build wrote, with the import of the module its handler is declared in —
100
+ * the file's server module, the one the route table loads too, so a page and a call reach one
101
+ * handler.
102
+ */
103
+ export type MountRow = Mount & { load: () => Promise<Record<string, unknown>> }
104
+
91
105
  /** A handler's row, mounted: the handler imported and its args schema probed. */
92
106
  export type Mounted = Mount & {
93
107
  method: Method
108
+ /** The route of the file declaring it, which its hooks are filed at (37.1, 37.2). */
109
+ route: string
110
+ /**
111
+ * The rungs filed over `route`, read once at mount (16.14) — what an http call and a socket
112
+ * subscription to it both pass through (37.2, 18.26).
113
+ */
114
+ rungs: AppRung[]
94
115
  handler: Rpc<unknown, unknown> & Gated<unknown>
95
116
  /**
96
117
  * The args' JSON Schema, probed once at mount.
@@ -127,6 +148,10 @@ export function mountedOwn(
127
148
  {
128
149
  address,
129
150
  method: 'GET',
151
+ route: '/',
152
+ // The root's rungs run before routing (37.3), and nothing is filed below a route
153
+ // of abide's own.
154
+ rungs: [],
130
155
  source: '',
131
156
  name,
132
157
  ui: false,
@@ -149,20 +174,17 @@ export function environmentTimeout(): number | undefined {
149
174
  }
150
175
 
151
176
  /**
152
- * The table's handlers, imported and keyed by address.
177
+ * The table's handlers, imported and keyed by method and address.
153
178
  *
154
- * Imported from the SOURCE tree, so a page rendering on the server and a call over the wire reach
155
- * one module and so one handler. AT BOOT rather than on the first call, because a page's render
156
- * reaches a handler through the page's own import and has to know its address before anything has
157
- * called it over the wire.
179
+ * Imported through each row's own loader, which the route table shares, so a page rendering on the
180
+ * server and a call over the wire reach one module and so one handler. AT BOOT rather than on the
181
+ * first call, because a page's render reaches a handler through the page's own import and has to
182
+ * know its address before anything has called it over the wire.
158
183
  */
159
184
  export async function mountHandlers(
160
- table: Mount[],
161
- moduleOf: (source: string) => Promise<Record<string, unknown>>,
185
+ table: MountRow[],
162
186
  ): Promise<{ handlers: Map<string, Mounted> }> {
163
- const modules = await Promise.all(
164
- table.map((mount) => moduleOf(mount.source)),
165
- )
187
+ const modules = await Promise.all(table.map((mount) => mount.load()))
166
188
  const handlers = new Map<string, Mounted>()
167
189
  for (let at = 0; at < table.length; at += 1) {
168
190
  const mount = table[at] as Mount
@@ -172,9 +194,14 @@ export async function mountHandlers(
172
194
  // 16.9, 16.10 — the address `rpc.url` and `rpc.raw` build on, under the mount — and what a
173
195
  // page's render turns a call it made into the browser's request by (41.20).
174
196
  handler.address = underMount(mount.address)
175
- handlers.set(mount.address, {
197
+ // 23.19 — the route its address answers below, which is its mount's for a package's file,
198
+ // never the file's place on disk.
199
+ const route = routeOf(mount.address)
200
+ handlers.set(handlerKey(mount.method, mount.address), {
176
201
  ...mount,
177
202
  method: mount.method,
203
+ route,
204
+ rungs: rungsOver(route),
178
205
  handler,
179
206
  argsDocument: declared ?? (mount.args as Document | undefined),
180
207
  wireArgs: declared === undefined ? mount.args : undefined,
@@ -183,30 +210,34 @@ export async function mountHandlers(
183
210
  return { handlers }
184
211
  }
185
212
 
186
- /** The `Response` to a handler call made over http. */
213
+ /** What a handler is keyed by among the mounted: its method and its route. 19.1. */
214
+ export function handlerKey(method: string, address: string): string {
215
+ return `${method} ${address}`
216
+ }
217
+
218
+ /**
219
+ * The `Response` to a call of `found` made over http, `params` being what its route matched.
220
+ *
221
+ * `found` answers the request's method — or, for an `OPTIONS`, the method its preflight asks
222
+ * about: which handler a request reaches is the route table's to say (19.21).
223
+ */
187
224
  export async function answerRpc(
188
- handlers: Map<string, Mounted>,
225
+ found: Mounted,
226
+ params: Record<string, string | string[]>,
189
227
  url: URL,
190
228
  request: Request,
191
229
  // 16.38 — what paints a refusal for a request that came as a navigation.
192
230
  pages: ErrorPages,
193
231
  ): Promise<Response> {
194
232
  const pathname = url.pathname
195
- const found = handlers.get(pathname)
196
233
  const origin = request.headers.get('origin')
197
234
  // 16.24 — THE PREFLIGHT, answered by abide where the handler declared `crossOrigin`.
198
- if (
199
- found !== undefined &&
200
- request.method === 'OPTIONS' &&
201
- origin !== null &&
202
- allows(found.handler.crossOrigin, origin)
203
- )
204
- return preflight(found, origin, request)
235
+ if (request.method === 'OPTIONS')
236
+ return origin !== null && allows(found.handler.crossOrigin, origin)
237
+ ? preflight(found, origin, request)
238
+ : answered(notFound({ path: pathname, method: request.method }))
205
239
  // 16.50 — a `HEAD` is the `GET`'s answer, which the server sends without its body.
206
240
  const head = request.method === 'HEAD'
207
- // 15.7 — no handler at this address AND this method is one refusal, not two.
208
- if (found === undefined || found.method !== (head ? 'GET' : request.method))
209
- return answered(notFound({ path: pathname, method: request.method }))
210
241
  const handler = found.handler
211
242
  const inUrl = argsInURL(found.method)
212
243
  const form = isForm(request)
@@ -226,10 +257,12 @@ export async function answerRpc(
226
257
  return answered(overCeiling(ceiling))
227
258
  const raw = await argsOf(request, url, inUrl, form, ceiling)
228
259
  if (isFailed(raw)) return answered(raw)
260
+ const sent = withParameters(raw, params, inUrl || form, found)
261
+ if (isFailed(sent)) return answered(sent)
229
262
  let args: unknown
230
263
  try {
231
264
  // A URL's and a form's args are text, and the query tells a bare name from `name=` (19.7).
232
- args = argsFor(found, raw, inUrl ? url.search : form ? '' : undefined)
265
+ args = argsFor(found, sent, inUrl ? url.search : form ? '' : undefined)
233
266
  } catch (refused) {
234
267
  if (isFailed(refused)) return answered(refused)
235
268
  throw refused
@@ -243,24 +276,36 @@ export async function answerRpc(
243
276
  const caller = head ? undefined : callerOf()
244
277
  const seeded =
245
278
  caller === undefined ? undefined : takeSeed(caller, handler, args)
279
+ // The entry key of a `global` value, taken once for every read of it below.
280
+ const global = GLOBAL_GETS.get(handler)
281
+ const key = global?.key(args)
246
282
  // 21.15 — A `global` PRODUCTION ALREADY HELD, and not loading, answers without a run: a run
247
283
  // this call started moves the generation, and one it joined was loading when it came.
248
284
  const held =
249
- seeded === undefined ? GLOBAL_GETS.get(handler)?.(args) : undefined
285
+ seeded === undefined && key !== undefined ? global?.at(key) : undefined
250
286
  const heldAt = held === undefined ? undefined : heldGenerationOf(held)
251
287
  const timing = currentRequestScope()?.timing
252
288
  if (timing !== undefined) timing.answering = handler
253
289
  const started = performance.now()
254
290
  const outcome =
255
291
  seeded === undefined ? await outcomeOf(handler(args)) : await seeded
292
+ // The entry as the run left it, which the answer reads too.
293
+ const entry = key === undefined ? undefined : global?.at(key)
256
294
  const cached =
257
295
  heldAt !== undefined &&
258
- GLOBAL_GETS.get(handler)?.(args) === held &&
296
+ entry === held &&
259
297
  heldGenerationOf(held) === heldAt
260
298
  // 16.38 — A MUTATION A DOCUMENT MADE — a form posted with no script — is answered as a navigation.
261
299
  if (!inUrl && prefers(request, 'text/html', 'application/json'))
262
300
  return navigated(outcome, request, pathname, pages)
263
- const answer = await answerOutcome(outcome, request, found, args)
301
+ const answer = await answerOutcome(
302
+ outcome,
303
+ request,
304
+ found,
305
+ args,
306
+ key,
307
+ entry,
308
+ )
264
309
  // A `Response` the handler built is its own — but the origin answer below is laid over it, so it
265
310
  // varies on `Origin` too (21.7), which `handlerHeaders` never reached.
266
311
  if (outcome.settled && outcome.value instanceof Response) {
@@ -308,23 +353,24 @@ async function answerOutcome(
308
353
  request: Request,
309
354
  found: Mounted,
310
355
  args: unknown,
356
+ key: string | undefined,
357
+ entry: unknown,
311
358
  ): Promise<Response> {
312
359
  const handler = found.handler
313
360
  if (!outcome.settled) return answered(failureOf(outcome.error))
361
+ const value = outcome.value
314
362
  // The `global` value the answer came from, or `undefined` for one built per request. 18.22 — or
315
363
  // for one whose production has moved past what is answered: a seeded answer is what the render
316
364
  // reached (41.13). 18.23 — or for a binary.
317
- const global = GLOBAL_GETS.get(handler)?.(args)
318
- const value = outcome.value
319
365
  const serving =
320
- global !== undefined &&
366
+ entry !== undefined &&
321
367
  !(
322
368
  value instanceof Blob ||
323
369
  value instanceof ArrayBuffer ||
324
370
  ArrayBuffer.isView(value)
325
371
  ) &&
326
- (streams(value) || (global as { peek(): unknown }).peek() === value)
327
- ? global
372
+ (streams(value) || (entry as { peek(): unknown }).peek() === value)
373
+ ? entry
328
374
  : undefined
329
375
  // 21.3 — what the handler wrote through `response` is laid over it at the exit (21.10).
330
376
  const headers = handlerHeaders()
@@ -337,7 +383,7 @@ async function answerOutcome(
337
383
  const etag = serving !== undefined ? `"${versionOf(serving)}"` : undefined
338
384
  if (etag !== undefined) {
339
385
  headers.set(VERSION_HEADER, etag)
340
- RPC_SERVER.served?.(handler, serving)
386
+ RPC_SERVER.served?.(handler, serving, key as string)
341
387
  }
342
388
  // 21.8, 21.9, 21.11 — WHAT IS LEFT OF THE LIFE OF THE PRODUCTION ANSWERED, in place of `no-store`
343
389
  // and nothing else. A `ttl` of Infinity is bounded by `invalidate` alone (11.35), which no cache
@@ -423,6 +469,39 @@ async function argsOf(
423
469
  return parsed
424
470
  }
425
471
 
472
+ /**
473
+ * The args a request sent, with the parameters its route matched laid in.
474
+ *
475
+ * 16.65 — the path's, whatever the method. 16.66 — a parameter the query or the body names as
476
+ * well is refused rather than one of the two chosen. A JSON body's members are typed already, so the
477
+ * path's text is coerced to the declared types before it joins them (19.7); text args are coerced
478
+ * with the rest of the text.
479
+ */
480
+ function withParameters(
481
+ sent: unknown,
482
+ params: Record<string, string | string[]>,
483
+ text: boolean,
484
+ found: Mounted,
485
+ ): unknown | Failed<'HttpError', undefined> {
486
+ let none = true
487
+ for (const name in params) {
488
+ none = false
489
+ if (isRecord(sent) && name in sent)
490
+ return refuse(
491
+ 400,
492
+ `The parameter '${name}' is in the path and in the request as well.`,
493
+ )
494
+ }
495
+ if (none) return sent
496
+ const fromPath = text
497
+ ? params
498
+ : (coerceArgs(params, found.argsDocument, '') as Record<
499
+ string,
500
+ unknown
501
+ >)
502
+ return isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
503
+ }
504
+
426
505
  /**
427
506
  * The request's body read up to `ceiling`, as a `Response`, or a 413 refusal past it.
428
507
  *
@@ -474,6 +553,12 @@ function preflight(found: Mounted, origin: string, request: Request): Response {
474
553
  * 16.57 — `Infinity` is never reaped, which bun spells 0; any finite bound is capped at the 255
475
554
  * seconds bun's idle timeout can hold.
476
555
  *
556
+ * FOUR SECONDS PAST THE TIMEOUT, NOT AT IT: a load that goes its timeout is refused at that moment,
557
+ * and a stream's refusal is a frame written then (16.68). bun's sockets check an idle timeout on a
558
+ * tick four seconds wide, so an idle timeout of the same second — or one past it — fired anywhere in
559
+ * the tick before it and reaped the connection ahead of the frame, one stream in two under `abide
560
+ * dev`. Four is the tick, so the refusal always goes out first.
561
+ *
477
562
  * ON THE REQUEST THE LISTENER RECEIVED, never the one being answered: under a mount, or past a
478
563
  * rung that handed `next` its own, that is a copy, and bun's `timeout` does nothing for a request
479
564
  * it was not handed itself.
@@ -483,7 +568,7 @@ export function raiseTimeout(milliseconds: number): void {
483
568
  const received = scope?.received
484
569
  if (scope === undefined || received === undefined) return
485
570
  const seconds = Number.isFinite(milliseconds)
486
- ? Math.min(255, Math.ceil(milliseconds / 1000))
571
+ ? Math.min(255, Math.ceil(milliseconds / 1000) + 4)
487
572
  : Number.POSITIVE_INFINITY
488
573
  if (seconds <= scope.timeout) return
489
574
  scope.timeout = seconds
@@ -10,18 +10,20 @@
10
10
  // built per request, since a trigger names args rather than a value. A `global` value is also fed its productions, a guarded one only to a
11
11
  // subscription its rungs admitted when it opened (18.8) — they run once, in the connection's request.
12
12
 
13
- import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
14
13
  import { type Problem, problemOf } from '#shared/Failed.ts'
15
14
  import { GLOBAL_GETS } from '#shared/GLOBAL_GETS.ts'
16
15
  import { namedLogger } from '#shared/log.ts'
16
+ import { compose } from '#shared/Middleware.ts'
17
+ import { unmounted } from '#shared/mount.ts'
18
+ import type { BrowserFrame, ServerFrame } from '#shared/muxFrames.ts'
17
19
  import { FOLLOWED } from '#shared/reactive/FOLLOWED.ts'
18
20
  import { servedCellOf } from '#shared/reactive/index.ts'
19
21
  import { matchesPattern } from '#shared/reactive/matchesPattern.ts'
20
22
  import { PATCH_SINKS } from '#shared/reactive/PATCH_SINKS.ts'
21
23
  import { unowned } from '#shared/reactive/watch.ts'
22
24
  import { refuse } from '#shared/refuse.ts'
23
- import type { BrowserFrame, ServerFrame } from '#shared/muxFrames.ts'
24
- import type { Mounted } from './answerRpc.ts'
25
+ import { rpcRequest } from '#shared/rpcRequest.ts'
26
+ import { handlerKey, type Mounted } from './answerRpc.ts'
25
27
  import { argsFor } from './argsFor.ts'
26
28
  import { closedTo, crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
27
29
  import { failureOf } from './hooks.ts'
@@ -38,7 +40,7 @@ import { patchesSince, versionOf } from './versionOf.ts'
38
40
  * The far end of one connection to the mux, which its frames are written to.
39
41
  *
40
42
  * Bun's socket under `abide start`, and under `abide dev` a stand-in that posts each frame to the
41
- * listener holding the real one (38.15).
43
+ * listener holding the real one (38.14).
42
44
  */
43
45
  export type Peer = {
44
46
  send(text: string): unknown
@@ -54,19 +56,30 @@ type Follower = {
54
56
  peer: Peer
55
57
  id: number
56
58
  args: unknown
59
+ // The entry key its args are filed under (`GLOBAL_GETS`), taken once as it subscribes.
60
+ key: string
57
61
  // The `global` entry its productions are pushed from, which is what a refresh would otherwise
58
62
  // re-fetch — or `undefined`, and it is sent triggers instead.
59
63
  serving: unknown
60
64
  stopFeed: (() => void) | undefined
61
65
  // 18.6 — the version it was last sent, or named on subscribing.
62
66
  sent: number | undefined
63
- // 13.15 — the literal `tags` the build copied onto the browser's copy, for a sweep to match.
64
- tags: string[] | undefined
65
67
  // 18.8 — whether the handler's rungs let it through when it opened: a guarded `GET` feeds only
66
68
  // a follower they admitted, and sends the rest its triggers alone.
67
69
  admitted: boolean
68
70
  }
69
- const FOLLOWERS = new Map<object, Set<Follower>>()
71
+ type Followed = {
72
+ // 13.15 — the literal `tags` the build copied onto the browser's copy, for a sweep to match:
73
+ // the handler's, so one for all of its followers.
74
+ tags: string[] | undefined
75
+ // What a trigger walks, matching each follower's args against its pattern.
76
+ all: Set<Follower>
77
+ // BY ENTRY KEY, so an entry answered walks the followers of its own args alone (18.8). Filed
78
+ // apart from `all` rather than instead of it: a walk of every follower through a set per key
79
+ // measured twice a flat set's.
80
+ byKey: Map<string, Set<Follower>>
81
+ }
82
+ const FOLLOWERS = new Map<object, Followed>()
70
83
 
71
84
  // 13.12 — narrowed by the pattern the trigger was handed, as the handler's own entries are.
72
85
  export function broadcast(
@@ -74,9 +87,9 @@ export function broadcast(
74
87
  member: 'invalidate' | 'refresh',
75
88
  pattern: unknown,
76
89
  ): void {
77
- const followers = FOLLOWERS.get(rpc)
78
- if (followers === undefined) return
79
- for (const follower of followers) {
90
+ const followed = FOLLOWERS.get(rpc)
91
+ if (followed === undefined) return
92
+ for (const follower of followed.all) {
80
93
  if (!matchesPattern(follower.args, pattern)) continue
81
94
  // A fed copy is refreshed by the production the reload mints (7.18), and invalidated through
82
95
  // the cell it is fed from (`FOLLOWED`), whichever trigger reached it — where the entry it is
@@ -93,13 +106,14 @@ export function swept(
93
106
  member: 'invalidate' | 'refresh',
94
107
  tags: string[] | null,
95
108
  ): void {
96
- for (const [rpc, followers] of FOLLOWERS)
97
- for (const follower of followers) {
98
- if (tags !== null && !sharesTag(follower.tags, tags)) continue
109
+ for (const [rpc, followed] of FOLLOWERS) {
110
+ if (tags !== null && !sharesTag(followed.tags, tags)) continue
111
+ for (const follower of followed.all) {
99
112
  if (attach(rpc as Mounted['handler'], follower) !== undefined)
100
113
  continue
101
114
  send(follower.peer, { op: member, id: follower.id })
102
115
  }
116
+ }
103
117
  }
104
118
 
105
119
  function sharesTag(held: string[] | undefined, swept: string[]): boolean {
@@ -110,10 +124,14 @@ function sharesTag(held: string[] | undefined, swept: string[]): boolean {
110
124
 
111
125
  // 18.8 — AN ENTRY ANSWERED THAT NOBODY IS FED FROM: one made after a follower subscribed for its
112
126
  // args, or rebuilt after the one it was fed from was evicted. An entry already fed is the common
113
- // answer, and asks nothing more.
114
- export function feedFromServed(rpc: object, serving: unknown): void {
127
+ // answer, and asks nothing more; one that is not reaches the followers filed under its key.
128
+ export function feedFromServed(
129
+ rpc: object,
130
+ serving: unknown,
131
+ key: string,
132
+ ): void {
115
133
  if (FEEDS.has(serving as object)) return
116
- const followers = FOLLOWERS.get(rpc)
134
+ const followers = FOLLOWERS.get(rpc)?.byKey.get(key)
117
135
  if (followers === undefined) return
118
136
  for (const follower of followers)
119
137
  attach(rpc as Mounted['handler'], follower)
@@ -125,10 +143,10 @@ export function feedFromServed(rpc: object, serving: unknown): void {
125
143
  * Only where that entry is not the one it is fed from already.
126
144
  */
127
145
  function attach(rpc: Mounted['handler'], follower: Follower): unknown {
128
- const serving =
129
- rpc.guarded && !follower.admitted
130
- ? undefined
131
- : GLOBAL_GETS.get(rpc)?.(follower.args)
146
+ // 18.8, 18.26 — what its own rungs or its route's refused is sent its triggers alone.
147
+ const serving = !follower.admitted
148
+ ? undefined
149
+ : GLOBAL_GETS.get(rpc)?.at(follower.key)
132
150
  if (serving === follower.serving) return serving
133
151
  follower.stopFeed?.()
134
152
  // A version is one entry's count, so what it was sent of another says nothing about this one.
@@ -234,8 +252,8 @@ export class MuxConnection {
234
252
  return
235
253
  }
236
254
  const id = frame.id ?? 0
237
- // THE ADDRESS FROM `/__abide` ON, which is what both tables are keyed by: a browser names
238
- // it as its wrapper resolved it, which is a whole URL, or a path under the app's mount.
255
+ // THE ADDRESS BELOW THE MOUNT, which is what the handlers are keyed by: a browser names it
256
+ // as its wrapper resolved it, which is a whole URL, or a path under the app's mount.
239
257
  const address = addressIn(frame.address ?? '')
240
258
  const subscriptions = this.subscriptions
241
259
  if (frame.op === 'unsub') {
@@ -244,7 +262,9 @@ export class MuxConnection {
244
262
  return
245
263
  }
246
264
  const handler =
247
- frame.op === 'sub' ? this.mux.handlers.get(address) : undefined
265
+ frame.op === 'sub'
266
+ ? this.mux.handlers.get(handlerKey('GET', address))
267
+ : undefined
248
268
  const followed = handler?.ui ? handler : undefined
249
269
  if (followed === undefined) {
250
270
  send(this.peer, {
@@ -279,16 +299,24 @@ export class MuxConnection {
279
299
  // 18.8 — A GUARDED `GET`'s RUNGS RUN ONCE, as the subscription opens, in this
280
300
  // connection's request: one they admit is fed the value, and one they refuse is sent its
281
301
  // triggers alone.
282
- let admitted = !followed.handler.guarded
302
+ // 18.26 — AND THE MIDDLEWARE ITS FILE'S ROUTE FILED, over the request a `GET` of it
303
+ // would be: one a rung answers in the handler's place is not fed, as an http call it
304
+ // answered would never reach the handler.
305
+ const routed = followed.rungs.length > 0
306
+ let admitted = !followed.handler.guarded && !routed
283
307
  if (!admitted) {
284
308
  subscriptions.get(id)?.()
285
309
  subscriptions.set(id, pending)
286
- admitted = await Promise.resolve(
287
- followed.handler(args as never) as PromiseLike<unknown>,
288
- ).then(
289
- () => true,
290
- () => false,
291
- )
310
+ // No route rungs, no turn: a guarded `GET`'s rungs start in this frame's own run.
311
+ admitted =
312
+ !routed || (await routeAdmits(this.scope, followed, args))
313
+ if (admitted && followed.handler.guarded)
314
+ admitted = await Promise.resolve(
315
+ followed.handler(args as never) as PromiseLike<unknown>,
316
+ ).then(
317
+ () => true,
318
+ () => false,
319
+ )
292
320
  if (subscriptions.get(id) !== pending) return
293
321
  }
294
322
  follow(this, id, followed, args, frame.version, admitted)
@@ -299,6 +327,39 @@ export class MuxConnection {
299
327
  }
300
328
  }
301
329
 
330
+ /**
331
+ * Whether the rungs over `mounted`'s route let a `GET` of it with `args` through, asked over the
332
+ * upgrade's own request at the handler's address. 18.26.
333
+ */
334
+ async function routeAdmits(
335
+ scope: RequestScope,
336
+ mounted: Mounted,
337
+ args: unknown,
338
+ ): Promise<boolean> {
339
+ const upgrade = scope.request as Request
340
+ const asked = rpcRequest(
341
+ 'GET',
342
+ mounted.address,
343
+ args as Record<string, unknown> | undefined,
344
+ )
345
+ const request = new Request(new URL(asked.url, upgrade.url), {
346
+ headers: upgrade.headers,
347
+ signal: upgrade.signal,
348
+ })
349
+ // REACHED BY A FLAG, not by the answer's identity: a rung that wraps or re-heads what `next`
350
+ // answered still let the call through, and a shared answer would carry every rung's headers.
351
+ let reached = false
352
+ try {
353
+ const answered = await compose(mounted.rungs, async () => {
354
+ reached = true
355
+ return new Response(null, { status: 204 })
356
+ })({ request })
357
+ return reached && answered.ok
358
+ } catch {
359
+ return false
360
+ }
361
+ }
362
+
302
363
  /**
303
364
  * Follows a handler for `id`: its triggers always, its value where `global` and unguarded.
304
365
  *
@@ -331,26 +392,42 @@ function followWith(
331
392
  admitted: boolean,
332
393
  ): () => void {
333
394
  const rpc = mounted.handler
395
+ const key = GLOBAL_GETS.get(rpc)?.key(args) ?? ''
334
396
  const follower: Follower = {
335
397
  peer,
336
398
  id,
337
399
  args,
400
+ key,
338
401
  serving: undefined,
339
402
  stopFeed: undefined,
340
403
  sent: held,
341
- tags: mounted.copy?.tags,
342
404
  admitted,
343
405
  }
344
406
  attach(rpc, follower)
345
- let followers = FOLLOWERS.get(rpc)
407
+ let followed = FOLLOWERS.get(rpc)
408
+ if (followed === undefined) {
409
+ followed = {
410
+ tags: mounted.copy?.tags,
411
+ all: new Set(),
412
+ byKey: new Map(),
413
+ }
414
+ FOLLOWERS.set(rpc, followed)
415
+ }
416
+ const byKey = followed.byKey
417
+ let followers = byKey.get(key)
346
418
  if (followers === undefined) {
347
419
  followers = new Set()
348
- FOLLOWERS.set(rpc, followers)
420
+ byKey.set(key, followers)
349
421
  }
350
422
  followers.add(follower)
423
+ const all = followed.all
424
+ all.add(follower)
351
425
  return () => {
352
426
  follower.stopFeed?.()
353
- followers.delete(follower)
427
+ all.delete(follower)
428
+ // A key nobody follows leaves, or the index grows with every key ever followed.
429
+ if (followers.delete(follower) && followers.size === 0)
430
+ byKey.delete(key)
354
431
  }
355
432
  }
356
433
 
@@ -410,19 +487,35 @@ export function followAsEvents(
410
487
  })
411
488
  }
412
489
 
413
- /** One mux frame as the SSE event it is: `event`, `id` where it names a version, and `data`. */
490
+ /**
491
+ * One mux frame as the SSE event it is: `event`, `id` where it names a version, and `data`.
492
+ *
493
+ * 18.6 — READ OFF THE FRAME'S HEAD, never parsed: the body was serialized once for every follower
494
+ * (`sendAll`), and a parse and a stringify per event stream would pay it again per follower. Every
495
+ * frame this module writes is `{"op":…,"id":…`, numbers named `base` or `version`, and at most one
496
+ * body last — so the body is the rest of the text, as it was written.
497
+ */
414
498
  function eventOf(text: string): string {
415
- const frame = JSON.parse(text) as ServerFrame
416
- const data =
417
- frame.op === 'value'
418
- ? JSON.stringify(frame.value ?? null)
419
- : frame.op === 'patch'
420
- ? JSON.stringify(frame.ops ?? [])
421
- : frame.op === 'error'
422
- ? JSON.stringify(frame.error ?? null)
423
- : ''
424
- const id = frame.version === undefined ? '' : `id: ${frame.version}\n`
425
- return `event: ${frame.op}\n${id}data: ${data}\n\n`
499
+ const opEnd = text.indexOf('"', 7)
500
+ const op = text.slice(7, opEnd)
501
+ let id = ''
502
+ let data =
503
+ op === 'value' || op === 'error' ? 'null' : op === 'patch' ? '[]' : ''
504
+ // Past `,"id":` — a number, so the next comma is the next member's.
505
+ let at = text.indexOf(',', opEnd + 6)
506
+ while (at !== -1) {
507
+ const nameEnd = text.indexOf('"', at + 2)
508
+ const name = text.slice(at + 2, nameEnd)
509
+ const from = nameEnd + 2
510
+ if (name !== 'base' && name !== 'version') {
511
+ data = text.slice(from, -1)
512
+ break
513
+ }
514
+ at = text.indexOf(',', from)
515
+ if (name === 'version')
516
+ id = `id: ${text.slice(from, at === -1 ? -1 : at)}\n`
517
+ }
518
+ return `event: ${op}\n${id}data: ${data}\n\n`
426
519
  }
427
520
 
428
521
  // 18.6 — ONE WATCH PER VALUE SERVED, however many follow it, and each frame's body serialized once
@@ -516,8 +609,8 @@ function sendAll(serving: unknown, fed: Iterable<Follower>): void {
516
609
  }
517
610
 
518
611
  function addressIn(named: string): string {
519
- const at = named.indexOf(ABIDE_ADDRESS)
520
- return at === -1 ? named : named.slice(at)
612
+ const path = URL.parse(named)?.pathname ?? named
613
+ return unmounted(path) ?? path
521
614
  }
522
615
 
523
616
  /**