@abide/abide 0.101.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +223 -164
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +5 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -59
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +102 -30
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +114 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +73 -37
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  107. package/src/shared/cookies.ts +2 -2
  108. package/src/shared/csp.ts +1 -1
  109. package/src/shared/escapeText.ts +1 -1
  110. package/src/shared/filledPath.ts +85 -0
  111. package/src/shared/framing.ts +79 -6
  112. package/src/shared/headKeyOf.ts +2 -0
  113. package/src/shared/index.ts +1 -1
  114. package/src/shared/log.ts +27 -13
  115. package/src/shared/matchRoute.ts +23 -4
  116. package/src/shared/menuItem.ts +3 -3
  117. package/src/shared/reactive/Keyed.ts +2 -2
  118. package/src/shared/reactive/NotLanded.ts +1 -1
  119. package/src/shared/reactive/Reactive.ts +43 -43
  120. package/src/shared/reactive/argsContract.ts +4 -4
  121. package/src/shared/reactive/canonicalKey.ts +4 -4
  122. package/src/shared/reactive/chunksOfStream.ts +6 -0
  123. package/src/shared/reactive/index.ts +1 -1
  124. package/src/shared/reactive/recordWrites.ts +103 -5
  125. package/src/shared/reactive/structural.ts +1 -1
  126. package/src/shared/reactive/tracking.ts +1 -1
  127. package/src/shared/reactive/watch.ts +3 -3
  128. package/src/shared/refuse.ts +1 -1
  129. package/src/shared/routePattern.ts +82 -46
  130. package/src/shared/rpc.ts +37 -8
  131. package/src/shared/rpcRequest.ts +27 -3
  132. package/src/shared/url.ts +4 -44
  133. package/src/shared/writePath.ts +1 -1
  134. package/src/ui/LAYER_NAMES.ts +1 -1
  135. package/src/ui/List.ts +3 -3
  136. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  137. package/src/ui/Mismatch.ts +1 -1
  138. package/src/ui/RawRun.ts +1 -1
  139. package/src/ui/Router.ts +38 -31
  140. package/src/ui/Scope.ts +1 -1
  141. package/src/ui/TryRegion.ts +2 -2
  142. package/src/ui/applyHead.ts +33 -0
  143. package/src/ui/index.ts +1 -1
  144. package/src/ui/menuBridge.ts +1 -1
  145. package/src/ui/moveNode.ts +1 -1
  146. package/src/ui/reconcile.ts +2 -2
  147. package/src/ui/reportMenu.ts +2 -2
  148. package/src/ui/routing.ts +2 -2
  149. package/src/ui/setAttribute.ts +1 -1
  150. package/src/ui/setText.ts +1 -1
  151. package/src/ui/socketWire.ts +9 -7
  152. package/src/ui/wire.ts +1 -1
  153. package/scaffold/src/server/app.ts +0 -6
  154. package/scaffold/src/shared/.gitkeep +0 -0
  155. package/scaffold/src/ui/app.html +0 -8
  156. package/src/server/componentImports.ts +0 -56
  157. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -2,7 +2,7 @@
2
2
  // off the wire: over http (16.2) and a `GET` followed over the socket mux (18.3). Each lane reads the
3
3
  // args off its own transport and answers a refusal in its own form.
4
4
 
5
- import { normalised } from '#shared/reactive/argsContract.ts'
5
+ import { normalized } from '#shared/reactive/argsContract.ts'
6
6
  import { validationError } from '#shared/refuse.ts'
7
7
  import { validateJson } from '#shared/validateJson.ts'
8
8
  import type { Mounted } from './answerRpc.ts'
@@ -31,7 +31,7 @@ export function argsFor(
31
31
  found.argsDocument,
32
32
  query,
33
33
  )
34
- const args = normalised(found.handler, coerced)
34
+ const args = normalized(found.handler, coerced)
35
35
  if (found.wireArgs !== undefined && args !== undefined) {
36
36
  const issues = validateJson(found.wireArgs, args)
37
37
  if (issues !== null) throw validationError(issues)
@@ -115,10 +115,10 @@ function appSurface(): McpSurface {
115
115
  throw new Error(
116
116
  'abide: assistant tools are the app’s MCP surface, and no app is answering this request. RULEBOOK 45.6.',
117
117
  )
118
- // 45.8, 20.7 — asking for tools over a closed surface is a mistake to name.
118
+ // 45.8, 20.7 — asking for tools where no handler is published to MCP is a mistake to name.
119
119
  if (!app.mcp.opened)
120
120
  throw new Error(
121
- 'abide: assistant was asked for tools, and ABIDE_MCP has not opened the MCP surface. RULEBOOK 45.8.',
121
+ 'abide: assistant was asked for tools, and this app publishes no handler to MCP. RULEBOOK 45.8.',
122
122
  )
123
123
  return app.mcp
124
124
  }
@@ -9,13 +9,12 @@
9
9
  //
10
10
  // A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — for as long as the bridge's
11
11
  // stream is open. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach
12
- // the one its bridge dialled.
12
+ // the one its bridge dialed.
13
13
 
14
14
  import { refuse } from '#shared/refuse.ts'
15
15
  import { answered } from './answerFailure.ts'
16
16
  import { raiseTimeout } from './answerRpc.ts'
17
17
  import { framed } from './answerValue.ts'
18
- import { config } from './config.ts'
19
18
  import { Inbox } from './Inbox.ts'
20
19
  import { randomToken } from './randomToken.ts'
21
20
 
@@ -53,10 +52,8 @@ const BY_CALLER = new Map<string, Bridge>()
53
52
  const BY_TOKEN = new Map<string, Bridge>()
54
53
  const WATCHERS = new Map<string, Set<() => void>>()
55
54
 
56
- /** Whether `ABIDE_BRIDGE` has opened the relay. 45.19. */
57
- export function bridgeOpened(): boolean {
58
- return (config.peek()?.ABIDE_BRIDGE as unknown) === true
59
- }
55
+ // 45.19 — the relay answers once the app has handed out a pairing command, and never before.
56
+ let minted = false
60
57
 
61
58
  /** A single-use pairing code for `caller`, and when it stops standing. 45.14. */
62
59
  export function mintCode(caller: string): { code: string; expires: number } {
@@ -66,6 +63,7 @@ export function mintCode(caller: string): { code: string; expires: number } {
66
63
  const code = randomToken(12, 'base64url')
67
64
  const expires = now + CODE_LIFETIME
68
65
  CODES.set(code, { caller, expires })
66
+ minted = true
69
67
  return { code, expires }
70
68
  }
71
69
 
@@ -172,9 +170,9 @@ function dropBridge(bridge: Bridge): void {
172
170
  wakeWatchers(bridge.caller)
173
171
  }
174
172
 
175
- /** Answers `/__abide/bridge`, or `undefined` where `ABIDE_BRIDGE` is closed. 45.19. */
173
+ /** Answers `/__abide/bridge`, or `undefined` until a pairing code has been minted. 45.19. */
176
174
  export async function answerBridge(request: Request): Promise<unknown> {
177
- if (!bridgeOpened()) return undefined
175
+ if (!minted) return undefined
178
176
  if (request.method === 'GET') return pair(request)
179
177
  if (request.method === 'POST') return relay(request)
180
178
  return new Response(null, { status: 405, headers: { allow: 'GET, POST' } })
@@ -17,7 +17,6 @@ import {
17
17
  } from './assistant.ts'
18
18
  import { BRIDGE_VERSION } from './BRIDGE_VERSION.ts'
19
19
  import {
20
- bridgeOpened,
21
20
  mintCode,
22
21
  openTurn,
23
22
  pairedBridge,
@@ -110,11 +109,6 @@ function foldedPrompt(messages: AssistantMessage[]): string {
110
109
 
111
110
  /** The browser asking, which a pairing is to. 45.13. */
112
111
  function askingCaller(): string {
113
- // 45.19 — the relay is closed until `ABIDE_BRIDGE` opens it, and using it closed is a mistake to name.
114
- if (!bridgeOpened())
115
- throw new Error(
116
- 'abide: claudeCode reaches a visitor’s Claude Code through the bridge relay, and ABIDE_BRIDGE has not opened it. RULEBOOK 45.19.',
117
- )
118
112
  const caller = callerOf()
119
113
  if (caller === undefined)
120
114
  throw refuse(
@@ -1,4 +1,5 @@
1
1
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
2
+ import { tablePath } from './abideModules.ts'
2
3
  import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
3
4
 
4
5
  /**
@@ -9,13 +10,17 @@ import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
9
10
  * where the output goes and how it is named.
10
11
  */
11
12
  export function clientBuildOptions(
12
- entryDirectory: string,
13
+ root: string,
13
14
  plugins: Bun.BunPlugin[],
14
15
  publicAddresses: string[],
15
16
  ) {
16
17
  return {
17
- entrypoints: [`${entryDirectory}/${CLIENT_ENTRY}.ts`] as string[],
18
- // 16.28, 16.29 — a handler module is its wrapper here, and the rest of `server/` refuses.
18
+ // The entry is a table the plugin serves (`abideModules`), below the root.
19
+ entrypoints: [tablePath(root, `${CLIENT_ENTRY}.ts`)] as string[],
20
+ // The app's own root, because `Bun.build` opens the entry's directory otherwise, and the
21
+ // tables' is not on disk to open.
22
+ root,
23
+ // 16.28, 16.29 — a `<script server>` is its stand-ins here, and a `server/` module refuses.
19
24
  plugins,
20
25
  target: 'browser',
21
26
  format: 'esm',
@@ -30,7 +30,7 @@ export function argsDocumentOf(handler: Gated<unknown>): Document | undefined {
30
30
  /**
31
31
  * Name and value pairs as args: a name given more than once is a list, one given once its text.
32
32
  *
33
- * 16.18 — a query's and a form's members, and 38.62 — `abide call`'s `name=value` words, alike.
33
+ * 16.18 — a query's and a form's members, and 38.61 — `abide call`'s `name=value` words, alike.
34
34
  */
35
35
  export function collapsed(
36
36
  entries: URLSearchParams | FormData,
@@ -1,5 +1,5 @@
1
- // 38.13 — THE COMMAND LINE, for both hosts that have one: `abide`, over an app on disk, and a
2
- // compiled app's executable, over the app it holds (38.51). One list, so a command either has is a
1
+ // 38.12 — THE COMMAND LINE, for both hosts that have one: `abide`, over an app on disk, and a
2
+ // compiled app's executable, over the app it holds (38.50). One list, so a command either has is a
3
3
  // command both have — the executable answers the ones that need the source and the compiler with a
4
4
  // refusal naming `abide`, rather than not knowing them.
5
5
 
@@ -14,7 +14,6 @@ import { installServer } from './installServer.ts'
14
14
  import {
15
15
  appUrl,
16
16
  callHandler,
17
- followLogs,
18
17
  remoteHeaders,
19
18
  remoteUrl,
20
19
  watch as watchAddress,
@@ -30,7 +29,14 @@ type Command = (given: Given) => Promise<number | undefined>
30
29
 
31
30
  /** The commands that need the app's source and the compiler, which only `abide` holds. */
32
31
  type SourceCommands = Record<
33
- 'scaffold' | 'dev' | 'build' | 'check' | 'compile' | 'bundle' | 'lsp',
32
+ | 'scaffold'
33
+ | 'dev'
34
+ | 'build'
35
+ | 'check'
36
+ | 'sync'
37
+ | 'compile'
38
+ | 'bundle'
39
+ | 'lsp',
34
40
  Command
35
41
  >
36
42
 
@@ -41,7 +47,7 @@ export type Host = {
41
47
  /**
42
48
  * The handler table the shell lists and a call reads a handler's method off — so a call is
43
49
  * spelled the same whatever the handler's method, and `--method` is only for an app nobody here
44
- * knows the table of. Empty where there is none to read.
50
+ * knows the table of. Empty where there is none to read, and never built to be read.
45
51
  */
46
52
  handlers: (given: Given) => Promise<Mount[]>
47
53
  /** The identity a script `run` resolves its configuration with. 27.3. */
@@ -83,7 +89,7 @@ export async function runCommands(
83
89
  }
84
90
 
85
91
  // A command, and whether it is one of `SourceCommands` — which a host without them still answers
86
- // (38.52), and does not list (38.53).
92
+ // (38.51), and does not list (38.52).
87
93
  type Row = [usage: string, meaning: string, run: Command, sourced: boolean]
88
94
 
89
95
  function commandsOf(host: Host): Row[] {
@@ -133,12 +139,6 @@ function commandsOf(host: Host): Row[] {
133
139
  (given) => mcp(host, given),
134
140
  false,
135
141
  ],
136
- [
137
- 'logs',
138
- 'a live subscription to log.records',
139
- ({ values }) => followLogs(appUrl(values.url)),
140
- false,
141
- ],
142
142
  [
143
143
  'watch <address> [args]',
144
144
  "a live subscription to one of the app's followed GETs",
@@ -150,6 +150,11 @@ function commandsOf(host: Host): Row[] {
150
150
  'type-check the templates and the TypeScript, on the line you wrote',
151
151
  'check',
152
152
  ),
153
+ fromSource(
154
+ 'sync',
155
+ 'write the types a .ts reads a .abide by, into .abide/types',
156
+ 'sync',
157
+ ),
153
158
  fromSource(
154
159
  'compile',
155
160
  'the build, as one executable named after the app',
@@ -164,7 +169,7 @@ function commandsOf(host: Host): Row[] {
164
169
  ]
165
170
  }
166
171
 
167
- /** Refuses a command that needs the source, pointing at `abide`; returns the exit code. 38.52. */
172
+ /** Refuses a command that needs the source, pointing at `abide`; returns the exit code. 38.51. */
168
173
  async function needsSource(host: Host, name: string): Promise<number> {
169
174
  console.error(
170
175
  `${host.name} ${name}: needs the app's source and the compiler, which this executable does not hold. Run \`abide ${name}\` where the source is.`,
@@ -175,7 +180,7 @@ async function needsSource(host: Host, name: string): Promise<number> {
175
180
  /**
176
181
  * The usage text, listing what the host can run.
177
182
  *
178
- * 38.53 — an executable lists neither the commands it only refuses nor the flags only they read.
183
+ * 38.52 — an executable lists neither the commands it only refuses nor the flags only they read.
179
184
  */
180
185
  function usage(host: Host, commands: Row[]): string {
181
186
  const sourced = host.source !== undefined
@@ -189,16 +194,16 @@ function usage(host: Host, commands: Row[]): string {
189
194
  if (!sourced)
190
195
  return `${text}
191
196
  --port the listen port the app is served on
192
- --url the running app the shell, call, logs, openapi and mcp talk to
197
+ --url the running app the shell, call, watch, openapi and mcp talk to
193
198
  --out the file openapi writes
194
199
  `
195
200
  text += `
196
- --root the app's source directory, the one ui/ sits under —
201
+ --root the app's source directory, where its pages sit —
197
202
  src/ where you stand, or where you stand, whichever has one
198
203
  --out where the build goes, beside the app by default; the file openapi,
199
204
  compile and bundle write — named after the app, where you stand
200
205
  --port the listen port for dev, start and the shell's app
201
- --url the running app the shell, call, logs, openapi and mcp talk to
206
+ --url the running app the shell, call, watch, openapi and mcp talk to
202
207
  --target the platform compile builds for — linux-x64, darwin-arm64, windows-x64…
203
208
  --platforms several of them, comma-separated, one executable each
204
209
  `
@@ -215,7 +220,7 @@ function parsed(argv: string[]) {
215
220
  root: { type: 'string' },
216
221
  out: { type: 'string' },
217
222
  // NO DEFAULT, which is not an omission: `Bun.serve` given no port already reads
218
- // `PORT` and then falls back to 3000 — `abide dev` scanning on from there, 38.64 — and
223
+ // `PORT` and then falls back to 3000 — `abide dev` scanning on from there, 38.63 — and
219
224
  // a default written here would take that away from every host that sets it.
220
225
  port: { type: 'string' },
221
226
  url: { type: 'string' },
@@ -249,7 +254,7 @@ async function served(
249
254
  /**
250
255
  * `start`: serves the app.
251
256
  *
252
- * 38.68 — READS NO INPUT, and hands back no code for the process to exit on: the process is the
257
+ * 38.67 — READS NO INPUT, and hands back no code for the process to exit on: the process is the
253
258
  * server, for a container or a supervisor to stop.
254
259
  */
255
260
  async function start(host: Host, given: Given): Promise<number | undefined> {
@@ -263,18 +268,17 @@ async function start(host: Host, given: Given): Promise<number | undefined> {
263
268
  * The command line with no command: 38.7's shell, on the running app `--url` names or on the app
264
269
  * served here.
265
270
  *
266
- * 38.7 — WITH `--url`, BOOTS NOTHING. 38.54 — without, the app served and the shell on it, over http
267
- * as a remote one's is. 38.55 — `exit` stops the app. 38.56 — input that ends leaves it serving, and
271
+ * 38.7 — WITH `--url`, BOOTS NOTHING. 38.53 — without, the app served and the shell on it, over http
272
+ * as a remote one's is. 38.54 — `exit` stops the app. 38.55 — input that ends leaves it serving, and
268
273
  * hands back no code for the process to exit on.
269
274
  */
270
275
  async function shellOn(host: Host, given: Given): Promise<number | undefined> {
271
- const handlers = await host.handlers(given)
272
276
  const remote = remoteUrl(given.values.url)
273
277
  if (remote !== undefined) {
274
278
  const url = appUrl(remote)
275
279
  await shell({
276
280
  url,
277
- handlers,
281
+ handlers: await host.handlers(given),
278
282
  heading: `${host.name}: ${url}`,
279
283
  local: false,
280
284
  })
@@ -282,9 +286,10 @@ async function shellOn(host: Host, given: Given): Promise<number | undefined> {
282
286
  }
283
287
  const serving = await served(host, given)
284
288
  if (typeof serving === 'number') return serving
289
+ // After the serve, which built the app where nothing had.
285
290
  const ended = await shell({
286
291
  url: serving.url,
287
- handlers,
292
+ handlers: await host.handlers(given),
288
293
  heading: serving.heading,
289
294
  local: true,
290
295
  })
@@ -297,7 +302,7 @@ async function shellOn(host: Host, given: Given): Promise<number | undefined> {
297
302
  * `run`: runs a script under the abide runtime.
298
303
  *
299
304
  * 38.2 — the request scope installed, the configuration resolved against the app it stands in,
300
- * and the script run in a scope of its own — so it imports `#server` and `#shared` and reads
305
+ * and the script run in a scope of its own — so it imports through `#src/*` and reads
301
306
  * `config` without a build.
302
307
  *
303
308
  * HANDS BACK NO CODE ONCE THE SCRIPT IS RUNNING, as `start` does: the script's top level settling
@@ -316,7 +321,7 @@ async function run(
316
321
  }
317
322
  const path = resolve(file)
318
323
  process.argv = [process.argv[0] as string, path, ...rest]
319
- // 38.58 — RE-RESOLVED, as `bootApp` does: a module this process loaded before the script may already have
324
+ // 38.57 — RE-RESOLVED, as `bootApp` does: a module this process loaded before the script may already have
320
325
  // read the configuration, under no identity.
321
326
  installServer()
322
327
  APP_PACKAGE.current = host.appPackage()
@@ -326,7 +331,7 @@ async function run(
326
331
  }
327
332
 
328
333
  /** `call`: one call to one handler on a running app. 38.8. */
329
- /** `watch <address> [args]`: a `GET`'s value and each patch, as its event stream sends them (38.70). */
334
+ /** `watch <address> [args]`: a `GET`'s value and each patch, as its event stream sends them (38.69). */
330
335
  async function watch(host: Host, given: Given): Promise<number> {
331
336
  const {
332
337
  positionals: [, address, ...args],
@@ -370,13 +375,11 @@ async function call(host: Host, given: Given): Promise<number> {
370
375
  /**
371
376
  * A fetch against a generated surface, on the app `--url` names or on one booted here.
372
377
  *
373
- * 38.9 — the app booted in this process has that surface opened, and derives it from the same
374
- * route table the served one does.
378
+ * 38.9 — the app booted in this process derives it from the same route table the served one does.
375
379
  */
376
380
  async function surfaceAt(
377
381
  host: Host,
378
382
  url: string | undefined,
379
- surface: string,
380
383
  address: string,
381
384
  ): Promise<
382
385
  (
@@ -390,7 +393,6 @@ async function surfaceAt(
390
393
  ...init,
391
394
  headers: { ...init?.headers, ...remoteHeaders() },
392
395
  })
393
- process.env[surface] = '1'
394
396
  const app = await host.boot()
395
397
  return (init) =>
396
398
  app.fetch(new Request(`http://abide.local${address}`, init))
@@ -405,12 +407,10 @@ async function openapi(
405
407
  host: Host,
406
408
  { values: { url, out } }: Given,
407
409
  ): Promise<number> {
408
- const answer = await (
409
- await surfaceAt(host, url, 'ABIDE_OPENAPI', OPENAPI_ADDRESS)
410
- )()
410
+ const answer = await (await surfaceAt(host, url, OPENAPI_ADDRESS))()
411
411
  if (!answer.ok) {
412
412
  console.error(
413
- `${host.name} openapi: the app answered ${answer.status}. ABIDE_OPENAPI opens the document.`,
413
+ `${host.name} openapi: the app answered ${answer.status}. The document lists every handler with openapi in its clients; none is.`,
414
414
  )
415
415
  return 1
416
416
  }
@@ -423,11 +423,11 @@ async function openapi(
423
423
  /**
424
424
  * `mcp`: the app's MCP server over stdio.
425
425
  *
426
- * 38.9, 38.14 — a bridge a model client spawns and kills: each line in is a JSON-RPC message for
426
+ * 38.9, 38.13 — a bridge a model client spawns and kills: each line in is a JSON-RPC message for
427
427
  * `/__abide/mcp`, and each answer is a line out.
428
428
  */
429
429
  async function mcp(host: Host, { values: { url } }: Given): Promise<number> {
430
- const ask = await surfaceAt(host, url, 'ABIDE_MCP', MCP_ADDRESS)
430
+ const ask = await surfaceAt(host, url, MCP_ADDRESS)
431
431
  for await (const line of console) {
432
432
  if (line.trim() === '') continue
433
433
  const answer = await ask({
@@ -1,6 +1,8 @@
1
1
  // 35.4 — what a `.ts` holds where it imports a `.abide`: the component, called with its props to
2
- // bind them, answering the `Component` `render` takes (`componentImports`). A wildcard, so the props
2
+ // bind them, answering the `Component` `render` takes (`abideModules`). A wildcard, so the props
3
3
  // are not checked here; a declaration in a module would be an augmentation, which takes no wildcard.
4
+ // The names a `.abide` exports are typed by what 34.24 writes, which an app's tsconfig reaches
5
+ // before this.
4
6
  declare module '*.abide' {
5
7
  const component: (
6
8
  props?: Record<string, unknown>,
@@ -1,4 +1,4 @@
1
- // 38.30 — AN ANSWER, COMPRESSED WHOLE: a document, and a JSON value at least `COMPRESSION_FLOOR`
1
+ // 38.29 — AN ANSWER, COMPRESSED WHOLE: a document, and a JSON value at least `COMPRESSION_FLOOR`
2
2
  // bytes long. Both are in hand before a byte goes out — `render` sends nothing until the page is
3
3
  // built (41.1), and a value is stringified once — so one compression is the whole answer, with its
4
4
  // length known, where a stream compressed as it arrived had to be flushed at every chunk.
@@ -6,7 +6,7 @@
6
6
  // WHAT EACH LANE SENDS BESIDE THE BYTES is here too, so the two cannot drift. A document names
7
7
  // `accept-encoding` in `vary` through `DOCUMENT_HEADERS`, which its `HEAD` and error answers carry as
8
8
  // well; a JSON answer names it here. Both say their length: `abide dev` relays a body chunk by chunk
9
- // from its worker (38.36), and a relayed body with no declared length goes out chunked.
9
+ // from its worker (38.35), and a relayed body with no declared length goes out chunked.
10
10
  // BUN'S GZIP parts from zlib's past ~130 KB, which only the off-loop lane reaches — so that lane takes
11
11
  // zlib's, the loop keeps bun's, and the two make the same bytes.
12
12
  import { brotliCompress, brotliCompressSync, constants, gzip } from 'node:zlib' // bun has no brotli, and no compression off the loop
@@ -21,12 +21,12 @@ import {
21
21
  // already under gzip's size at twice gzip's 70 µs.
22
22
  const BROTLI_OPTIONS = { params: { [constants.BROTLI_PARAM_QUALITY]: 5 } }
23
23
 
24
- // 38.69 — the bytes under which a JSON answer goes out as it is. Under it an answer saves tens of
24
+ // 38.68 — the bytes under which a JSON answer goes out as it is. Under it an answer saves tens of
25
25
  // bytes that leave in the same packet anyway, for 3–8 µs of a ~57 µs request; at 8 KB the same CPU
26
26
  // saves seven kilobytes.
27
27
  const COMPRESSION_FLOOR = 1024
28
28
 
29
- // 38.96 — the bytes from which an answer is compressed off the event loop. Brotli 5 holds the loop
29
+ // 38.95 — the bytes from which an answer is compressed off the event loop. Brotli 5 holds the loop
30
30
  // ~360 µs at 64 KB and ~7 ms at 700 KB — every other request and socket waits that long — where
31
31
  // the thread pool's hop adds ~20 µs to the answer it carries: 6% at 64 KB, 27% at 16 KB.
32
32
  const OFF_LOOP_FLOOR = 64 * 1024
@@ -1,16 +1,17 @@
1
1
  // SECTION 27 — THE RESOLVED CONFIGURATION. One process-wide value (27.9) over three layers, the last
2
2
  // winning (27.2): what abide derives, what the app's `onConfig` defaulted, and the environment.
3
3
  //
4
- // A `reactive` BODY, so `config.invalidate` is the core's own trigger and re-runs all of it (27.5),
4
+ // A `reactive` BODY, so `config.invalidate` is the core's own trigger and reruns all of it (27.5),
5
5
  // and a throw out of `onConfig` is the value's standing error, which a read throws (27.1).
6
6
 
7
7
  import { homedir } from 'node:os' // bun exposes no home directory of its own
8
+ import { isRecord } from '#shared/isRecord.ts'
8
9
  import { numberFromText } from '#shared/numberFromText.ts'
9
10
  import { reactive } from '#shared/reactive/index.ts'
10
11
  import { validationError } from '#shared/refuse.ts'
11
12
  import { runSchema } from '#shared/Schema.ts'
12
13
  import { appDirectories } from './appDirectories.ts'
13
- import { type Env, HOOKS } from './hooks.ts'
14
+ import { type Env, HOOKS, inOrder } from './hooks.ts'
14
15
  import { withoutTrailingSlash } from './withoutTrailingSlash.ts'
15
16
 
16
17
  /** The fields of an app's `package.json` 27.3 derives from. */
@@ -46,10 +47,6 @@ export interface Config {
46
47
  APP_NAME: string
47
48
  APP_VERSION: string
48
49
  APP_DATA_DIR: string
49
- ABIDE_LOGS: boolean
50
- ABIDE_OPENAPI: boolean
51
- ABIDE_MCP: boolean
52
- ABIDE_BRIDGE: boolean
53
50
  }
54
51
 
55
52
  /** The resolved configuration: abide's derived values, then `onConfig`'s defaults, then the env. */
@@ -57,24 +54,20 @@ export const config = reactive(resolve, { global: true })
57
54
 
58
55
  function resolve(): Config {
59
56
  const env: Env = { ...process.env }
60
- const hooked = HOOKS.config
61
- // 27.4 — SYNCHRONOUS BY CONTRACT; a promise handed back is a contract broken, not a value.
57
+ const hooked = inOrder(HOOKS.config)
62
58
  // 27.11 — handed what 27.3 derives under the environment, so a default can be built on
63
59
  // `APP_DATA_DIR` without deriving it a second time.
64
60
  const derivedValues = derived()
65
- const defaults = hooked?.defaults?.({ ...derivedValues, ...env }) ?? {}
66
- if (defaults instanceof Promise)
67
- throw new Error(
68
- 'abide: onConfig returned a promise. ConfigDefaults is synchronous; read what the defaults need before registering them. RULEBOOK 27.4.',
69
- )
70
- const document: Record<string, unknown> = {
71
- // 36.7 — CLOSED BY DEFAULT, and a boolean, so 27.8 reads the environment's spelling of one.
72
- ABIDE_LOGS: false,
73
- ABIDE_OPENAPI: false,
74
- ABIDE_MCP: false,
75
- ABIDE_BRIDGE: false,
76
- ...derivedValues,
77
- ...(defaults as Record<string, unknown>),
61
+ const document: Record<string, unknown> = { ...derivedValues }
62
+ // 27.6, 37.7 — EVERY `onConfig`'S DEFAULTS, merged over the ones before it.
63
+ for (const { defaults } of hooked) {
64
+ const given = defaults?.({ ...derivedValues, ...env }) ?? {}
65
+ // 27.4 — SYNCHRONOUS BY CONTRACT; a promise handed back is a contract broken, not a value.
66
+ if (given instanceof Promise)
67
+ throw new Error(
68
+ 'abide: onConfig returned a promise. ConfigDefaults is synchronous; read what the defaults need before registering them. RULEBOOK 27.4.',
69
+ )
70
+ Object.assign(document, given as Record<string, unknown>)
78
71
  }
79
72
  // 27.8 — THE ENVIRONMENT, COERCED TO THE DEFAULT IT OVERRIDES; a value that will not coerce leaves
80
73
  // the default standing.
@@ -83,13 +76,16 @@ function resolve(): Config {
83
76
  const coerced = coerce(text, document[name])
84
77
  if (coerced !== undefined) document[name] = coerced
85
78
  }
86
- // 27.7 — LAST, over the whole document.
87
- const schema = hooked?.options?.schema
88
- if (schema === undefined) return document as Config
89
- const checked = runSchema(schema, document)
90
- if (checked.issues !== null)
91
- return validationError(checked.issues as never) as never
92
- return checked.value as Config
79
+ // 27.7 — LAST, each over the whole document, and what each accepts merged back over it.
80
+ for (const { options } of hooked) {
81
+ const schema = options?.schema
82
+ if (schema === undefined) continue
83
+ const checked = runSchema(schema, document)
84
+ if (checked.issues !== null)
85
+ return validationError(checked.issues as never) as never
86
+ if (isRecord(checked.value)) Object.assign(document, checked.value)
87
+ }
88
+ return document as Config
93
89
  }
94
90
 
95
91
  function coerce(text: string, standing: unknown): unknown {
@@ -129,7 +125,7 @@ export function dataDirectory(name: string): string {
129
125
  return `${process.env.XDG_DATA_HOME ?? `${home}/.local/share`}/${name}`
130
126
  }
131
127
 
132
- // 28.5 — THE APP'S NAME IS `log`'s ROOT NAMESPACE, and the config is where the name is decided.
128
+ // 28.4 — THE APP'S NAME IS `log`'s ROOT NAMESPACE, and the config is where the name is decided.
133
129
  // PEEKED, so a log line written inside an effect does not subscribe it to the configuration.
134
130
  export function derivedAppName(): string | undefined {
135
131
  const resolved = config.peek()
@@ -1,8 +1,8 @@
1
- // 38.73 — THE FIELDS THE DESKTOP LAUNCHER'S START FORM ASKS FOR: the members of the `onConfig`
1
+ // 38.72 — THE FIELDS THE DESKTOP LAUNCHER'S START FORM ASKS FOR: the members of the `onConfig`
2
2
  // schema's JSON Schema, probed as 20.11 probes a handler's, and the fields `onConfig`'s defaults
3
3
  // return where the schema has no document to read.
4
4
 
5
- import { HOOKS } from './hooks.ts'
5
+ import { HOOKS, inOrder } from './hooks.ts'
6
6
 
7
7
  type Property = {
8
8
  type?: string | string[]
@@ -30,32 +30,38 @@ export type ConfigField = {
30
30
  export function configFields(
31
31
  env: Record<string, string | undefined>,
32
32
  ): ConfigField[] {
33
- const hooked = HOOKS.config
34
- let defaults: Record<string, unknown> = {}
35
- try {
36
- const given = hooked?.defaults?.({ ...env })
37
- if (given !== null && typeof given === 'object')
38
- defaults = given as Record<string, unknown>
39
- } catch {}
40
- const schema = hooked?.options?.schema as
41
- | {
42
- '~standard'?: {
43
- jsonSchema?: { input?: (options: unknown) => unknown }
33
+ // 27.6, 37.7 — EVERY `onConfig`, its defaults and its schema's fields merged in route order.
34
+ const defaults: Record<string, unknown> = {}
35
+ let declared: Record<string, Property> | undefined
36
+ const required = new Set<string>()
37
+ for (const { defaults: given, options } of inOrder(HOOKS.config)) {
38
+ try {
39
+ const answered = given?.({ ...env })
40
+ if (answered !== null && typeof answered === 'object')
41
+ Object.assign(defaults, answered)
42
+ } catch {}
43
+ const schema = options?.schema as
44
+ | {
45
+ '~standard'?: {
46
+ jsonSchema?: { input?: (options: unknown) => unknown }
47
+ }
44
48
  }
45
- }
46
- | undefined
47
- let document:
48
- | { properties?: Record<string, Property>; required?: string[] }
49
- | undefined
50
- try {
51
- document = schema?.['~standard']?.jsonSchema?.input?.({
52
- target: 'draft-2020-12',
53
- }) as typeof document
54
- } catch {}
49
+ | undefined
50
+ let document:
51
+ | { properties?: Record<string, Property>; required?: string[] }
52
+ | undefined
53
+ try {
54
+ document = schema?.['~standard']?.jsonSchema?.input?.({
55
+ target: 'draft-2020-12',
56
+ }) as typeof document
57
+ } catch {}
58
+ if (document?.properties !== undefined)
59
+ declared = { ...declared, ...document.properties }
60
+ for (const name of document?.required ?? []) required.add(name)
61
+ }
55
62
  const properties: Record<string, Property> =
56
- document?.properties ??
63
+ declared ??
57
64
  Object.fromEntries(Object.keys(defaults).map((name) => [name, {}]))
58
- const required = new Set(document?.required ?? [])
59
65
  const fields: ConfigField[] = []
60
66
  for (const [name, property] of Object.entries(properties)) {
61
67
  const fallback = defaults[name] ?? property.default