@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
@@ -6,7 +6,7 @@
6
6
  // Every block of an assistant turn goes back as it came — a `thinking` block with its signature
7
7
  // included, which a turn continuing after a tool call is refused without.
8
8
 
9
- import { framedStream, framingPrefix } from '#shared/framing.ts'
9
+ import { framedStream } from '#shared/framing.ts'
10
10
  import { refuse } from '#shared/refuse.ts'
11
11
  import type { AssistantEvent, Provider } from './assistant.ts'
12
12
 
@@ -112,10 +112,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
112
112
  const partials: string[] = []
113
113
  const called: Block[] = []
114
114
  let stop = 'end_turn'
115
- for await (const chunk of framedStream(
116
- answered.body,
117
- framingPrefix('text/event-stream') as string,
118
- )) {
115
+ for await (const chunk of framedStream(answered.body, 'sse')) {
119
116
  const event = chunk as ApiEvent
120
117
  switch (event.type) {
121
118
  case 'message_start':
@@ -0,0 +1,198 @@
1
+ // 38.5, 38.16 — THE CLIENT BUNDLE, for both lanes that build one: `abide build`, which writes it to
2
+ // disk for `abide start`, and `abide dev`'s worker, which holds it in memory. What decides what the
3
+ // bundle IS — its plugins and their order, whether there is a page to boot, how a stylesheet's
4
+ // addresses are written, the manifest — is here once, so dev resolves an address the way the
5
+ // built app does by construction; a lane picks only a `mode`. And both answer it at
6
+ // `/__abide/client/<name>` through `bundleServer`, differing in where a byte is read from and how
7
+ // long a browser may keep it.
8
+
9
+ import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
10
+ import { abideModules, type Compilation, tablePath } from './abideModules.ts'
11
+ import { appPlugins } from './appPlugins.ts'
12
+ import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
13
+ import { fenceServer } from './fenceServer.ts'
14
+ import { type Manifest, manifestOf, NO_MANIFEST } from './manifestOf.ts'
15
+ import { type Encoding, negotiateEncoding } from './negotiateEncoding.ts'
16
+
17
+ /** One file the bundle produced: its bytes — a stylesheet's already rewritten — and its kind. */
18
+ type BundledFile = {
19
+ bytes: Uint8Array<ArrayBuffer>
20
+ type: string
21
+ kind: Bun.BuildArtifact['kind']
22
+ }
23
+
24
+ /** A client bundle: every file it produced, by its name below `/__abide/client`, and its manifest. */
25
+ export type ClientBundle = {
26
+ files: Map<string, BundledFile>
27
+ manifest: Manifest
28
+ }
29
+
30
+ /**
31
+ * The client bundle of the app at `root`, out of `compilation`, built for `mode`.
32
+ *
33
+ * `published` is each public file's source path to its address. `'build'` is 38.5's — minified and
34
+ * every name hashed; `'dev'` is 38.16's — unminified, the entry named by its source, with linked
35
+ * maps. A bundle that fails throws, its logs the error's.
36
+ *
37
+ * AN APP OF HANDLERS ALONE has no page to boot, and is bundled as nothing with no manifest.
38
+ */
39
+ export async function clientBundle(
40
+ root: string,
41
+ compilation: Compilation,
42
+ published: Map<string, string>,
43
+ mode: 'build' | 'dev',
44
+ ): Promise<ClientBundle> {
45
+ const bundle: ClientBundle = { files: new Map(), manifest: NO_MANIFEST }
46
+ // A page among what compiled: the compiler writes the entry only where there is one to boot.
47
+ if (compilation.tables[`${CLIENT_ENTRY}.ts`] === undefined) return bundle
48
+ const built = await Bun.build({
49
+ // The entry is a table the plugin serves (`abideModules`), below the root.
50
+ entrypoints: [tablePath(root, `${CLIENT_ENTRY}.ts`)],
51
+ // The app's own root, because `Bun.build` opens the entry's directory otherwise, and the
52
+ // tables' is not on disk to open.
53
+ root,
54
+ // 16.28, 16.29 — the browser's view of the server: a `<script server>` is its stand-ins and a
55
+ // `server/` module refuses — FENCED FIRST, before the plugin that would serve the module. 38.43
56
+ // — then the app's own, as `bunfig.toml` declares them.
57
+ plugins: [
58
+ fenceServer(root, compilation.mounted),
59
+ abideModules(root, compilation),
60
+ ...(await appPlugins(root)),
61
+ ],
62
+ target: 'browser',
63
+ format: 'esm',
64
+ // So a destination's markup and bindings stay in the chunk its own route loads (30.11)
65
+ // rather than landing in the entry every page pays for.
66
+ splitting: true,
67
+ metafile: true,
68
+ // 36.15 — a stylesheet's `url('/fonts/…')` is the public file's address, not a path here,
69
+ // and `Bun.build` reads a leading `/` as a path on this disk and fails on it not being
70
+ // there. The list is the files that exist rather than every `/…`: a URL naming nothing the
71
+ // app serves is still worth a build error.
72
+ external: [...published.values()],
73
+ throw: false,
74
+ ...(mode === 'build'
75
+ ? {
76
+ // 38.5 — MINIFIED, and EVERY NAME HASHED, the entry's included: chunks and assets are
77
+ // already `[name]-[hash]` by default and the entry is not, which left the one file
78
+ // every page loads served for a year under a name the next deploy writes different
79
+ // bytes to. The manifest is how the document learns what the entry was called.
80
+ //
81
+ // NAMES KEPT, because a class's name is what an error reads to say what it was
82
+ // handed (`canonicalKey`), and a production error naming `a e` is one nobody can
83
+ // act on. It is free where it is paid: the JS the docs app's `/values` sends is
84
+ // 25 600 bytes brotli with the names and without, against 31 900 unminified.
85
+ minify: {
86
+ whitespace: true,
87
+ syntax: true,
88
+ identifiers: true,
89
+ keepNames: true,
90
+ },
91
+ naming: { entry: '[name]-[hash].[ext]' },
92
+ }
93
+ : {
94
+ // 38.97 — a debugger in the browser reads the `.abide` (`withAbideSources`).
95
+ sourcemap: 'linked' as const,
96
+ // 38.16 — the entry named by its source, so what a browser loads and what a stack
97
+ // trace names are the module the author wrote. A dev chunk that changed has to be
98
+ // fetched again, and a name that changes with it is a cache the reload cannot clear.
99
+ naming: {
100
+ entry: '[name].[ext]',
101
+ chunk: '[name]-[hash].[ext]',
102
+ asset: '[name]-[hash].[ext]',
103
+ },
104
+ }),
105
+ })
106
+ // THROWN RATHER THAN LOGGED: a build that emitted server modules and no bundle is an app that
107
+ // serves every page dead — the markup right and nothing wired — which is the one failure a
108
+ // reader cannot see. Dev catches it (38.17); the build stops on it.
109
+ if (!built.success)
110
+ throw new AggregateError(
111
+ built.logs,
112
+ `abide ${mode}: the client bundle failed`,
113
+ )
114
+ // The outputs are independent, so they are read at once rather than one after another.
115
+ const read = await Promise.all(
116
+ built.outputs.map((output) => output.arrayBuffer()),
117
+ )
118
+ for (let index = 0; index < built.outputs.length; index += 1) {
119
+ const output = built.outputs[index] as Bun.BuildArtifact
120
+ const name = output.path.slice(output.path.lastIndexOf('/') + 1)
121
+ let bytes = new Uint8Array(read[index] as ArrayBuffer)
122
+ if (name.endsWith('.css'))
123
+ bytes = new TextEncoder().encode(
124
+ stylesheetUnderMount(new TextDecoder().decode(bytes)),
125
+ )
126
+ bundle.files.set(name, { bytes, type: output.type, kind: output.kind })
127
+ }
128
+ bundle.manifest = manifestOf(built.metafile as Bun.BuildMetafile, root)
129
+ return bundle
130
+ }
131
+
132
+ // From the bundle's own directory up to the app's root: `../../` below `/__abide/client`.
133
+ const UP_TO_ROOT = '../'.repeat(CLIENT_ADDRESS.split('/').length - 1)
134
+
135
+ /**
136
+ * A built stylesheet with every root-absolute `url()` written relative to the bundle's directory.
137
+ *
138
+ * 16.10, 36.15 — THE SAME MECHANISM AS THE HANDLER WRAPPERS: the address is written relative to
139
+ * where the bundle is served and the browser resolves it against the sheet, so one build serves
140
+ * at the root and below a mount. What survives the build as `url(/…)` is a public file's address
141
+ * (the `external` list above), and the browser would resolve it against the origin. `//` is
142
+ * another host's.
143
+ */
144
+ function stylesheetUnderMount(css: string): string {
145
+ return css.replace(
146
+ /url\((["']?)\/(?!\/)/g,
147
+ (_whole, quote: string) => `url(${quote}${UP_TO_ROOT}`,
148
+ )
149
+ }
150
+
151
+ /** A bundle file as it is answered: its type, its bytes, and each precompressed copy of them. */
152
+ export type ServedFile = {
153
+ type: string
154
+ body: () => BodyInit
155
+ encoded: Partial<Record<Encoding, () => BodyInit>>
156
+ }
157
+
158
+ /**
159
+ * Answers `/__abide/client/<name>` out of `files`, each with `cacheControl`; `undefined` for any
160
+ * other address, and for a name the bundle does not hold.
161
+ *
162
+ * THE BUNDLE, ENUMERATED RATHER THAN RESOLVED. A request names a file in this set or it names
163
+ * nothing — there is no path to normalize, so `..` is not a case that has to be got right, and what
164
+ * is servable is exactly what the bundle produced.
165
+ */
166
+ export function bundleServer(
167
+ files: Map<string, ServedFile>,
168
+ cacheControl: string,
169
+ ): (pathname: string, request: Request) => Response | undefined {
170
+ // Each file's encodings listed once, rather than on every request for it.
171
+ const offeredOf = new Map<string, Encoding[]>()
172
+ for (const [name, file] of files)
173
+ offeredOf.set(name, Object.keys(file.encoded) as Encoding[])
174
+ return (pathname, request) => {
175
+ if (!pathname.startsWith(`${CLIENT_ADDRESS}/`)) return undefined
176
+ const name = pathname.slice(CLIENT_ADDRESS.length + 1)
177
+ const file = files.get(name)
178
+ if (file === undefined) return undefined
179
+ const headers: Record<string, string> = {
180
+ 'cache-control': cacheControl,
181
+ 'content-type': file.type,
182
+ }
183
+ const offered = offeredOf.get(name) as Encoding[]
184
+ if (offered.length === 0) return new Response(file.body(), { headers })
185
+ headers.vary = 'accept-encoding'
186
+ // 38.29 — the precompressed copy where the request takes its encoding.
187
+ const encoding = negotiateEncoding(
188
+ request.headers.get('accept-encoding'),
189
+ offered,
190
+ )
191
+ const encoded =
192
+ encoding === undefined ? undefined : file.encoded[encoding]
193
+ if (encoding === undefined || encoded === undefined)
194
+ return new Response(file.body(), { headers })
195
+ headers['content-encoding'] = encoding
196
+ return new Response(encoded(), { headers })
197
+ }
198
+ }
@@ -7,8 +7,8 @@
7
7
  // has declared no types, and its args stay the text they arrived as.
8
8
 
9
9
  import { numberFromText } from '#shared/numberFromText.ts'
10
+ import { bareName } from '#shared/queryText.ts'
10
11
  import { type Gated, gateOf } from '#shared/reactive/argsContract.ts'
11
- import { bareName } from '#shared/rpcRequest.ts'
12
12
 
13
13
  export type Document = {
14
14
  type?: string | string[]
@@ -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({