@abide/abide 0.37.0 → 0.38.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 (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +38 -0
  3. package/package.json +2 -1
  4. package/src/abideResolverPlugin.ts +3 -17
  5. package/src/appEntry.ts +18 -8
  6. package/src/controlServerWorker.ts +8 -1
  7. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  8. package/src/lib/mcp/createMcpServer.ts +8 -0
  9. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  10. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  11. package/src/lib/server/rpc/parseArgs.ts +12 -1
  12. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  13. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  14. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  15. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  17. package/src/lib/server/sockets/defineSocket.ts +6 -1
  18. package/src/lib/shared/cache.ts +6 -0
  19. package/src/lib/shared/createPushIterator.ts +7 -2
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  22. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  23. package/src/lib/ui/compile/asOutlet.ts +30 -0
  24. package/src/lib/ui/compile/compileModule.ts +13 -9
  25. package/src/lib/ui/compile/generateBuild.ts +83 -72
  26. package/src/lib/ui/compile/generateSSR.ts +56 -37
  27. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  28. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  29. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  30. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  31. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  32. package/src/lib/ui/compile/skeletonContext.ts +97 -35
  33. package/src/lib/ui/compile/skeletonable.ts +3 -2
  34. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  35. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  36. package/src/lib/ui/dom/commentData.ts +14 -0
  37. package/src/lib/ui/dom/disposeRange.ts +21 -0
  38. package/src/lib/ui/dom/fillBefore.ts +10 -0
  39. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  40. package/src/lib/ui/dom/fillRange.ts +30 -0
  41. package/src/lib/ui/dom/hydrate.ts +11 -21
  42. package/src/lib/ui/dom/mount.ts +16 -25
  43. package/src/lib/ui/dom/mountChild.ts +27 -14
  44. package/src/lib/ui/dom/mountRange.ts +45 -0
  45. package/src/lib/ui/dom/mountSlot.ts +7 -2
  46. package/src/lib/ui/dom/outlet.ts +62 -0
  47. package/src/lib/ui/dom/scopeLabel.ts +13 -7
  48. package/src/lib/ui/dom/skeleton.ts +11 -27
  49. package/src/lib/ui/dom/withScope.ts +33 -0
  50. package/src/lib/ui/installHotBridge.ts +2 -0
  51. package/src/lib/ui/persist.ts +4 -1
  52. package/src/lib/ui/renderChain.ts +23 -15
  53. package/src/lib/ui/router.ts +78 -38
  54. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  55. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  56. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  57. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  58. package/src/lib/ui/runtime/createDoc.ts +20 -7
  59. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  60. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  61. package/src/lib/ui/runtime/types/Route.ts +6 -5
  62. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  63. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  64. package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
  65. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
package/AGENTS.md CHANGED
@@ -162,7 +162,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
162
162
  - `abide/ui/enterScope`, `abide/ui/exitScope` — push/pop the lexical scope around an SSR render.
163
163
  - `abide/ui/router`, `abide/ui/startClient`, `abide/ui/renderToStream` — the client router, the official client entry (reads `window.__SSR__`, seeds cache, starts the router), and the out-of-order SSR streamer.
164
164
  - `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the bundler-emitted client substitutes that swap a server verb/socket import for a `fetch` proxy / ws-multiplexed `Socket`.
165
- - `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mountSlot`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`.
165
+ - `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mountSlot`, `outlet`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`. `outlet` is the marker-range a layout `<slot/>` compiles to (no wrapper element — the router fills it with the next chain layer as a direct child).
166
166
  - `abide/ui/runtime/*` — render-pass helpers: `escapeKey` (JSON-Pointer key escaping), `nextBlockId`, `enterRenderPass`, `exitRenderPass`.
167
167
 
168
168
  ## Build / tooling — @documentation building
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # abide
2
2
 
3
+ ## 0.38.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`67ac25f`](https://github.com/briancray/abide/commit/67ac25faf78b029d0b14659df1e4ec1a05ae7a10) - document outlet marker-range in AGENTS.md surface map ([`ab23673`](https://github.com/briancray/abide/commit/ab236733ee1f81e19ce09ee35fe44624e955a6e4))
8
+
9
+ - [`67ac25f`](https://github.com/briancray/abide/commit/67ac25faf78b029d0b14659df1e4ec1a05ae7a10) - unify pages/layouts/outlets on marker-range model ([`df1c95f`](https://github.com/briancray/abide/commit/df1c95f89ff8d6d6806606dbece531c6be7aabdf))
10
+
11
+ ## 0.38.0
12
+
13
+ ### Minor Changes
14
+
15
+ - [`bcfd15a`](https://github.com/briancray/abide/commit/bcfd15af169787d733355d5879bf95aec164e698) - feat(dom): make `{snippet(args)}` interpolations reactive in their arguments. Previously `appendSnippet` read the call once at mount and never re-ran, so an argument derived from reactive state froze its initial value (e.g. `{grouped(Object.groupBy([...aired, ...upcoming]))}` rendered with `upcoming` still `[]`, never updating when a later effect populated it). The call is now bounded by a range and wrapped in an effect — like `when`/`each` — so an argument change tears the snippet down and re-mounts it with fresh args (the body's own reads stay fine-grained within a mount; args behave like props). Create mode now emits the `<!--abide:snippet-->` range markers the server already rendered, so the markers are congruent on both sides rather than a server-only asymmetry.
16
+
17
+ ### Patch Changes
18
+
19
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - restart ttl on revalidation and don't clobber a live streamed seed ([`2d07d53`](https://github.com/briancray/abide/commit/2d07d53b5aec54681e34f7490f636de2bf47fc7a))
20
+
21
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - harden rpc/runtime/socket edge cases ([`39581f4`](https://github.com/briancray/abide/commit/39581f46601b71ccad6c6112e37c55a03b069068))
22
+
23
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - propagate control-server worker start failure ([`3bb529b`](https://github.com/briancray/abide/commit/3bb529b00dc939c73212215cf6ae027b1078c053))
24
+
25
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - handle JSON-RPC notifications without replying ([`4315aa1`](https://github.com/briancray/abide/commit/4315aa11c7b834607e315e6403bc41c4c9bd6a8c))
26
+
27
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - drop unused error-page manifest ([`7f4f78a`](https://github.com/briancray/abide/commit/7f4f78af9829dca10a6062dd76b6e7fb9fddf739))
28
+
29
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - auto-vivify nested bind paths and escape persisted doc keys ([`a2f7d15`](https://github.com/briancray/abide/commit/a2f7d1546201b6edb3b645ffede56519890415cf))
30
+
31
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - avoid stdin hang and reject blank numeric flags ([`f1d47ef`](https://github.com/briancray/abide/commit/f1d47ef731235d7a4c4ebb727a83bb642102aac0))
32
+
33
+ - [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - parse unquoted attributes and skip destructured binding rewrites ([`ff66d57`](https://github.com/briancray/abide/commit/ff66d57f5362af68f06d0e62dfc78d5f95322f99))
34
+
35
+ - [`fab46dd`](https://github.com/briancray/abide/commit/fab46ddb0d3b8c4c8a1fa4f4615e8c5b1dfbf9e9) - fix(dom): skip control-flow rebuild when the end marker is detached. An effect for a `when`/`switch` block could fire one final time after an enclosing await/each block tore its branch down in the same microtask flush (before the owner scope disposed it), making `fillBefore` insert a fragment before a parentless comment — `HierarchyRequestError: The operation would yield an incorrect node tree`. `fillBefore` now bails when `end.parentNode` is null.
36
+
37
+ - [`b081dd5`](https://github.com/briancray/abide/commit/b081dd5f7087c9f88960349d2477eab2c8d78005) - fix(compile): unify the layout `<slot/>` outlet rewrite across both back-ends and emit it scope-free. The hole-numbering refactor keys skeleton hole indices by node identity, but `generateBuild` ran `skeletonContext` over the original nodes while traversing `nodes.map(asOutlet)` — and `asOutlet` clones every element it descends, so a layout with any reactive hole (e.g. the kitchen-sink `layout.abide`'s active-link `<a href={url('/')}>`) threw `[abide] skeleton hole not numbered by the shared positional walk` at compile time, failing the page render. Both back-ends now apply the shared `asOutlet` before `skeletonContext`, so the indexed tree and the traversed tree are one. The rewrite also strips the outlet's style scope: the client cloned `<abide-outlet>` with the slot's annotated `data-a-…` scope while the server emitted it bare, a hydration mismatch (and `renderChain` folds the child layer via an exact bare-`<abide-outlet></abide-outlet>` match) — the outlet is a structural mount container, not styled content, so it now carries no attributes or scope on either side.
38
+
39
+ - [`b081dd5`](https://github.com/briancray/abide/commit/b081dd5f7087c9f88960349d2477eab2c8d78005) - refactor(compile): fold skeleton hole numbering into the single `skeletonContext` walk. `generateBuild` previously threaded its own mutable `{ el, an }` counter through a second document-order walk parallel to the decision walk, free to drift from it — the compiler half of the hand-mirrored hole-ordering protocol. `skeletonContext` now assigns each hole its `el`/`an` index in the same pass that records `inSkeleton`/`markText` (el keyed by node, an keyed by node or by the reactive-text part), and `generateBuild` reads them via `holeIndex` instead of counting. One walk owns both the decisions and the numbering, so a counter-vs-decision drift is structurally impossible; a hole the shared walk didn't number now throws at compile time rather than surfacing as a runtime hydration desync. Behavior-preserving — no API or output change.
40
+
3
41
  ## 0.37.0
4
42
 
5
43
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -89,6 +89,7 @@
89
89
  "./ui/dom/skeleton": "./src/lib/ui/dom/skeleton.ts",
90
90
  "./ui/dom/anchorCursor": "./src/lib/ui/dom/anchorCursor.ts",
91
91
  "./ui/dom/mountSlot": "./src/lib/ui/dom/mountSlot.ts",
92
+ "./ui/dom/outlet": "./src/lib/ui/dom/outlet.ts",
92
93
  "./ui/dom/attr": "./src/lib/ui/dom/attr.ts",
93
94
  "./ui/dom/on": "./src/lib/ui/dom/on.ts",
94
95
  "./ui/dom/attach": "./src/lib/ui/dom/attach.ts",
@@ -201,7 +201,7 @@ export function abideResolverPlugin({
201
201
  setup(build) {
202
202
  build.onResolve(
203
203
  {
204
- filter: /\/_virtual\/(rpc|sockets|prompts|pages|layouts|errors|app|config|mcp-resources|mcp|assets|public-assets|shell|app-info|cli-manifest|cli-name|cli-chrome|bundle-window|bundle-disconnected-component|bundle-disconnected)\.ts$/,
204
+ filter: /\/_virtual\/(rpc|sockets|prompts|pages|layouts|app|config|mcp-resources|mcp|assets|public-assets|shell|app-info|cli-manifest|cli-name|cli-chrome|bundle-window|bundle-disconnected-component|bundle-disconnected)\.ts$/,
205
205
  },
206
206
  (args) => {
207
207
  const name = fileStem(args.path)
@@ -434,17 +434,6 @@ ${optionLines}
434
434
  })
435
435
  }
436
436
 
437
- if (args.path === 'abide:errors') {
438
- const { errorFiles } = await scanPagesOnce()
439
- return manifestModule({
440
- files: errorFiles,
441
- keyForFile: pageUrlForFile,
442
- importDir: pagesDir,
443
- exportName: 'errors',
444
- label: 'error pages',
445
- })
446
- }
447
-
448
437
  if (args.path === 'abide:app') {
449
438
  const userApp = `${cwd}/src/app.ts`
450
439
  const hasAppModule = await Bun.file(userApp).exists()
@@ -788,7 +777,6 @@ ${encoded.map((entry) => entry.line).join('\n')}
788
777
  type PagesScan = {
789
778
  pageFiles: string[]
790
779
  layoutFiles: string[]
791
- errorFiles: string[]
792
780
  }
793
781
 
794
782
  /*
@@ -796,19 +784,17 @@ Walks src/ui/pages once and classifies each `.abide` leaf by filename: a
796
784
  `page.abide` is a route (its URL is the folder path), a `layout.abide` is a
797
785
  layout that wraps every page at or below its folder (keyed by the same folder
798
786
  URL). Any other `.abide` file (a shared component) is ignored here — free to
799
- live anywhere and be imported relatively. errorFiles stay empty — there is no
800
- framework error resolution.
787
+ live anywhere and be imported relatively.
801
788
  */
802
789
  async function scanPages(pagesDir: string): Promise<PagesScan> {
803
790
  if (!existsSync(pagesDir)) {
804
- return { pageFiles: [], layoutFiles: [], errorFiles: [] }
791
+ return { pageFiles: [], layoutFiles: [] }
805
792
  }
806
793
  const allFiles = await Array.fromAsync(new Glob('**/*.abide').scan({ cwd: pagesDir }))
807
794
  const leafIs = (name: string) => (file: string) => (file.split('/').pop() ?? '') === name
808
795
  return {
809
796
  pageFiles: allFiles.filter(leafIs('page.abide')),
810
797
  layoutFiles: allFiles.filter(leafIs('layout.abide')),
811
- errorFiles: [],
812
798
  }
813
799
  }
814
800
 
package/src/appEntry.ts CHANGED
@@ -80,14 +80,24 @@ the connect screen. Resolving before this means a successful resume opens straig
80
80
  at the app — no connect-screen flash — at the cost of a brief window-less moment
81
81
  while it boots/probes (the OS shows the launching dock icon meanwhile).
82
82
  */
83
- const { origin, target } = await new Promise<{ origin: string; target: string }>((resolve) => {
84
- worker.addEventListener('message', (event: MessageEvent) => {
85
- const data = event.data as { type: string; origin?: string; target?: string }
86
- if (data.type === 'ready' && data.origin && data.target) {
87
- resolve({ origin: data.origin, target: data.target })
88
- }
89
- })
90
- })
83
+ const { origin, target } = await new Promise<{ origin: string; target: string }>(
84
+ (resolve, reject) => {
85
+ worker.addEventListener('message', (event: MessageEvent) => {
86
+ const data = event.data as {
87
+ type: string
88
+ origin?: string
89
+ target?: string
90
+ error?: string
91
+ }
92
+ if (data.type === 'ready' && data.origin && data.target) {
93
+ resolve({ origin: data.origin, target: data.target })
94
+ } else if (data.type === 'error') {
95
+ /* The worker's start() rejected — fail fast instead of hanging on `ready`. */
96
+ reject(new Error(data.error ?? 'control server worker failed to start'))
97
+ }
98
+ })
99
+ },
100
+ )
91
101
 
92
102
  /*
93
103
  The built-in File menu (Start / Disconnect), placed before Edit. Each item is a
@@ -12,6 +12,7 @@ import { binaryDirEnvPath } from './lib/shared/binaryDirEnvPath.ts'
12
12
  import { clearLastConnection } from './lib/shared/clearLastConnection.ts'
13
13
  import { createLivenessWatch } from './lib/shared/createLivenessWatch.ts'
14
14
  import { dataDirEnvPath } from './lib/shared/dataDirEnvPath.ts'
15
+ import { messageFromError } from './lib/shared/messageFromError.ts'
15
16
  import { readEnvFile } from './lib/shared/readEnvFile.ts'
16
17
  import { readLastConnection } from './lib/shared/readLastConnection.ts'
17
18
  import { serializeEnv } from './lib/shared/serializeEnv.ts'
@@ -417,7 +418,13 @@ self.addEventListener('message', (event: MessageEvent) => {
417
418
  | { type: 'window'; handle: number }
418
419
  | { type: 'shutdown' }
419
420
  if (data.type === 'init') {
420
- void start(data.init)
421
+ /*
422
+ A rejected start() would leave the launcher's `await {type:'ready'}`
423
+ hanging forever — surface the failure so it can fail fast instead.
424
+ */
425
+ start(data.init).catch((startError) => {
426
+ self.postMessage({ type: 'error', error: messageFromError(startError) })
427
+ })
421
428
  } else if (data.type === 'window') {
422
429
  webviewHandle = data.handle
423
430
  } else if (data.type === 'shutdown') {
@@ -26,9 +26,12 @@ export async function parseArgvForRpc(
26
26
  /*
27
27
  Stdin override: if a JSON object is piped in, treat it as the
28
28
  starting args bag. `Bun.stdin.text()` reads the whole pipe; if
29
- nothing was piped, the read resolves with an empty string.
29
+ nothing was piped, the read resolves with an empty string. Skip the
30
+ read entirely when argv already supplied args — a non-TTY pipe that
31
+ never sends EOF would otherwise hang the call forever even though the
32
+ args are fully on the command line.
30
33
  */
31
- if (!process.stdin.isTTY) {
34
+ if (!process.stdin.isTTY && argv.length === 0) {
32
35
  const text = await Bun.stdin.text()
33
36
  if (text.trim()) {
34
37
  try {
@@ -81,7 +84,9 @@ export async function parseArgvForRpc(
81
84
  throw new Error(`--${name} requires a value`)
82
85
  }
83
86
  if (propType === 'number' || propType === 'integer') {
84
- const n = Number(value)
87
+ // Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
88
+ // not NaN, so the NaN guard alone would silently coerce it to zero.
89
+ const n = value.trim() === '' ? Number.NaN : Number(value)
85
90
  if (Number.isNaN(n)) {
86
91
  throw new Error(`--${name} expects a number, got ${value}`)
87
92
  }
@@ -42,6 +42,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
42
42
  })
43
43
  }
44
44
  const envelope = await dispatchMcpRequest(request, opts, serverInfo)
45
+ /* A notification yields no envelope — JSON-RPC forbids replying, so
46
+ ack the receipt with an empty 202 instead of a response body. */
47
+ if (envelope === undefined) {
48
+ return new Response(undefined, {
49
+ status: 202,
50
+ headers: { 'Cache-Control': NO_STORE },
51
+ })
52
+ }
45
53
  return Response.json(envelope, { headers: { 'Cache-Control': NO_STORE } })
46
54
  },
47
55
  }
@@ -53,18 +53,36 @@ export async function dispatchMcpRequest(
53
53
  request: Request,
54
54
  opts: McpServerOptions,
55
55
  serverInfo: { name: string; version: string },
56
- ): Promise<JsonRpcResponse> {
56
+ ): Promise<JsonRpcResponse | undefined> {
57
57
  let envelope: JsonRpcRequest
58
58
  try {
59
59
  envelope = (await request.clone().json()) as JsonRpcRequest
60
60
  } catch {
61
61
  return jsonRpcError(null, -32700, 'Parse error')
62
62
  }
63
+ /* A notification has no `id` field at all (absent — `id: 0`/`id: null` are
64
+ valid ids). JSON-RPC 2.0 forbids replying to a notification, so dispatch it
65
+ for its side effect and return undefined to signal "no reply". */
66
+ const isNotification = !('id' in envelope)
63
67
  const id = envelope.id ?? null
64
68
  if (envelope.jsonrpc !== '2.0' || typeof envelope.method !== 'string') {
65
- return jsonRpcError(id, -32600, 'Invalid Request')
69
+ return isNotification ? undefined : jsonRpcError(id, -32600, 'Invalid Request')
66
70
  }
67
71
 
72
+ // Run the method for its side effect, then drop the reply for a notification.
73
+ const response = await dispatchMethod(envelope, id, request, opts, serverInfo)
74
+ return isNotification ? undefined : response
75
+ }
76
+
77
+ /* Resolves a parsed, validated request to its JSON-RPC response. The caller
78
+ discards this for notifications; non-notifications get it verbatim. */
79
+ async function dispatchMethod(
80
+ envelope: JsonRpcRequest,
81
+ id: string | number | null,
82
+ request: Request,
83
+ opts: McpServerOptions,
84
+ serverInfo: { name: string; version: string },
85
+ ): Promise<JsonRpcResponse> {
68
86
  if (opts.authorize) {
69
87
  try {
70
88
  await opts.authorize(request)
@@ -5,7 +5,12 @@ import { responseErrorText } from '../shared/responseErrorText.ts'
5
5
  import { streamResponse } from '../shared/streamResponse.ts'
6
6
 
7
7
  // Frames a value as MCP text content — strings verbatim, everything else as JSON.
8
+ // An undefined body (204 / empty) becomes '' — `JSON.stringify(undefined)` is the
9
+ // value `undefined`, not a string, which would make an invalid text content block.
8
10
  function asText(value: unknown): string {
11
+ if (value === undefined) {
12
+ return ''
13
+ }
9
14
  return typeof value === 'string' ? value : JSON.stringify(value)
10
15
  }
11
16
 
@@ -1,4 +1,5 @@
1
1
  import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
2
+ import { contentTypeOf } from '../../shared/contentTypeOf.ts'
2
3
  import { HttpError } from '../../shared/HttpError.ts'
3
4
  import type { HttpVerb } from '../../shared/types/HttpVerb.ts'
4
5
  import { error } from '../error.ts'
@@ -86,7 +87,7 @@ export async function parseArgs(
86
87
  store.req = bounded
87
88
  }
88
89
  }
89
- const contentType = (bounded.headers.get('content-type') ?? '').toLowerCase()
90
+ const contentType = contentTypeOf(bounded.headers)
90
91
  try {
91
92
  if (contentType.includes('application/json')) {
92
93
  const text = await bounded.text()
@@ -116,6 +117,16 @@ export async function parseArgs(
116
117
  return body
117
118
  }
118
119
 
120
+ /*
121
+ TODO(query-coercion): query params arrive as strings, so a numeric/boolean
122
+ field reaches schema validation as `'2'`/`'true'`. Deferred deliberately:
123
+ parseArgs has no access to the verb's inputSchema (it lives in defineVerb),
124
+ and Standard Schema exposes no type structure to drive type-aware coercion.
125
+ Blind value-shape coercion is unsafe — it would corrupt legitimately
126
+ string-typed fields whose value looks numeric/boolean (ids, zip codes,
127
+ version strings like '1.0'), silently breaking GET validation. A correct fix
128
+ needs the schema threaded in here (or a coercing schema adapter at the verb).
129
+ */
119
130
  const bodyObject = (body ?? {}) as Record<string, unknown>
120
131
  const merged = { ...Object.fromEntries(url.searchParams), ...bodyObject }
121
132
  if (Object.keys(merged).length === 0) {
@@ -20,5 +20,14 @@ export function acceptsGzip(req: Request): boolean {
20
20
  return false
21
21
  }
22
22
  const quality = directive.match(/;\s*q=([\d.]+)/)
23
- return quality === null || Number(quality[1]) > 0
23
+ if (quality === null) {
24
+ return true
25
+ }
26
+ /*
27
+ A malformed q (e.g. `q=.` → NaN) is not an explicit refusal — fall back to
28
+ the default-accept path, matching how an absent q is treated. Only a parsed
29
+ quality of exactly 0 denies compression.
30
+ */
31
+ const parsed = Number(quality[1])
32
+ return Number.isNaN(parsed) || parsed > 0
24
33
  }
@@ -1,3 +1,4 @@
1
+ import { contentTypeOf } from '../../shared/contentTypeOf.ts'
1
2
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
2
3
  import { acceptsGzip } from './acceptsGzip.ts'
3
4
  import { flushingGzipStream } from './flushingGzipStream.ts'
@@ -40,7 +41,7 @@ export function gzipResponse(req: Request, response: Response): Response {
40
41
  if (!acceptsGzip(req) || isStreamingResponse(response)) {
41
42
  return response
42
43
  }
43
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
44
+ const contentType = contentTypeOf(response.headers)
44
45
  if (!COMPRESSIBLE_TYPE.test(contentType)) {
45
46
  return response
46
47
  }
@@ -1,3 +1,4 @@
1
+ import { contentTypeOf } from '../../shared/contentTypeOf.ts'
1
2
  import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
2
3
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
3
4
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
@@ -47,7 +48,7 @@ export async function snapshotEntryFromCache(
47
48
  if (isStreamingResponse(response)) {
48
49
  return undefined
49
50
  }
50
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
51
+ const contentType = contentTypeOf(response.headers)
51
52
  if (!isTextual(contentType)) {
52
53
  return undefined
53
54
  }
@@ -46,6 +46,14 @@ export function streamFromIterator<T>(
46
46
  enqueue throws on a closed/errored stream, and an uncaught
47
47
  throw in a timer crashes the process. Guard + self-stop.
48
48
  */
49
+ /*
50
+ Skip the tick under backpressure: a stalled consumer leaves
51
+ desiredSize <= 0, and enqueuing anyway grows the internal
52
+ queue unbounded. null = no HWM set, > 0 = consumer keeping up.
53
+ */
54
+ if (controller.desiredSize !== null && controller.desiredSize <= 0) {
55
+ return
56
+ }
49
57
  try {
50
58
  controller.enqueue(payload)
51
59
  } catch {
@@ -170,7 +170,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
170
170
  can ask for "as many as available, up to N" — `0` (bare
171
171
  `for await`) replays nothing.
172
172
  */
173
- send(ws, { type: 'replay', sub: frame.sub, messages: entry.snapshotTail(frame.replay) })
173
+ /*
174
+ A non-finite `replay` (NaN/Infinity from a buggy client) would make
175
+ snapshotTail's slice(NaN) leak the whole retained buffer — coerce it
176
+ to undefined, matching the REST `?tail=` guard below.
177
+ */
178
+ const replay = Number.isFinite(frame.replay) ? frame.replay : undefined
179
+ send(ws, { type: 'replay', sub: frame.sub, messages: entry.snapshotTail(replay) })
174
180
  }
175
181
 
176
182
  function handleUnsub(
@@ -176,7 +176,12 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
176
176
  clients,
177
177
  snapshotTail: (count?: number) => {
178
178
  pruneExpired(Date.now())
179
- const start = count === undefined ? 0 : Math.max(0, buffer.length - count)
179
+ /* A non-finite count (NaN/Infinity) would make slice() leak the whole
180
+ buffer — treat it as absent (full tail), the same as count === undefined. */
181
+ const start =
182
+ count === undefined || !Number.isFinite(count)
183
+ ? 0
184
+ : Math.max(0, buffer.length - count)
180
185
  return buffer.slice(start).map((entry) => entry.value)
181
186
  },
182
187
  })
@@ -870,6 +870,12 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
870
870
  entry.promise = inflight
871
871
  entry.value = undefined
872
872
  entry.settled = true
873
+ /* Restart the freshness clock from the revalidation — without this the
874
+ entry keeps its original expiresAt and is evicted at the old deadline
875
+ despite holding fresh data. Mirrors registerEntry's settle path. */
876
+ if (entry.ttl !== undefined && entry.ttl !== 0) {
877
+ armTtlExpiry(store, entry, entry.ttl)
878
+ }
873
879
  store.markLifecycle(entry.key)
874
880
  emit(store, [entry.key])
875
881
  },
@@ -59,9 +59,14 @@ export function createPushIterator<T>(
59
59
  wake(slot)
60
60
  return
61
61
  }
62
- // Drop the oldest pending value before exceeding the cap.
62
+ /* Drop the OLDEST value slot before exceeding the cap — never a control or
63
+ terminal slot (end/error/disconnect), which the contract guarantees are
64
+ never dropped. If the buffer holds only non-value slots, drop nothing. */
63
65
  if (slot.kind === 'value' && buffer.length >= maxBuffer) {
64
- buffer.shift()
66
+ const oldestValue = buffer.findIndex((pending) => pending.kind === 'value')
67
+ if (oldestValue !== -1) {
68
+ buffer.splice(oldestValue, 1)
69
+ }
65
70
  }
66
71
  buffer.push(slot)
67
72
  }
@@ -0,0 +1,5 @@
1
+ /* The `abide-` tag prefix on framework-owned custom elements (the router's
2
+ `abide-outlet`, the streaming `abide-resolve`/`abide-cache` fragments). `scopeLabel`
3
+ strips it to read the outlet host's bare name. Child components no longer mount into
4
+ an `abide-<name>` wrapper — they build as marker ranges (see `mountRange`). */
5
+ export const COMPONENT_WRAPPER_PREFIX = 'abide-'
@@ -30,6 +30,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
30
30
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock' },
31
31
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock' },
32
32
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
33
+ { name: 'outlet', specifier: 'ui/dom/outlet' },
33
34
  { name: 'mountChild', specifier: 'ui/dom/mountChild' },
34
35
  { name: 'hydrate', specifier: 'ui/dom/hydrate' },
35
36
  { name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { BunPlugin } from 'bun'
3
+ import { messageFromError } from '../../shared/messageFromError.ts'
3
4
  import { AbideCompileError } from './AbideCompileError.ts'
4
5
  import { analyzeComponent } from './analyzeComponent.ts'
5
6
  import { compileModule } from './compileModule.ts'
@@ -57,7 +58,7 @@ export const abideUiPlugin: BunPlugin = {
57
58
  : (({ line, column }) => `${moduleId}:${line}:${column}`)(
58
59
  offsetToLineColumn(source, offset),
59
60
  )
60
- const message = error instanceof Error ? error.message : String(error)
61
+ const message = messageFromError(error)
61
62
  throw new Error(`${message.replace(/^\[abide\]\s*/, `[abide] ${at} — `)}`)
62
63
  }
63
64
  }
@@ -0,0 +1,30 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import type { TemplateNode } from './types/TemplateNode.ts'
3
+
4
+ /*
5
+ In a layout the `<slot/>` page outlet is a bare empty `OUTLET_TAG` element the router fills
6
+ later. Rewriting it to an element node up front lets the static-clone path carry it as ordinary
7
+ structure AND lets BOTH back-ends feed the same tree to `skeletonContext` — one decision site
8
+ for "a layout slot is an outlet, not an anchor", instead of the client running `asOutlet` while
9
+ SSR mirrors it with an inline special-case.
10
+
11
+ The outlet is a structural mount point, not styled content, so it carries NO attrs and NO style
12
+ scope — both back-ends lower it to the bare `<!--abide:outlet-->`…`<!--/abide:outlet-->` boundary
13
+ `renderChain` folds the child layer into (matching its `OUTLET_PLACEHOLDER`). Stripping `scopes`
14
+ is what makes the two back-ends agree: SSR emits the boundary bare, but without this the client
15
+ clone would read the slot's annotated `scopes` and stamp them — a hydration mismatch for any
16
+ scoped layout.
17
+
18
+ Control-flow children are fresh build contexts (their own runtime mounts a nested slot), so they
19
+ are not descended into — a `<slot>` inside an `{#if}` stays a slot node, handled at its own
20
+ mount site in each back-end.
21
+ */
22
+ export function asOutlet(node: TemplateNode): TemplateNode {
23
+ if (node.kind !== 'element') {
24
+ return node
25
+ }
26
+ if (node.tag === 'slot') {
27
+ return { ...node, tag: OUTLET_TAG, attrs: [], children: [], scopes: [] }
28
+ }
29
+ return { ...node, children: node.children.map(asOutlet) }
30
+ }
@@ -42,11 +42,13 @@ export function compileModule(
42
42
  const id = JSON.stringify(options.moduleId)
43
43
  return `const { ${names}, hotReplace } = window.__abide
44
44
  ${userImports}
45
- function component(host, $props) {
46
- return mount(host, (host) => {
45
+ function build(host, $props) {
47
46
  ${body}
48
- })
49
47
  }
48
+ function component(host, $props) {
49
+ return mount(host, build, $props)
50
+ }
51
+ component.build = build
50
52
  component.__abideId = ${id}
51
53
  if (!hotReplace(${id}, component)) location.reload()
52
54
  `
@@ -61,17 +63,17 @@ if (!hotReplace(${id}, component)) location.reload()
61
63
  exactly which names it emitted, so it filters here. A name absent from the body
62
64
  is unreferenced; erring toward inclusion (a stray match in user script) only
63
65
  keeps a harmless unused import, never drops a needed one. */
64
- const moduleBody = `export default function component(host, $props) {
65
- return mount(host, (host) => {
66
+ const moduleBody = `function build(host, $props) {
66
67
  ${body}
67
- })
68
+ }
69
+
70
+ export default function component(host, $props) {
71
+ return mount(host, build, $props)
68
72
  }
69
73
 
70
74
  /* Adopt the server-rendered DOM in place instead of rebuilding it. */
71
75
  export function hydrateInto(host, $props) {
72
- return hydrate(host, (host) => {
73
- ${body}
74
- })
76
+ return hydrate(host, build, $props)
75
77
  }
76
78
 
77
79
  export function render($props) {
@@ -80,6 +82,8 @@ ${ssrBody}
80
82
 
81
83
  component.render = render
82
84
  component.hydrate = hydrateInto
85
+ /* The bare build, so a parent can range-mount this as a nested child (no wrapper). */
86
+ component.build = build
83
87
  component.hydratable = ${analyzed.hydratable}
84
88
  ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(options.moduleId)}\n`}`
85
89
  /* Scope each name to the surface that genuinely references it. The SSR body