@abide/abide 0.101.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +223 -164
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +5 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -59
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +102 -30
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +114 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +73 -37
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  107. package/src/shared/cookies.ts +2 -2
  108. package/src/shared/csp.ts +1 -1
  109. package/src/shared/escapeText.ts +1 -1
  110. package/src/shared/filledPath.ts +85 -0
  111. package/src/shared/framing.ts +79 -6
  112. package/src/shared/headKeyOf.ts +2 -0
  113. package/src/shared/index.ts +1 -1
  114. package/src/shared/log.ts +27 -13
  115. package/src/shared/matchRoute.ts +23 -4
  116. package/src/shared/menuItem.ts +3 -3
  117. package/src/shared/reactive/Keyed.ts +2 -2
  118. package/src/shared/reactive/NotLanded.ts +1 -1
  119. package/src/shared/reactive/Reactive.ts +43 -43
  120. package/src/shared/reactive/argsContract.ts +4 -4
  121. package/src/shared/reactive/canonicalKey.ts +4 -4
  122. package/src/shared/reactive/chunksOfStream.ts +6 -0
  123. package/src/shared/reactive/index.ts +1 -1
  124. package/src/shared/reactive/recordWrites.ts +103 -5
  125. package/src/shared/reactive/structural.ts +1 -1
  126. package/src/shared/reactive/tracking.ts +1 -1
  127. package/src/shared/reactive/watch.ts +3 -3
  128. package/src/shared/refuse.ts +1 -1
  129. package/src/shared/routePattern.ts +82 -46
  130. package/src/shared/rpc.ts +37 -8
  131. package/src/shared/rpcRequest.ts +27 -3
  132. package/src/shared/url.ts +4 -44
  133. package/src/shared/writePath.ts +1 -1
  134. package/src/ui/LAYER_NAMES.ts +1 -1
  135. package/src/ui/List.ts +3 -3
  136. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  137. package/src/ui/Mismatch.ts +1 -1
  138. package/src/ui/RawRun.ts +1 -1
  139. package/src/ui/Router.ts +38 -31
  140. package/src/ui/Scope.ts +1 -1
  141. package/src/ui/TryRegion.ts +2 -2
  142. package/src/ui/applyHead.ts +33 -0
  143. package/src/ui/index.ts +1 -1
  144. package/src/ui/menuBridge.ts +1 -1
  145. package/src/ui/moveNode.ts +1 -1
  146. package/src/ui/reconcile.ts +2 -2
  147. package/src/ui/reportMenu.ts +2 -2
  148. package/src/ui/routing.ts +2 -2
  149. package/src/ui/setAttribute.ts +1 -1
  150. package/src/ui/setText.ts +1 -1
  151. package/src/ui/socketWire.ts +9 -7
  152. package/src/ui/wire.ts +1 -1
  153. package/scaffold/src/server/app.ts +0 -6
  154. package/scaffold/src/shared/.gitkeep +0 -0
  155. package/scaffold/src/ui/app.html +0 -8
  156. package/src/server/componentImports.ts +0 -56
  157. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -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
@@ -436,6 +469,39 @@ async function argsOf(
436
469
  return parsed
437
470
  }
438
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
+
439
505
  /**
440
506
  * The request's body read up to `ceiling`, as a `Response`, or a 413 refusal past it.
441
507
  *
@@ -487,6 +553,12 @@ function preflight(found: Mounted, origin: string, request: Request): Response {
487
553
  * 16.57 — `Infinity` is never reaped, which bun spells 0; any finite bound is capped at the 255
488
554
  * seconds bun's idle timeout can hold.
489
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
+ *
490
562
  * ON THE REQUEST THE LISTENER RECEIVED, never the one being answered: under a mount, or past a
491
563
  * rung that handed `next` its own, that is a copy, and bun's `timeout` does nothing for a request
492
564
  * it was not handed itself.
@@ -496,7 +568,7 @@ export function raiseTimeout(milliseconds: number): void {
496
568
  const received = scope?.received
497
569
  if (scope === undefined || received === undefined) return
498
570
  const seconds = Number.isFinite(milliseconds)
499
- ? Math.min(255, Math.ceil(milliseconds / 1000))
571
+ ? Math.min(255, Math.ceil(milliseconds / 1000) + 4)
500
572
  : Number.POSITIVE_INFINITY
501
573
  if (seconds <= scope.timeout) return
502
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
@@ -141,10 +143,10 @@ export function feedFromServed(
141
143
  * Only where that entry is not the one it is fed from already.
142
144
  */
143
145
  function attach(rpc: Mounted['handler'], follower: Follower): unknown {
144
- const serving =
145
- rpc.guarded && !follower.admitted
146
- ? undefined
147
- : GLOBAL_GETS.get(rpc)?.at(follower.key)
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)
148
150
  if (serving === follower.serving) return serving
149
151
  follower.stopFeed?.()
150
152
  // A version is one entry's count, so what it was sent of another says nothing about this one.
@@ -250,8 +252,8 @@ export class MuxConnection {
250
252
  return
251
253
  }
252
254
  const id = frame.id ?? 0
253
- // THE ADDRESS FROM `/__abide` ON, which is what both tables are keyed by: a browser names
254
- // 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.
255
257
  const address = addressIn(frame.address ?? '')
256
258
  const subscriptions = this.subscriptions
257
259
  if (frame.op === 'unsub') {
@@ -260,7 +262,9 @@ export class MuxConnection {
260
262
  return
261
263
  }
262
264
  const handler =
263
- frame.op === 'sub' ? this.mux.handlers.get(address) : undefined
265
+ frame.op === 'sub'
266
+ ? this.mux.handlers.get(handlerKey('GET', address))
267
+ : undefined
264
268
  const followed = handler?.ui ? handler : undefined
265
269
  if (followed === undefined) {
266
270
  send(this.peer, {
@@ -295,16 +299,24 @@ export class MuxConnection {
295
299
  // 18.8 — A GUARDED `GET`'s RUNGS RUN ONCE, as the subscription opens, in this
296
300
  // connection's request: one they admit is fed the value, and one they refuse is sent its
297
301
  // triggers alone.
298
- 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
299
307
  if (!admitted) {
300
308
  subscriptions.get(id)?.()
301
309
  subscriptions.set(id, pending)
302
- admitted = await Promise.resolve(
303
- followed.handler(args as never) as PromiseLike<unknown>,
304
- ).then(
305
- () => true,
306
- () => false,
307
- )
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
+ )
308
320
  if (subscriptions.get(id) !== pending) return
309
321
  }
310
322
  follow(this, id, followed, args, frame.version, admitted)
@@ -315,6 +327,39 @@ export class MuxConnection {
315
327
  }
316
328
  }
317
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
+
318
363
  /**
319
364
  * Follows a handler for `id`: its triggers always, its value where `global` and unguarded.
320
365
  *
@@ -564,8 +609,8 @@ function sendAll(serving: unknown, fed: Iterable<Follower>): void {
564
609
  }
565
610
 
566
611
  function addressIn(named: string): string {
567
- const at = named.indexOf(ABIDE_ADDRESS)
568
- return at === -1 ? named : named.slice(at)
612
+ const path = URL.parse(named)?.pathname ?? named
613
+ return unmounted(path) ?? path
569
614
  }
570
615
 
571
616
  /**
@@ -3,6 +3,8 @@
3
3
  // (16.35), a document where `page` named one (17.2), a stream — a streaming handler's chunks, or
4
4
  // `Values` a handler returned — framed one chunk per pull (16.32, 16.44–16.47).
5
5
 
6
+ import { isFailed, problemOf } from '#shared/Failed.ts'
7
+ import { refusalFrame } from '#shared/framing.ts'
6
8
  import { compressAnswer } from './compressAnswer.ts'
7
9
  import { prefers } from './prefers.ts'
8
10
  import {
@@ -158,7 +160,7 @@ export function linesType(request: Request): string {
158
160
  : 'application/jsonl'
159
161
  }
160
162
 
161
- /** A stream answered at a framing already decided — a handler's, and `log.records` (36.7). */
163
+ /** A stream answered at a framing already decided — a handler's. */
162
164
  export function framed(
163
165
  chunks: AsyncIterator<unknown>,
164
166
  framing: Framing,
@@ -185,7 +187,7 @@ export function framed(
185
187
  * was reading in its scope.
186
188
  *
187
189
  * AN EVENT STREAM OPENS ON A COMMENT: a streamed answer's head goes out with its first byte, so a
188
- * stream with nothing yet to say — an empty `log.records` — held its reader's headers until it did.
190
+ * stream with nothing yet to say held its reader's headers until it did.
189
191
  *
190
192
  * `first` is a result already pulled — the one a framing was decided from — and is sent ahead of
191
193
  * the rest.
@@ -230,7 +232,20 @@ export function bodyOf(
230
232
  next = await chunks.next()
231
233
  } catch (thrown) {
232
234
  if (ended) return
233
- controller.error(thrown)
235
+ // 16.68 — A REFUSAL AFTER THE STATUS WAS SENT is the stream's last frame, so the
236
+ // reader refuses with it rather than reading a dropped connection. Bytes carry no
237
+ // framing to hold one, and anything else is not a refusal to send.
238
+ if (framing !== 'bytes' && isFailed(thrown)) {
239
+ controller.enqueue(
240
+ ENCODER.encode(
241
+ refusalFrame(
242
+ problemOf(thrown),
243
+ framing === 'sse' ? 'data: ' : '',
244
+ ),
245
+ ),
246
+ )
247
+ controller.close()
248
+ } else controller.error(thrown)
234
249
  release()
235
250
  return
236
251
  }
@@ -314,12 +329,10 @@ export function answerBlobTagged(
314
329
  })
315
330
  }
316
331
  }
317
- // A RANGE DECLINED — `If-Range` naming another version, or one this does not parse. Bun.serve
318
- // ranges a file-backed 200 body on its own and reads no `If-Range` when it does, so the file, or
319
- // its `stream()`, would go out 206 anyway; piped, the body is no longer one it can see is a file.
320
- const body =
321
- range === null ? blob : blob.stream().pipeThrough(new TransformStream())
322
- return new Response(body, { status, headers })
332
+ // A RANGE DECLINED — `If-Range` naming another version, or one this does not parse — goes out
333
+ // whole as the blob itself: Bun.serve ranges a file-backed body on its own, but declines exactly
334
+ // these, so a file still leaves from disk.
335
+ return new Response(blob, { status, headers })
323
336
  }
324
337
 
325
338
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The directories an app's own files stand in: `root`, and the one above it.
3
3
  *
4
- * `root` is the directory `ui/` sits under — `src/` — and the one above it is the package beside
4
+ * `root` is the app's root — `src/` — and the one above it is the package beside
5
5
  * it. The app's `package.json` and `bunfig.toml` are found in the first that has one.
6
6
  */
7
7
  export function appDirectories(root: string): string[] {
@@ -3,7 +3,7 @@ import { appDirectories } from './appDirectories.ts'
3
3
  /**
4
4
  * The app's own bundler plugins, loaded from `bunfig.toml`'s `[serve.static] plugins`.
5
5
  *
6
- * 38.44 — declared where bun already has an app declare them, the key `bun-plugin-tailwind`
6
+ * 38.43 — declared where bun already has an app declare them, the key `bun-plugin-tailwind`
7
7
  * documents. `Bun.build` reads no bunfig, so both client builds read this one — in
8
8
  * `appDirectories`, as `config` finds the app's `package.json`.
9
9
  */
@@ -0,0 +1,179 @@
1
+ // 23.14 — WHERE AN APP'S ADDRESSES ARE, read off the tree in one walk. Every directory below the root
2
+ // is a segment of an address, and a `page.abide`, `layout.abide` or `error.abide` in it answers there;
3
+ // a `server` or `public` directory is never one (23.15) — the first fences a graph and the second
4
+ // holds files answered as authored, at the path of the directories above it. So an app is organized
5
+ // by subject (`media/[id]/page.abide`, `media/server`) and the addresses follow.
6
+ //
7
+ // ONE WALK for the sources and the public files `abide build` copies and `abide dev` answers, and
8
+ // the refusals (23.16, 23.17, 23.18) — and ONE SKIP RULE, `skipped`, for every walk of the tree,
9
+ // beside `placeOf`, which answers the same question of one path.
10
+ //
11
+ // A WALK RATHER THAN A GLOB: a glob descends `node_modules` and the build output before a filter
12
+ // can drop them.
13
+
14
+ import { type Dirent, readdirSync } from 'node:fs' // bun has no directory listing of its own
15
+ import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
16
+ import {
17
+ PUBLIC_DIRECTORY,
18
+ RESERVED_DIRECTORIES,
19
+ } from '#shared/RESERVED_DIRECTORIES.ts'
20
+ import { fileKind, routeOf, skipped } from '#shared/routePattern.ts'
21
+
22
+ /** What one walk of the app finds, every path relative to the root. */
23
+ export type AppFiles = {
24
+ /** Every `.abide`, in path order. */
25
+ abide: string[]
26
+ /** Every file under a `public` directory, to the address it is answered at below the mount. */
27
+ published: Map<string, string>
28
+ }
29
+
30
+ /** Told of each thing the tree refuses, at the path relative to the root. */
31
+ type Refuse = (path: string, message: string) => void
32
+
33
+ const THROW: Refuse = (_path, message) => {
34
+ throw new Error(message)
35
+ }
36
+
37
+ /**
38
+ * The app's `.abide` sources and public files, refusing a tree 23.16, 23.17 or 23.18 forbids.
39
+ *
40
+ * `refuse` throws by default, which is the build's answer; `abide check` collects instead (38.33),
41
+ * and the checker's route table ignores them, since a refused tree is the build's to report.
42
+ *
43
+ * A missing root is an app with nothing in it, not a fault.
44
+ */
45
+ export function appFiles(root: string, refuse: Refuse = THROW): AppFiles {
46
+ const files: AppFiles = { abide: [], published: new Map() }
47
+ walk(root, '', undefined, files, refuse)
48
+ files.abide.sort()
49
+ return files
50
+ }
51
+
52
+ // `reserved` is the nearest reserved directory above `relative` — its path and its name — or
53
+ // `undefined` where none is.
54
+ function walk(
55
+ directory: string,
56
+ relative: string,
57
+ reserved: { path: string; name: string } | undefined,
58
+ files: AppFiles,
59
+ refuse: Refuse,
60
+ ): void {
61
+ let entries: Dirent[]
62
+ try {
63
+ entries = readdirSync(directory, { withFileTypes: true })
64
+ } catch (thrown) {
65
+ if ((thrown as { code?: unknown }).code === 'ENOENT') return
66
+ throw thrown
67
+ }
68
+ for (const entry of entries) {
69
+ if (skipped(entry.name)) continue
70
+ const path = `${relative}${entry.name}`
71
+ if (entry.isDirectory()) {
72
+ let within = reserved
73
+ if (RESERVED_DIRECTORIES.has(entry.name)) {
74
+ if (reserved !== undefined)
75
+ refuse(
76
+ path,
77
+ `abide: ${path} is a ${entry.name} directory under the ${reserved.name} directory ${reserved.path}. RULEBOOK 23.16.`,
78
+ )
79
+ within = { path, name: entry.name }
80
+ }
81
+ walk(
82
+ `${directory}/${entry.name}`,
83
+ `${path}/`,
84
+ within,
85
+ files,
86
+ refuse,
87
+ )
88
+ continue
89
+ }
90
+ if (path.endsWith('.abide')) files.abide.push(path)
91
+ if (reserved === undefined) {
92
+ // 23.17 — anything that could answer here would answer in abide's own namespace.
93
+ if (path.endsWith('.abide'))
94
+ refuseFrameworkRoute(routeOf(path), path, refuse)
95
+ } else if (fileKind(entry.name) !== 'component')
96
+ refuse(
97
+ path,
98
+ `abide: ${path} is a ${entry.name} under the ${reserved.name} directory ${reserved.path}, where nothing has an address. RULEBOOK 23.18.`,
99
+ )
100
+ else if (reserved.name === PUBLIC_DIRECTORY)
101
+ files.published.set(path, publicAddress(path, reserved.path))
102
+ }
103
+ }
104
+
105
+ /** Refuses `source` where `route` lies in the framework's own namespace. 23.17. */
106
+ export function refuseFrameworkRoute(
107
+ route: string,
108
+ source: string,
109
+ refuse: Refuse = THROW,
110
+ ): void {
111
+ if (route === ABIDE_ADDRESS || route.startsWith(`${ABIDE_ADDRESS}/`))
112
+ refuse(
113
+ source,
114
+ `abide: ${source} answers at ${route}, which is under ${ABIDE_ADDRESS}, where abide's own addresses are. RULEBOOK 23.17.`,
115
+ )
116
+ }
117
+
118
+ // 36.11 — the directories above `public`, then the file's own path below it.
119
+ function publicAddress(path: string, directory: string): string {
120
+ const above = directory.slice(0, -PUBLIC_DIRECTORY.length)
121
+ return `/${above}${path.slice(directory.length + 1)}`
122
+ }
123
+
124
+ /**
125
+ * The public files as the server reads them: each address, without its leading `/`, to the file.
126
+ *
127
+ * 38.19 — out of the source tree, where `abide dev` answers them.
128
+ */
129
+ export function publishedUnder(
130
+ root: string,
131
+ published: Map<string, string>,
132
+ ): Map<string, string> {
133
+ const paths = new Map<string, string>()
134
+ for (const [source, address] of published)
135
+ paths.set(address.slice(1), `${root}/${source}`)
136
+ return paths
137
+ }
138
+
139
+ /**
140
+ * Every `.abide` under `root`, relative to it, in path order — the app's own, and none a
141
+ * dependency ships.
142
+ *
143
+ * Refuses nothing: the language server lists them on every check, and a tree 23.16 forbids is the
144
+ * build's to refuse (`appFiles`).
145
+ */
146
+ export function abideUnder(root: string): string[] {
147
+ const found: string[] = []
148
+ for (const path of abideFilesUnder(root, '')) found.push(path)
149
+ return found.sort()
150
+ }
151
+
152
+ /**
153
+ * Whether a `.abide` lies anywhere under `root` — stopping at the first.
154
+ *
155
+ * 38.26's question, asked of a root before anything is built from it, so asking it of a large tree
156
+ * is as cheap as the tree allows.
157
+ */
158
+ export function holdsAbide(root: string): boolean {
159
+ return !abideFilesUnder(root, '').next().done
160
+ }
161
+
162
+ function* abideFilesUnder(
163
+ directory: string,
164
+ relative: string,
165
+ ): Generator<string> {
166
+ let entries: Dirent[]
167
+ try {
168
+ entries = readdirSync(directory, { withFileTypes: true })
169
+ } catch {
170
+ return
171
+ }
172
+ for (const entry of entries) {
173
+ if (skipped(entry.name)) continue
174
+ const path = `${relative}${entry.name}`
175
+ if (entry.isDirectory())
176
+ yield* abideFilesUnder(`${directory}/${entry.name}`, `${path}/`)
177
+ else if (path.endsWith('.abide')) yield path
178
+ }
179
+ }