@abide/abide 0.103.0 → 0.105.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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +27 -27
  4. package/docs/RULEBOOK.md +68 -24
  5. package/docs/abide.md +31 -46
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +46 -89
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -25
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +12 -10
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +183 -478
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/projectShadow.ts +1 -1
  25. package/src/compiler/readSources.ts +27 -42
  26. package/src/compiler/readsIn.ts +5 -16
  27. package/src/compiler/resolverIn.ts +77 -0
  28. package/src/server/abideModules.ts +152 -146
  29. package/src/server/answerRequest.ts +18 -4
  30. package/src/server/answerSocket.ts +136 -76
  31. package/src/server/answerValue.ts +28 -15
  32. package/src/server/appTree.ts +59 -73
  33. package/src/server/claudeApi.ts +2 -5
  34. package/src/server/clientBundle.ts +198 -0
  35. package/src/server/coerceArgs.ts +1 -1
  36. package/src/server/commands.ts +93 -57
  37. package/src/server/createApp.ts +90 -16
  38. package/src/server/deferredBuilds.ts +175 -0
  39. package/src/server/devWorker.ts +107 -319
  40. package/src/server/faceOf.ts +120 -0
  41. package/src/server/fenceServer.ts +10 -18
  42. package/src/server/generatedSurfaces.ts +12 -53
  43. package/src/server/index.ts +3 -0
  44. package/src/server/placement.ts +167 -0
  45. package/src/server/remote.ts +14 -42
  46. package/src/server/render.ts +25 -17
  47. package/src/server/renderComponent.ts +9 -5
  48. package/src/server/requestScope.ts +10 -4
  49. package/src/server/responseRelay.ts +389 -0
  50. package/src/server/runCompiled.ts +9 -12
  51. package/src/server/runDesktop.ts +6 -9
  52. package/src/server/serveBuild.ts +26 -73
  53. package/src/server/serveDev.ts +19 -176
  54. package/src/server/streamEvent.ts +29 -0
  55. package/src/server/versionOf.ts +1 -2
  56. package/src/server/withAbideSources.ts +2 -1
  57. package/src/shared/HandlerRoute.ts +68 -0
  58. package/src/shared/REFUSAL_KEY.ts +5 -0
  59. package/src/shared/RESERVED_DIRECTORIES.ts +6 -0
  60. package/src/shared/answerOf.ts +105 -78
  61. package/src/shared/filledPath.ts +1 -9
  62. package/src/shared/framing.ts +127 -128
  63. package/src/shared/queryText.ts +15 -7
  64. package/src/shared/reactive/Cursor.ts +69 -124
  65. package/src/shared/reactive/Reactive.ts +296 -162
  66. package/src/shared/reactive/callLoad.ts +4 -18
  67. package/src/shared/reactive/index.ts +11 -8
  68. package/src/shared/reactive/pairReactive.ts +1 -1
  69. package/src/shared/reactive/scoped.ts +4 -3
  70. package/src/shared/reactive/share.ts +11 -1
  71. package/src/shared/routePattern.ts +0 -110
  72. package/src/shared/rpc.ts +17 -24
  73. package/src/shared/rpcRequest.ts +18 -47
  74. package/src/shared/takeEarlyCall.ts +12 -37
  75. package/src/shared/url.ts +1 -1
  76. package/src/ui/Level.ts +276 -0
  77. package/src/ui/List.ts +17 -17
  78. package/src/ui/Router.ts +20 -87
  79. package/src/ui/applyHead.ts +92 -79
  80. package/src/ui/index.ts +4 -2
  81. package/src/ui/reconcile.ts +4 -110
  82. package/src/ui/rows.ts +123 -2
  83. package/src/ui/wire.ts +12 -27
  84. package/src/compiler/hooksCalled.ts +0 -35
  85. package/src/server/clientBuildOptions.ts +0 -54
  86. package/src/server/refuseAddressCollision.ts +0 -37
  87. package/src/shared/reactive/chunksOfStream.ts +0 -57
  88. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -21,6 +21,19 @@ import { basename, dirname, resolve } from 'node:path' // bun exposes no path he
21
21
  import { SERVER_SPECIFIER } from '#shared/SPECIFIERS.ts'
22
22
  import type { ServerTable } from './answerRequest.ts'
23
23
  import type { MountRow } from './answerRpc.ts'
24
+ import {
25
+ type Asked,
26
+ BOUND_NAMESPACE,
27
+ CLIENT_NAMESPACE,
28
+ componentNamespaceOf,
29
+ type Face,
30
+ faceOf,
31
+ type HandlerFace,
32
+ type Lane,
33
+ realFileOf,
34
+ STYLE_NAMESPACE,
35
+ TEXT_NAMESPACE,
36
+ } from './faceOf.ts'
24
37
  import { installSourceMaps, type SourceMaps } from './installSourceMaps.ts'
25
38
  import {
26
39
  CLIENT_QUERY,
@@ -68,24 +81,8 @@ export type Compilation = {
68
81
  mounted: Record<string, string>
69
82
  }
70
83
 
71
- // A VIRTUAL FACE'S PATH: its file's, with a suffix that is not a query. Both registries key a module
72
- // by its path whatever its namespace, and the runtime's drops a query from it — so a file's text, its
73
- // stylesheet or its bound face under `?…` took the place of the module the file compiles to.
74
- const BOUND = '::bound'
75
- const CLIENT = '::client'
76
- const TEXT = '::text'
77
- const STYLE = '::style'
78
-
79
- /** The namespace a bundle's component module is answered in, at its file's own path. */
80
- export const COMPONENT_NAMESPACE = 'abide-component'
81
- /** The namespaces a file's text and a component's stylesheet are loaded in. */
82
- export const TEXT_NAMESPACE = 'abide-text'
83
- export const STYLE_NAMESPACE = 'abide-style'
84
84
  const RELATIVE_OR_SUBPATH = /^(?:\.\.?\/|#)/
85
85
 
86
- /** One handler file, as `Compilation.handlers` holds it. */
87
- type HandlerFace = { scope: string; client: string }
88
-
89
86
  /** The plugin serving `compilation`'s files and tables, for one bundle. */
90
87
  export function abideModules(
91
88
  root: string,
@@ -123,13 +120,39 @@ let registered = false
123
120
  */
124
121
  export function serveCompilation(root: string, compilation: Compilation): void {
125
122
  const real = realOrAsGiven(root)
126
- addCompilation(real, compilation, RUNTIME)
123
+ const handlerFiles = addCompilation(real, compilation, RUNTIME)
127
124
  installSourceMaps(serverMapsOf(compilation, real), real)
125
+ if (handlerFiles.length !== 0) Bun.plugin(handlerFilesPlugin(handlerFiles))
128
126
  if (registered) return
129
127
  registered = true
130
128
  Bun.plugin(modulesPlugin(RUNTIME))
131
129
  }
132
130
 
131
+ // 16.2 — A HANDLER FILE LOADED AT ITS OWN PATH RE-EXPORTS ITS FACE, so every spelling of its import
132
+ // is the module the server mounted. The runtime asks `onResolve` only for a specifier with an
133
+ // extension, while `onLoad` is asked by the path the file resolved to, however it was spelled. One
134
+ // plugin per compilation because the filter is fixed at registration, and the runtime's `onLoad`
135
+ // cannot fall through, so the filter names the handler files and nothing else. The face it names
136
+ // is resolved by `modulesPlugin`, registered once.
137
+ function handlerFilesPlugin(paths: string[]): Bun.BunPlugin {
138
+ return {
139
+ name: 'abide-handler-files',
140
+ setup(build) {
141
+ build.onLoad(
142
+ {
143
+ filter: new RegExp(
144
+ `^(?:${paths.map(RegExp.escape).join('|')})$`,
145
+ ),
146
+ },
147
+ (args) => ({
148
+ contents: `export { default } from ${JSON.stringify(`${args.path}${HANDLER_SUFFIX}`)}\n`,
149
+ loader: 'js',
150
+ }),
151
+ )
152
+ },
153
+ }
154
+ }
155
+
133
156
  /** What the server's table exports: its routes and error pages, server blocks and handlers. */
134
157
  export type BuiltTable = ServerTable & {
135
158
  /** 37.1 — every module a `<script server>` runs in, imported before the app boots. */
@@ -162,18 +185,24 @@ export function serverMapsOf(
162
185
  return maps
163
186
  }
164
187
 
188
+ /** Adds `compilation` to `held`, and answers its handler files' absolute paths. */
165
189
  function addCompilation(
166
190
  root: string,
167
191
  compilation: Compilation,
168
192
  held: Held,
169
- ): void {
193
+ ): string[] {
170
194
  // RESOLVED, not joined: a dependency's file is keyed above the root (`../node_modules/…`).
171
195
  for (const [relative, compiled] of Object.entries(compilation.files))
172
196
  held.files.set(resolve(root, relative), compiled)
173
- for (const [relative, face] of Object.entries(compilation.handlers))
174
- held.handlers.set(resolve(root, relative), face)
197
+ const handlerFiles: string[] = []
198
+ for (const [relative, face] of Object.entries(compilation.handlers)) {
199
+ const path = resolve(root, relative)
200
+ held.handlers.set(path, face)
201
+ handlerFiles.push(path)
202
+ }
175
203
  for (const [name, text] of Object.entries(compilation.tables))
176
204
  held.tables.set(`${root}/${VIRTUAL_DIRECTORY}/${name}`, text)
205
+ return handlerFiles
177
206
  }
178
207
 
179
208
  // `Bun.resolveSync` asks EVERY abide plugin's `onResolve` again — this one's, and the runtime's, which
@@ -203,79 +232,59 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
203
232
  name: 'abide-modules',
204
233
  setup(build) {
205
234
  // THE LANE IS THE BUILD'S TARGET: a browser bundle gets each file's browser module, and
206
- // the runtime — which has no config — and a server bundle get its server module. A
207
- // browser module the runtime was asked for (`CLIENT_QUERY`) is the one exception, and
208
- // what it imports is in its lane.
235
+ // the runtime — which has no config — and a server bundle get its server module.
209
236
  const bundling: boolean = 'config' in build
210
- const browser = bundling && build.config.target === 'browser'
211
- const inBrowser = (importer: string): boolean =>
212
- browser || pathOf(importer).endsWith(CLIENT)
213
- // A BUNDLE'S COMPONENT MODULE IS IN A NAMESPACE OF ITS OWN: the bundler refuses a
214
- // `file` path a plugin answers with `..` anywhere in it, which a `[...rest]` directory
215
- // writes (23.5). The runtime has no such check, and its stack frames name the path.
216
- const componentNamespace = bundling ? COMPONENT_NAMESPACE : 'file'
217
- // The module `path` compiles to, in the lane its importer is in.
218
- const componentFor = (path: string, importer: string) =>
219
- inBrowser(importer) && !browser
220
- ? { path: `${path}${CLIENT}`, namespace: 'abide-client' }
221
- : { path, namespace: componentNamespace }
237
+ const lane: Lane = {
238
+ bundling,
239
+ browser: bundling && build.config.target === 'browser',
240
+ }
222
241
  const resolved = (specifier: string, importer: string): string =>
223
242
  resolvedFile(specifier, dirname(pathOf(importer)))
243
+ // Each hook below only adapts bun's ask to `faceOf`'s: the file the specifier resolved to,
244
+ // its query taken off, and the answer handed back — a refusal thrown.
245
+ const answer = (
246
+ asked: Asked,
247
+ file: string,
248
+ importer: string,
249
+ ): Face | undefined => {
250
+ const face = faceOf(
251
+ asked,
252
+ file,
253
+ pathOf(importer),
254
+ lane,
255
+ served.handlers,
256
+ )
257
+ if (face instanceof Error) throw face
258
+ return face
259
+ }
224
260
  // A filter off the query's own constant, so renaming one cannot silently stop a match.
261
+ const asking = (asked: Asked, query: string, filter: RegExp) =>
262
+ build.onResolve({ filter }, (args) =>
263
+ resolving
264
+ ? undefined
265
+ : answer(
266
+ asked,
267
+ resolved(
268
+ args.path.slice(
269
+ 0,
270
+ args.path.length - query.length,
271
+ ),
272
+ args.importer,
273
+ ),
274
+ args.importer,
275
+ ),
276
+ )
225
277
  const abideWith = (query: string) =>
226
278
  new RegExp(`\\.abide${RegExp.escape(query)}$`)
227
-
228
- build.onResolve(
229
- { filter: new RegExp(`${RegExp.escape(TEXT_QUERY)}$`) },
230
- (args) => {
231
- if (resolving) return undefined
232
- const specifier = args.path.slice(0, -TEXT_QUERY.length)
233
- // A path of its own (`TEXT`): a file is often imported as code and as text at once
234
- // — an example's Files tab.
235
- return {
236
- path: `${resolved(specifier, args.importer)}${TEXT}`,
237
- namespace: TEXT_NAMESPACE,
238
- }
239
- },
279
+ asking(
280
+ 'text',
281
+ TEXT_QUERY,
282
+ new RegExp(`${RegExp.escape(TEXT_QUERY)}$`),
240
283
  )
241
- build.onResolve({ filter: abideWith(STYLE_QUERY) }, (args) => {
242
- if (resolving) return undefined
243
- const specifier = args.path.slice(0, -STYLE_QUERY.length)
244
- return {
245
- path: `${resolved(specifier, args.importer)}${STYLE}`,
246
- namespace: STYLE_NAMESPACE,
247
- }
248
- })
249
- build.onResolve({ filter: abideWith(COMPONENT_QUERY) }, (args) => {
250
- if (resolving) return undefined
251
- const specifier = args.path.slice(0, -COMPONENT_QUERY.length)
252
- return componentFor(
253
- resolved(specifier, args.importer),
254
- args.importer,
255
- )
256
- })
257
- build.onResolve({ filter: abideWith(CLIENT_QUERY) }, (args) => {
258
- if (resolving) return undefined
259
- const path = resolved(
260
- args.path.slice(0, -CLIENT_QUERY.length),
261
- args.importer,
262
- )
263
- return browser
264
- ? { path, namespace: componentNamespace }
265
- : { path: `${path}${CLIENT}`, namespace: 'abide-client' }
266
- })
267
- build.onResolve({ filter: /\.abide$/ }, (args) => {
268
- if (resolving) return undefined
269
- const path = resolved(args.path, args.importer)
270
- // A bound face's own import of its component, and every browser import, is the
271
- // component module.
272
- if (
273
- inBrowser(args.importer) ||
274
- pathOf(args.importer) === `${path}${BOUND}`
275
- )
276
- return componentFor(path, args.importer)
277
- return { path: `${path}${BOUND}`, namespace: 'abide-bound' }
278
- })
284
+ asking('style', STYLE_QUERY, abideWith(STYLE_QUERY))
285
+ asking('component', COMPONENT_QUERY, abideWith(COMPONENT_QUERY))
286
+ asking('client', CLIENT_QUERY, abideWith(CLIENT_QUERY))
287
+ asking('abide', '', /\.abide$/)
279
288
  build.onResolve(
280
289
  { filter: new RegExp(`/${VIRTUAL_DIRECTORY}/[^/]+\\.ts$`) },
281
290
  (args) => {
@@ -290,48 +299,39 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
290
299
  },
291
300
  )
292
301
 
293
- // A HANDLER FILE IS FOUND BY WHERE ITS IMPORT RESOLVES, however the import spells it, and
294
- // only from the app's own modules: a package's never names one, and asking bun again for
295
- // each of a package's relative imports is a resolve per module of the bundle. IN A BUNDLE
302
+ // A HANDLER FILE IS FOUND BY WHERE ITS IMPORT RESOLVES, however the import spells it — the
303
+ // app's own, or a mounted package's own page importing a handler file of its (23.19).
296
304
  // THE FILTER IS THE HANDLER FILES' NAMES: a hook matching every relative import costs the
297
305
  // bundle abide's own `sideEffects`, and the barrel's rest — the validator, the csp — moved
298
- // into the chunk every page loads (38.94). The runtime is told of each compilation after
299
- // it registered, so it matches every relative or `#` import and asks.
306
+ // into the chunk every page loads (38.94); and since only an import ending in one of those
307
+ // names is resolved again, a package's other imports cost nothing, so a package's are not
308
+ // passed over by where they lie. ONLY IN A BUNDLE: the runtime finds a handler file by the
309
+ // path it loads at (`handlerFilesPlugin`), and asks no `onResolve` of an extensionless
310
+ // specifier at all.
300
311
  // Each handler file's name without `.ts` — what an import of one ends in.
301
- let stems: string[] | undefined
302
- if (bundling) {
303
- const names = new Set<string>()
312
+ const stems = new Set<string>()
313
+ if (bundling)
304
314
  for (const path of served.handlers.keys())
305
- names.add(basename(path, '.ts'))
306
- stems = [...names]
307
- }
308
- const handlerImport =
309
- stems === undefined
310
- ? RELATIVE_OR_SUBPATH
311
- : new RegExp(
312
- `(?:^|/)(?:${stems.map(RegExp.escape).join('|')})(?:\\.ts)?$`,
313
- )
314
- if (stems?.length !== 0)
315
- build.onResolve({ filter: handlerImport }, (args) => {
316
- if (resolving || !RELATIVE_OR_SUBPATH.test(args.path))
317
- return undefined
318
- const importer = pathOf(args.importer)
319
- if (importer.includes('/node_modules/')) return undefined
320
- let path: string
321
- try {
322
- path = resolved(args.path, args.importer)
323
- } catch {
324
- return undefined
325
- }
326
- const face = served.handlers.get(path)
327
- if (face === undefined) return undefined
328
- // 20.4 — a handler the browser is handed no wrapper for is no import of its.
329
- if (inBrowser(args.importer) && face.client === '')
330
- throw new Error(
331
- `abide: ${pathOf(args.importer)} imports ${args.path}, whose handler withholds itself from the browser (\`clients: { ui: false }\`). Call it from a <script server>, or let the browser have it. RULEBOOK 20.4.`,
332
- )
333
- return { path: `${path}${HANDLER_SUFFIX}` }
334
- })
315
+ stems.add(basename(path, '.ts'))
316
+ if (stems.size !== 0)
317
+ build.onResolve(
318
+ {
319
+ filter: new RegExp(
320
+ `(?:^|/)(?:${[...stems].map(RegExp.escape).join('|')})(?:\\.ts)?$`,
321
+ ),
322
+ },
323
+ (args) => {
324
+ if (resolving || !RELATIVE_OR_SUBPATH.test(args.path))
325
+ return undefined
326
+ let file: string
327
+ try {
328
+ file = resolved(args.path, args.importer)
329
+ } catch {
330
+ return undefined
331
+ }
332
+ return answer('handler', file, args.importer)
333
+ },
334
+ )
335
335
 
336
336
  // A RELATIVE SPECIFIER A FACE IMPORTS AT RUN TIME is resolved from the file the face
337
337
  // stands for: bun's runtime reads one from an importer in a namespace as no path at
@@ -340,19 +340,27 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
340
340
  // and where a hook matching every relative import in a package costs the bundle that
341
341
  // package's `sideEffects`: a two-page app grew by 12 KB, the validator one page never
342
342
  // calls moved into the chunk both pages load.
343
- if (!bundling)
343
+ if (!bundling) {
344
+ // A face `handlerFilesPlugin` re-exports, named by its absolute path, which no file
345
+ // is at.
346
+ build.onResolve({ filter: HANDLER_FACE }, (args) => ({
347
+ path: args.path,
348
+ }))
344
349
  build.onResolve({ filter: /^\.\.?\// }, (args) =>
345
350
  resolving || pathOf(args.importer) === args.importer
346
351
  ? undefined
347
352
  : { path: resolved(args.path, args.importer) },
348
353
  )
354
+ }
349
355
 
350
356
  build.onLoad(
351
- { filter: /\.abide$/, namespace: componentNamespace },
357
+ { filter: /\.abide$/, namespace: componentNamespaceOf(lane) },
352
358
  (args) => {
353
359
  const compiled = compiledAt(served, args.path)
354
360
  return {
355
- contents: browser ? compiled.client : compiled.server,
361
+ contents: lane.browser
362
+ ? compiled.client
363
+ : compiled.server,
356
364
  loader: 'ts',
357
365
  }
358
366
  },
@@ -364,9 +372,9 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
364
372
  // resolves from its directory as the file's own imports do. A namespace of abide's own
365
373
  // had bun resolve a bare one from the working directory: a second abide, run from `/`.
366
374
  build.onLoad({ filter: HANDLER_FACE }, async (args) => {
367
- const path = args.path.slice(0, -HANDLER_SUFFIX.length)
375
+ const path = realFileOf(args.path)
368
376
  const face = served.handlers.get(path) as HandlerFace
369
- if (browser) return { contents: face.client, loader: 'js' }
377
+ if (lane.browser) return { contents: face.client, loader: 'js' }
370
378
  const text = await Bun.file(path).text()
371
379
  return {
372
380
  contents: `import { fileRoute as __fileRoute } from '${SERVER_SPECIFIER}';__fileRoute(${JSON.stringify(face.scope)});${text}\n;__fileRoute(undefined)\n`,
@@ -374,22 +382,22 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
374
382
  }
375
383
  })
376
384
  build.onLoad(
377
- { filter: /.*/, namespace: 'abide-client' },
385
+ { filter: /.*/, namespace: CLIENT_NAMESPACE },
378
386
  (args) => ({
379
- contents: compiledAt(
380
- served,
381
- args.path.slice(0, -CLIENT.length),
382
- ).client,
387
+ contents: compiledAt(served, realFileOf(args.path)).client,
383
388
  loader: 'ts',
384
389
  }),
385
390
  )
386
- build.onLoad({ filter: /.*/, namespace: 'abide-bound' }, (args) => {
387
- const path = args.path.slice(0, -BOUND.length)
388
- return {
389
- contents: boundModule(path),
390
- loader: 'js',
391
- }
392
- })
391
+ build.onLoad(
392
+ { filter: /.*/, namespace: BOUND_NAMESPACE },
393
+ (args) => {
394
+ const path = realFileOf(args.path)
395
+ return {
396
+ contents: boundModule(path),
397
+ loader: 'js',
398
+ }
399
+ },
400
+ )
393
401
  // A stylesheet is the bundle's; outside one there is no document to apply it to.
394
402
  build.onLoad(
395
403
  { filter: /.*/, namespace: STYLE_NAMESPACE },
@@ -397,10 +405,8 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
397
405
  bundling
398
406
  ? {
399
407
  contents:
400
- compiledAt(
401
- served,
402
- args.path.slice(0, -STYLE.length),
403
- ).sheet ?? '',
408
+ compiledAt(served, realFileOf(args.path))
409
+ .sheet ?? '',
404
410
  loader: 'css',
405
411
  }
406
412
  : { contents: '', loader: 'js' },
@@ -408,7 +414,7 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
408
414
  build.onLoad(
409
415
  { filter: /.*/, namespace: TEXT_NAMESPACE },
410
416
  async (args) => ({
411
- contents: `export default ${JSON.stringify(await Bun.file(args.path.slice(0, -TEXT.length)).text())}\n`,
417
+ contents: `export default ${JSON.stringify(await Bun.file(realFileOf(args.path)).text())}\n`,
412
418
  loader: 'js',
413
419
  }),
414
420
  )
@@ -20,6 +20,7 @@ import { DEBUG_META } from '#shared/DEBUG_META.ts'
20
20
  import { decodedOrKept } from '#shared/decodedOrKept.ts'
21
21
  import { escapeAttribute } from '#shared/escapeText.ts'
22
22
  import { type Failed, isFailed } from '#shared/Failed.ts'
23
+ import { HandlerRoute } from '#shared/HandlerRoute.ts'
23
24
  import { health } from '#shared/health.ts'
24
25
  import { appName } from '#shared/log.ts'
25
26
  import { compose } from '#shared/Middleware.ts'
@@ -50,6 +51,7 @@ import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
50
51
  import { callerOrMint } from './callerOf.ts'
51
52
  import { compressAnswer } from './compressAnswer.ts'
52
53
  import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
54
+ import { noteCallInRender } from './deferredBuilds.ts'
53
55
  import { answerMcp, openApi } from './generatedSurfaces.ts'
54
56
  import { type AppRung, failureOf, rungsOver } from './hooks.ts'
55
57
  import { chunksOf, type Manifest } from './manifestOf.ts'
@@ -208,8 +210,8 @@ export async function answerRequest(
208
210
  }
209
211
 
210
212
  // 36.11 — A PUBLIC FILE IS ITS PATH BELOW THE DIRECTORY, so `/favicon.ico` is answered
211
- // before any route is tried. The order is not load-bearing: `abide build` and `abide dev`
212
- // run `refuseAddressCollision` (36.13) over the same two trees before anything is served,
213
+ // before any route is tried. The order is not load-bearing: the compile `abide build` and
214
+ // `abide dev` share refuses a collision (36.13) over the same two trees before anything is served,
213
215
  // so at run time there can never be a collision to resolve. 36.11 — keyed by its name on disk
214
216
  // and asked for percent-encoded: `my photo.jpg` is `/my%20photo.jpg`.
215
217
  const file = app.published.get(decodedOrKept(asked))
@@ -383,6 +385,10 @@ const SURFACES = new Map<
383
385
  [BRIDGE_ADDRESS, (_app, request) => answerBridge(request)],
384
386
  ])
385
387
 
388
+ // Each handler's route, compiled once rather than per render that calls it — as its `Rpc` keeps its
389
+ // own; compiled again only where it was mounted at another address since.
390
+ const ROUTE_OF = new WeakMap<object, HandlerRoute>()
391
+
386
392
  // What a call made in a page's render reports into: the seed buffer (41.13), and — where the handler
387
393
  // is mounted — the request the browser will make for it, for the early-call script (41.20). Once per
388
394
  // render, as the browser's `Rpc` makes it once, and the seed buffer is what says which is the once.
@@ -392,18 +398,26 @@ export function reachedInRender(
392
398
  reached: unknown,
393
399
  ): void {
394
400
  const page = currentRequestScope()?.page
395
- if (page === undefined || !page.seed(rpc, args, reached)) return
401
+ if (page === undefined) return
402
+ noteCallInRender()
403
+ if (!page.seed(rpc, args, reached)) return
396
404
  // 16.10 — the address the browser's own call is made at, stamped under the mount by
397
405
  // `mountHandlers` — and `undefined` on a handler nothing mounted.
398
406
  const { address, method } = rpc as Rpc<unknown, unknown>
399
407
  if (address === undefined) return
408
+ let route = ROUTE_OF.get(rpc)
409
+ if (route?.address !== address) {
410
+ route = new HandlerRoute(address)
411
+ ROUTE_OF.set(rpc, route)
412
+ }
400
413
  page.calls.push({
401
414
  request: rpcRequest(
402
415
  method,
403
- address,
416
+ route,
404
417
  args as Record<string, unknown> | undefined,
405
418
  ),
406
419
  reached,
420
+ painted: undefined,
407
421
  })
408
422
  }
409
423