@abide/abide 0.105.1 → 0.106.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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +375 -87
  5. package/docs/abide.md +7243 -10256
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +2 -1
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +304 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +19 -13
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +7 -7
  55. package/src/server/claudeCode.ts +11 -11
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindProperty.ts +70 -22
  95. package/src/ui/index.ts +1 -1
  96. package/src/ui/listenForRoutes.ts +14 -3
  97. package/src/ui/reconcile.ts +23 -28
  98. package/src/ui/regions.ts +9 -2
  99. package/src/ui/rows.ts +108 -28
@@ -21,7 +21,7 @@ import { shell } from './shell.ts'
21
21
  import { withoutTrailingSlash } from './withoutTrailingSlash.ts'
22
22
 
23
23
  /** What a command is handed: the words after the flags, the command's own first, and the flags. */
24
- export type Given = ReturnType<typeof parsed>
24
+ export type Given = ReturnType<typeof parseFlags>
25
25
 
26
26
  type Command = (given: Given) => Promise<number | undefined>
27
27
 
@@ -42,6 +42,8 @@ type SourceCommands = Record<
42
42
  export type Host = {
43
43
  /** What usage and every message speak as: `abide`, or the name the executable was run by. */
44
44
  name: string
45
+ /** What `--version` prints: abide's own, or the compiled app's. 38.115. */
46
+ version: string
45
47
  /**
46
48
  * The app the invocation means, found once off its flags (38.25): every command that serves,
47
49
  * boots, lists or announces it derives from this one handle.
@@ -60,26 +62,70 @@ export async function runCommands(
60
62
  host: Host,
61
63
  argv: string[],
62
64
  ): Promise<number | undefined> {
65
+ const commands = commandsOf(host)
63
66
  // 38.2 — EVERYTHING AFTER THE FILE IS THE SCRIPT'S, flags included, so `run` is taken before the
64
67
  // parser sees an argument it does not know.
65
- const given: Given =
68
+ const given =
66
69
  argv[0] === 'run' ? { positionals: argv, values: {} } : parsed(argv)
70
+ // 38.113 — a flag the parser refuses is one line and the usage, never the parser's stack.
71
+ if (typeof given === 'string') {
72
+ console.error(`${host.name}: ${given}\n\n${usage(host, commands)}`)
73
+ return 1
74
+ }
67
75
  const command = given.positionals[0]
68
- const commands = commandsOf(host)
69
76
  if (command === 'help' || given.values.help) {
70
77
  console.log(usage(host, commands))
71
78
  return 0
72
79
  }
80
+ if (given.values.version) {
81
+ console.log(host.version)
82
+ return 0
83
+ }
73
84
  if (command === undefined)
74
85
  return await shellOn(host, host.app(given), given)
75
- for (const [name, , body] of commands)
76
- if (name.split(' ')[0] === command) return await body(given)
86
+ let nearest: string | undefined
87
+ let nearestDistance = NEAREST_COMMAND_DISTANCE + 1
88
+ for (const [name, , body] of commands) {
89
+ const word = name.split(' ')[0] as string
90
+ if (word === command) return await body(given)
91
+ const distance = editDistance(word, command)
92
+ if (distance < nearestDistance) {
93
+ nearest = word
94
+ nearestDistance = distance
95
+ }
96
+ }
97
+ // 38.114 — a near miss is named, and the usage is left for a word nothing is near.
77
98
  console.error(
78
- `${host.name}: unknown command '${command}'\n\n${usage(host, commands)}`,
99
+ nearest === undefined
100
+ ? `${host.name}: unknown command '${command}'\n\n${usage(host, commands)}`
101
+ : `${host.name}: unknown command '${command}' — did you mean \`${host.name} ${nearest}\`?`,
79
102
  )
80
103
  return 1
81
104
  }
82
105
 
106
+ // Two edits covers a dropped, doubled or swapped letter; three reaches unrelated words.
107
+ const NEAREST_COMMAND_DISTANCE = 2
108
+
109
+ /** The Levenshtein distance between two words: the fewest insertions, deletions and substitutions. */
110
+ function editDistance(from: string, to: string): number {
111
+ let previous: number[] = []
112
+ for (let column = 0; column <= to.length; column++) previous.push(column)
113
+ for (let row = 1; row <= from.length; row++) {
114
+ const current = [row]
115
+ for (let column = 1; column <= to.length; column++)
116
+ current.push(
117
+ Math.min(
118
+ (previous[column] as number) + 1,
119
+ (current[column - 1] as number) + 1,
120
+ (previous[column - 1] as number) +
121
+ (from[row - 1] === to[column - 1] ? 0 : 1),
122
+ ),
123
+ )
124
+ previous = current
125
+ }
126
+ return previous[to.length] as number
127
+ }
128
+
83
129
  // A command, and whether it is one of `SourceCommands` — which a host without them still answers
84
130
  // (38.51), and does not list (38.52).
85
131
  type Row = [usage: string, meaning: string, run: Command, sourced: boolean]
@@ -190,8 +236,10 @@ function usage(host: Host, commands: Row[]): string {
190
236
  text += ` ${command.padEnd(width)} ${meaning}\n`
191
237
  if (!sourced)
192
238
  return `${text}
193
- --port the listen port the app is served on
194
- --url the running app the shell, call, watch, openapi and mcp talk to
239
+ --port the listen port the app is served on
240
+ --url the running app the shell, call, watch, openapi and mcp talk to
241
+ --method the method call sends, where the handler answers more than one
242
+ --version the version of this executable's app
195
243
  `
196
244
  text += `
197
245
  --root the app's source directory, where its pages sit —
@@ -202,11 +250,26 @@ function usage(host: Host, commands: Row[]): string {
202
250
  --url the running app the shell, call, watch, openapi and mcp talk to
203
251
  --target the platform compile builds for — linux-x64, darwin-arm64, windows-x64…
204
252
  --platforms several of them, comma-separated, one executable each
253
+ --method the method call sends, where the handler answers more than one
254
+ --version the version of abide
205
255
  `
206
256
  return text
207
257
  }
208
258
 
259
+ /** The words and flags `argv` holds, or the parser's refusal of a flag, as one line. */
209
260
  function parsed(argv: string[]) {
261
+ try {
262
+ return parseFlags(argv)
263
+ } catch (thrown) {
264
+ const { code, message } = thrown as { code?: string; message: string }
265
+ if (!code?.startsWith('ERR_PARSE_ARGS_')) throw thrown
266
+ // Bun's message goes on to advise `--`, which no command here reads.
267
+ const refusal = message.split('. ')[0] as string
268
+ return `${refusal[0]?.toLowerCase()}${refusal.slice(1)}`
269
+ }
270
+ }
271
+
272
+ function parseFlags(argv: string[]) {
210
273
  return parseArgs({
211
274
  args: argv,
212
275
  options: {
@@ -225,6 +288,7 @@ function parsed(argv: string[]) {
225
288
  platforms: { type: 'string' },
226
289
  json: { type: 'boolean' },
227
290
  help: { type: 'boolean', short: 'h' },
291
+ version: { type: 'boolean', short: 'v' },
228
292
  },
229
293
  allowPositionals: true,
230
294
  })
@@ -361,7 +425,6 @@ async function run(
361
425
  return undefined
362
426
  }
363
427
 
364
- /** `call`: one call to one handler on a running app. 38.8. */
365
428
  /** `watch <address> [args]`: a `GET`'s value and each patch, as its event stream sends them (38.69). */
366
429
  async function watch(host: Host, given: Given): Promise<number> {
367
430
  const {
@@ -380,6 +443,7 @@ async function watch(host: Host, given: Given): Promise<number> {
380
443
  })
381
444
  }
382
445
 
446
+ /** `call`: one call to one handler on a running app. 38.8. */
383
447
  async function call(host: Host, app: AppHandle, given: Given): Promise<number> {
384
448
  const {
385
449
  positionals: [, address, ...args],
@@ -17,11 +17,17 @@ import { Mux, type MuxConnection } from './answerSocket.ts'
17
17
  import { appDirectories } from './appDirectories.ts'
18
18
  import { holdsApp } from './appTree.ts'
19
19
  import { buildDirectory } from './buildDirectory.ts'
20
- import { APP_PACKAGE, type AppPackage, config, packageUnder } from './config.ts'
20
+ import {
21
+ APP_PACKAGE,
22
+ type AppPackage,
23
+ config,
24
+ packageUnder,
25
+ positiveSetting,
26
+ } from './config.ts'
21
27
  import { McpSurface } from './generatedSurfaces.ts'
22
28
  import { HOOKS, inOrder, reportUnexpected, rootRungs } from './hooks.ts'
23
29
  import { installServer } from './installServer.ts'
24
- import { admitsMux, upgraded } from './muxUpgrade.ts'
30
+ import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
25
31
  import { plainAnswer } from './plainAnswer.ts'
26
32
  import { realOrAsGiven } from './realOrAsGiven.ts'
27
33
  import {
@@ -39,6 +45,11 @@ import { traceResponse } from './trace.ts'
39
45
  // 28.8 — one line per request.
40
46
  const REQUEST_LOG = namedLogger('abide:request')
41
47
 
48
+ // 42.18 — HALF OF `docker stop`'s TEN-SECOND GRACE, the shortest a common supervisor gives before its
49
+ // `SIGKILL`: the other half is `onStop`'s, so the teardown runs before the kill rather than being cut
50
+ // by it.
51
+ const STOP_TIMEOUT = 5000
52
+
42
53
  /** The app object: what a host, a test or a script holds the app as. */
43
54
  export interface App {
44
55
  fetch(request: Request): Promise<Response>
@@ -360,11 +371,33 @@ export async function bootEntered(
360
371
  // stream ended, which a listener's stop otherwise waits on for as long as it streams.
361
372
  mux?.closeAll(1001, 'the app is stopping')
362
373
  endHeld()
363
- await bound?.stop()
364
- if (inFlight > 0)
365
- await new Promise<void>((done) => {
366
- drained = done
367
- })
374
+ const drain = (async () => {
375
+ await bound?.stop()
376
+ if (inFlight > 0)
377
+ await new Promise<void>((done) => {
378
+ drained = done
379
+ })
380
+ return true
381
+ })()
382
+ // 42.17, 42.18 — THE DRAIN HAS A DEADLINE: past it, every connection still answering is
383
+ // closed and `onStop` runs without waiting on the rest. A request `App.fetch` was handed with
384
+ // no socket bound has no connection to close, and is only no longer waited on.
385
+ let deadline: ReturnType<typeof setTimeout> | undefined
386
+ const finished = await Promise.race([
387
+ drain,
388
+ new Promise<false>((done) => {
389
+ deadline = setTimeout(
390
+ () => done(false),
391
+ // setTimeout reads anything past a signed 32-bit count as 1 ms.
392
+ Math.min(
393
+ positiveSetting('ABIDE_STOP_TIMEOUT', STOP_TIMEOUT),
394
+ 2 ** 31 - 1,
395
+ ),
396
+ )
397
+ }),
398
+ ])
399
+ clearTimeout(deadline)
400
+ if (!finished) await bound?.stop(true)
368
401
  // What the requests drained here left behind them.
369
402
  endHeld()
370
403
  if (LISTENING.server === bound) LISTENING.server = undefined
@@ -409,6 +442,7 @@ export async function bootEntered(
409
442
  : answered
410
443
  },
411
444
  websocket: {
445
+ ...muxSocketOptions(),
412
446
  open(socket) {
413
447
  socket.data.connection = opened.connect(
414
448
  socket.data.request,
@@ -37,7 +37,7 @@ import {
37
37
  import { type BootedApp, bootApp, originUnder } from './createApp.ts'
38
38
  import { keepServing } from './faultPolicy.ts'
39
39
  import { type Manifest, NO_MANIFEST } from './manifestOf.ts'
40
- import { admitsMux } from './muxUpgrade.ts'
40
+ import { admitsMux, muxSocketOptions } from './muxUpgrade.ts'
41
41
  import { LISTENING } from './requestScope.ts'
42
42
  import {
43
43
  describe,
@@ -82,7 +82,7 @@ export type ToWorker =
82
82
  * Out: ready, a frame the mux sends a browser, and what answers a request (`FromRelay`).
83
83
  */
84
84
  export type FromWorker =
85
- | { kind: 'ready' }
85
+ | { kind: 'ready'; muxOptions: ReturnType<typeof muxSocketOptions> }
86
86
  | { kind: 'socket-send'; connection: number; text: string }
87
87
  | FromRelay
88
88
 
@@ -151,7 +151,7 @@ async function receive(message: ToWorker): Promise<void> {
151
151
  if (message.kind === 'start') {
152
152
  opening = open(message)
153
153
  await opening
154
- toListener({ kind: 'ready' })
154
+ toListener({ kind: 'ready', muxOptions: muxSocketOptions() })
155
155
  return
156
156
  }
157
157
  if (
@@ -19,7 +19,7 @@ import { type Reactive, reactive } from '#shared/reactive/index.ts'
19
19
  import { parseRoutePattern } from '#shared/routePattern.ts'
20
20
  import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
21
21
  import { answered } from './answerFailure.ts'
22
- import type { Mounted } from './answerRpc.ts'
22
+ import { bodyWithin, type Mounted } from './answerRpc.ts'
23
23
  import type { ToolDefinition } from './assistant.ts'
24
24
  import { appIdentity } from './config.ts'
25
25
  import { crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
@@ -97,7 +97,13 @@ function openApiOf(handlers: Map<string, Mounted>): {
97
97
  responses: {
98
98
  '200': {
99
99
  description: 'The value.',
100
- content: { 'application/json': {} },
100
+ // 20.15 — what the handler answers, where its type says (19.21).
101
+ content: {
102
+ 'application/json':
103
+ each.result === undefined
104
+ ? {}
105
+ : { schema: each.result },
106
+ },
101
107
  },
102
108
  '204': { description: 'Nothing to say.' },
103
109
  default: {
@@ -214,7 +220,6 @@ export function openApi(
214
220
 
215
221
  const published = new WeakMap<Map<string, Mounted>, Reactive<Published[]>>()
216
222
 
217
-
218
223
  type JsonRpc = {
219
224
  jsonrpc: '2.0'
220
225
  id?: number | string | null
@@ -276,6 +281,17 @@ export class McpSurface {
276
281
  name: tool.name,
277
282
  description: tool.description ?? '',
278
283
  inputSchema: tool.schema ?? { type: 'object' },
284
+ // 20.14 — what the method promises: a `GET` is safe, and a `GET`, a `PUT` and a
285
+ // `DELETE` idempotent. Any other write may destroy, and nothing says it does not.
286
+ annotations: {
287
+ readOnlyHint: tool.method === 'GET',
288
+ destructiveHint: tool.method !== 'GET',
289
+ idempotentHint:
290
+ tool.method === 'GET' ||
291
+ tool.method === 'PUT' ||
292
+ tool.method === 'DELETE',
293
+ },
294
+ outputSchema: structured(tool) ? tool.result : undefined,
279
295
  })
280
296
  }
281
297
  return listing
@@ -350,6 +366,12 @@ export class McpSurface {
350
366
  return {
351
367
  result: {
352
368
  content: [{ type: 'text', text }],
369
+ // 20.16 — the same value as structured content, where the tool declares
370
+ // its shape; a refusal is not that value.
371
+ structuredContent:
372
+ failed || !structured(tool)
373
+ ? undefined
374
+ : JSON.parse(text),
353
375
  isError: failed,
354
376
  },
355
377
  }
@@ -360,6 +382,14 @@ export class McpSurface {
360
382
  }
361
383
  }
362
384
 
385
+ /**
386
+ * Whether a tool declares an `outputSchema`: MCP's structured content is an object, so only a
387
+ * result schema that is one. 20.16.
388
+ */
389
+ function structured(tool: Listed): boolean {
390
+ return (tool.result as { type?: unknown } | undefined)?.type === 'object'
391
+ }
392
+
363
393
  /**
364
394
  * Answers one MCP JSON-RPC request over `surface`, or `undefined` where it publishes nothing.
365
395
  *
@@ -377,9 +407,12 @@ export async function answerMcp(
377
407
  // with the caller's own cookies, and a `text/plain` body is sent cross-origin with no preflight.
378
408
  const crossing = crossingOrigin(request.headers.get('origin'), request.url)
379
409
  if (crossing !== null) return answered(crossingRefusal(crossing, 'POST'))
410
+ // 16.78 — under the environment's body ceiling, as a handler's body is.
411
+ const body = await bodyWithin(request, undefined)
412
+ if (isFailed(body)) return answered(body)
380
413
  let message: JsonRpc
381
414
  try {
382
- message = (await request.json()) as JsonRpc
415
+ message = (await body.json()) as JsonRpc
383
416
  } catch {
384
417
  return {
385
418
  jsonrpc: '2.0',
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { SOCKET_ADDRESS } from '#shared/ADDRESSES.ts'
12
12
  import { unmounted } from '#shared/mount.ts'
13
+ import { positiveSetting } from './config.ts'
13
14
  import { plainAnswer } from './plainAnswer.ts'
14
15
 
15
16
  const MUX_ADMITTED = 204
@@ -70,3 +71,49 @@ export function upgraded<Data>(
70
71
  ? undefined
71
72
  : plainAnswer('expected a websocket', 400)
72
73
  }
74
+
75
+ /**
76
+ * The limits a listener holds every connection to the mux to, `App.listen`'s and `abide dev`'s alike —
77
+ * read off the resolved configuration, which `abide dev`'s worker reports to its listener.
78
+ *
79
+ * 18.28 — A FRAME IN past `ABIDE_MAX_SOCKET_MESSAGE_SIZE`, a mebibyte unless set: a frame names an
80
+ * address and its args, which a `GET` carries in its URL. 18.29 — A CONNECTION IDLE is pinged, and
81
+ * closed where two minutes pass with nothing back. 18.30 — WHAT IS SENT is buffered to bun's 16 MiB,
82
+ * past which `send` drops it and the mux closes the peer (`SocketPeer`).
83
+ */
84
+ export function muxSocketOptions(): {
85
+ maxPayloadLength: number
86
+ idleTimeout: number
87
+ sendPings: boolean
88
+ backpressureLimit: number
89
+ } {
90
+ return {
91
+ maxPayloadLength: positiveSetting(
92
+ 'ABIDE_MAX_SOCKET_MESSAGE_SIZE',
93
+ 1024 * 1024,
94
+ ),
95
+ idleTimeout: 120,
96
+ sendPings: true,
97
+ backpressureLimit: 16 * 1024 * 1024,
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Sends `text` to `socket`, and closes it where bun dropped the frame rather than sending or buffering it.
103
+ *
104
+ * 18.30 — A FOLLOWER THAT MISSED A FRAME stands at a version the next one does not build on, so the
105
+ * peer is closed rather than written past: the browser reconnects naming the version it holds (18.9)
106
+ * and is sent what stands. No buffer of our own — what bun buffers is the bound.
107
+ */
108
+ export function sendOrClose(
109
+ socket:
110
+ | {
111
+ send(text: string): unknown
112
+ close(code: number, reason: string): unknown
113
+ }
114
+ | undefined,
115
+ text: string,
116
+ ): void {
117
+ if (socket?.send(text) === 0)
118
+ socket.close(1013, 'the connection fell behind')
119
+ }
@@ -8,6 +8,17 @@
8
8
  import { appRoot, servedBuild } from './createApp.ts'
9
9
 
10
10
  const root = appRoot(undefined)
11
- // A run from a directory that holds no app — a workspace's root — preloads nothing.
11
+ // A run from a directory that holds no app preloads nothing — nor from a workspace's root, whose
12
+ // packages' `.abide` files are each their own app's rather than one app laid out across them.
12
13
  // Fresh whatever `NODE_ENV` says: `bun test` sets it only where nothing else has.
13
- if (root !== undefined) await servedBuild(root, true)
14
+ if (root !== undefined && !(await isWorkspaceRoot(root)))
15
+ await servedBuild(root, true)
16
+
17
+ async function isWorkspaceRoot(directory: string): Promise<boolean> {
18
+ const manifest = Bun.file(`${directory}/package.json`)
19
+ if (!(await manifest.exists())) return false
20
+ const json = (await manifest.json()) as { workspaces?: unknown }
21
+ return json.workspaces !== undefined
22
+ }
23
+
24
+ export { type Mounted, mount } from './testMount.ts'
@@ -76,7 +76,10 @@ export async function composePage(
76
76
  if (at < layouts.length)
77
77
  levelProps.children = (): string => {
78
78
  const below = levels[at + 1] as Rendered
79
- if (below.head !== '') heads.push(below.head)
79
+ // THE SLOT IS TAKEN BEFORE THE HEAD IS READ: reading it builds the level, which
80
+ // pushes the heads deeper still, and the tree's order is what the merge keys on.
81
+ const slot = heads.push('') - 1
82
+ heads[slot] = below.head
80
83
  return below.body
81
84
  }
82
85
  const level = (
@@ -33,6 +33,7 @@ export async function runCompiled(
33
33
  }
34
34
  const host: Host = {
35
35
  name: process.execPath.slice(process.execPath.lastIndexOf('/') + 1),
36
+ version: origin.package.version ?? '0.0.0',
36
37
  app: () => app,
37
38
  source: undefined,
38
39
  }
@@ -31,7 +31,12 @@ import {
31
31
  streamingOrSettled,
32
32
  } from '#shared/reactive/index.ts'
33
33
  import { positiveSetting } from './config.ts'
34
- import { heldByApp, type PageRender } from './requestScope.ts'
34
+ import {
35
+ currentRequestScope,
36
+ heldByApp,
37
+ type PageRender,
38
+ releaseScope,
39
+ } from './requestScope.ts'
35
40
 
36
41
  const HYDRATE_LOG = namedLogger('abide:hydrate')
37
42
 
@@ -64,6 +69,10 @@ type Entry = {
64
69
  release: () => void
65
70
  // 42.11 — unfiles the entry from the app whose render left it, which drops it when it stops.
66
71
  unhold: () => void
72
+ // 41.13 — lets the render's scope go, which the entry holds while what it reached is in flight.
73
+ letGo: () => void
74
+ // Whether a call took it, which then answers from what the render reached until that settles.
75
+ taken: boolean
67
76
  }
68
77
 
69
78
  // INDEXED BY THE THREE THINGS 41.18 MATCHES ON — caller, handler, args key — so a call finds its entry
@@ -103,6 +112,22 @@ export function seedFor(caller: string): PageRender['seed'] {
103
112
  timer: undefined,
104
113
  release: NOTHING_HELD,
105
114
  unhold: NOTHING_HELD,
115
+ letGo: NOTHING_HELD,
116
+ taken: false,
117
+ }
118
+ // 41.13 — THE RENDER'S SCOPE IS HELD WHILE WHAT IT REACHED IS IN FLIGHT: the page is answered
119
+ // before a slow value lands, and letting the scope go stops that value's run — a stream the
120
+ // browser's call would join then never closes. Let go once it settles, a stream at its close.
121
+ const scope = currentRequestScope()
122
+ if (scope !== undefined && isReactive(reached)) {
123
+ scope.holds += 1
124
+ let held = true
125
+ entry.letGo = () => {
126
+ if (!held) return
127
+ held = false
128
+ releaseScope(scope)
129
+ }
130
+ Promise.resolve(reached).then(entry.letGo, entry.letGo)
106
131
  }
107
132
  const bound = positiveSetting(
108
133
  'ABIDE_MAX_STREAM_BUFFER_SIZE',
@@ -208,6 +233,9 @@ export function takeSeed(
208
233
  }
209
234
  const entry = entries[0]
210
235
  if (entry === undefined) return undefined
236
+ entry.taken = true
237
+ // And no longer than the call that took it: a stream that never closes ends with its reader.
238
+ currentRequestScope()?.stops.push(entry.letGo)
211
239
  drop(caller, entry)
212
240
  // READ AS WELL AS SWEPT: a timer can fire late, and an entry past its life answers nothing.
213
241
  if (entry.expires <= Date.now()) {
@@ -244,6 +272,8 @@ function drop(caller: string, entry: Entry): void {
244
272
  clearTimeout(entry.timer)
245
273
  entry.release()
246
274
  entry.unhold()
275
+ // Nothing will answer from an entry no call took, so its run need not finish.
276
+ if (!entry.taken) entry.letGo()
247
277
  const byRpc = BUFFER.get(caller)
248
278
  const byKey = byRpc?.get(entry.rpc)
249
279
  const entries = byKey?.get(entry.key)