@abide/abide 0.103.0 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +27 -27
  4. package/docs/RULEBOOK.md +68 -24
  5. package/docs/abide.md +31 -46
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +46 -89
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -25
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +12 -10
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +183 -478
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/projectShadow.ts +1 -1
  25. package/src/compiler/readSources.ts +27 -42
  26. package/src/compiler/readsIn.ts +5 -16
  27. package/src/compiler/resolverIn.ts +77 -0
  28. package/src/server/abideModules.ts +152 -146
  29. package/src/server/answerRequest.ts +18 -4
  30. package/src/server/answerSocket.ts +136 -76
  31. package/src/server/answerValue.ts +28 -15
  32. package/src/server/appTree.ts +59 -73
  33. package/src/server/claudeApi.ts +2 -5
  34. package/src/server/clientBundle.ts +198 -0
  35. package/src/server/coerceArgs.ts +1 -1
  36. package/src/server/commands.ts +93 -57
  37. package/src/server/createApp.ts +90 -16
  38. package/src/server/deferredBuilds.ts +175 -0
  39. package/src/server/devWorker.ts +107 -319
  40. package/src/server/faceOf.ts +120 -0
  41. package/src/server/fenceServer.ts +10 -18
  42. package/src/server/generatedSurfaces.ts +12 -53
  43. package/src/server/index.ts +3 -0
  44. package/src/server/placement.ts +167 -0
  45. package/src/server/remote.ts +14 -42
  46. package/src/server/render.ts +25 -17
  47. package/src/server/renderComponent.ts +9 -5
  48. package/src/server/requestScope.ts +10 -4
  49. package/src/server/responseRelay.ts +389 -0
  50. package/src/server/runCompiled.ts +9 -12
  51. package/src/server/runDesktop.ts +6 -9
  52. package/src/server/serveBuild.ts +26 -73
  53. package/src/server/serveDev.ts +19 -176
  54. package/src/server/streamEvent.ts +29 -0
  55. package/src/server/versionOf.ts +1 -2
  56. package/src/server/withAbideSources.ts +2 -1
  57. package/src/shared/HandlerRoute.ts +68 -0
  58. package/src/shared/REFUSAL_KEY.ts +5 -0
  59. package/src/shared/RESERVED_DIRECTORIES.ts +6 -0
  60. package/src/shared/answerOf.ts +105 -78
  61. package/src/shared/filledPath.ts +1 -9
  62. package/src/shared/framing.ts +127 -128
  63. package/src/shared/queryText.ts +15 -7
  64. package/src/shared/reactive/Cursor.ts +69 -124
  65. package/src/shared/reactive/Reactive.ts +296 -162
  66. package/src/shared/reactive/callLoad.ts +4 -18
  67. package/src/shared/reactive/index.ts +11 -8
  68. package/src/shared/reactive/pairReactive.ts +1 -1
  69. package/src/shared/reactive/scoped.ts +4 -3
  70. package/src/shared/reactive/share.ts +11 -1
  71. package/src/shared/routePattern.ts +0 -110
  72. package/src/shared/rpc.ts +17 -24
  73. package/src/shared/rpcRequest.ts +18 -47
  74. package/src/shared/takeEarlyCall.ts +12 -37
  75. package/src/shared/url.ts +1 -1
  76. package/src/ui/Level.ts +276 -0
  77. package/src/ui/List.ts +17 -17
  78. package/src/ui/Router.ts +20 -87
  79. package/src/ui/applyHead.ts +92 -79
  80. package/src/ui/index.ts +4 -2
  81. package/src/ui/reconcile.ts +4 -110
  82. package/src/ui/rows.ts +123 -2
  83. package/src/ui/wire.ts +12 -27
  84. package/src/compiler/hooksCalled.ts +0 -35
  85. package/src/server/clientBuildOptions.ts +0 -54
  86. package/src/server/refuseAddressCollision.ts +0 -37
  87. package/src/shared/reactive/chunksOfStream.ts +0 -57
  88. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -6,11 +6,9 @@
6
6
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
7
7
  import { parseArgs } from 'node:util' // bun exposes no argv parser of its own yet
8
8
  import { MCP_ADDRESS, OPENAPI_ADDRESS } from '#shared/ADDRESSES.ts'
9
- import type { Mount } from './answerRpc.ts'
10
9
  import { banner } from './banner.ts'
11
- import { APP_PACKAGE, type AppPackage, config } from './config.ts'
12
- import type { App } from './createApp.ts'
13
- import { installServer } from './installServer.ts'
10
+ import { type App, type AppHandle, enterApp } from './createApp.ts'
11
+ import { stopOnSignalOrFault } from './faultPolicy.ts'
14
12
  import {
15
13
  appUrl,
16
14
  callHandler,
@@ -45,17 +43,10 @@ export type Host = {
45
43
  /** What usage and every message speak as: `abide`, or the name the executable was run by. */
46
44
  name: string
47
45
  /**
48
- * The handler table the shell lists and a call reads a handler's method off — so a call is
49
- * spelled the same whatever the handler's method, and `--method` is only for an app nobody here
50
- * knows the table of. Empty where there is none to read, and never built to be read.
46
+ * The app the invocation means, found once off its flags (38.25): every command that serves,
47
+ * boots, lists or announces it derives from this one handle.
51
48
  */
52
- handlers: (given: Given) => Promise<Mount[]>
53
- /** The identity a script `run` resolves its configuration with. 27.3. */
54
- appPackage: () => AppPackage
55
- /** The app booted in this process: `openapi` and `mcp`'s answer where no `--url` is given. */
56
- boot: () => Promise<App>
57
- /** The app served, or the exit code of a refusal to. 38.6. */
58
- serve: (given: Given) => Promise<App | number>
49
+ app: (given: Given) => AppHandle
59
50
  source: SourceCommands | undefined
60
51
  }
61
52
 
@@ -79,7 +70,8 @@ export async function runCommands(
79
70
  console.log(usage(host, commands))
80
71
  return 0
81
72
  }
82
- if (command === undefined) return await shellOn(host, given)
73
+ if (command === undefined)
74
+ return await shellOn(host, host.app(given), given)
83
75
  for (const [name, , body] of commands)
84
76
  if (name.split(' ')[0] === command) return await body(given)
85
77
  console.error(
@@ -111,7 +103,7 @@ function commandsOf(host: Host): Row[] {
111
103
  [
112
104
  'run <file> [args…]',
113
105
  'run a script under the abide runtime',
114
- (given) => run(host, given),
106
+ (given) => run(host, host.app(given), given),
115
107
  false,
116
108
  ],
117
109
  fromSource('dev', 'serve with the compiler in the loop', 'dev'),
@@ -120,23 +112,28 @@ function commandsOf(host: Host): Row[] {
120
112
  'compile every .abide under the app into .abide/',
121
113
  'build',
122
114
  ),
123
- ['start', 'serve the app', (given) => start(host, given), false],
115
+ [
116
+ 'start',
117
+ 'serve the app',
118
+ (given) => start(host, host.app(given), given),
119
+ false,
120
+ ],
124
121
  [
125
122
  'call <address> [args]',
126
123
  'one call to one handler',
127
- (given) => call(host, given),
124
+ (given) => call(host, host.app(given), given),
128
125
  false,
129
126
  ],
130
127
  [
131
128
  'openapi',
132
129
  'the OpenAPI document, written rather than served',
133
- (given) => openapi(host, given),
130
+ (given) => openapi(host, host.app(given), given),
134
131
  false,
135
132
  ],
136
133
  [
137
134
  'mcp',
138
135
  "the app's MCP server over stdio",
139
- (given) => mcp(host, given),
136
+ (given) => mcp(host, host.app(given), given),
140
137
  false,
141
138
  ],
142
139
  [
@@ -195,13 +192,12 @@ function usage(host: Host, commands: Row[]): string {
195
192
  return `${text}
196
193
  --port the listen port the app is served on
197
194
  --url the running app the shell, call, watch, openapi and mcp talk to
198
- --out the file openapi writes
199
195
  `
200
196
  text += `
201
197
  --root the app's source directory, where its pages sit —
202
198
  src/ where you stand, or where you stand, whichever has one
203
- --out where the build goes, beside the app by default; the file openapi,
204
- compile and bundle write — named after the app, where you stand
199
+ --out where the build goes, beside the app by default; the file compile and
200
+ bundle write — named after the app, where you stand
205
201
  --port the listen port for dev, start and the shell's app
206
202
  --url the running app the shell, call, watch, openapi and mcp talk to
207
203
  --target the platform compile builds for — linux-x64, darwin-arm64, windows-x64…
@@ -234,32 +230,63 @@ function parsed(argv: string[]) {
234
230
  })
235
231
  }
236
232
 
237
- /** The app served, with the address it answers at and the banner it is announced by. 38.6. */
233
+ /**
234
+ * The app served and listening, with the address it answers at and the banner it is announced by —
235
+ * or the exit code of a refusal to. 38.6.
236
+ *
237
+ * Stopped on a signal or an unawaited fault ONCE SERVING, and not before: a boot that fails is
238
+ * 37.6's to fail hard.
239
+ */
238
240
  async function served(
239
241
  host: Host,
242
+ app: AppHandle,
240
243
  given: Given,
241
244
  ): Promise<{ app: App; url: string; heading: string } | number> {
242
245
  // 36.3 — `dev`, `build` and `start` each name the environment where the process was started with
243
246
  // none, and leave one it was started with exactly as given.
244
247
  process.env.NODE_ENV ??= 'production'
245
- const app = await host.serve(given)
246
- if (typeof app === 'number') return app
248
+ const booted = await booting(host, app, given)
249
+ if (typeof booted === 'number') return booted
250
+ const port = given.values.port
251
+ await booted.listen(port === undefined ? {} : { port: Number(port) })
252
+ stopOnSignalOrFault(booted)
247
253
  const server = LISTENING.server as Bun.Server<unknown>
248
254
  return {
249
- app,
255
+ app: booted,
250
256
  url: withoutTrailingSlash(server.url.href),
251
- heading: banner(host.name, 'start', host.appPackage(), server),
257
+ heading: banner(host.name, 'start', app.package, server),
252
258
  }
253
259
  }
254
260
 
261
+ /** The app booted in this process, or the exit code of a refusal naming why there is none. */
262
+ async function booting(
263
+ host: Host,
264
+ app: AppHandle,
265
+ given: Given,
266
+ ): Promise<App | number> {
267
+ const booted = await app.boot()
268
+ if (typeof booted === 'string') {
269
+ const command = given.positionals[0]
270
+ console.error(
271
+ `${command === undefined ? host.name : `${host.name} ${command}`}: ${booted}`,
272
+ )
273
+ return 1
274
+ }
275
+ return booted
276
+ }
277
+
255
278
  /**
256
279
  * `start`: serves the app.
257
280
  *
258
281
  * 38.67 — READS NO INPUT, and hands back no code for the process to exit on: the process is the
259
282
  * server, for a container or a supervisor to stop.
260
283
  */
261
- async function start(host: Host, given: Given): Promise<number | undefined> {
262
- const serving = await served(host, given)
284
+ async function start(
285
+ host: Host,
286
+ app: AppHandle,
287
+ given: Given,
288
+ ): Promise<number | undefined> {
289
+ const serving = await served(host, app, given)
263
290
  if (typeof serving === 'number') return serving
264
291
  console.log(serving.heading)
265
292
  return undefined
@@ -273,24 +300,28 @@ async function start(host: Host, given: Given): Promise<number | undefined> {
273
300
  * as a remote one's is. 38.54 — `exit` stops the app. 38.55 — input that ends leaves it serving, and
274
301
  * hands back no code for the process to exit on.
275
302
  */
276
- async function shellOn(host: Host, given: Given): Promise<number | undefined> {
303
+ async function shellOn(
304
+ host: Host,
305
+ app: AppHandle,
306
+ given: Given,
307
+ ): Promise<number | undefined> {
277
308
  const remote = remoteUrl(given.values.url)
278
309
  if (remote !== undefined) {
279
310
  const url = appUrl(remote)
280
311
  await shell({
281
312
  url,
282
- handlers: await host.handlers(given),
313
+ handlers: await app.handlers(),
283
314
  heading: `${host.name}: ${url}`,
284
315
  local: false,
285
316
  })
286
317
  return 0
287
318
  }
288
- const serving = await served(host, given)
319
+ const serving = await served(host, app, given)
289
320
  if (typeof serving === 'number') return serving
290
321
  // After the serve, which built the app where nothing had.
291
322
  const ended = await shell({
292
323
  url: serving.url,
293
- handlers: await host.handlers(given),
324
+ handlers: await app.handlers(),
294
325
  heading: serving.heading,
295
326
  local: true,
296
327
  })
@@ -312,6 +343,7 @@ async function shellOn(host: Host, given: Given): Promise<number | undefined> {
312
343
  */
313
344
  async function run(
314
345
  host: Host,
346
+ app: AppHandle,
315
347
  { positionals: [, file, ...rest] }: Given,
316
348
  ): Promise<number | undefined> {
317
349
  if (file === undefined) {
@@ -322,11 +354,9 @@ async function run(
322
354
  }
323
355
  const path = resolve(file)
324
356
  process.argv = [process.argv[0] as string, path, ...rest]
325
- // 38.57 — RE-RESOLVED, as `bootApp` does: a module this process loaded before the script may already have
326
- // read the configuration, under no identity.
327
- installServer()
328
- APP_PACKAGE.current = host.appPackage()
329
- config.invalidate()
357
+ // 38.57 — entered as `bootApp` enters it, its server blocks aside: a script is run against the
358
+ // app's source, which nothing built.
359
+ await enterApp({ hooks: undefined, package: app.package })
330
360
  await inScope(undefined, () => import(Bun.pathToFileURL(path).href))
331
361
  return undefined
332
362
  }
@@ -350,7 +380,7 @@ async function watch(host: Host, given: Given): Promise<number> {
350
380
  })
351
381
  }
352
382
 
353
- async function call(host: Host, given: Given): Promise<number> {
383
+ async function call(host: Host, app: AppHandle, given: Given): Promise<number> {
354
384
  const {
355
385
  positionals: [, address, ...args],
356
386
  values: { url, method },
@@ -366,7 +396,7 @@ async function call(host: Host, given: Given): Promise<number> {
366
396
  address,
367
397
  args,
368
398
  method,
369
- await host.handlers(given),
399
+ await app.handlers(),
370
400
  ).catch((thrown: Error) => ({ code: 1, text: thrown.message }))
371
401
  if (answered.text !== '')
372
402
  (answered.code === 0 ? console.log : console.error)(answered.text)
@@ -374,50 +404,55 @@ async function call(host: Host, given: Given): Promise<number> {
374
404
  }
375
405
 
376
406
  /**
377
- * A fetch against a generated surface, on the app `--url` names or on one booted here.
407
+ * A fetch against a generated surface, on the app `--url` names or on one booted here — or the exit
408
+ * code of a refusal to boot one.
378
409
  *
379
410
  * 38.9 — the app booted in this process derives it from the same route table the served one does.
380
411
  */
381
412
  async function surfaceAt(
382
413
  host: Host,
383
- url: string | undefined,
414
+ app: AppHandle,
415
+ given: Given,
384
416
  address: string,
385
417
  ): Promise<
386
- (
387
- init?: RequestInit & { headers?: Record<string, string> },
388
- ) => Promise<Response>
418
+ | ((
419
+ init?: RequestInit & { headers?: Record<string, string> },
420
+ ) => Promise<Response>)
421
+ | number
389
422
  > {
390
- const remote = remoteUrl(url)
423
+ const remote = remoteUrl(given.values.url)
391
424
  if (remote !== undefined)
392
425
  return (init) =>
393
426
  fetch(`${appUrl(remote)}${address}`, {
394
427
  ...init,
395
428
  headers: { ...init?.headers, ...remoteHeaders() },
396
429
  })
397
- const app = await host.boot()
430
+ const booted = await booting(host, app, given)
431
+ if (typeof booted === 'number') return booted
398
432
  return (init) =>
399
- app.fetch(new Request(`http://abide.local${address}`, init))
433
+ booted.fetch(new Request(`http://abide.local${address}`, init))
400
434
  }
401
435
 
402
436
  /**
403
- * `openapi`: writes the OpenAPI document.
437
+ * `openapi`: prints the OpenAPI document — redirected to a file, where one is wanted.
404
438
  *
405
439
  * 38.9 — from `--url` where one is given and from the app in this process where not.
406
440
  */
407
441
  async function openapi(
408
442
  host: Host,
409
- { values: { url, out } }: Given,
443
+ app: AppHandle,
444
+ given: Given,
410
445
  ): Promise<number> {
411
- const answer = await (await surfaceAt(host, url, OPENAPI_ADDRESS))()
446
+ const ask = await surfaceAt(host, app, given, OPENAPI_ADDRESS)
447
+ if (typeof ask === 'number') return ask
448
+ const answer = await ask()
412
449
  if (!answer.ok) {
413
450
  console.error(
414
451
  `${host.name} openapi: the app answered ${answer.status}. The document lists every handler with openapi in its clients; none is.`,
415
452
  )
416
453
  return 1
417
454
  }
418
- const text = JSON.stringify(await answer.json(), null, 4)
419
- if (out === undefined) console.log(text)
420
- else await Bun.write(resolve(out), `${text}\n`)
455
+ console.log(JSON.stringify(await answer.json(), null, 4))
421
456
  return 0
422
457
  }
423
458
 
@@ -427,8 +462,9 @@ async function openapi(
427
462
  * 38.9, 38.13 — a bridge a model client spawns and kills: each line in is a JSON-RPC message for
428
463
  * `/__abide/mcp`, and each answer is a line out.
429
464
  */
430
- async function mcp(host: Host, { values: { url } }: Given): Promise<number> {
431
- const ask = await surfaceAt(host, url, MCP_ADDRESS)
465
+ async function mcp(host: Host, app: AppHandle, given: Given): Promise<number> {
466
+ const ask = await surfaceAt(host, app, given, MCP_ADDRESS)
467
+ if (typeof ask === 'number') return ask
432
468
  for await (const line of console) {
433
469
  if (line.trim() === '') continue
434
470
  const answer = await ask({
@@ -12,6 +12,7 @@ import { MOUNT, unmounted } from '#shared/mount.ts'
12
12
  import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
13
13
  import { servedTable } from './abideModules.ts'
14
14
  import { type AppSources, answerRequest } from './answerRequest.ts'
15
+ import type { Mount } from './answerRpc.ts'
15
16
  import { Mux, type MuxConnection } from './answerSocket.ts'
16
17
  import { appDirectories } from './appDirectories.ts'
17
18
  import { holdsApp } from './appTree.ts'
@@ -61,17 +62,69 @@ export interface App {
61
62
  */
62
63
  export async function createApp(): Promise<App> {
63
64
  const root = appRoot(undefined)
64
- if (root === undefined)
65
- throw new Error(
66
- `abide: createApp found no app. Looked for a .abide under ${resolve('src')}, or under ${process.cwd()} where it has no src.`,
67
- )
68
- const out = await servedBuild(root)
69
- if (out === undefined)
70
- throw new Error(
71
- `abide: createApp found nothing under ${root} to build.`,
72
- )
73
- const { loadBuild } = await import('./serveBuild.ts')
74
- return bootApp(originUnder(root), () => loadBuild({ root, out }))
65
+ const app = await appOnDisk(
66
+ root === undefined ? undefined : { root, out: buildDirectory(root) },
67
+ (root) => servedBuild(root),
68
+ ).boot()
69
+ if (typeof app === 'string')
70
+ throw new Error(`abide: createApp found ${app}`)
71
+ return app
72
+ }
73
+
74
+ /**
75
+ * One app, as one invocation means it: FOUND ONCE, and everything a command derives — the banner's
76
+ * identity, the shell's handler table, the boot — read off this rather than found again from where
77
+ * the process happens to stand. An app on disk (`appOnDisk`), or the one an executable holds.
78
+ */
79
+ export type AppHandle = {
80
+ /** 27.3's name and version: the banner's, and what a script `run` resolves its configuration with. */
81
+ package: AppPackage
82
+ /**
83
+ * The handler table of a build already there, NEVER ONE MADE FOR THE ASKING: `call` and the
84
+ * shell are aimed at a running app (38.7), which this tree may not even be. Empty where none.
85
+ */
86
+ handlers: () => Promise<Mount[]>
87
+ /** The app's build made this process's, and booted — or why there is nothing to boot. */
88
+ boot: () => Promise<App | string>
89
+ }
90
+
91
+ /** Why there is no app where none was named: where 38.25 looked. 38.26. */
92
+ export function noAppHere(): string {
93
+ return `no app here. Looked for a .abide under ${resolve('src')}, or under ${process.cwd()} where it has no src.`
94
+ }
95
+
96
+ /**
97
+ * The app on disk at `found` — its source root and build directory, both absolute — or the
98
+ * refusal where nothing was found. `built` makes its build this process's (`serveBuild`, which
99
+ * builds one nobody built — 38.100) and answers the directory it is in, or `undefined` where
100
+ * nothing under the root builds.
101
+ */
102
+ export function appOnDisk(
103
+ found: { root: string; out: string } | undefined,
104
+ built: (root: string, out: string) => Promise<string | undefined>,
105
+ ): AppHandle {
106
+ if (found === undefined)
107
+ return {
108
+ package: packageUnder(process.cwd()),
109
+ handlers: async () => [],
110
+ boot: async () => noAppHere(),
111
+ }
112
+ const { root, out } = found
113
+ const origin = originUnder(root)
114
+ return {
115
+ package: origin.package,
116
+ handlers: async () => {
117
+ const { serveBuild } = await import('./serveBuild.ts')
118
+ if (!(await serveBuild(root, out, async () => undefined))) return []
119
+ return (await servedTable(root)).handlers
120
+ },
121
+ boot: async () => {
122
+ const served = await built(root, out)
123
+ if (served === undefined) return `nothing under ${root} to build.`
124
+ const { loadBuild } = await import('./serveBuild.ts')
125
+ return bootApp(origin, () => loadBuild({ root, out: served }))
126
+ },
127
+ }
75
128
  }
76
129
 
77
130
  /**
@@ -152,22 +205,43 @@ export function originUnder(root: string): AppOrigin {
152
205
  */
153
206
  export type BootedApp = App & { openMux(): Mux }
154
207
 
208
+ /**
209
+ * Enters the app as this process's server — every slot `#shared` leaves for one filled, the app's
210
+ * identity set, every server block imported — and re-resolves the configuration: the one preamble
211
+ * `bootApp`, a script `run` and the desktop launcher each start with.
212
+ *
213
+ * 37.1 — EVERY SERVER BLOCK, imported for what it CALLS: every hook it runs is filed by the time the
214
+ * import settles, which is before `onStart` is composed around a boot. 38.57 — RE-RESOLVED: a module
215
+ * this process loaded first may already have read the configuration, under no identity.
216
+ */
217
+ export async function enterApp(origin: AppOrigin): Promise<void> {
218
+ installServer()
219
+ APP_PACKAGE.current = origin.package
220
+ if (origin.hooks !== undefined) await origin.hooks()
221
+ config.invalidate()
222
+ }
223
+
155
224
  /** An `App` booted over the sources `load` supplies, inside the app's `onStart` hooks. */
156
225
  export async function bootApp(
157
226
  origin: AppOrigin,
158
227
  load: () => Promise<AppSources>,
159
228
  ): Promise<BootedApp> {
160
- installServer()
161
- // 37.1 — EVERY SERVER BLOCK, imported for what it CALLS: every hook it runs is filed by the
162
- // time the import settles, which is before `onStart` is composed around the boot.
163
- if (origin.hooks !== undefined) await origin.hooks()
229
+ await enterApp(origin)
230
+ return bootEntered(load)
231
+ }
164
232
 
233
+ /**
234
+ * `bootApp` in a process `enterApp` already entered the app in: the desktop launcher's, which enters
235
+ * once for the hooks its own page reads and boots again on every start.
236
+ */
237
+ export async function bootEntered(
238
+ load: () => Promise<AppSources>,
239
+ ): Promise<BootedApp> {
165
240
  // 37.4 — THE BOOT, INSIDE `onStart`. 37.6 — a throw out of a hook or out of the boot rejects
166
241
  // `createApp`, and nothing is served.
167
242
  //
168
243
  // 42.2 — THE CONFIGURATION IS READ AS PART OF THE BOOT, so a configuration that does not resolve
169
244
  // is a boot that fails rather than a first request that does.
170
- APP_PACKAGE.current = origin.package
171
245
  let sources: AppSources | undefined
172
246
  await compose(inOrder(HOOKS.starts), async () => {
173
247
  config.invalidate()
@@ -0,0 +1,175 @@
1
+ // 41.28 — A CHILD WHOSE SETUP MADE A CALL IS BUILT AS A PAGE IS: after the turn.
2
+ // A component is set up where its parent's markup invokes it, so its build cannot wait inline; it is
3
+ // left as a marker in the parent's head and body, built once the turn has run, and written in where
4
+ // its marker stands. A child whose setup made no call builds where it stands, as before, and pays a
5
+ // counter read on either side of its setup for the difference.
6
+ //
7
+ // THE MARKERS ARE NEVER IN WHAT IS SENT. Every one is replaced before `composePage` answers, and the
8
+ // nonce is never written anywhere, so no text an author or a caller supplies can stand in for one.
9
+
10
+ import { regionMarkup } from '#shared/anchorMarkup.ts'
11
+ import { isReactive, isStreaming, loadOf } from '#shared/reactive/index.ts'
12
+ import type { Rendered } from './render.ts'
13
+ import type { PageCall } from './requestScope.ts'
14
+
15
+ // SYNCHRONOUS SETUP IS WHAT MAKES ONE COUNTER ENOUGH. 34.3 keeps a setup from awaiting, so no other
16
+ // request's render runs between a component's two reads, however many renders are in flight.
17
+ let callsInRenders = 0
18
+
19
+ /** How many calls renders have made, read on either side of a component's setup. */
20
+ export function callsInRender(): number {
21
+ return callsInRenders
22
+ }
23
+
24
+ /** Counts a call a page's render made (`reachedInRender`). */
25
+ export function noteCallInRender(): void {
26
+ callsInRenders += 1
27
+ }
28
+
29
+ /** A deferred child: its record, and the page's calls its setup made, marked with its build. */
30
+ type Deferred = { rendered: Rendered; callsFrom: number; callsTo: number }
31
+
32
+ type Build = { deferred: Deferred[]; calls: PageCall[] }
33
+
34
+ /**
35
+ * The render whose markup is being built. Set only while `pass` runs, which is synchronous, so
36
+ * renders in flight never see each other's.
37
+ */
38
+ let building: Build | undefined
39
+
40
+ const NONCE = crypto.randomUUID()
41
+ const MARKER = new RegExp(`\u0000${NONCE}([hb])(\\d+)\u0000`, 'g')
42
+
43
+ /** The page's calls so far, for a child to note where its setup's begin; `-1` outside a build. */
44
+ export function callsMadeSoFar(): number {
45
+ return building === undefined ? -1 : building.calls.length
46
+ }
47
+
48
+ /**
49
+ * Where a child that deferred stands in its parent's markup: a marker in the head and in the body,
50
+ * or `undefined` where no page is being built and the child builds where it stands.
51
+ */
52
+ export function deferBuild(
53
+ rendered: Rendered,
54
+ heads: string[],
55
+ callsFrom: number,
56
+ ): string | undefined {
57
+ if (building === undefined) return undefined
58
+ const at = building.deferred.length
59
+ building.deferred.push({
60
+ rendered,
61
+ callsFrom,
62
+ callsTo: building.calls.length,
63
+ })
64
+ heads.push(`\u0000${NONCE}h${at}\u0000`)
65
+ return regionMarkup(`\u0000${NONCE}b${at}\u0000`)
66
+ }
67
+
68
+ /**
69
+ * `build`'s markup, with every child it deferred built after the turn — and the children those
70
+ * defer after another — each written in where its marker stands.
71
+ */
72
+ export async function buildDeferred(
73
+ calls: PageCall[],
74
+ build: () => { head: string; body: string },
75
+ ): Promise<{ head: string; body: string }> {
76
+ const state: Build = { deferred: [], calls }
77
+ let built = { head: '', body: '' }
78
+ pass(state, () => {
79
+ built = build()
80
+ })
81
+ if (state.deferred.length === 0) return built
82
+ const fills: { head: string; body: string }[] = []
83
+ let from = 0
84
+ while (from < state.deferred.length) {
85
+ await new Promise((settled) => setImmediate(settled))
86
+ const to = state.deferred.length
87
+ pass(state, () => {
88
+ for (let at = from; at < to; at++)
89
+ fills.push(filled((state.deferred[at] as Deferred).rendered))
90
+ })
91
+ from = to
92
+ }
93
+ return {
94
+ head: expanded(built.head, fills),
95
+ body: expanded(built.body, fills),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Marks every call not yet marked: settled or not, as the markup just built read it.
101
+ *
102
+ * 41.25 — READ WHERE THE MARKUP HAS JUST BEEN BUILT, before anything has run since: a call that
103
+ * settles a microtask later was painted in flight, and marked settled it has hydration choose an
104
+ * arm the server never painted.
105
+ */
106
+ export function markPainted(calls: PageCall[]): void {
107
+ for (const call of calls)
108
+ if (call.painted === undefined) call.painted = paintedSettled(call)
109
+ }
110
+
111
+ // One synchronous build pass, and the calls it built from marked at its end — all but those a
112
+ // child it deferred made, which its own pass marks.
113
+ function pass(state: Build, run: () => void): void {
114
+ const deferredFrom = state.deferred.length
115
+ building = state
116
+ try {
117
+ run()
118
+ } finally {
119
+ building = undefined
120
+ }
121
+ const { calls, deferred } = state
122
+ for (let at = 0; at < calls.length; at++) {
123
+ const call = calls[at] as PageCall
124
+ if (
125
+ call.painted !== undefined ||
126
+ ownedByDeferred(at, deferred, deferredFrom)
127
+ )
128
+ continue
129
+ call.painted = paintedSettled(call)
130
+ }
131
+ }
132
+
133
+ function ownedByDeferred(
134
+ call: number,
135
+ deferred: Deferred[],
136
+ from: number,
137
+ ): boolean {
138
+ for (let at = from; at < deferred.length; at++) {
139
+ const child = deferred[at] as Deferred
140
+ if (call >= child.callsFrom && call < child.callsTo) return true
141
+ }
142
+ return false
143
+ }
144
+
145
+ // 41.26 — a call that streams is never marked.
146
+ function paintedSettled({ reached }: PageCall): boolean {
147
+ return (
148
+ isReactive(reached) &&
149
+ loadOf(reached) === undefined &&
150
+ !isStreaming(reached)
151
+ )
152
+ }
153
+
154
+ /** 35.16 — a document is written by the outermost component alone, and `rendered` is placed. */
155
+ export function refuseDocument(rendered: Rendered): void {
156
+ if (rendered.document !== undefined)
157
+ throw new Error(
158
+ 'abide: a component that writes <html> is placed inside another, and only the outermost component writes the document. RULEBOOK 35.16.',
159
+ )
160
+ }
161
+
162
+ function filled(rendered: Rendered): { head: string; body: string } {
163
+ refuseDocument(rendered)
164
+ return { head: rendered.head, body: rendered.body }
165
+ }
166
+
167
+ function expanded(
168
+ markup: string,
169
+ fills: { head: string; body: string }[],
170
+ ): string {
171
+ return markup.replace(MARKER, (_, kind: string, at: string) => {
172
+ const fill = fills[Number(at)] as { head: string; body: string }
173
+ return expanded(kind === 'h' ? fill.head : fill.body, fills)
174
+ })
175
+ }