@abide/abide 0.100.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/abide.ts +227 -163
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +10 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -56
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +124 -39
  53. package/src/server/answerSocket.ts +141 -48
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +48 -14
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +144 -183
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +81 -39
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/GLOBAL_GETS.ts +9 -3
  107. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  108. package/src/shared/RPC_SERVER.ts +2 -2
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +1 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/shared/menuItem.ts +3 -3
  119. package/src/shared/reactive/Cursor.ts +3 -9
  120. package/src/shared/reactive/Keyed.ts +4 -3
  121. package/src/shared/reactive/NotLanded.ts +1 -1
  122. package/src/shared/reactive/Reactive.ts +81 -60
  123. package/src/shared/reactive/argsContract.ts +4 -4
  124. package/src/shared/reactive/canonicalKey.ts +4 -4
  125. package/src/shared/reactive/chunksOfStream.ts +6 -0
  126. package/src/shared/reactive/index.ts +1 -1
  127. package/src/shared/reactive/recordWrites.ts +103 -5
  128. package/src/shared/reactive/structural.ts +1 -1
  129. package/src/shared/reactive/tracking.ts +155 -21
  130. package/src/shared/reactive/watch.ts +10 -6
  131. package/src/shared/refuse.ts +1 -1
  132. package/src/shared/routePattern.ts +82 -46
  133. package/src/shared/rpc.ts +47 -13
  134. package/src/shared/rpcRequest.ts +27 -3
  135. package/src/shared/url.ts +4 -44
  136. package/src/shared/writePath.ts +1 -1
  137. package/src/ui/LAYER_NAMES.ts +1 -1
  138. package/src/ui/List.ts +3 -3
  139. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  140. package/src/ui/Mismatch.ts +1 -1
  141. package/src/ui/RawRun.ts +1 -1
  142. package/src/ui/Router.ts +38 -31
  143. package/src/ui/Scope.ts +1 -1
  144. package/src/ui/TryRegion.ts +2 -2
  145. package/src/ui/applyHead.ts +33 -0
  146. package/src/ui/index.ts +1 -1
  147. package/src/ui/menuBridge.ts +1 -1
  148. package/src/ui/moveNode.ts +1 -1
  149. package/src/ui/reconcile.ts +2 -2
  150. package/src/ui/reportMenu.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -1,4 +1,4 @@
1
- // 38.15 — THE APP, IN A CHILD PROCESS. Everything an app's own code touches is in here: the modules
1
+ // 38.14 — THE APP, IN A CHILD PROCESS. Everything an app's own code touches is in here: the modules
2
2
  // it compiled to, the client bundle, and the render. The listener is the parent (`serveDev`), so
3
3
  // replacing this process is how a reload happens and nothing an app left behind — a module in the
4
4
  // registry, a timer, a `share`, a file bun once failed to find — survives one.
@@ -6,15 +6,15 @@
6
6
  // THE COMPILER IS NOT THE APP, and it stays outside: `serveDev` compiles and sends the modules
7
7
  // in, so the compiler is loaded once for every reload — `serveDev` says what that saves.
8
8
  //
9
- // NOTHING IS WRITTEN TO DISK (38.17). The modules arrive as text and are reached through a
10
- // plugin: `Bun.plugin` for the server modules this worker imports, and a build plugin for the
11
- // bundle a browser fetches. The paths they are keyed under mirror the output tree `abide build`
12
- // would have written, so a module's imports resolve the same in either.
9
+ // NOTHING IS WRITTEN TO DISK (38.16). The compilation arrives as text and is served through the one
10
+ // plugin `abide start` serves a build's through (`abideModules`): at runtime for the server modules
11
+ // this worker imports, and in the bundle a browser fetches — each `.abide` at its own path, so a
12
+ // module's imports resolve from the source that wrote them in either.
13
13
  //
14
- // A REQUEST ARRIVES AS A MESSAGE, and its answer leaves as a head and then a chunk per pull (38.36,
15
- // 38.37): the listener asks for the next chunk when its connection has taken the last, so a stream
14
+ // A REQUEST ARRIVES AS A MESSAGE, and its answer leaves as a head and then a chunk per pull (38.35,
15
+ // 38.36): the listener asks for the next chunk when its connection has taken the last, so a stream
16
16
  // is read no faster than the browser reads it. A request BODY is still collected, which is what
17
- // lets the listener re-ask a request of the replacement (38.25).
17
+ // lets the listener re-ask a request of the replacement (38.24).
18
18
  //
19
19
  // 18.1, 18.3 — AND SO DOES A SOCKET. The listener holds the browser's web socket, which has to
20
20
  // outlive this worker; what crosses is each frame, tagged with the connection it came over, and
@@ -23,13 +23,16 @@
23
23
  import { CLIENT_ADDRESS, DEV_ADDRESS } from '#shared/ADDRESSES.ts'
24
24
  import { escapeText } from '#shared/escapeText.ts'
25
25
  import {
26
- appSources,
27
- type LoadedSources,
28
- type ServerTable,
29
- } from './answerRequest.ts'
26
+ abideModules,
27
+ type Compilation,
28
+ serveCompilation,
29
+ servedTable,
30
+ } from './abideModules.ts'
31
+ import { type AppSources, appSources } from './answerRequest.ts'
30
32
  import type { Mux, MuxConnection } from './answerSocket.ts'
31
33
  import { appPlugins } from './appPlugins.ts'
32
- import { CLIENT_ENTRY, PUBLIC_DIRECTORY } from './CLIENT_BUILD.ts'
34
+ import { publishedUnder } from './appTree.ts'
35
+ import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
33
36
  import {
34
37
  clientBuildOptions,
35
38
  stylesheetUnderMount,
@@ -40,26 +43,28 @@ import { fenceServer } from './fenceServer.ts'
40
43
  import { type Manifest, manifestOf, NO_MANIFEST } from './manifestOf.ts'
41
44
  import { admitsMux } from './muxUpgrade.ts'
42
45
  import { refuseAddressCollision } from './refuseAddressCollision.ts'
43
- import { shellUnder } from './render.ts'
44
46
  import { LISTENING } from './requestScope.ts'
45
- import { handlersIn, pathsUnder, sourceModuleLoader } from './serveBuild.ts'
46
- import { VIRTUAL_DIRECTORY } from './VIRTUAL_DIRECTORY.ts'
47
+ import { withAbideSources } from './withAbideSources.ts'
47
48
 
48
49
  // The reload client, which the worker serves and every document it answers loads.
49
50
  const RELOAD_CLIENT_ADDRESS = `${DEV_ADDRESS}/client.js`
50
51
 
51
52
  // The bundle this worker answers `/__abide/client/…` out of, the type each name is served as, and
52
- // the chunks each route module needs (38.29).
53
+ // the chunks each route module needs (38.28).
53
54
  type Bundle = {
54
- // A stylesheet is held as its text, rewritten (`stylesheetUnderMount`); the rest as built.
55
- files: Map<string, { bytes: ArrayBuffer | string; type: string }>
55
+ // A stylesheet is held as its text, rewritten (`stylesheetUnderMount`); a source map as the
56
+ // rewrite of it, taken on the first ask (`withAbideSources`); the rest as built.
57
+ files: Map<
58
+ string,
59
+ { bytes: ArrayBuffer | string | (() => Promise<string>); type: string }
60
+ >
56
61
  manifest: Manifest
57
62
  }
58
63
 
59
64
  /**
60
65
  * A message from the listener to this worker.
61
66
  *
62
- * 38.15 — WHAT CROSSES BETWEEN THE LISTENER AND THIS WORKER, one union per direction. In: the app
67
+ * 38.14 — WHAT CROSSES BETWEEN THE LISTENER AND THIS WORKER, one union per direction. In: the app
63
68
  * compiled (and what stopped it compiling, where something did), a request, a pull or cancel of
64
69
  * its body, and each browser web socket's frames for the mux.
65
70
  */
@@ -67,7 +72,9 @@ export type ToWorker =
67
72
  | {
68
73
  kind: 'start'
69
74
  root: string
70
- modules: Map<string, string>
75
+ compilation: Compilation
76
+ /** Every public file, to its address, as the compile's walk found them. */
77
+ published: Map<string, string>
71
78
  failure: string
72
79
  }
73
80
  | ({ kind: 'request' } & Ask)
@@ -120,13 +127,32 @@ export type Ask = {
120
127
  /**
121
128
  * Sends a message to the listener.
122
129
  *
123
- * 38.15 — over bun's IPC: this is its child process (`serveDev` says why a process).
130
+ * 38.14 — over bun's IPC: this is its child process (`serveDev` says why a process).
124
131
  */
125
132
  function toListener(message: FromWorker): void {
126
133
  process.send?.(message)
127
134
  }
128
- // The listener gone — its process killed rather than ended — takes this one with it.
129
- process.on('disconnect', () => process.exit(0))
135
+ // 38.96 — EACH REQUEST'S SIGNAL, by request id, until its answer ends: the listener holds the
136
+ // connection, so a hang-up reaches this worker as a `cancel` rather than through bun.
137
+ const HANG_UPS = new Map<number, AbortController>()
138
+
139
+ /** Files away the hang-up of the request `id`, whose answer has ended, and hands it back. */
140
+ function answered(id: number): AbortController | undefined {
141
+ const hangUp = HANG_UPS.get(id)
142
+ HANG_UPS.delete(id)
143
+ return hangUp
144
+ }
145
+
146
+ // EVERY REQUEST IN FLIGHT HANGS UP WITH THIS WORKER, as each would with `abide start`'s process: a
147
+ // handler that spawned under `request().signal` takes its child down, where an exit alone left it
148
+ // running under launchd. The listener ends this worker on every reload, and its own end — killed
149
+ // rather than ended — is a disconnect.
150
+ function hangUpAndExit(): void {
151
+ for (const hangUp of HANG_UPS.values()) hangUp.abort()
152
+ process.exit(0)
153
+ }
154
+ process.on('disconnect', hangUpAndExit)
155
+ process.on('SIGTERM', hangUpAndExit)
130
156
 
131
157
  // THE COMPILE, AS SOMETHING TO WAIT ON. A request that arrives while the worker is still
132
158
  // compiling has to wait for the app rather than be told there is none: the listener forwards
@@ -138,9 +164,9 @@ let app: BootedApp | undefined
138
164
  // 18.1 — the mux over this worker's handlers, and each connection the listener holds.
139
165
  let mux: Mux | undefined
140
166
  // What the mux carries, which `admitsMux` reads: an app with no handler has no mux to open.
141
- let carried: LoadedSources | undefined
167
+ let carried: AppSources | undefined
142
168
  const connections = new Map<number, MuxConnection>()
143
- // What stopped the app compiling, kept rather than thrown: 38.4 keeps the server up and 38.18
169
+ // What stopped the app compiling, kept rather than thrown: 38.4 keeps the server up and 38.17
144
170
  // keeps it up through a bundle that failed, so the failure is something to SERVE.
145
171
  let failure = ''
146
172
 
@@ -159,6 +185,7 @@ async function receive(message: ToWorker): Promise<void> {
159
185
  if (message.kind === 'cancel') {
160
186
  const reading = bodies.get(message.id)
161
187
  bodies.delete(message.id)
188
+ answered(message.id)?.abort()
162
189
  return reading?.reader.cancel()
163
190
  }
164
191
  await opening
@@ -176,7 +203,7 @@ async function receive(message: ToWorker): Promise<void> {
176
203
  })
177
204
  },
178
205
  // NOTHING TO CLOSE: the listener holds the browser's socket, and it outlives this
179
- // worker (38.15). Nothing here stops the mux, either.
206
+ // worker (38.14). Nothing here stops the mux, either.
180
207
  close() {},
181
208
  },
182
209
  )
@@ -217,10 +244,13 @@ const BODILESS = new Set([101, 204, 205, 304])
217
244
  * they gave up.
218
245
  */
219
246
  async function answerAsk(ask: Ask): Promise<void> {
247
+ const hangUp = new AbortController()
248
+ HANG_UPS.set(ask.id, hangUp)
220
249
  const request = new Request(ask.url, {
221
250
  method: ask.method,
222
251
  headers: ask.headers,
223
252
  body: ask.body,
253
+ signal: hangUp.signal,
224
254
  })
225
255
  ASKS.set(request, ask.id)
226
256
  let response: Response
@@ -251,7 +281,10 @@ async function answerAsk(ask: Ask): Promise<void> {
251
281
  carried !== undefined && admitsMux(request, response, carried),
252
282
  whole,
253
283
  })
254
- if (body === null || whole !== undefined) return
284
+ if (body === null || whole !== undefined) {
285
+ answered(ask.id)
286
+ return
287
+ }
255
288
  bodies.set(ask.id, {
256
289
  reader: body.getReader(),
257
290
  html: (response.headers.get('content-type') ?? '').startsWith(
@@ -274,6 +307,7 @@ async function pull(id: number): Promise<void> {
274
307
  const { done, value } = await reading.reader.read()
275
308
  if (done) {
276
309
  bodies.delete(id)
310
+ answered(id)
277
311
  toListener({ kind: 'end', id })
278
312
  return
279
313
  }
@@ -284,6 +318,7 @@ async function pull(id: number): Promise<void> {
284
318
  toListener({ kind: 'chunk', id, bytes })
285
319
  } catch (thrown) {
286
320
  bodies.delete(id)
321
+ answered(id)
287
322
  const why = describe(thrown)
288
323
  if (reading.html) {
289
324
  const shown = new TextEncoder().encode(failureMarkup(why))
@@ -315,79 +350,74 @@ async function open(
315
350
  failure = start.failure
316
351
  if (failure !== '') return
317
352
  const root = start.root
353
+ const compilation = start.compilation
318
354
  try {
319
- const base = `${root}/${VIRTUAL_DIRECTORY}`
320
- const modules = new Map<string, string>()
321
- for (const [name, text] of start.modules)
322
- modules.set(`${base}/${name}`, text)
323
355
  // A page among what compiled — an app of handlers alone has the tables and no client to
324
356
  // bundle.
325
- const paged = modules.has(`${base}/${CLIENT_ENTRY}.ts`)
357
+ const paged = compilation.tables[`${CLIENT_ENTRY}.ts`] !== undefined
326
358
 
327
359
  // ONE PLUGIN, TWO CALLERS: the runtime resolves this worker's `import()` through it and
328
360
  // the bundler resolves the browser's graph through it, so the two lanes cannot disagree
329
361
  // about what a specifier means. Registering it is process-wide — which is a worker rather
330
362
  // than the command, so a reload is a new registry rather than a cache to bust.
331
- const plugin = virtualModules(modules)
332
- Bun.plugin(plugin)
363
+ serveCompilation(root, compilation)
333
364
 
334
- // ASKED FOR TOGETHER. The bundle, the shell, the public directory and the server's own
365
+ // ASKED FOR TOGETHER. The bundle, the public directory and the server's own
335
366
  // tables depend on none of each other — the plugin is registered above, before any of
336
367
  // them asks it anything — and on a reload every one of them is latency the author waits on,
337
368
  // so the bundle is handed over still building (`SourceParts.client`).
338
369
  //
339
- // ONE LISTING of the public directory, for the bundle's externals and for what is served.
340
- // 38.20 — OUT OF THE SOURCE TREE. `abide build` copies these into its output and dev has no
341
- // output, so the address answers where the author's file actually is.
342
- const published = pathsUnder(`${root}/ui/${PUBLIC_DIRECTORY}`)
343
- const publicAddresses = Array.from(
344
- published.keys(),
345
- (name) => `/${name}`,
346
- )
347
- // 38.44 — the app's own plugins, imported beside the reads below rather than ahead of them.
370
+ // ONE LISTING of the public directories — the compile's — for the bundle's externals and for
371
+ // what is served. 38.19 — OUT OF THE SOURCE TREE. `abide build` copies these into its output
372
+ // and dev has no output, so the address answers where the author's file actually is.
373
+ const publicSources = start.published
374
+ const published = publishedUnder(root, publicSources)
375
+ const publicAddresses = [...publicSources.values()]
376
+ // 38.43 — the app's own plugins, imported beside the reads below rather than ahead of them.
348
377
  const bundling = appPlugins(root).then((own) =>
349
378
  bundleInMemory(
350
379
  root,
351
- base,
352
- // 16.28, 16.29 — the browser's view of `server/`, as `abide build` gives it.
353
- [plugin, fenceServer(root, start.modules), ...own],
380
+ // 16.28, 16.29 — the browser's view of the server, as `abide build` gives it.
381
+ [fenceServer(root), abideModules(root, compilation), ...own],
354
382
  paged,
355
383
  publicAddresses,
384
+ compilation,
356
385
  ),
357
386
  )
358
- const [shell, table, handlers] = await Promise.all([
359
- shellUnder(root),
360
- import(
361
- Bun.pathToFileURL(`${base}/server.ts`).href
362
- ) as Promise<ServerTable>,
363
- handlersIn(base),
364
- ])
365
- // 38.18 — THE RELOAD CLIENT IS THIS WORKER'S, not the bundle's, and it goes in whether or
366
- // not there is an entry beside it: a bundle that failed is exactly when a reader needs the
367
- // next save to reach them. `clientTags` writes nothing for a manifest with no entry.
368
- const sources = await appSources({
369
- table,
370
- shell,
371
- handlers,
372
- handlerModule: sourceModuleLoader(root, base),
373
- published,
374
- client: bundling.then((bundle) => ({
375
- manifest: bundle.manifest,
376
- head: `<script type="module" src="${RELOAD_CLIENT_ADDRESS}"></script>`,
377
- framework: (pathname) => served(bundle, pathname),
378
- })),
379
- explained: (thrown) => failedDocument(describe(thrown)),
380
- })
381
-
387
+ const table = await servedTable(root)
388
+ const handlers = table.handlers
382
389
  // 36.13, IN THIS LANE TOO. A throw here lands in `failure`, which is what 38.4 asks
383
390
  // for: the command keeps answering and what it answers names both halves. Without it
384
391
  // `answerRequest` below would silently prefer the file — its public-before-routes
385
392
  // order rests on this collision being impossible.
386
- refuseAddressCollision(table.routes, publicAddresses, 'abide dev')
393
+ refuseAddressCollision(
394
+ table.routes,
395
+ handlers,
396
+ publicSources,
397
+ 'abide dev',
398
+ )
387
399
 
388
- carried = sources
389
- // THE SAME `App` `abide start` boots, so an app's hooks and middleware run in dev too.
390
- app = await bootApp(originUnder(root), async () => sources)
400
+ // THE SAME `App` `abide start` boots, so an app's hooks and middleware run in dev too — and its
401
+ // sources are read INSIDE the boot, as `abide start` reads them: a document's head carries
402
+ // what the resolved configuration says (28.4's app name), which is not resolved before it.
403
+ app = await bootApp(originUnder(root), async () => {
404
+ // 38.17 — THE RELOAD CLIENT IS THIS WORKER'S, not the bundle's, and it goes in whether or
405
+ // not there is an entry beside it: a bundle that failed is exactly when a reader needs the
406
+ // next save to reach them. `clientTags` writes nothing for a manifest with no entry.
407
+ const sources = await appSources({
408
+ table,
409
+ handlers,
410
+ published,
411
+ client: bundling.then((bundle) => ({
412
+ manifest: bundle.manifest,
413
+ head: `<script type="module" src="${RELOAD_CLIENT_ADDRESS}"></script>`,
414
+ framework: (pathname) => served(bundle, pathname),
415
+ })),
416
+ explained: (thrown) => failedDocument(describe(thrown)),
417
+ })
418
+ carried = sources
419
+ return sources
420
+ })
391
421
  // 18.1 — the mux `App.listen` would open, over the app's own record: a frame's `rpc.raw` and
392
422
  // MCP surface are this app's.
393
423
  mux = app.openMux()
@@ -407,9 +437,21 @@ function served(bundle: Bundle, pathname: string): Response | undefined {
407
437
  if (!pathname.startsWith(`${CLIENT_ADDRESS}/`)) return undefined
408
438
  const found = bundle.files.get(pathname.slice(CLIENT_ADDRESS.length + 1))
409
439
  if (found === undefined) return undefined
440
+ const { bytes } = found
441
+ const body =
442
+ typeof bytes === 'function'
443
+ ? new ReadableStream({
444
+ async start(controller) {
445
+ controller.enqueue(
446
+ new TextEncoder().encode(await bytes()),
447
+ )
448
+ controller.close()
449
+ },
450
+ })
451
+ : bytes
410
452
  // NO CACHING AT ALL, which is where dev parts company with 21.5: the names are stable across
411
453
  // rebuilds, so a browser told to keep `entry.js` for a year keeps the app it had at boot.
412
- return new Response(found.bytes, {
454
+ return new Response(body, {
413
455
  headers: { 'content-type': found.type, 'cache-control': 'no-store' },
414
456
  })
415
457
  }
@@ -417,30 +459,28 @@ function served(bundle: Bundle, pathname: string): Response | undefined {
417
459
  /**
418
460
  * The client bundle, built into memory.
419
461
  *
420
- * 38.17 — Unminified, and the entry named by its source rather than content-hashed, so what a
462
+ * 38.16 — Unminified, and the entry named by its source rather than content-hashed, so what a
421
463
  * browser loads and what a stack trace names are the module the author wrote. 38.5's hashes and
422
464
  * manifest are the BUILD's, and the difference is deliberate: a dev chunk that changed has to be
423
465
  * fetched again, and a name that changes with it is a cache the reload cannot clear.
424
466
  *
425
- * A FAILURE IS AN EMPTY BUNDLE AND NOT A THROW (38.18). The pages still render, the document
467
+ * A FAILURE IS AN EMPTY BUNDLE AND NOT A THROW (38.17). The pages still render, the document
426
468
  * still loads the reload client, and the next save is what fixes it.
427
469
  */
428
470
  async function bundleInMemory(
429
471
  root: string,
430
- base: string,
431
472
  plugins: Bun.BunPlugin[],
432
473
  paged: boolean,
433
474
  publicAddresses: string[],
475
+ compilation: Compilation,
434
476
  ): Promise<Bundle> {
435
477
  const bundle: Bundle = { files: new Map(), manifest: NO_MANIFEST }
436
478
  if (!paged) return bundle
437
479
  const built = await Bun.build({
438
- // 36.15's addresses off the source tree this lane answers them out of (38.20).
439
- ...clientBuildOptions(base, plugins, publicAddresses),
440
- // The app's own root, because `Bun.build` opens this directory and a virtual one is not
441
- // there to open. What it decides is where the output names are rooted, and the naming
442
- // below makes them flat anyway.
443
- root,
480
+ // 36.15's addresses off the source tree this lane answers them out of (38.19).
481
+ ...clientBuildOptions(root, plugins, publicAddresses),
482
+ // 38.97 — a debugger in the browser reads the `.abide` (`withAbideSources`).
483
+ sourcemap: 'linked',
444
484
  naming: {
445
485
  entry: '[name].[ext]',
446
486
  chunk: '[name]-[hash].[ext]',
@@ -448,7 +488,7 @@ async function bundleInMemory(
448
488
  },
449
489
  throw: false,
450
490
  })
451
- // THE TERMINAL RATHER THAN THE DOCUMENT. 38.18 keeps the server up, and a page that still
491
+ // THE TERMINAL RATHER THAN THE DOCUMENT. 38.17 keeps the server up, and a page that still
452
492
  // paints is what it keeps up — so the pages stay served, unhydrated, and the author reads the
453
493
  // build error where they started the command.
454
494
  if (!built.success) {
@@ -465,103 +505,24 @@ async function bundleInMemory(
465
505
  const output = built.outputs[index] as Bun.BuildArtifact
466
506
  const name = output.path.slice(output.path.lastIndexOf('/') + 1)
467
507
  const bytes = read[index] as ArrayBuffer
468
- bundle.files.set(name, {
469
- bytes: name.endsWith('.css')
470
- ? stylesheetUnderMount(new TextDecoder().decode(bytes))
471
- : bytes,
472
- type: output.type,
473
- })
474
- }
475
- // `base` AS GIVEN, with no real path taken: every route module is one the plugin served, and
476
- // the metafile names those under the plugin's namespace exactly as the plugin spelled them.
477
- bundle.manifest = manifestOf(built.metafile as Bun.BuildMetafile, base)
478
- return bundle
479
- }
480
-
481
- /**
482
- * The plugin serving the compiled modules out of `modules`, for the runtime and the bundler alike.
483
- *
484
- * A module in the map wins; everything else is resolved from where that module PRETENDS to be,
485
- * which is inside the app's package — so `abide`, `#ui/…` and a stylesheet all answer exactly as
486
- * they do for a build on disk.
487
- */
488
- function virtualModules(modules: Map<string, string>): Bun.BunPlugin {
489
- return {
490
- name: 'abide-dev',
491
- setup(build) {
492
- build.onResolve({ filter: /.*/ }, (args) => {
493
- if (modules.has(args.path))
494
- return { path: args.path, namespace: 'abide-dev' }
495
- // The importer carries its namespace in front of the path when it is one of
496
- // ours — and the runtime puts a `/` in front of that again, where the bundler
497
- // does not. Both spellings are stripped here because a `file://` URL refuses to
498
- // take either as a base, and the one that was missed cost the whole route table:
499
- // every dynamic import in it read as a module nobody could find.
500
- const importer = args.importer.replace(/^\/?abide-dev:/, '')
501
- if (!modules.has(importer)) return undefined
502
- const from = importer.slice(0, importer.lastIndexOf('/'))
503
- if (args.path.startsWith('.')) {
504
- const beside = Bun.fileURLToPath(
505
- new URL(args.path, Bun.pathToFileURL(`${from}/`)),
506
- )
507
- if (modules.has(beside))
508
- return { path: beside, namespace: 'abide-dev' }
509
- }
510
- // OUT OF THE MAP AND INTO THE PACKAGE. Resolved from the virtual directory rather
511
- // than left to the default, which has an `abide-dev:` prefix to walk up from and
512
- // finds nothing.
513
- try {
514
- return {
515
- path: Bun.resolveSync(args.path, from),
516
- namespace: 'file',
517
- }
518
- } catch {
519
- return undefined
520
- }
521
- })
522
- // ONLY THE RUNTIME'S LOADS ARE REWRITTEN: the bundler asks `onResolve` above for a `#`
523
- // specifier itself, and a reload's loads are nearly all the bundler's — 181 of 183 on
524
- // the docs app, where the rewrite's parse was ~25 ms of each reload.
525
- const bundling = 'config' in build
526
- build.onLoad({ filter: /.*/, namespace: 'abide-dev' }, (args) => ({
527
- contents: bundling
528
- ? (modules.get(args.path) ?? '')
529
- : withSeamsResolved(
530
- modules.get(args.path) ?? '',
531
- args.path,
532
- ),
533
- // 34.4 — a component's scoped styles are a stylesheet the bundle collects.
534
- loader: args.path.endsWith('.css') ? 'css' : 'ts',
535
- }))
536
- },
537
- }
538
- }
539
-
540
- // A `#` SPECIFIER IN A MODULE FROM THE MAP, resolved before the module is handed over. The runtime
541
- // takes a `#` specifier to the package's `imports` itself rather than through this plugin, and from
542
- // a module in the map it takes the target as an exact file — so the extensionless
543
- // `#server/rpc/contacts` a component writes found nothing, where the bundler, which does ask the
544
- // plugin, probed its way to the `.ts` through the same `Bun.resolveSync` — so the rewrite is the
545
- // runtime's alone, and both lanes land on the same path.
546
- const TRANSPILER = new Bun.Transpiler({ loader: 'ts' })
547
-
548
- function withSeamsResolved(source: string, path: string): string {
549
- if (!path.endsWith('.ts')) return source
550
- const from = path.slice(0, path.lastIndexOf('/'))
551
- let resolved = source
552
- for (const { path: specifier } of TRANSPILER.scanImports(source)) {
553
- if (!specifier.startsWith('#')) continue
554
- let target: string
555
- try {
556
- target = Bun.resolveSync(specifier, from)
557
- } catch {
558
- continue
508
+ let served: ArrayBuffer | string | (() => Promise<string>) = bytes
509
+ if (name.endsWith('.css'))
510
+ served = stylesheetUnderMount(new TextDecoder().decode(bytes))
511
+ // 38.97 — PLACED ON THE FIRST ASK: only a debugger reads a map, and placing every chunk's
512
+ // up front was ~45 ms of each reload on the docs app, whether or not one was open.
513
+ else if (output.kind === 'sourcemap') {
514
+ let placed: Promise<string> | undefined
515
+ served = () =>
516
+ (placed ??= withAbideSources(
517
+ new TextDecoder().decode(bytes),
518
+ compilation,
519
+ root,
520
+ ))
559
521
  }
560
- resolved = resolved
561
- .replaceAll(`'${specifier}'`, `'${target}'`)
562
- .replaceAll(`"${specifier}"`, `"${target}"`)
522
+ bundle.files.set(name, { bytes: served, type: output.type })
563
523
  }
564
- return resolved
524
+ bundle.manifest = manifestOf(built.metafile as Bun.BuildMetafile, root)
525
+ return bundle
565
526
  }
566
527
 
567
528
  /**
@@ -592,7 +553,7 @@ function failureMarkup(why: string): string {
592
553
  return `<main><h1>abide dev</h1><pre>${escapeText(why)}</pre></main>`
593
554
  }
594
555
 
595
- // 38.16's other end. The listener force-closes every live socket when it replaces this worker, so
556
+ // 38.15's other end. The listener force-closes every live socket when it replaces this worker, so
596
557
  // a CLOSE is the rebuild and the reconnect that follows is the new app — there is no message to
597
558
  // send and nothing to version. A reader who never opened the socket is told nothing, which is the
598
559
  // same as before the save.
@@ -0,0 +1,4 @@
1
+ // 38.46 — A SCOPED NAME IS NAMED BY ITS LAST PART: `@acme/ledger` is `ledger`, as `bunx` calls it.
2
+ export function executableName(packageName: string | undefined): string {
3
+ return (packageName ?? 'app').replace(/^@[^/]+\//, '')
4
+ }
@@ -1,48 +1,50 @@
1
- // 16.28 AND 16.29 — WHAT THE BROWSER'S BUNDLE SEES OF `server/`. A page imports a handler from the
2
- // module the app wrote it in, and the server's render reaches that module as written; the bundle a
3
- // browser loads reaches it through here instead, and gets the wrapper the build wrote in its place
4
- // (`WIRE_DIRECTORY`). Anything else under `server/` is refused, so a secret, a database client or
5
- // the server barrel cannot ride into the browser by being imported from the wrong file.
1
+ // 16.29 — WHAT THE BROWSER'S BUNDLE SEES OF THE SERVER: nothing under a `server` directory, at any
2
+ // depth, so a secret or a database client cannot ride into the browser by being imported from the
3
+ // wrong file. A handler crosses as the wrapper its file's browser module carries in place of the
4
+ // server block (34.23), which the compiler wrote; this is the fence around everything else.
6
5
  //
7
6
  // A PLUGIN ON THE BUNDLE, NOT A REWRITE OF THE PAGES. A rewrite reaches the specifiers the compiler
8
- // emitted; the bundle reaches every edge — a `#ui` helper importing a handler included.
7
+ // emitted; the bundle reaches every edge — a helper the page imports, importing a server module,
8
+ // included.
9
+ //
10
+ // REGISTERED AHEAD OF `abideModules`, AND IN ITS NAMESPACES: a load is answered by the first plugin
11
+ // that answers it, and a component or a file's text is loaded there rather than as a `file`.
12
+
13
+ import { SERVER_DIRECTORY } from '#shared/RESERVED_DIRECTORIES.ts'
14
+ import { placeOf } from '#shared/routePattern.ts'
15
+ import {
16
+ COMPONENT_NAMESPACE,
17
+ STYLE_NAMESPACE,
18
+ TEXT_NAMESPACE,
19
+ } from './abideModules.ts'
9
20
 
10
- import { realpathSync } from 'node:fs' // bun exposes no realpath of its own
11
- import { WIRE_DIRECTORY } from './CLIENT_BUILD.ts'
21
+ // Every namespace a browser bundle loads a file of the app's in: its modules, its components, a
22
+ // file's text and a component's stylesheet.
23
+ const FENCED = ['file', COMPONENT_NAMESPACE, TEXT_NAMESPACE, STYLE_NAMESPACE]
12
24
 
13
25
  /**
14
- * The bundler plugin handing the browser each handler's wrapper and refusing the rest of `server/`.
26
+ * The bundler plugin refusing everything under a `server` directory to the browser's bundle.
15
27
  *
16
- * `modules` is the compiled app, keyed by path below the output directory as `compileApp` hands it
17
- * back — where the wrappers are.
28
+ * `root` is its real path (`appRoot`), which is how the bundler names a file. Listed before
29
+ * `abideModules`.
18
30
  */
19
- export function fenceServer(
20
- root: string,
21
- modules: Map<string, string>,
22
- ): Bun.BunPlugin {
23
- // BOTH SPELLINGS OF THE ROOT, because the bundler names a file with its symlinks resolved and an
24
- // app under `/tmp` is `/private/tmp` to it.
25
- const roots = new Set([root, realpathSync(root)])
31
+ export function fenceServer(root: string): Bun.BunPlugin {
32
+ // Anything with a `server` directory in its path below the root, and `placeOf` decides — a load
33
+ // it passes on falls through to the next plugin's, or the bundler's own.
34
+ const filter = new RegExp(
35
+ `^${RegExp.escape(root)}/(?:.*/)?${SERVER_DIRECTORY}/`,
36
+ )
26
37
  return {
27
38
  name: 'abide-fence',
28
39
  setup(build) {
29
- for (const at of roots)
30
- build.onLoad(
31
- {
32
- filter: new RegExp(`^${RegExp.escape(at)}/server/`),
33
- },
34
- (args) => {
35
- const source = args.path.slice(at.length + 1)
36
- const wrapper = modules.get(
37
- `${WIRE_DIRECTORY}/${source}`,
38
- )
39
- if (wrapper === undefined)
40
- throw new Error(
41
- `abide: ${source} is server code, and the browser's bundle imports it. A browser may import a handler from a module under server/rpc and nothing else under server/. RULEBOOK 16.29.`,
42
- )
43
- return { contents: wrapper, loader: 'ts' }
44
- },
45
- )
40
+ for (const namespace of FENCED)
41
+ build.onLoad({ filter, namespace }, (args) => {
42
+ const below = args.path.slice(root.length + 1)
43
+ if (placeOf(below) !== 'server') return undefined
44
+ throw new Error(
45
+ `abide: ${below.replace(/::\w+$/, '')} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
46
+ )
47
+ })
46
48
  },
47
49
  }
48
50
  }