@abide/abide 0.104.0 → 0.105.1

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +17 -17
  4. package/docs/RULEBOOK.md +50 -12
  5. package/docs/abide.md +9 -9
  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 +45 -86
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -20
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +11 -9
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +180 -512
  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/readSources.ts +27 -42
  25. package/src/compiler/readsIn.ts +5 -16
  26. package/src/compiler/resolverIn.ts +77 -0
  27. package/src/server/abideModules.ts +92 -123
  28. package/src/server/answerRequest.ts +18 -4
  29. package/src/server/answerSocket.ts +136 -76
  30. package/src/server/answerValue.ts +28 -15
  31. package/src/server/appTree.ts +49 -71
  32. package/src/server/claudeApi.ts +2 -5
  33. package/src/server/clientBundle.ts +198 -0
  34. package/src/server/coerceArgs.ts +1 -1
  35. package/src/server/commands.ts +93 -57
  36. package/src/server/createApp.ts +90 -16
  37. package/src/server/deferredBuilds.ts +175 -0
  38. package/src/server/devWorker.ts +107 -319
  39. package/src/server/faceOf.ts +120 -0
  40. package/src/server/fenceServer.ts +10 -18
  41. package/src/server/index.ts +3 -0
  42. package/src/server/placement.ts +167 -0
  43. package/src/server/remote.ts +14 -42
  44. package/src/server/render.ts +25 -17
  45. package/src/server/renderComponent.ts +9 -5
  46. package/src/server/requestScope.ts +10 -4
  47. package/src/server/responseRelay.ts +389 -0
  48. package/src/server/runCompiled.ts +9 -12
  49. package/src/server/runDesktop.ts +6 -9
  50. package/src/server/serveBuild.ts +26 -73
  51. package/src/server/serveDev.ts +19 -176
  52. package/src/server/streamEvent.ts +29 -0
  53. package/src/server/withAbideSources.ts +2 -1
  54. package/src/shared/HandlerRoute.ts +68 -0
  55. package/src/shared/REFUSAL_KEY.ts +5 -0
  56. package/src/shared/answerOf.ts +105 -78
  57. package/src/shared/filledPath.ts +1 -9
  58. package/src/shared/framing.ts +127 -128
  59. package/src/shared/queryText.ts +15 -7
  60. package/src/shared/reactive/Cursor.ts +4 -2
  61. package/src/shared/reactive/Reactive.ts +285 -197
  62. package/src/shared/reactive/index.ts +11 -8
  63. package/src/shared/reactive/pairReactive.ts +1 -1
  64. package/src/shared/reactive/scoped.ts +4 -3
  65. package/src/shared/reactive/share.ts +11 -1
  66. package/src/shared/routePattern.ts +0 -123
  67. package/src/shared/rpc.ts +17 -24
  68. package/src/shared/rpcRequest.ts +18 -47
  69. package/src/shared/takeEarlyCall.ts +12 -37
  70. package/src/shared/url.ts +1 -1
  71. package/src/ui/Level.ts +276 -0
  72. package/src/ui/List.ts +17 -17
  73. package/src/ui/Router.ts +20 -87
  74. package/src/ui/applyHead.ts +92 -79
  75. package/src/ui/index.ts +4 -2
  76. package/src/ui/leave.ts +5 -0
  77. package/src/ui/reconcile.ts +23 -104
  78. package/src/ui/regions.ts +27 -2
  79. package/src/ui/rows.ts +123 -2
  80. package/src/ui/wire.ts +12 -27
  81. package/src/compiler/hooksCalled.ts +0 -35
  82. package/src/server/clientBuildOptions.ts +0 -54
  83. package/src/server/refuseAddressCollision.ts +0 -37
  84. package/src/shared/reactive/chunksOfStream.ts +0 -33
  85. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -12,55 +12,44 @@
12
12
  // module's imports resolve from the source that wrote them in either.
13
13
  //
14
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
- // 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.24).
15
+ // 38.36) — `responseRelay`'s worker half, `serveOver`.
18
16
  //
19
17
  // 18.1, 18.3 — AND SO DOES A SOCKET. The listener holds the browser's web socket, which has to
20
18
  // outlive this worker; what crosses is each frame, tagged with the connection it came over, and
21
19
  // what goes back is each frame this worker's mux sends. The mux is the one `abide start` runs.
22
20
 
23
- import { CLIENT_ADDRESS, DEV_ADDRESS } from '#shared/ADDRESSES.ts'
21
+ import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
24
22
  import { escapeText } from '#shared/escapeText.ts'
25
23
  import {
26
- abideModules,
27
24
  type Compilation,
28
25
  serveCompilation,
29
26
  servedTable,
30
27
  } from './abideModules.ts'
31
28
  import { type AppSources, appSources } from './answerRequest.ts'
32
29
  import type { Mux, MuxConnection } from './answerSocket.ts'
33
- import { appPlugins } from './appPlugins.ts'
34
30
  import { publishedUnder } from './appTree.ts'
35
- import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
36
31
  import {
37
- clientBuildOptions,
38
- stylesheetUnderMount,
39
- } from './clientBuildOptions.ts'
32
+ bundleServer,
33
+ type ClientBundle,
34
+ clientBundle,
35
+ type ServedFile,
36
+ } from './clientBundle.ts'
40
37
  import { type BootedApp, bootApp, originUnder } from './createApp.ts'
41
38
  import { keepServing } from './faultPolicy.ts'
42
- import { fenceServer } from './fenceServer.ts'
43
- import { type Manifest, manifestOf, NO_MANIFEST } from './manifestOf.ts'
39
+ import { type Manifest, NO_MANIFEST } from './manifestOf.ts'
44
40
  import { admitsMux } from './muxUpgrade.ts'
45
- import { refuseAddressCollision } from './refuseAddressCollision.ts'
46
41
  import { LISTENING } from './requestScope.ts'
42
+ import {
43
+ describe,
44
+ type FromRelay,
45
+ serveOver,
46
+ type ToRelay,
47
+ } from './responseRelay.ts'
47
48
  import { withAbideSources } from './withAbideSources.ts'
48
49
 
49
50
  // The reload client, which the worker serves and every document it answers loads.
50
51
  const RELOAD_CLIENT_ADDRESS = `${DEV_ADDRESS}/client.js`
51
52
 
52
- // The bundle this worker answers `/__abide/client/…` out of, the type each name is served as, and
53
- // the chunks each route module needs (38.28).
54
- type Bundle = {
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
- >
61
- manifest: Manifest
62
- }
63
-
64
53
  /**
65
54
  * A message from the listener to this worker.
66
55
  *
@@ -77,9 +66,7 @@ export type ToWorker =
77
66
  published: Map<string, string>
78
67
  failure: string
79
68
  }
80
- | ({ kind: 'request' } & Ask)
81
- | { kind: 'pull'; id: number }
82
- | { kind: 'cancel'; id: number }
69
+ | ToRelay
83
70
  | {
84
71
  kind: 'socket-open'
85
72
  connection: number
@@ -92,37 +79,12 @@ export type ToWorker =
92
79
  /**
93
80
  * A message from this worker to the listener.
94
81
  *
95
- * Out: ready, a frame the mux sends a browser, a request's idle timeout raised, and its head, each
96
- * chunk, and its end.
82
+ * Out: ready, a frame the mux sends a browser, and what answers a request (`FromRelay`).
97
83
  */
98
84
  export type FromWorker =
99
85
  | { kind: 'ready' }
100
86
  | { kind: 'socket-send'; connection: number; text: string }
101
- | { kind: 'timeout'; id: number; seconds: number }
102
- | {
103
- kind: 'head'
104
- id: number
105
- status: number
106
- statusText: string
107
- headers: [string, string][]
108
- empty: boolean
109
- /** 37.3 — whether the listener upgrades this request to the mux (`admitsMux`). */
110
- admitted: boolean
111
- /** A body whose length the answer declared, sent with its head rather than pulled. */
112
- whole: ArrayBuffer | undefined
113
- }
114
- | { kind: 'chunk'; id: number; bytes: ArrayBuffer }
115
- | { kind: 'end'; id: number }
116
- | { kind: 'fail'; id: number; message: string }
117
-
118
- /** A request as it crosses to the worker: its id, and what the worker rebuilds it from. */
119
- export type Ask = {
120
- id: number
121
- method: string
122
- url: string
123
- headers: [string, string][]
124
- body: ArrayBuffer | null
125
- }
87
+ | FromRelay
126
88
 
127
89
  /**
128
90
  * Sends a message to the listener.
@@ -132,27 +94,6 @@ export type Ask = {
132
94
  function toListener(message: FromWorker): void {
133
95
  process.send?.(message)
134
96
  }
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)
156
97
 
157
98
  // THE COMPILE, AS SOMETHING TO WAIT ON. A request that arrives while the worker is still
158
99
  // compiling has to wait for the app rather than be told there is none: the listener forwards
@@ -170,6 +111,38 @@ const connections = new Map<number, MuxConnection>()
170
111
  // keeps it up through a bundle that failed, so the failure is something to SERVE.
171
112
  let failure = ''
172
113
 
114
+ // Each request the listener sends, answered by the app this worker booted.
115
+ const relay = serveOver(
116
+ { postMessage: toListener },
117
+ {
118
+ async fetch(request) {
119
+ await opening
120
+ return app === undefined
121
+ ? failedDocument(failure)
122
+ : await app.fetch(request)
123
+ },
124
+ admits: (request, response) =>
125
+ carried !== undefined && admitsMux(request, response, carried),
126
+ failed: failedDocument,
127
+ failureMarkup,
128
+ },
129
+ )
130
+
131
+ // 16.22 — THE LISTENER HOLDS THE CONNECTION, so the idle timeout a call raises is raised there, on
132
+ // the request bun handed it.
133
+ LISTENING.timeout = relay.timeout
134
+
135
+ // EVERY REQUEST IN FLIGHT HANGS UP WITH THIS WORKER, as each would with `abide start`'s process: a
136
+ // handler that spawned under `request().signal` takes its child down, where an exit alone left it
137
+ // running under launchd. The listener ends this worker on every reload, and its own end — killed
138
+ // rather than ended — is a disconnect.
139
+ function hangUpAndExit(): void {
140
+ relay.hangUpAll()
141
+ process.exit(0)
142
+ }
143
+ process.on('disconnect', hangUpAndExit)
144
+ process.on('SIGTERM', hangUpAndExit)
145
+
173
146
  keepServing()
174
147
 
175
148
  process.on('message', (message: ToWorker) => void receive(message))
@@ -181,15 +154,13 @@ async function receive(message: ToWorker): Promise<void> {
181
154
  toListener({ kind: 'ready' })
182
155
  return
183
156
  }
184
- if (message.kind === 'pull') return pull(message.id)
185
- if (message.kind === 'cancel') {
186
- const reading = bodies.get(message.id)
187
- bodies.delete(message.id)
188
- answered(message.id)?.abort()
189
- return reading?.reader.cancel()
190
- }
157
+ if (
158
+ message.kind === 'request' ||
159
+ message.kind === 'pull' ||
160
+ message.kind === 'cancel'
161
+ )
162
+ return relay.take(message)
191
163
  await opening
192
- if (message.kind === 'request') return answerAsk(message)
193
164
  if (message.kind === 'socket-open') {
194
165
  const numbered = message.connection
195
166
  const connection = mux?.connect(
@@ -218,123 +189,6 @@ async function receive(message: ToWorker): Promise<void> {
218
189
  connection.close()
219
190
  }
220
191
 
221
- // Each answer whose head has gone out, by request id: what its chunks are read from, and whether a
222
- // failure in the middle can still be shown as markup.
223
- const bodies = new Map<
224
- number,
225
- { reader: ReadableStreamDefaultReader<Uint8Array>; html: boolean }
226
- >()
227
-
228
- // 16.22 — THE LISTENER HOLDS THE CONNECTION, so the idle timeout a call raises is raised there, on
229
- // the request bun handed it: each request built here is known by the ask it was built from.
230
- const ASKS = new WeakMap<Request, number>()
231
- LISTENING.timeout = (received, seconds) => {
232
- const id = ASKS.get(received)
233
- if (id !== undefined) toListener({ kind: 'timeout', id, seconds })
234
- }
235
-
236
- // A status whose response has no body to send, which `Response` refuses to be built with one.
237
- const BODILESS = new Set([101, 204, 205, 304])
238
-
239
- /**
240
- * Answers one request the listener sent, sending back its head and holding its body for pulls.
241
- *
242
- * A THROW BEFORE THE HEAD IS AN ANSWER, not a dropped message. The listener is waiting on a head
243
- * for this id and nothing else will send one, so an escape left the reader's tab hanging until
244
- * they gave up.
245
- */
246
- async function answerAsk(ask: Ask): Promise<void> {
247
- const hangUp = new AbortController()
248
- HANG_UPS.set(ask.id, hangUp)
249
- const request = new Request(ask.url, {
250
- method: ask.method,
251
- headers: ask.headers,
252
- body: ask.body,
253
- signal: hangUp.signal,
254
- })
255
- ASKS.set(request, ask.id)
256
- let response: Response
257
- try {
258
- response =
259
- app === undefined
260
- ? failedDocument(failure)
261
- : await app.fetch(request)
262
- } catch (thrown) {
263
- response = failedDocument(describe(thrown))
264
- }
265
- const body = BODILESS.has(response.status) ? null : response.body
266
- // A DECLARED LENGTH IS A BODY ALREADY WHOLE, and it crosses with its head: re-streamed through the
267
- // listener, bun meets a first read that has to wait on this worker and answers it chunked,
268
- // dropping the length the app declared.
269
- const whole =
270
- body !== null && response.headers.has('content-length')
271
- ? await response.arrayBuffer()
272
- : undefined
273
- toListener({
274
- kind: 'head',
275
- id: ask.id,
276
- status: response.status,
277
- statusText: response.statusText,
278
- headers: [...response.headers.entries()],
279
- empty: body === null,
280
- admitted:
281
- carried !== undefined && admitsMux(request, response, carried),
282
- whole,
283
- })
284
- if (body === null || whole !== undefined) {
285
- answered(ask.id)
286
- return
287
- }
288
- bodies.set(ask.id, {
289
- reader: body.getReader(),
290
- html: (response.headers.get('content-type') ?? '').startsWith(
291
- 'text/html',
292
- ),
293
- })
294
- }
295
-
296
- /**
297
- * Sends the listener the next chunk of an answer's body, or its end.
298
- *
299
- * ONE CHUNK PER ASK. A throw in the middle ends the stream in an error, as `abide start` does —
300
- * and where the answer is html a handler streams, what broke goes out as markup first, a reader
301
- * shown a blank tab otherwise being told nothing. A page is never here: it is built whole (41.1).
302
- */
303
- async function pull(id: number): Promise<void> {
304
- const reading = bodies.get(id)
305
- if (reading === undefined) return
306
- try {
307
- const { done, value } = await reading.reader.read()
308
- if (done) {
309
- bodies.delete(id)
310
- answered(id)
311
- toListener({ kind: 'end', id })
312
- return
313
- }
314
- // THE VIEW'S OWN BYTES. A chunk may be a view on a buffer its producer still owns — a
315
- // compressor's `Buffer` is a slice of a shared pool, and `Buffer#slice` copies nothing — so
316
- // sending `value.buffer` sent the whole pool.
317
- const bytes = new Uint8Array(value).buffer as ArrayBuffer
318
- toListener({ kind: 'chunk', id, bytes })
319
- } catch (thrown) {
320
- bodies.delete(id)
321
- answered(id)
322
- const why = describe(thrown)
323
- if (reading.html) {
324
- const shown = new TextEncoder().encode(failureMarkup(why))
325
- .buffer as ArrayBuffer
326
- toListener({ kind: 'chunk', id, bytes: shown })
327
- }
328
- toListener({ kind: 'fail', id, message: why })
329
- }
330
- }
331
-
332
- function describe(thrown: unknown): string {
333
- return thrown instanceof Error
334
- ? (thrown.stack ?? thrown.message)
335
- : String(thrown)
336
- }
337
-
338
192
  /**
339
193
  * Boots the app over the modules the listener sent, or records why it could not.
340
194
  *
@@ -352,10 +206,6 @@ async function open(
352
206
  const root = start.root
353
207
  const compilation = start.compilation
354
208
  try {
355
- // A page among what compiled — an app of handlers alone has the tables and no client to
356
- // bundle.
357
- const paged = compilation.tables[`${CLIENT_ENTRY}.ts`] !== undefined
358
-
359
209
  // ONE PLUGIN, TWO CALLERS: the runtime resolves this worker's `import()` through it and
360
210
  // the bundler resolves the browser's graph through it, so the two lanes cannot disagree
361
211
  // about what a specifier means. Registering it is process-wide — which is a worker rather
@@ -370,36 +220,10 @@ async function open(
370
220
  // ONE LISTING of the public directories — the compile's — for the bundle's externals and for
371
221
  // what is served. 38.19 — OUT OF THE SOURCE TREE. `abide build` copies these into its output
372
222
  // 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.
377
- const bundling = appPlugins(root).then((own) =>
378
- bundleInMemory(
379
- root,
380
- // 16.28, 16.29 — the browser's view of the server, as `abide build` gives it.
381
- [
382
- fenceServer(root, compilation.mounted),
383
- abideModules(root, compilation),
384
- ...own,
385
- ],
386
- paged,
387
- publicAddresses,
388
- compilation,
389
- ),
390
- )
223
+ const published = publishedUnder(root, start.published)
224
+ const bundling = bundleInMemory(root, compilation, start.published)
391
225
  const table = await servedTable(root)
392
226
  const handlers = table.handlers
393
- // 36.13, IN THIS LANE TOO. A throw here lands in `failure`, which is what 38.4 asks
394
- // for: the command keeps answering and what it answers names both halves. Without it
395
- // `answerRequest` below would silently prefer the file — its public-before-routes
396
- // order rests on this collision being impossible.
397
- refuseAddressCollision(
398
- table.routes,
399
- handlers,
400
- publicSources,
401
- 'abide dev',
402
- )
403
227
 
404
228
  // THE SAME `App` `abide start` boots, so an app's hooks and middleware run in dev too — and its
405
229
  // sources are read INSIDE the boot, as `abide start` reads them: a document's head carries
@@ -415,7 +239,16 @@ async function open(
415
239
  client: bundling.then((bundle) => ({
416
240
  manifest: bundle.manifest,
417
241
  head: `<script type="module" src="${RELOAD_CLIENT_ADDRESS}"></script>`,
418
- framework: (pathname) => served(bundle, pathname),
242
+ framework: (pathname, request) =>
243
+ pathname === RELOAD_CLIENT_ADDRESS
244
+ ? new Response(RELOAD_CLIENT, {
245
+ headers: {
246
+ 'content-type':
247
+ 'text/javascript; charset=utf-8',
248
+ 'cache-control': 'no-store',
249
+ },
250
+ })
251
+ : bundle.framework(pathname, request),
419
252
  })),
420
253
  explained: (thrown) => failedDocument(describe(thrown)),
421
254
  })
@@ -430,103 +263,58 @@ async function open(
430
263
  }
431
264
  }
432
265
 
433
- function served(bundle: Bundle, pathname: string): Response | undefined {
434
- if (pathname === RELOAD_CLIENT_ADDRESS)
435
- return new Response(RELOAD_CLIENT, {
436
- headers: {
437
- 'content-type': 'text/javascript; charset=utf-8',
438
- 'cache-control': 'no-store',
439
- },
440
- })
441
- if (!pathname.startsWith(`${CLIENT_ADDRESS}/`)) return undefined
442
- const found = bundle.files.get(pathname.slice(CLIENT_ADDRESS.length + 1))
443
- if (found === undefined) return undefined
444
- const { bytes } = found
445
- const body =
446
- typeof bytes === 'function'
447
- ? new ReadableStream({
448
- async start(controller) {
449
- controller.enqueue(
450
- new TextEncoder().encode(await bytes()),
451
- )
452
- controller.close()
453
- },
454
- })
455
- : bytes
456
- // NO CACHING AT ALL, which is where dev parts company with 21.5: the names are stable across
457
- // rebuilds, so a browser told to keep `entry.js` for a year keeps the app it had at boot.
458
- return new Response(body, {
459
- headers: { 'content-type': found.type, 'cache-control': 'no-store' },
460
- })
461
- }
462
-
463
266
  /**
464
- * The client bundle, built into memory.
465
- *
466
- * 38.16 — Unminified, and the entry named by its source rather than content-hashed, so what a
467
- * browser loads and what a stack trace names are the module the author wrote. 38.5's hashes and
468
- * manifest are the BUILD's, and the difference is deliberate: a dev chunk that changed has to be
469
- * fetched again, and a name that changes with it is a cache the reload cannot clear.
267
+ * The client bundle (`clientBundle`), built into memory, and what answers it.
470
268
  *
471
- * A FAILURE IS AN EMPTY BUNDLE AND NOT A THROW (38.17). The pages still render, the document
472
- * still loads the reload client, and the next save is what fixes it.
269
+ * A FAILURE IS AN EMPTY BUNDLE AND NOT A THROW (38.17). The pages still render, the document still
270
+ * loads the reload client, and the next save is what fixes it — the error goes to THE TERMINAL
271
+ * RATHER THAN THE DOCUMENT, where the author started the command.
473
272
  */
474
273
  async function bundleInMemory(
475
274
  root: string,
476
- plugins: Bun.BunPlugin[],
477
- paged: boolean,
478
- publicAddresses: string[],
479
275
  compilation: Compilation,
480
- ): Promise<Bundle> {
481
- const bundle: Bundle = { files: new Map(), manifest: NO_MANIFEST }
482
- if (!paged) return bundle
483
- const built = await Bun.build({
484
- // 36.15's addresses off the source tree this lane answers them out of (38.19).
485
- ...clientBuildOptions(root, plugins, publicAddresses),
486
- // 38.97 — a debugger in the browser reads the `.abide` (`withAbideSources`).
487
- sourcemap: 'linked',
488
- naming: {
489
- entry: '[name].[ext]',
490
- chunk: '[name]-[hash].[ext]',
491
- asset: '[name]-[hash].[ext]',
492
- },
493
- throw: false,
494
- })
495
- // THE TERMINAL RATHER THAN THE DOCUMENT. 38.17 keeps the server up, and a page that still
496
- // paints is what it keeps up — so the pages stay served, unhydrated, and the author reads the
497
- // build error where they started the command.
498
- if (!built.success) {
276
+ published: Map<string, string>,
277
+ ): Promise<{ manifest: Manifest; framework: AppSources['framework'] }> {
278
+ let bundle: ClientBundle
279
+ try {
280
+ bundle = await clientBundle(root, compilation, published, 'dev')
281
+ } catch (thrown) {
282
+ const logs = thrown instanceof AggregateError ? thrown.errors : []
499
283
  console.error(
500
- `abide dev: the client bundle failed\n${built.logs.map((log) => String(log)).join('\n')}`,
284
+ `${(thrown as Error).message}\n${logs.map((log) => String(log)).join('\n')}`,
501
285
  )
502
- return bundle
286
+ bundle = { files: new Map(), manifest: NO_MANIFEST }
503
287
  }
504
- // The chunks are independent, so they are read at once rather than one after another.
505
- const read = await Promise.all(
506
- built.outputs.map((output) => output.arrayBuffer()),
507
- )
508
- for (let index = 0; index < built.outputs.length; index += 1) {
509
- const output = built.outputs[index] as Bun.BuildArtifact
510
- const name = output.path.slice(output.path.lastIndexOf('/') + 1)
511
- const bytes = read[index] as ArrayBuffer
512
- let served: ArrayBuffer | string | (() => Promise<string>) = bytes
513
- if (name.endsWith('.css'))
514
- served = stylesheetUnderMount(new TextDecoder().decode(bytes))
515
- // 38.97 — PLACED ON THE FIRST ASK: only a debugger reads a map, and placing every chunk's
516
- // up front was ~45 ms of each reload on the docs app, whether or not one was open.
517
- else if (output.kind === 'sourcemap') {
288
+ const files = new Map<string, ServedFile>()
289
+ for (const [name, { bytes, type, kind }] of bundle.files) {
290
+ let body: () => BodyInit = () => bytes
291
+ // 38.97 — PLACED ON THE FIRST ASK: only a debugger reads a map, and placing every chunk's up
292
+ // front was ~45 ms of each reload on the docs app, whether or not one was open.
293
+ if (kind === 'sourcemap') {
518
294
  let placed: Promise<string> | undefined
519
- served = () =>
520
- (placed ??= withAbideSources(
521
- new TextDecoder().decode(bytes),
522
- compilation,
523
- root,
524
- ))
295
+ body = () =>
296
+ new ReadableStream({
297
+ async start(controller) {
298
+ placed ??= withAbideSources(
299
+ new TextDecoder().decode(bytes),
300
+ compilation,
301
+ root,
302
+ )
303
+ controller.enqueue(
304
+ new TextEncoder().encode(await placed),
305
+ )
306
+ controller.close()
307
+ },
308
+ })
525
309
  }
526
- bundle.files.set(name, { bytes: served, type: output.type })
310
+ files.set(name, { type, body, encoded: {} })
311
+ }
312
+ // NO CACHING AT ALL, which is where dev parts company with 21.5: the names are stable across
313
+ // rebuilds, so a browser told to keep `entry.js` for a year keeps the app it had at boot.
314
+ return {
315
+ manifest: bundle.manifest,
316
+ framework: bundleServer(files, 'no-store'),
527
317
  }
528
- bundle.manifest = manifestOf(built.metafile as Bun.BuildMetafile, root)
529
- return bundle
530
318
  }
531
319
 
532
320
  /**
@@ -0,0 +1,120 @@
1
+ // WHICH FACE AN IMPORT OF THE APP'S IS ANSWERED WITH — one decision, of the file an import resolved
2
+ // to, the importer asking and the lane it is asked in, and nothing of bun's: `abideModules` is the
3
+ // adapter that hands bun's hooks to it, because bun decides which hook asks (an `onResolve` for a
4
+ // specifier, an `onLoad` for a path), and this decides what the answer is.
5
+
6
+ import { HANDLER_SUFFIX } from './MODULE_QUERIES.ts'
7
+
8
+ // A VIRTUAL FACE'S PATH: its file's, with a suffix that is not a query. Both registries key a module
9
+ // by its path whatever its namespace, and the runtime's drops a query from it — so a file's text, its
10
+ // stylesheet or its bound face under `?…` took the place of the module the file compiles to.
11
+ export const BOUND = '::bound'
12
+ export const CLIENT = '::client'
13
+ export const TEXT = '::text'
14
+ export const STYLE = '::style'
15
+
16
+ /** The namespace a bundle's component module is answered in, at its file's own path. */
17
+ export const COMPONENT_NAMESPACE = 'abide-component'
18
+ /** The namespaces a file's text and a component's stylesheet are loaded in. */
19
+ export const TEXT_NAMESPACE = 'abide-text'
20
+ export const STYLE_NAMESPACE = 'abide-style'
21
+ /** The namespaces a browser module asked for outside a bundle, and a bound face, are loaded in. */
22
+ export const CLIENT_NAMESPACE = 'abide-client'
23
+ export const BOUND_NAMESPACE = 'abide-bound'
24
+
25
+ /**
26
+ * Where an import is asked: in a bundle or by the runtime, and for a browser or a server. The runtime
27
+ * is never a browser's, save for what a browser module it was asked for imports (`CLIENT`).
28
+ */
29
+ export type Lane = { bundling: boolean; browser: boolean }
30
+
31
+ /** What a resolve answers an import with: the face's path, and the namespace it loads in. */
32
+ export type Face = { path: string; namespace: string }
33
+
34
+ /**
35
+ * What an import asks of a file, by its spelling: its text (`?text`), a component's stylesheet
36
+ * (`?style`), the module it compiles to (`?component`), its browser module (`?client`), a `.abide`
37
+ * named bare, or a module that may be a handler file.
38
+ */
39
+ export type Asked =
40
+ | 'text'
41
+ | 'style'
42
+ | 'component'
43
+ | 'client'
44
+ | 'abide'
45
+ | 'handler'
46
+
47
+ /** One handler file, as `Compilation.handlers` holds it. */
48
+ export type HandlerFace = { scope: string; client: string }
49
+
50
+ /**
51
+ * The face an import of `file` is answered with, asked `asked` from `importer` in `lane` — or
52
+ * `undefined` where the file is no face of abide's and bun's own resolve answers, or the refusal.
53
+ *
54
+ * `importer` is the path the importer is named by, its face's suffix kept: a browser module the
55
+ * runtime was asked for (`CLIENT`) imports in a browser's lane, and a bound face (`BOUND`) imports the
56
+ * component it binds.
57
+ *
58
+ * - 34.21 — a file's text, and 34.4 — a component's stylesheet, each at a path of its own, since a
59
+ * file is often imported as code and as text at once;
60
+ * - 32.17 — the module a `.abide` compiles to: in a browser's lane its browser module, else its
61
+ * server module — in a namespace of its own in a bundle, where bun refuses a `file` path with `..`
62
+ * in it, which a `[...rest]` directory writes (23.5);
63
+ * - 34.16 — a `.abide` named bare is a module abide did not compile asking: on a server its bound
64
+ * face, and in a browser the component module itself;
65
+ * - 16.2, 16.74 — a handler file, however its import is spelled and whoever imports it — the app or a
66
+ * package whose directory the app mounts (23.19) — is the one face the server mounts, and in a
67
+ * browser the wrapper; 20.4 — one withholding itself from the browser is no browser import.
68
+ */
69
+ export function faceOf(
70
+ asked: Asked,
71
+ file: string,
72
+ importer: string,
73
+ lane: Lane,
74
+ handlers: ReadonlyMap<string, HandlerFace>,
75
+ ): Face | Error | undefined {
76
+ const inBrowser = lane.browser || importer.endsWith(CLIENT)
77
+ const component = (): Face =>
78
+ inBrowser && !lane.browser
79
+ ? { path: `${file}${CLIENT}`, namespace: CLIENT_NAMESPACE }
80
+ : { path: file, namespace: componentNamespaceOf(lane) }
81
+ switch (asked) {
82
+ case 'text':
83
+ return { path: `${file}${TEXT}`, namespace: TEXT_NAMESPACE }
84
+ case 'style':
85
+ return { path: `${file}${STYLE}`, namespace: STYLE_NAMESPACE }
86
+ case 'component':
87
+ return component()
88
+ case 'client':
89
+ return lane.browser
90
+ ? { path: file, namespace: COMPONENT_NAMESPACE }
91
+ : { path: `${file}${CLIENT}`, namespace: CLIENT_NAMESPACE }
92
+ case 'abide':
93
+ return inBrowser || importer === `${file}${BOUND}`
94
+ ? component()
95
+ : { path: `${file}${BOUND}`, namespace: BOUND_NAMESPACE }
96
+ case 'handler': {
97
+ const face = handlers.get(file)
98
+ if (face === undefined) return undefined
99
+ if (inBrowser && face.client === '')
100
+ return new Error(
101
+ `abide: ${realFileOf(importer)} imports ${file}, 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.`,
102
+ )
103
+ return { path: `${file}${HANDLER_SUFFIX}`, namespace: 'file' }
104
+ }
105
+ }
106
+ }
107
+
108
+ /** The namespace a component's own module loads in: abide's in a bundle, bun's own outside one. */
109
+ export function componentNamespaceOf(lane: Lane): string {
110
+ return lane.bundling ? COMPONENT_NAMESPACE : 'file'
111
+ }
112
+
113
+ /** The file a face's path stands for: the path with its face's suffix taken off, or as given. */
114
+ export function realFileOf(path: string): string {
115
+ for (const suffix of FACE_SUFFIXES)
116
+ if (path.endsWith(suffix)) return path.slice(0, -suffix.length)
117
+ return path
118
+ }
119
+
120
+ const FACE_SUFFIXES = [BOUND, CLIENT, TEXT, STYLE, HANDLER_SUFFIX]