@abide/abide 0.104.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 (83) hide show
  1. package/CHANGELOG.md +16 -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/reconcile.ts +4 -110
  77. package/src/ui/rows.ts +123 -2
  78. package/src/ui/wire.ts +12 -27
  79. package/src/compiler/hooksCalled.ts +0 -35
  80. package/src/server/clientBuildOptions.ts +0 -54
  81. package/src/server/refuseAddressCollision.ts +0 -37
  82. package/src/shared/reactive/chunksOfStream.ts +0 -33
  83. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -0,0 +1,389 @@
1
+ // 38.14, 38.35, 38.36 — ONE RESPONSE, ANSWERED IN ONE PROCESS AND SENT FROM ANOTHER. `abide dev`'s
2
+ // listener holds the connection and its worker holds the app, so a request crosses as a message and
3
+ // its answer comes back as a head and then a chunk per pull. Both halves of that protocol are here —
4
+ // `serveOver` is the worker's, `askOver` the listener's — over a channel neither half knows the kind
5
+ // of, so the protocol is read in one file and runs in one test process (`responseRelay.test.ts`).
6
+ // What `serveDev` and `devWorker` keep is the processes and the sockets.
7
+
8
+ import { plainAnswer } from './plainAnswer.ts'
9
+
10
+ /** One direction of the line between the halves: bun's IPC in `abide dev`, a pair of queues in a test. */
11
+ export type Channel<Message> = { postMessage(message: Message): void }
12
+
13
+ /** A request as it crosses to the worker: its id, and what the worker rebuilds it from. */
14
+ export type Ask = {
15
+ id: number
16
+ method: string
17
+ url: string
18
+ headers: [string, string][]
19
+ body: ArrayBuffer | null
20
+ }
21
+
22
+ /** What the listener's half sends: a request, and a pull or cancel of its body. */
23
+ export type ToRelay =
24
+ | ({ kind: 'request' } & Ask)
25
+ | { kind: 'pull'; id: number }
26
+ | { kind: 'cancel'; id: number }
27
+
28
+ /** What the worker's half sends: a request's idle timeout raised, and its head, each chunk, and its end. */
29
+ export type FromRelay =
30
+ | { kind: 'timeout'; id: number; seconds: number }
31
+ | {
32
+ kind: 'head'
33
+ id: number
34
+ status: number
35
+ statusText: string
36
+ headers: [string, string][]
37
+ empty: boolean
38
+ /** 37.3 — whether the listener upgrades this request to the mux (`admitsMux`). */
39
+ admitted: boolean
40
+ /** A body whose length the answer declared, sent with its head rather than pulled. */
41
+ whole: ArrayBuffer | undefined
42
+ }
43
+ | { kind: 'chunk'; id: number; bytes: ArrayBuffer }
44
+ | { kind: 'end'; id: number }
45
+ | { kind: 'fail'; id: number; message: string }
46
+
47
+ // A status whose response has no body to send, which `Response` refuses to be built with one.
48
+ const BODILESS = new Set([101, 204, 205, 304])
49
+
50
+ /** A throw, as the text a reader is shown: its stack where it has one. */
51
+ export function describe(thrown: unknown): string {
52
+ return thrown instanceof Error
53
+ ? (thrown.stack ?? thrown.message)
54
+ : String(thrown)
55
+ }
56
+
57
+ /**
58
+ * The worker's half: answers each request the listener sends with `fetch`, and its body a chunk per
59
+ * pull. `take` is handed every `ToRelay` message; `timeout` raises a request's idle timeout where the
60
+ * listener holds its connection (16.22); `hangUpAll` aborts every request still being answered.
61
+ */
62
+ export function serveOver(
63
+ channel: Channel<FromRelay>,
64
+ answering: {
65
+ fetch(request: Request): Promise<Response>
66
+ /** 37.3 — whether the answer admits the request to the mux. */
67
+ admits(request: Request, response: Response): boolean
68
+ /** The answer to a throw out of `fetch`, which is an answer and not a dropped message. */
69
+ failed(why: string): Response
70
+ /** What a failure in the middle of an html body is shown as, appended to it. */
71
+ failureMarkup(why: string): string
72
+ },
73
+ ) {
74
+ // 38.96 — EACH REQUEST'S SIGNAL, by request id, until its answer ends: the listener holds the
75
+ // connection, so a hang-up reaches the worker as a `cancel` rather than through bun.
76
+ const hangUps = new Map<number, AbortController>()
77
+ // Each answer whose head has gone out, by request id: what its chunks are read from, and whether a
78
+ // failure in the middle can still be shown as markup.
79
+ const bodies = new Map<
80
+ number,
81
+ { reader: ReadableStreamDefaultReader<Uint8Array>; html: boolean }
82
+ >()
83
+ // 16.22 — each request built here, known by the ask it was built from.
84
+ const asks = new WeakMap<Request, number>()
85
+
86
+ const ended = (id: number): void => {
87
+ hangUps.delete(id)
88
+ bodies.delete(id)
89
+ }
90
+
91
+ /**
92
+ * Answers one request, sending back its head and holding its body for pulls.
93
+ *
94
+ * A THROW BEFORE THE HEAD IS AN ANSWER, not a dropped message. The listener is waiting on a head
95
+ * for this id and nothing else will send one, so an escape left the reader's tab hanging until
96
+ * they gave up.
97
+ */
98
+ async function answer(ask: Ask): Promise<void> {
99
+ const hangUp = new AbortController()
100
+ hangUps.set(ask.id, hangUp)
101
+ const request = new Request(ask.url, {
102
+ method: ask.method,
103
+ headers: ask.headers,
104
+ body: ask.body,
105
+ signal: hangUp.signal,
106
+ })
107
+ asks.set(request, ask.id)
108
+ let response: Response
109
+ try {
110
+ response = await answering.fetch(request)
111
+ } catch (thrown) {
112
+ response = answering.failed(describe(thrown))
113
+ }
114
+ const body = BODILESS.has(response.status) ? null : response.body
115
+ // A DECLARED LENGTH IS A BODY ALREADY WHOLE, and it crosses with its head: re-streamed through
116
+ // the listener, bun meets a first read that has to wait on the worker and answers it chunked,
117
+ // dropping the length the app declared.
118
+ const whole =
119
+ body !== null && response.headers.has('content-length')
120
+ ? await response.arrayBuffer()
121
+ : undefined
122
+ channel.postMessage({
123
+ kind: 'head',
124
+ id: ask.id,
125
+ status: response.status,
126
+ statusText: response.statusText,
127
+ headers: [...response.headers.entries()],
128
+ empty: body === null,
129
+ admitted: answering.admits(request, response),
130
+ whole,
131
+ })
132
+ if (body === null || whole !== undefined) {
133
+ ended(ask.id)
134
+ return
135
+ }
136
+ bodies.set(ask.id, {
137
+ reader: body.getReader(),
138
+ html: (response.headers.get('content-type') ?? '').startsWith(
139
+ 'text/html',
140
+ ),
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Sends the next chunk of an answer's body, or its end.
146
+ *
147
+ * ONE CHUNK PER ASK. A throw in the middle ends the stream in an error, as `abide start` does —
148
+ * and where the answer is html a handler streams, what broke goes out as markup first, a reader
149
+ * shown a blank tab otherwise being told nothing. A page is never here: it is built whole (41.1).
150
+ */
151
+ async function pull(id: number): Promise<void> {
152
+ const reading = bodies.get(id)
153
+ if (reading === undefined) return
154
+ try {
155
+ const { done, value } = await reading.reader.read()
156
+ if (done) {
157
+ ended(id)
158
+ channel.postMessage({ kind: 'end', id })
159
+ return
160
+ }
161
+ // THE VIEW'S OWN BYTES. A chunk may be a view on a buffer its producer still owns — a
162
+ // compressor's `Buffer` is a slice of a shared pool, and `Buffer#slice` copies nothing —
163
+ // so sending `value.buffer` sent the whole pool.
164
+ const bytes = new Uint8Array(value).buffer as ArrayBuffer
165
+ channel.postMessage({ kind: 'chunk', id, bytes })
166
+ } catch (thrown) {
167
+ ended(id)
168
+ const why = describe(thrown)
169
+ if (reading.html)
170
+ channel.postMessage({
171
+ kind: 'chunk',
172
+ id,
173
+ bytes: new TextEncoder().encode(
174
+ answering.failureMarkup(why),
175
+ ).buffer as ArrayBuffer,
176
+ })
177
+ channel.postMessage({ kind: 'fail', id, message: why })
178
+ }
179
+ }
180
+
181
+ return {
182
+ take(message: ToRelay): Promise<void> | undefined {
183
+ if (message.kind === 'request') return answer(message)
184
+ if (message.kind === 'pull') return pull(message.id)
185
+ const reading = bodies.get(message.id)
186
+ hangUps.get(message.id)?.abort()
187
+ ended(message.id)
188
+ return reading?.reader.cancel()
189
+ },
190
+ timeout(request: Request, seconds: number): void {
191
+ const id = asks.get(request)
192
+ if (id !== undefined)
193
+ channel.postMessage({ kind: 'timeout', id, seconds })
194
+ },
195
+ hangUpAll(): void {
196
+ for (const hangUp of hangUps.values()) hangUp.abort()
197
+ },
198
+ }
199
+ }
200
+
201
+ // A request in flight: the request itself — because a worker replaced before it answered answers
202
+ // nothing, and the ask has to be put to the one that exists (38.24) — the channel it was asked over,
203
+ // and, once its head has come back, the stream the body is fed into and the pull waiting on the
204
+ // worker's next chunk (38.35).
205
+ type InFlight = {
206
+ // 16.22 — the request bun handed the listener, which is the one its idle timeout is raised on.
207
+ request: Request
208
+ headers: [string, string][]
209
+ body: ArrayBuffer | null
210
+ settle: (response: Response) => void
211
+ asked: Channel<ToRelay> | undefined
212
+ stream: ReadableStreamDefaultController<Uint8Array> | null
213
+ pulled: (() => void) | null
214
+ // A failure the worker sent behind a chunk still queued, raised on the pull after it — raising it
215
+ // at once would discard the chunk, which is the markup saying what broke.
216
+ failed: string | null
217
+ }
218
+
219
+ /**
220
+ * The listener's half: asks each request of the worker on its channel, and settles it with the
221
+ * response the worker's head and chunks describe. `take` is handed every `FromRelay` message;
222
+ * `replace` points it at a worker's channel — there is none until the first — or at none while one
223
+ * compiles; `endAll` ends what the worker that is gone was answering.
224
+ */
225
+ export function askOver(
226
+ raiseTimeout: (request: Request, seconds: number) => void,
227
+ ) {
228
+ let channel: Channel<ToRelay> | undefined
229
+ const inFlight = new Map<number, InFlight>()
230
+ // ASKED BEFORE THERE IS ANYONE TO ASK. A replacement ends the worker and the next one does not
231
+ // exist until its compile finishes, so a request arriving in that window waits here.
232
+ const queued: InFlight[] = []
233
+ // 37.3 — the answers the worker said `admitsMux`, which the listener upgrades on.
234
+ const admitted = new WeakSet<Response>()
235
+ let nextId = 0
236
+
237
+ // NOT TRANSFERRED. Handing the buffer over detaches it here, and a request re-asked of the
238
+ // replacement would post a body that is no longer there.
239
+ function forward(waiting: InFlight): void {
240
+ if (channel === undefined) {
241
+ queued.push(waiting)
242
+ return
243
+ }
244
+ nextId += 1
245
+ const id = nextId
246
+ waiting.asked = channel
247
+ inFlight.set(id, waiting)
248
+ channel.postMessage({
249
+ kind: 'request',
250
+ id,
251
+ method: waiting.request.method,
252
+ url: waiting.request.url,
253
+ headers: waiting.headers,
254
+ body: waiting.body,
255
+ })
256
+ }
257
+
258
+ // 38.35, 38.36 — THE HEAD SETTLES THE RESPONSE, and its body pulls one chunk from the worker per
259
+ // read the connection makes: a high-water mark of 0 asks for nothing ahead of the reader.
260
+ function settleHead(
261
+ id: number,
262
+ waiting: InFlight,
263
+ head: Extract<FromRelay, { kind: 'head' }>,
264
+ ): void {
265
+ const init = {
266
+ status: head.status,
267
+ statusText: head.statusText,
268
+ headers: head.headers,
269
+ }
270
+ if (head.empty || head.whole !== undefined) {
271
+ inFlight.delete(id)
272
+ const answered = new Response(head.whole ?? null, init)
273
+ // The admission has no body, so an admitted answer is always settled here.
274
+ if (head.admitted) admitted.add(answered)
275
+ waiting.settle(answered)
276
+ return
277
+ }
278
+ const asked = waiting.asked
279
+ const body = new ReadableStream<Uint8Array>(
280
+ {
281
+ start(controller) {
282
+ waiting.stream = controller
283
+ },
284
+ pull(controller) {
285
+ if (waiting.failed !== null) {
286
+ inFlight.delete(id)
287
+ controller.error(new Error(waiting.failed))
288
+ return
289
+ }
290
+ return new Promise<void>((done) => {
291
+ waiting.pulled = done
292
+ asked?.postMessage({ kind: 'pull', id })
293
+ })
294
+ },
295
+ cancel() {
296
+ inFlight.delete(id)
297
+ asked?.postMessage({ kind: 'cancel', id })
298
+ },
299
+ },
300
+ { highWaterMark: 0 },
301
+ )
302
+ waiting.settle(new Response(body, init))
303
+ }
304
+
305
+ return {
306
+ /**
307
+ * The worker's answer to `request`.
308
+ *
309
+ * A REQUEST AS A MESSAGE. `Request` does not survive a structured clone, so what crosses is
310
+ * what the worker rebuilds one from — and the body is collected ONCE, HERE, which is what lets
311
+ * it be asked again of a replacement (38.24): a body can only be read off a `Request` once.
312
+ */
313
+ async ask(request: Request): Promise<Response> {
314
+ const body =
315
+ request.body === null ? null : await request.arrayBuffer()
316
+ return await new Promise<Response>((settle) => {
317
+ forward({
318
+ request,
319
+ headers: identityOnly(request.headers),
320
+ body,
321
+ settle,
322
+ asked: undefined,
323
+ stream: null,
324
+ pulled: null,
325
+ failed: null,
326
+ })
327
+ })
328
+ },
329
+ /** Whether the worker admitted this answer's request to the mux. 37.3. */
330
+ admitted(response: Response): boolean {
331
+ return admitted.has(response)
332
+ },
333
+ take(message: FromRelay): void {
334
+ const waiting = inFlight.get(message.id)
335
+ if (waiting === undefined) return
336
+ if (message.kind === 'timeout') {
337
+ raiseTimeout(waiting.request, message.seconds)
338
+ return
339
+ }
340
+ if (message.kind === 'head') {
341
+ settleHead(message.id, waiting, message)
342
+ return
343
+ }
344
+ const stream = waiting.stream
345
+ const pulled = waiting.pulled
346
+ waiting.pulled = null
347
+ if (stream === null) return
348
+ if (message.kind === 'chunk')
349
+ stream.enqueue(new Uint8Array(message.bytes))
350
+ else if (message.kind === 'end') {
351
+ inFlight.delete(message.id)
352
+ stream.close()
353
+ } else waiting.failed = message.message
354
+ pulled?.()
355
+ },
356
+ /** Asks the worker on `next` from now on — every request that waited for one included. */
357
+ replace(next: Channel<ToRelay> | undefined): void {
358
+ channel = next
359
+ if (next === undefined) return
360
+ for (const again of queued.splice(0)) forward(again)
361
+ },
362
+ /**
363
+ * Ends what a worker that is gone was answering. A request whose head has not gone out is
364
+ * asked again of the next (38.24) where `reask`, and answered 500 where not — the request in
365
+ * hand may be what ended the app (38.41). 38.37 — one whose head has gone out cannot be
366
+ * answered again, and ends as `abide start`'s does.
367
+ */
368
+ endAll(why: string, reask: boolean): void {
369
+ for (const held of inFlight.values()) {
370
+ if (held.stream !== null) {
371
+ held.stream.error(new Error(why))
372
+ held.pulled?.()
373
+ } else if (reask) queued.push(held)
374
+ else held.settle(plainAnswer(why, 500))
375
+ }
376
+ inFlight.clear()
377
+ },
378
+ }
379
+ }
380
+
381
+ // 38.38 — ASKED WITHOUT `accept-encoding`, so the app answers uncompressed. A failure after the head
382
+ // is shown as markup appended to the stream, and markup appended to a compressed stream is a stream
383
+ // no browser can decode. Localhost buys nothing from the bytes saved.
384
+ function identityOnly(headers: Headers): [string, string][] {
385
+ const kept: [string, string][] = []
386
+ for (const [name, value] of headers)
387
+ if (name !== 'accept-encoding') kept.push([name, value])
388
+ return kept
389
+ }
@@ -3,8 +3,8 @@
3
3
  // is `ledger-linux-x64`, and that is what a reader types.
4
4
 
5
5
  import { type Host, runCommands } from './commands.ts'
6
- import { type AppOrigin, bootApp } from './createApp.ts'
7
- import { type BuildParts, builtSources, serveApp } from './serveBuild.ts'
6
+ import { type AppHandle, type AppOrigin, bootApp } from './createApp.ts'
7
+ import { type BuildParts, builtSources } from './serveBuild.ts'
8
8
 
9
9
  export async function runCompiled(
10
10
  origin: AppOrigin,
@@ -25,18 +25,15 @@ export async function runCompiled(
25
25
  }))
26
26
  },
27
27
  })
28
+ // 38.49 — THE APP IT HOLDS, whatever the flags say.
29
+ const app: AppHandle = {
30
+ package: origin.package,
31
+ handlers: async () => parts.handlers,
32
+ boot: async () => bootApp(origin, () => builtSources(parts)),
33
+ }
28
34
  const host: Host = {
29
35
  name: process.execPath.slice(process.execPath.lastIndexOf('/') + 1),
30
- handlers: async () => parts.handlers,
31
- appPackage: () => origin.package,
32
- boot: () => bootApp(origin, () => builtSources(parts)),
33
- // 38.49.
34
- serve: ({ values: { port } }) =>
35
- serveApp(
36
- origin,
37
- () => builtSources(parts),
38
- port === undefined ? undefined : Number(port),
39
- ),
36
+ app: () => app,
40
37
  source: undefined,
41
38
  }
42
39
  const code = await runCommands(host, Bun.argv.slice(2))
@@ -22,12 +22,11 @@ import {
22
22
  sendTyped,
23
23
  stringOf,
24
24
  } from './appKit.ts'
25
- import { APP_PACKAGE, configuredMount, dataDirectory } from './config.ts'
25
+ import { configuredMount, dataDirectory } from './config.ts'
26
26
  import { configFields } from './configFields.ts'
27
- import { type App, type AppOrigin, bootApp } from './createApp.ts'
27
+ import { type App, type AppOrigin, bootEntered, enterApp } from './createApp.ts'
28
28
  import { executableName } from './executableName.ts'
29
29
  import { HOOKS, inOrder, reportUnexpected } from './hooks.ts'
30
- import { installServer } from './installServer.ts'
31
30
  import { launcherOf } from './launcherOptions.ts'
32
31
  import { type Launch, launcherPage } from './launcherPage.ts'
33
32
  import { installMenu, menuBar, type NativeItem } from './nativeMenu.ts'
@@ -84,11 +83,9 @@ async function launch(
84
83
  load: () => ReturnType<typeof builtSources>,
85
84
  { launch: launchModule, document, icon, accent }: Desktop,
86
85
  ): Promise<void> {
87
- installServer()
88
- APP_PACKAGE.current = origin.package
89
- // 37.1 — imported for what it files, so the start form reads `onConfig` and the launcher's
90
- // `onLauncher` is filed.
91
- await origin.hooks?.()
86
+ // 37.1 — ENTERED ONCE, before the window: the start form reads `onConfig` and the launcher's
87
+ // `onLauncher` is filed. Every start after it boots in what this entered (`bootEntered`).
88
+ await enterApp(origin)
92
89
  // 38.85 — a launcher page that fails to load leaves the built-in page, with its defaults.
93
90
  const launchPage = await launchModule?.().catch((thrown: unknown) => {
94
91
  reportUnexpected(thrown)
@@ -228,7 +225,7 @@ async function launch(
228
225
  else process.env[name] = value
229
226
  let app: App
230
227
  try {
231
- app = await bootApp(origin, load)
228
+ app = await bootEntered(load)
232
229
  } catch (thrown) {
233
230
  failure = refused('start', thrown)
234
231
  return failure
@@ -6,7 +6,6 @@
6
6
  // what it found, so the addresses the server answers and the addresses the client router knows
7
7
  // are two readings of one walk rather than two walks that have to agree (30.8).
8
8
 
9
- import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
10
9
  import {
11
10
  type Compilation,
12
11
  serveCompilation,
@@ -24,15 +23,10 @@ import {
24
23
  MANIFEST,
25
24
  PUBLIC_DIRECTORY,
26
25
  } from './CLIENT_BUILD.ts'
27
- import { type App, type AppOrigin, bootApp } from './createApp.ts'
28
- import { stopOnSignalOrFault } from './faultPolicy.ts'
26
+ import { bundleServer, type ServedFile } from './clientBundle.ts'
29
27
  import type { Manifest } from './manifestOf.ts'
30
28
  import { namesUnder } from './namesUnder.ts'
31
- import {
32
- ENCODINGS,
33
- type Encoding,
34
- negotiateEncoding,
35
- } from './negotiateEncoding.ts'
29
+ import { ENCODINGS } from './negotiateEncoding.ts'
36
30
  import { runBuild } from './runBuild.ts'
37
31
 
38
32
  /**
@@ -49,23 +43,6 @@ export type BuildParts = Omit<SourceParts, 'client' | 'explained'> & {
49
43
  client: Map<string, string>
50
44
  }
51
45
 
52
- /**
53
- * The app booted (`bootApp`) and listening, stopped on a signal or an unawaited fault.
54
- *
55
- * Listening on `LISTENING.server`, for the caller to say where.
56
- */
57
- export async function serveApp(
58
- origin: AppOrigin,
59
- load: () => Promise<AppSources>,
60
- port: number | undefined,
61
- ): Promise<App> {
62
- const app = await bootApp(origin, load)
63
- await app.listen(port === undefined ? {} : { port })
64
- // ONCE SERVING, and not before: a boot that fails is 37.6's to fail hard.
65
- stopOnSignalOrFault(app)
66
- return app
67
- }
68
-
69
46
  /**
70
47
  * Serves the compilation `abide build` kept in `out` to this process's imports; `false` where there
71
48
  * is none, because the app under `root` holds nothing to build.
@@ -128,62 +105,38 @@ export function pathsUnder(directory: string): Map<string, string> {
128
105
 
129
106
  /** The sources an `App` is booted over, out of the parts `abide build` produced. */
130
107
  export function builtSources(parts: BuildParts): Promise<AppSources> {
131
- // THE BUNDLE, ENUMERATED RATHER THAN RESOLVED. A request names a file in this set or it names
132
- // nothing — there is no path to normalize, so `..` is not a case that has to be got right,
133
- // and what is servable is exactly what the build produced.
134
- //
135
108
  // THE PRECOMPRESSED COPIES ARE NOT ADDRESSES OF THEIR OWN. They are the same file in another
136
109
  // encoding (38.5), so they are found under the name they encode and served as that name.
137
110
  const suffixes = Object.values(ENCODING_SUFFIX) as string[]
138
- const assets = new Map<string, Encoding[]>()
139
- for (const name of parts.client.keys()) {
111
+ const files = new Map<string, ServedFile>()
112
+ for (const [name, path] of parts.client) {
140
113
  if (suffixes.some((suffix) => name.endsWith(suffix))) continue
141
- const encodings: Encoding[] = []
142
- for (const encoding of ENCODINGS)
143
- if (parts.client.has(name + ENCODING_SUFFIX[encoding]))
144
- encodings.push(encoding)
145
- assets.set(name, encodings)
146
- }
147
-
148
- const framework = (
149
- pathname: string,
150
- request: Request,
151
- ): Response | undefined => {
152
- if (!pathname.startsWith(`${CLIENT_ADDRESS}/`)) return undefined
153
- const name = pathname.slice(CLIENT_ADDRESS.length + 1)
154
- const encodings = assets.get(name)
155
- if (encodings === undefined) return undefined
156
- // THE TYPE IS THE NAME'S, not the path's: a compiled app's path carries a hash the
157
- // bundler put before the extension, and a `.br`'s extension names nothing.
158
- const type = Bun.file(name).type
159
- // 21.5 — EVERY NAME IN THE BUNDLE CARRIES A HASH OF ITS BYTES, the entry's too (38.5),
160
- // so a name never answers two different files and a year is safe for all of them. A
161
- // deploy is new names in a new document, never new bytes at an old address.
162
- const headers: Record<string, string> = {
163
- 'cache-control': 'public, max-age=31536000, immutable',
164
- 'content-type': type,
165
- vary: 'accept-encoding',
114
+ const encoded: ServedFile['encoded'] = {}
115
+ for (const encoding of ENCODINGS) {
116
+ const copy = parts.client.get(name + ENCODING_SUFFIX[encoding])
117
+ if (copy !== undefined) encoded[encoding] = () => Bun.file(copy)
166
118
  }
167
- // 38.29 — the precompressed copy where the request takes its encoding.
168
- const encoding = negotiateEncoding(
169
- request.headers.get('accept-encoding'),
170
- encodings,
171
- )
172
- if (encoding === undefined)
173
- return new Response(Bun.file(parts.client.get(name) as string), {
174
- headers,
175
- })
176
- headers['content-encoding'] = encoding
177
- return new Response(
178
- Bun.file(
179
- parts.client.get(name + ENCODING_SUFFIX[encoding]) as string,
180
- ),
181
- { headers },
182
- )
119
+ files.set(name, {
120
+ // THE TYPE IS THE NAME'S, not the path's: a compiled app's path carries a hash the
121
+ // bundler put before the extension, and a `.br`'s extension names nothing.
122
+ type: Bun.file(name).type,
123
+ body: () => Bun.file(path),
124
+ encoded,
125
+ })
183
126
  }
184
127
  return appSources({
185
128
  ...parts,
186
129
  explained: undefined,
187
- client: { manifest: parts.manifest, head: '', framework },
130
+ client: {
131
+ manifest: parts.manifest,
132
+ head: '',
133
+ // 21.5 — EVERY NAME IN THE BUNDLE CARRIES A HASH OF ITS BYTES, the entry's too (38.5), so
134
+ // a name never answers two different files and a year is safe for all of them. A deploy
135
+ // is new names in a new document, never new bytes at an old address.
136
+ framework: bundleServer(
137
+ files,
138
+ 'public, max-age=31536000, immutable',
139
+ ),
140
+ },
188
141
  })
189
142
  }