@abide/abide 0.101.0 → 0.103.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 (161) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/abide.ts +226 -164
  3. package/docs/REGISTRY.md +730 -0
  4. package/docs/RULEBOOK.md +2768 -0
  5. package/docs/abide.md +13127 -0
  6. package/package.json +6 -3
  7. package/scaffold/AGENTS.md +38 -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 +341 -71
  13. package/src/compiler/appMap.ts +96 -0
  14. package/src/compiler/argsSchemaOf.ts +1 -1
  15. package/src/compiler/checkApp.ts +53 -8
  16. package/src/compiler/checkedShadow.ts +73 -6
  17. package/src/compiler/classifyRead.ts +6 -4
  18. package/src/compiler/classifyTemplate.ts +1 -1
  19. package/src/compiler/compileApp.ts +598 -200
  20. package/src/compiler/compiledEntry.ts +48 -40
  21. package/src/compiler/componentGraph.ts +57 -71
  22. package/src/compiler/declaredExports.ts +26 -9
  23. package/src/compiler/emitClient.ts +142 -72
  24. package/src/compiler/emitComponent.ts +41 -12
  25. package/src/compiler/emitSource.ts +102 -13
  26. package/src/compiler/handlersUnder.ts +164 -76
  27. package/src/compiler/hooksCalled.ts +35 -0
  28. package/src/compiler/index.ts +7 -0
  29. package/src/compiler/mountsOf.ts +150 -0
  30. package/src/compiler/nearest.ts +14 -0
  31. package/src/compiler/parseAbide.ts +157 -20
  32. package/src/compiler/projectShadow.ts +250 -19
  33. package/src/compiler/reactiveTypes.ts +20 -7
  34. package/src/compiler/readSources.ts +15 -2
  35. package/src/compiler/readsIn.ts +62 -8
  36. package/src/compiler/renderMarkup.ts +3 -3
  37. package/src/compiler/scaffold.ts +37 -15
  38. package/src/compiler/serveLanguage.ts +101 -34
  39. package/src/compiler/sharedModuleWrites.ts +2 -2
  40. package/src/compiler/sourceMapOf.ts +193 -0
  41. package/src/compiler/splitImports.ts +205 -83
  42. package/src/compiler/tokenize.ts +14 -0
  43. package/src/compiler/writeDefaultProject.ts +29 -2
  44. package/src/server/CLIENT_BUILD.ts +11 -13
  45. package/src/server/DEFAULT_PORT.ts +1 -1
  46. package/src/server/MODULE_QUERIES.ts +27 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +457 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +241 -107
  52. package/src/server/answerRpc.ts +107 -33
  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 +238 -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 +38 -37
  65. package/src/server/componentModules.d.ts +2 -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 +77 -79
  70. package/src/server/devWorker.ts +118 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +57 -32
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +137 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +12 -7
  77. package/src/server/installSourceMaps.ts +93 -0
  78. package/src/server/launcherOptions.ts +33 -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 +6 -6
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/preload.ts +13 -0
  85. package/src/server/principal.ts +10 -5
  86. package/src/server/realOrAsGiven.ts +10 -0
  87. package/src/server/refuseAddressCollision.ts +21 -14
  88. package/src/server/remote.ts +49 -52
  89. package/src/server/render.ts +65 -94
  90. package/src/server/renderComponent.ts +5 -0
  91. package/src/server/reportHealth.ts +2 -2
  92. package/src/server/requestScope.ts +12 -1
  93. package/src/server/runBuild.ts +24 -0
  94. package/src/server/runCompiled.ts +5 -5
  95. package/src/server/runDesktop.ts +48 -28
  96. package/src/server/serveBuild.ts +43 -52
  97. package/src/server/serveDev.ts +79 -37
  98. package/src/server/shell.ts +7 -22
  99. package/src/server/sourceMappings.ts +139 -0
  100. package/src/server/surfaceOf.ts +20 -3
  101. package/src/server/testBuild.ts +1 -19
  102. package/src/server/withAbideSources.ts +117 -0
  103. package/src/shared/ADDRESSES.ts +10 -18
  104. package/src/shared/APP_META.ts +7 -0
  105. package/src/shared/CopyOptions.ts +1 -1
  106. package/src/shared/DEBUG_META.ts +1 -1
  107. package/src/shared/LAUNCHER_FILES.ts +9 -0
  108. package/src/shared/RESERVED_DIRECTORIES.ts +24 -0
  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 +2 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/{ui → shared}/menuBridge.ts +2 -2
  119. package/src/shared/menuItem.ts +4 -4
  120. package/src/shared/onMenu.ts +58 -0
  121. package/src/shared/reactive/Keyed.ts +2 -2
  122. package/src/shared/reactive/NotLanded.ts +1 -1
  123. package/src/shared/reactive/Reactive.ts +43 -43
  124. package/src/shared/reactive/argsContract.ts +4 -4
  125. package/src/shared/reactive/canonicalKey.ts +4 -4
  126. package/src/shared/reactive/chunksOfStream.ts +6 -0
  127. package/src/shared/reactive/index.ts +1 -1
  128. package/src/shared/reactive/recordWrites.ts +103 -5
  129. package/src/shared/reactive/structural.ts +1 -1
  130. package/src/shared/reactive/tracking.ts +1 -1
  131. package/src/shared/reactive/watch.ts +3 -3
  132. package/src/shared/refuse.ts +1 -1
  133. package/src/{ui → shared}/reportMenu.ts +8 -10
  134. package/src/shared/routePattern.ts +122 -48
  135. package/src/shared/rpc.ts +37 -8
  136. package/src/shared/rpcRequest.ts +27 -3
  137. package/src/shared/url.ts +4 -44
  138. package/src/shared/writePath.ts +1 -1
  139. package/src/ui/LAYER_NAMES.ts +1 -1
  140. package/src/ui/List.ts +3 -3
  141. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  142. package/src/ui/Mismatch.ts +1 -1
  143. package/src/ui/RawRun.ts +1 -1
  144. package/src/ui/Router.ts +37 -36
  145. package/src/ui/Scope.ts +1 -1
  146. package/src/ui/TryRegion.ts +2 -2
  147. package/src/ui/applyHead.ts +33 -0
  148. package/src/ui/index.ts +1 -1
  149. package/src/ui/moveNode.ts +1 -1
  150. package/src/ui/reconcile.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 → index.abide} +0 -0
  161. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -30,7 +30,12 @@ import { followAsEvents } from './answerSocket.ts'
30
30
  import { answerValue, namesEtag, sseHeaders } from './answerValue.ts'
31
31
  import { argsFor } from './argsFor.ts'
32
32
  import { callerOf } from './callerOf.ts'
33
- import { argsDocumentOf, collapsed, type Document } from './coerceArgs.ts'
33
+ import {
34
+ argsDocumentOf,
35
+ coerceArgs,
36
+ collapsed,
37
+ type Document,
38
+ } from './coerceArgs.ts'
34
39
  import { positiveSetting } from './config.ts'
35
40
  import {
36
41
  allows,
@@ -38,7 +43,7 @@ import {
38
43
  crossingOrigin,
39
44
  crossingRefusal,
40
45
  } from './crossingOrigin.ts'
41
- import { failureOf } from './hooks.ts'
46
+ import { type AppRung, failureOf, rungsOver } from './hooks.ts'
42
47
  import { prefers } from './prefers.ts'
43
48
  import {
44
49
  currentRequestScope,
@@ -51,7 +56,8 @@ import { type Outcome, outcomeOf, takeSeed } from './seedBuffer.ts'
51
56
  import { versionOf } from './versionOf.ts'
52
57
 
53
58
  /**
54
- * One row of the table the build writes to `<out>/rpc.ts`: where a handler answers, and its module.
59
+ * One row of the handler table the build writes into the server's table (`SERVER_TABLE`): where a
60
+ * handler answers, and its module.
55
61
  *
56
62
  * The module is relative to the app's root, which both commands that serve already know.
57
63
  */
@@ -59,10 +65,17 @@ export type Mount = {
59
65
  address: string
60
66
  method: Method
61
67
  source: string
68
+ /** The handler file's name, or the private handler's binding. */
62
69
  name: string
70
+ /** What the source's module exports it as: `default` for a handler file. */
71
+ export: string
72
+ /** The route its hooks are filed at, which its rungs are read over (37.1, 16.72). */
73
+ scope: string
74
+ /** Whether it is a private handler, listed nowhere (16.71). */
75
+ private: boolean
63
76
  /** Whether a browser is handed a wrapper for it. 20.4. */
64
77
  ui: boolean
65
- /** Whether the command line lists it — the shell's listing and completion, never whether `call` reaches it. 20.3, 38.63. */
78
+ /** Whether the command line lists it — the shell's listing and completion, never whether `call` reaches it. 20.3, 38.62. */
66
79
  cli: boolean
67
80
  /**
68
81
  * Whether MCP lists it where its runtime `clients` does not say.
@@ -88,9 +101,20 @@ export type Mount = {
88
101
  args: JsonValue | undefined
89
102
  }
90
103
 
104
+ /**
105
+ * A row of the table the build wrote, with the import of the module its handler is declared in —
106
+ * the handler file itself, or a private handler's `.abide` server module, the one the route table
107
+ * loads too, so a page and a call reach one handler.
108
+ */
109
+ export type MountRow = Mount & { load: () => Promise<Record<string, unknown>> }
110
+
91
111
  /** A handler's row, mounted: the handler imported and its args schema probed. */
92
112
  export type Mounted = Mount & {
93
- method: Method
113
+ /**
114
+ * The rungs filed over `scope`, read once at mount (16.14) — what an http call and a socket
115
+ * subscription to it both pass through (37.2, 18.26).
116
+ */
117
+ rungs: AppRung[]
94
118
  handler: Rpc<unknown, unknown> & Gated<unknown>
95
119
  /**
96
120
  * The args' JSON Schema, probed once at mount.
@@ -127,8 +151,14 @@ export function mountedOwn(
127
151
  {
128
152
  address,
129
153
  method: 'GET',
154
+ // The root's rungs run before routing (37.3), and nothing is filed below a route
155
+ // of abide's own.
156
+ rungs: [],
130
157
  source: '',
131
158
  name,
159
+ export: name,
160
+ scope: '/',
161
+ private: false,
132
162
  ui: false,
133
163
  cli: false,
134
164
  mcp: false,
@@ -149,32 +179,31 @@ export function environmentTimeout(): number | undefined {
149
179
  }
150
180
 
151
181
  /**
152
- * The table's handlers, imported and keyed by address.
182
+ * The table's handlers, imported and keyed by method and address.
153
183
  *
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.
184
+ * Imported through each row's own loader, which the route table shares, so a page rendering on the
185
+ * server and a call over the wire reach one module and so one handler. AT BOOT rather than on the
186
+ * first call, because a page's render reaches a handler through the page's own import and has to
187
+ * know its address before anything has called it over the wire.
158
188
  */
159
189
  export async function mountHandlers(
160
- table: Mount[],
161
- moduleOf: (source: string) => Promise<Record<string, unknown>>,
190
+ table: MountRow[],
162
191
  ): Promise<{ handlers: Map<string, Mounted> }> {
163
- const modules = await Promise.all(
164
- table.map((mount) => moduleOf(mount.source)),
165
- )
192
+ const modules = await Promise.all(table.map((mount) => mount.load()))
166
193
  const handlers = new Map<string, Mounted>()
167
194
  for (let at = 0; at < table.length; at += 1) {
168
195
  const mount = table[at] as Mount
169
- const exported = (modules[at] as Record<string, unknown>)[mount.name]
196
+ const exported = (modules[at] as Record<string, unknown>)[mount.export]
170
197
  const handler = exported as Rpc<unknown, unknown> & Gated<unknown>
171
198
  const declared = argsDocumentOf(handler)
172
199
  // 16.9, 16.10 — the address `rpc.url` and `rpc.raw` build on, under the mount — and what a
173
200
  // page's render turns a call it made into the browser's request by (41.20).
174
201
  handler.address = underMount(mount.address)
175
- handlers.set(mount.address, {
202
+ handlers.set(handlerKey(mount.method, mount.address), {
176
203
  ...mount,
177
- method: mount.method,
204
+ // 37.1, 16.72 — the route its file's hooks are filed at, which the build read off
205
+ // where the file answers (23.19's mount for a package's), never its place on disk.
206
+ rungs: rungsOver(mount.scope),
178
207
  handler,
179
208
  argsDocument: declared ?? (mount.args as Document | undefined),
180
209
  wireArgs: declared === undefined ? mount.args : undefined,
@@ -183,30 +212,34 @@ export async function mountHandlers(
183
212
  return { handlers }
184
213
  }
185
214
 
186
- /** The `Response` to a handler call made over http. */
215
+ /** What a handler is keyed by among the mounted: its method and its route. 19.1. */
216
+ export function handlerKey(method: string, address: string): string {
217
+ return `${method} ${address}`
218
+ }
219
+
220
+ /**
221
+ * The `Response` to a call of `found` made over http, `params` being what its route matched.
222
+ *
223
+ * `found` answers the request's method — or, for an `OPTIONS`, the method its preflight asks
224
+ * about: which handler a request reaches is the route table's to say (19.20).
225
+ */
187
226
  export async function answerRpc(
188
- handlers: Map<string, Mounted>,
227
+ found: Mounted,
228
+ params: Record<string, string | string[]>,
189
229
  url: URL,
190
230
  request: Request,
191
231
  // 16.38 — what paints a refusal for a request that came as a navigation.
192
232
  pages: ErrorPages,
193
233
  ): Promise<Response> {
194
234
  const pathname = url.pathname
195
- const found = handlers.get(pathname)
196
235
  const origin = request.headers.get('origin')
197
236
  // 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)
237
+ if (request.method === 'OPTIONS')
238
+ return origin !== null && allows(found.handler.crossOrigin, origin)
239
+ ? preflight(found, origin, request)
240
+ : answered(notFound({ path: pathname, method: request.method }))
205
241
  // 16.50 — a `HEAD` is the `GET`'s answer, which the server sends without its body.
206
242
  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
243
  const handler = found.handler
211
244
  const inUrl = argsInURL(found.method)
212
245
  const form = isForm(request)
@@ -226,10 +259,12 @@ export async function answerRpc(
226
259
  return answered(overCeiling(ceiling))
227
260
  const raw = await argsOf(request, url, inUrl, form, ceiling)
228
261
  if (isFailed(raw)) return answered(raw)
262
+ const sent = withParameters(raw, params, inUrl || form, found)
263
+ if (isFailed(sent)) return answered(sent)
229
264
  let args: unknown
230
265
  try {
231
266
  // 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)
267
+ args = argsFor(found, sent, inUrl ? url.search : form ? '' : undefined)
233
268
  } catch (refused) {
234
269
  if (isFailed(refused)) return answered(refused)
235
270
  throw refused
@@ -436,6 +471,39 @@ async function argsOf(
436
471
  return parsed
437
472
  }
438
473
 
474
+ /**
475
+ * The args a request sent, with the parameters its route matched laid in.
476
+ *
477
+ * 16.65 — the path's, whatever the method. 16.66 — a parameter the query or the body names as
478
+ * well is refused rather than one of the two chosen. A JSON body's members are typed already, so the
479
+ * path's text is coerced to the declared types before it joins them (19.7); text args are coerced
480
+ * with the rest of the text.
481
+ */
482
+ function withParameters(
483
+ sent: unknown,
484
+ params: Record<string, string | string[]>,
485
+ text: boolean,
486
+ found: Mounted,
487
+ ): unknown | Failed<'HttpError', undefined> {
488
+ let none = true
489
+ for (const name in params) {
490
+ none = false
491
+ if (isRecord(sent) && name in sent)
492
+ return refuse(
493
+ 400,
494
+ `The parameter '${name}' is in the path and in the request as well.`,
495
+ )
496
+ }
497
+ if (none) return sent
498
+ const fromPath = text
499
+ ? params
500
+ : (coerceArgs(params, found.argsDocument, '') as Record<
501
+ string,
502
+ unknown
503
+ >)
504
+ return isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
505
+ }
506
+
439
507
  /**
440
508
  * The request's body read up to `ceiling`, as a `Response`, or a 413 refusal past it.
441
509
  *
@@ -487,6 +555,12 @@ function preflight(found: Mounted, origin: string, request: Request): Response {
487
555
  * 16.57 — `Infinity` is never reaped, which bun spells 0; any finite bound is capped at the 255
488
556
  * seconds bun's idle timeout can hold.
489
557
  *
558
+ * FOUR SECONDS PAST THE TIMEOUT, NOT AT IT: a load that goes its timeout is refused at that moment,
559
+ * and a stream's refusal is a frame written then (16.68). bun's sockets check an idle timeout on a
560
+ * tick four seconds wide, so an idle timeout of the same second — or one past it — fired anywhere in
561
+ * the tick before it and reaped the connection ahead of the frame, one stream in two under `abide
562
+ * dev`. Four is the tick, so the refusal always goes out first.
563
+ *
490
564
  * ON THE REQUEST THE LISTENER RECEIVED, never the one being answered: under a mount, or past a
491
565
  * rung that handed `next` its own, that is a copy, and bun's `timeout` does nothing for a request
492
566
  * it was not handed itself.
@@ -496,7 +570,7 @@ export function raiseTimeout(milliseconds: number): void {
496
570
  const received = scope?.received
497
571
  if (scope === undefined || received === undefined) return
498
572
  const seconds = Number.isFinite(milliseconds)
499
- ? Math.min(255, Math.ceil(milliseconds / 1000))
573
+ ? Math.min(255, Math.ceil(milliseconds / 1000) + 4)
500
574
  : Number.POSITIVE_INFINITY
501
575
  if (seconds <= scope.timeout) return
502
576
  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
  */