@abide/abide 0.47.0 → 0.49.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 (84) hide show
  1. package/AGENTS.md +26 -13
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +9 -3
  4. package/package.json +3 -1
  5. package/src/buildCli.ts +3 -5
  6. package/src/buildDisconnected.ts +2 -3
  7. package/src/bundleApp.ts +2 -3
  8. package/src/compile.ts +2 -4
  9. package/src/lib/bundle/installDownloads.ts +13 -3
  10. package/src/lib/bundle/installMacMenu.ts +13 -3
  11. package/src/lib/cli/parseArgvForRpc.ts +36 -9
  12. package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
  13. package/src/lib/mcp/createMcpResourceServer.ts +13 -2
  14. package/src/lib/mcp/toolResultFromResponse.ts +40 -12
  15. package/src/lib/server/rpc/parseArgs.ts +15 -1
  16. package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
  17. package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
  18. package/src/lib/server/runtime/finalizeResponse.ts +11 -1
  19. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
  20. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  21. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  22. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  23. package/src/lib/shared/buildArtifact.ts +17 -0
  24. package/src/lib/shared/buildRpcRequest.ts +6 -2
  25. package/src/lib/shared/cache.ts +66 -28
  26. package/src/lib/shared/cacheStores.ts +3 -3
  27. package/src/lib/shared/canonicalJson.ts +24 -1
  28. package/src/lib/shared/createChannelLog.ts +24 -0
  29. package/src/lib/shared/exitOnBuildFailure.ts +4 -3
  30. package/src/lib/shared/parseEnv.ts +17 -6
  31. package/src/lib/shared/refresh.ts +3 -3
  32. package/src/lib/shared/serializeEnv.ts +18 -6
  33. package/src/lib/shared/sharedCacheStore.ts +14 -0
  34. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  35. package/src/lib/shared/snippet.ts +11 -6
  36. package/src/lib/shared/types/CacheOptions.ts +15 -11
  37. package/src/lib/shared/types/CacheStats.ts +1 -1
  38. package/src/lib/shared/types/SmartReadOptions.ts +15 -11
  39. package/src/lib/shared/url.ts +5 -0
  40. package/src/lib/test/createTestApp.ts +6 -6
  41. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  42. package/src/lib/ui/compile/bindListenEvent.ts +5 -6
  43. package/src/lib/ui/compile/compileSSR.ts +1 -1
  44. package/src/lib/ui/compile/compileShadow.ts +121 -25
  45. package/src/lib/ui/compile/composeProps.ts +3 -2
  46. package/src/lib/ui/compile/desugarSignals.ts +1 -1
  47. package/src/lib/ui/compile/generateBuild.ts +23 -9
  48. package/src/lib/ui/compile/generateSSR.ts +49 -37
  49. package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
  50. package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
  51. package/src/lib/ui/compile/parseTemplate.ts +3 -1
  52. package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
  53. package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
  54. package/src/lib/ui/compile/scopeCss.ts +27 -4
  55. package/src/lib/ui/dom/awaitBlock.ts +67 -29
  56. package/src/lib/ui/dom/discardBoundary.ts +24 -6
  57. package/src/lib/ui/dom/each.ts +15 -8
  58. package/src/lib/ui/dom/fillBefore.ts +6 -7
  59. package/src/lib/ui/dom/mergeProps.ts +1 -1
  60. package/src/lib/ui/dom/mountSlot.ts +1 -1
  61. package/src/lib/ui/dom/mutateDocArray.ts +38 -0
  62. package/src/lib/ui/dom/restProps.ts +2 -2
  63. package/src/lib/ui/dom/spreadProps.ts +3 -4
  64. package/src/lib/ui/dom/tryBlock.ts +10 -11
  65. package/src/lib/ui/dom/withScope.ts +7 -0
  66. package/src/lib/ui/history.ts +14 -7
  67. package/src/lib/ui/installHotBridge.ts +2 -0
  68. package/src/lib/ui/props.ts +17 -0
  69. package/src/lib/ui/renderChain.ts +7 -3
  70. package/src/lib/ui/router.ts +23 -17
  71. package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
  72. package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
  73. package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
  74. package/src/lib/ui/runtime/createDoc.ts +28 -12
  75. package/src/lib/ui/runtime/flushEffects.ts +23 -1
  76. package/src/lib/ui/runtime/scope.ts +11 -0
  77. package/src/lib/ui/runtime/toTeardown.ts +10 -3
  78. package/src/lib/ui/runtime/types/UiProps.ts +6 -5
  79. package/src/lib/ui/runtime/withoutHydration.ts +22 -0
  80. package/src/lib/ui/startClient.ts +3 -3
  81. package/src/lib/ui/state.ts +9 -3
  82. package/src/serverEntry.ts +11 -9
  83. package/src/lib/shared/globalCacheStore.ts +0 -15
  84. package/src/lib/shared/globalCacheStoreSlot.ts +0 -12
package/AGENTS.md CHANGED
@@ -92,12 +92,17 @@ validate as args, `File` parts validate against `filesSchema`.
92
92
 
93
93
  **Consuming an rpc** — the bare call `fn(args, opts?)` IS the smart read:
94
94
  cached, coalesced, reactive, stale-while-revalidate for replayable (GET/HEAD)
95
- reads; the second arg takes `{ ttl, tags, throttle, debounce, global, n }`
96
- (retention and the refetch clock — not transport). `global: true` stores the
97
- entry in the process-level store instead of the request-scoped default
98
- (server), so later requests reuse the value for memoising an external
99
- endpoint, never per-user data; on the client it is a no-op (one tab store).
100
- During SSR the same call resolves
95
+ reads; the second arg takes `{ ttl, tags, throttle, debounce, shared, n }`
96
+ (retention and the refetch clock — not transport). `ttl` defaults to `0` on
97
+ the server (coalesce-only the request is the atomic unit, nothing is
98
+ retained past it) and `Infinity` on the client (retain until invalidate/
99
+ refresh). `shared: true` selects the process-level store instead of the
100
+ request-scoped default (server) it does not by itself retain, pair it with
101
+ an explicit `ttl` (e.g. `{ shared: true, ttl: Infinity }`) to memoise across
102
+ requests, for an external endpoint, never per-user data; on the client it is
103
+ a no-op (one tab store). A read with no request in flight (e.g. a background
104
+ job) also resolves against the process-level store. During SSR the same call
105
+ resolves
101
106
  in-process and its value is baked into the HTML so hydration starts warm —
102
107
  there is no `cache()` wrapper; the bare call carries the caching. Around it:
103
108
  `fn.raw(args, init?)` returns the raw `Response` (per-call transport options —
@@ -184,7 +189,7 @@ cells read/written through `.value`.
184
189
  | `state.share(key, value)` / `state.shared(key)` | members of `state` | Put a named value on the ambient scope / read the closest ancestor's |
185
190
  | `watch(source, handler)` | `@abide/abide/ui/watch` | The single reaction primitive (client-only, stripped from SSR). Sources: a bare thunk `watch(() => …)` (auto-tracked effect), a state cell, a cell array, a socket/stream (`handler(frame)` per frame with reconnect replay), an rpc (`watch(fn, args?, handler)` — runs the smart read, `handler(value)` on each change). Returns a scope-tied disposer |
186
191
  | `html(str)` / `` html`…` `` | `@abide/abide/ui/html` | Brands trusted raw HTML so `{expr}` inserts nodes instead of escaped text; plain `{value}` always escapes |
187
- | `props()` | ambient (no import) | The prop reader: `const { name = fallback, ...rest } = props()`; a page's props are its route params |
192
+ | `props()` | `@abide/abide/ui/props` | The prop reader, resolved by import binding (alias-safe) like `state`: `const { name = fallback, ...rest } = props()`; a page/layout's declared props are additive with its route-param shape. `children` is an ordinary declared prop (`const { children } = props<{ children: Snippet }>()`), not ambient |
188
193
 
189
194
  Bindings and directives (the attribute kinds `readAttributes` parses):
190
195
 
@@ -216,9 +221,15 @@ error):
216
221
  | `{#snippet name(args)}…{/snippet}` | Declares a reusable builder, called as an interpolation: `{name(args)}`; a snippet value passes through props like any other value |
217
222
 
218
223
  Components are capitalised tags (`<Panel prop={x}>…</Panel>`); nested content
219
- renders where the component calls `{children()}` — the single fill point
220
- (`{#if children}{children()}{:else}…{/if}` for a fallback; there are no named
221
- slots and no `<slot>` element). A layout's `{children()}` is the route outlet.
224
+ becomes the component's `children` prop an ordinary declared prop of type
225
+ `Snippet`, read with `const { children } = props<{ children: Snippet }>()`
226
+ and called as `{children()}` the single fill point (`{#if children}
227
+ {children()}{:else}…{/if}` for a fallback; there are no named slots and no
228
+ `<slot>` element). Slotted content (`<Panel>…</Panel>`) and an explicit
229
+ `children={aSnippet}` attribute set the same prop — slotted content rides in
230
+ as the trailing prop layer, so it wins over an explicit `children` attribute
231
+ on the same tag (`mergeProps`, last layer wins per key). A layout's
232
+ `{children()}` is the route outlet.
222
233
 
223
234
  `<script>` and `<style>` are **not component-root-only**: either may sit
224
235
  inside a control-flow branch, scoped to that branch. A nested `<script>`
@@ -228,8 +239,9 @@ there — imports live in the leading `<script>`). A **root** `<style>` is
228
239
  component-scoped; a nested `<style>` scopes to its sibling subtree only.
229
240
 
230
241
  Removed forms throw migration errors at parse time: the `<slot>` element (use
231
- `{children()}`), `<template name>` snippets (use `{#snippet}`), and all
232
- `<template if/each/await/switch/…>` control flow (use `{#…}` blocks).
242
+ a declared `children: Snippet` prop, called `{children()}`), `<template
243
+ name>` snippets (use `{#snippet}`), and all `<template
244
+ if/each/await/switch/…>` control flow (use `{#…}` blocks).
233
245
 
234
246
  ## Server surface — `abide/server/*`
235
247
 
@@ -320,7 +332,7 @@ Probes report, never act — reading one opens no fetch and no stream.
320
332
 
321
333
  ### Templating — `@documentation templating`
322
334
 
323
- - `@abide/abide/shared/snippet` — `snippet(payload)`: brands a snippet payload so a `{expr}` interpolation mounts it (client: a DOM builder; server: the rendered string); the compiler wraps `{#snippet}` bodies in this. Also exports the `Snippet<P>` type and `snippetPayload(value)` (the payload, or undefined for plain values).
335
+ - `@abide/abide/shared/snippet` — `snippet(payload)`: brands a snippet payload so a `{expr}` interpolation mounts it (client: a DOM builder; server: the rendered string); the compiler wraps `{#snippet}` bodies in this. Also exports the `Snippet<Args>` type — a callable `(...args: Args) => SnippetValue`, generic over its call arguments (`children` is `Snippet`, invoked `children()`; a row snippet is `Snippet<[Item]>`, invoked `row(item)`) — plus `SnippetValue` (the internal payload brand) and `snippetPayload(value)` (a branded value's payload, or undefined for plain values).
324
336
 
325
337
  ### Shared plumbing — `@documentation plumbing`
326
338
 
@@ -332,6 +344,7 @@ Probes report, never act — reading one opens no fetch and no stream.
332
344
 
333
345
  - `@abide/abide/ui/state` — the `state` primitive: `state(initial, transform?)` writable cell, `state.computed(fn)` read-only derived, `state.linked(fn, transform?)` writable-reseeded, `state.share(key, value)` / `state.shared(key)` ambient context. In `.abide` files the compiler lowers reads/writes to plain variable syntax; in `.ts` the cell is read/written through `.value`.
334
346
  - `@abide/abide/ui/watch` — `watch(source, handler)`: the single reaction primitive over a thunk, cell, cell array, socket/stream, or rpc (see the grammar table). Client-only; the compiler strips author calls from SSR, and the `socket.watch` / `fn.watch` instance sugar is SSR-inert.
347
+ - `@abide/abide/ui/props` — `props<T>()`: the prop reader, resolved by import binding (alias-safe) like `state`; a required import — there is no ambient `props()`. Destructure declared props off it (`const { name, ...rest } = props<T>()`); a page/layout's declared `T` is additive with its auto-typed route-param shape. `children` is an ordinary declared prop, not ambient: `const { children } = props<{ children: Snippet }>()` (`Snippet` from `@abide/abide/shared/snippet`).
335
348
 
336
349
  ### Templating — `@documentation templating`
337
350
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # abide
2
2
 
3
+ ## 0.49.0
4
+
5
+ ### Minor Changes
6
+
7
+ - dfb4f13: props import required + additive route-shape type; drop ambient children ([`0dbe248`](https://github.com/briancray/abide/commit/0dbe248fc959f3ca8168fb83e6f30619a17eff3b))
8
+ - dfb4f13: slotted content rides the children prop key as a Snippet ([`0de19e5`](https://github.com/briancray/abide/commit/0de19e594dabb6ddd92b08d5e473f13296f17247))
9
+ - dfb4f13: publish abide/ui/props module for the props reader ([`197c1a9`](https://github.com/briancray/abide/commit/197c1a900c0ea81b40d1cbb4a6bec1e021b9e932))
10
+ - dfb4f13: type element attach node param from the element tag ([`444ef35`](https://github.com/briancray/abide/commit/444ef356372f8fc39a0b5786bca2f70dcf750c53))
11
+ - 951e90b: `props` is now a required import (`@abide/abide/ui/props`), resolved by import binding like `state` — a bare ambient `props()` no longer works, and every component that reads props must `import { props } from '@abide/abide/ui/props'`.
12
+
13
+ `children` is now an ordinary declared prop instead of an ambient callable: read it with `const { children } = props<{ children: Snippet }>()` (`Snippet` from `@abide/abide/shared/snippet`). Slotted content (`<Panel>…</Panel>`) and an explicit `children={aSnippet}` attribute now set the same `children` prop key (slotted content wins if both are present). `props<T>()` stays additive — a page/layout still gets its route-param shape auto-typed, and declaring `children` (or any other prop) only adds to it.
14
+
15
+ The `Snippet` type's generic changed from its internal payload to its call arguments: `Snippet<Args extends unknown[] = []>` is now `(...args: Args) => SnippetValue` — `children` is `Snippet` (invoked `children()`), a row/label snippet is `Snippet<[Item]>` (invoked `row(item)`). The former payload-generic form (`Snippet<Payload>` describing the value flowing through) is gone; the internal payload is the newly exported `SnippetValue`.
16
+
17
+ **Behavior change:** an unguarded `{children()}` on a component mounted without children now throws at render, where it previously rendered nothing. Guard with `{#if children}{children()}{:else}…{/if}`, or declare `children: Snippet` as a required (non-optional) prop so `abide check` catches missing children at the call site.
18
+
19
+ Migration: add `import { props } from '@abide/abide/ui/props'` to every component that calls `props()`; for any component that reads `children`, add `import type { Snippet } from '@abide/abide/shared/snippet'` and declare it — `const { children } = props<{ children: Snippet }>()` (or `children?: Snippet` if optional).
20
+
21
+ ### Patch Changes
22
+
23
+ - 4a6bf93: mount a reactive/loop-bound component tag by its value, not the cell ([`32a7cb8`](https://github.com/briancray/abide/commit/32a7cb8bdef0800d76b418eefbe550b0a939cd73))
24
+ - dfb4f13: correct UiProps.children to the ordinary prop-thunk shape ([`33a0de7`](https://github.com/briancray/abide/commit/33a0de72dc2fe6ce4a0e025e4158ab192e47f1f0))
25
+ - c7d25e4: type state<T>(undefined) and binding-annotated state() as T | undefined ([`3c40d99`](https://github.com/briancray/abide/commit/3c40d9981617968330a00bc2f034456b80365419))
26
+ - c7d25e4: await a snippet whose body fills a {children()} slot ([`4c2744b`](https://github.com/briancray/abide/commit/4c2744bc1aa42728a80adb3426a77c02c79b30d4))
27
+ - dfb4f13: repair pre-existing gate failures blocking release ([`4c55a4a`](https://github.com/briancray/abide/commit/4c55a4a1cbcb0b2afc842f7cc7134aed590006ce))
28
+ - dfb4f13: layout child sentinel + restProps skip move to the children key ([`500d1d8`](https://github.com/briancray/abide/commit/500d1d8d696147602eb1ce34706cbbbf724ceb0b))
29
+ - dfb4f13: fix stale props-declaration comment in compileShadow ([`532e079`](https://github.com/briancray/abide/commit/532e0796c54aa77c7b155145ae2d16193b7de4e5))
30
+ - c7d25e4: expand a thrown Bun.build AggregateError into per-file diagnostics ([`5643d95`](https://github.com/briancray/abide/commit/5643d95691dd9211bc5f3edb5a2ae77aa3989f63))
31
+ - dfb4f13: children lowers as a destructured prop, not $children ([`60bbbeb`](https://github.com/briancray/abide/commit/60bbbeb15cae41d34e5f5b4f07590bf0a5a1b795))
32
+ - dfb4f13: resolve 35 correctness/ergonomics findings from full-tree audit ([`73a72ac`](https://github.com/briancray/abide/commit/73a72ac1946f901f76d790b38fa3ebbf29b661fe))
33
+ - dfb4f13: extract buildArtifact (build-or-die primitive) ([`938e312`](https://github.com/briancray/abide/commit/938e3129f03cb6787624703422cd9472fbdcb83b))
34
+ - dfb4f13: format spreadProps + renderChain to canonical prettier ([`97b1565`](https://github.com/briancray/abide/commit/97b1565670eb0977b8079425f8e92becd6deb68e))
35
+ - dfb4f13: thunk-wrap layout children sentinel so {#if children} presence reads truthy ([`a884de1`](https://github.com/briancray/abide/commit/a884de1bbb0fcffaea91268cf49aa1d1b24a3d7a))
36
+ - 4a6bf93: drop an await settle whose anchor was detached from the tree ([`ad3d00c`](https://github.com/briancray/abide/commit/ad3d00c36ecfd935721164e7ebff7a7b3fffca4e))
37
+ - dfb4f13: resolve props by import binding, not ambient special-case ([`b624893`](https://github.com/briancray/abide/commit/b624893745aa0e1fdb407047b15f29ac0ceac3ec))
38
+ - dfb4f13: shadow declares props under the author's local alias ([`b821a5b`](https://github.com/briancray/abide/commit/b821a5bfef17233b2aa24502ea397eede62329a0))
39
+ - dfb4f13: extract withoutHydration (5 sites -> 1 helper) ([`c36be09`](https://github.com/briancray/abide/commit/c36be09d64846f76216bfcc7932cdbc026631f04))
40
+ - dfb4f13: redefine Snippet<Args> as a callable; add SnippetValue ([`ecc861f`](https://github.com/briancray/abide/commit/ecc861f589d5149a299b92ba6d08fd362644bcd1))
41
+
42
+ ## 0.48.0
43
+
44
+ ### Minor Changes
45
+
46
+ - 182cdf5: server reads coalesce-only by default, SWR retain client-only ([`98a3e84`](https://github.com/briancray/abide/commit/98a3e84b1e6bdb50b8950a4197ec4a255c560d2a))
47
+ - 182cdf5: warn on a request-scoped ttl>0 read (add shared or drop ttl) ([`b55425c`](https://github.com/briancray/abide/commit/b55425c986fb4c7cebcb1e8c29d1c616466d7d63))
48
+
49
+ ### Patch Changes
50
+
51
+ - 182cdf5: rename global option/exports/comments to shared ([`1256934`](https://github.com/briancray/abide/commit/125693481ba23152a44375bbe200d575b66fdf9a))
52
+ - 182cdf5: evict outside-request coalesce-only entries; correct shared help copy ([`1b73e21`](https://github.com/briancray/abide/commit/1b73e21c33c8ee985f7109fdedcbe302322ce7d1))
53
+ - 182cdf5: route outside-a-request server reads to the shared store ([`456cfa6`](https://github.com/briancray/abide/commit/456cfa662405714df99545e02ed6e3b851da84df))
54
+ - 182cdf5: Breaking: the `global` cache option is renamed `shared`, and it no longer implies retention. `{ global: true }` (memoise across requests forever) becomes `{ shared: true, ttl: Infinity }` — `shared` now only selects the process store, while `ttl` alone controls retention (default `0` on the server = coalesce-only, `Infinity` on the client). Reads made with no request in flight resolve to the shared store instead of an orphan fallback. ([`d94f0f9`](https://github.com/briancray/abide/commit/d94f0f9cb15a5822b2d5824db7e32da27368fe04))
55
+
3
56
  ## 0.47.0
4
57
 
5
58
  ### Minor Changes
package/README.md CHANGED
@@ -130,11 +130,16 @@ component you read and write state as plain variables. The same file renders on
130
130
  the server — blocking awaits inline, streaming awaits out of order, cached
131
131
  reads seeded warm — and hydrates in the browser.
132
132
 
133
- A child component renders whatever you nest in it at `{children()}`:
133
+ Nested content becomes the component's `children` prop an ordinary declared
134
+ prop of type `Snippet` — and renders wherever the component calls
135
+ `{children()}`:
134
136
 
135
137
  ```html
136
138
  <script>
137
- const { title } = props()
139
+ import { props } from '@abide/abide/ui/props'
140
+ import type { Snippet } from '@abide/abide/shared/snippet'
141
+
142
+ const { title, children } = props<{ title: string; children?: Snippet }>()
138
143
  </script>
139
144
 
140
145
  <section>
@@ -160,9 +165,10 @@ template grammar:
160
165
  import { state } from '@abide/abide/ui/state'
161
166
  import { watch } from '@abide/abide/ui/watch'
162
167
  import { html } from '@abide/abide/ui/html'
168
+ import { props } from '@abide/abide/ui/props'
163
169
  import Panel from '$ui/components/Panel.abide'
164
170
 
165
- const { room = 'lobby' } = props()
171
+ const { room = 'lobby' } = props<{ room?: string }>()
166
172
 
167
173
  let draft = state('')
168
174
  let author = state('anon')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
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",
@@ -77,6 +77,7 @@
77
77
  "./shared/createSubscriber": "./src/lib/shared/createSubscriber.ts",
78
78
  "./ui/html": "./src/lib/ui/html.ts",
79
79
  "./ui/state": "./src/lib/ui/state.ts",
80
+ "./ui/props": "./src/lib/ui/props.ts",
80
81
  "./ui/effect": "./src/lib/ui/effect.ts",
81
82
  "./ui/currentScope": "./src/lib/ui/scope.ts",
82
83
  "./ui/enterRenderScope": "./src/lib/ui/enterScope.ts",
@@ -89,6 +90,7 @@
89
90
  "./ui/dom/restProps": "./src/lib/ui/dom/restProps.ts",
90
91
  "./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
91
92
  "./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
93
+ "./ui/dom/mutateDocArray": "./src/lib/ui/dom/mutateDocArray.ts",
92
94
  "./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
93
95
  "./ui/dom/text": "./src/lib/ui/dom/text.ts",
94
96
  "./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
package/src/buildCli.ts CHANGED
@@ -2,9 +2,9 @@ import { dirname, join } from 'node:path'
2
2
  import { build } from './build.ts'
3
3
  import { compile } from './compile.ts'
4
4
  import { abideLog } from './lib/shared/abideLog.ts'
5
+ import { buildArtifact } from './lib/shared/buildArtifact.ts'
5
6
  import { detectTarget } from './lib/shared/detectTarget.ts'
6
7
  import { exeSuffix } from './lib/shared/exeSuffix.ts'
7
- import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
8
8
  import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
9
9
  import { readPackageJson } from './lib/shared/readPackageJson.ts'
10
10
  import type { CompileTarget } from './lib/shared/types/CompileTarget.ts'
@@ -56,14 +56,13 @@ export async function buildCli({
56
56
  stdout. We don't `bun build --compile` here because the discovery output is
57
57
  throwaway; a plain JS bundle runs faster. Additive — does not clear dist.
58
58
  */
59
- const discoveryResult = await Bun.build({
59
+ await buildArtifact({
60
60
  entrypoints: [DISCOVERY_ENTRY],
61
61
  target: 'bun',
62
62
  outdir: distDir,
63
63
  naming: '_discovery.js',
64
64
  plugins,
65
65
  })
66
- exitOnBuildFailure(discoveryResult)
67
66
 
68
67
  const proc = Bun.spawn({
69
68
  cmd: ['bun', discoveryOut],
@@ -94,13 +93,12 @@ export async function buildCli({
94
93
  async function buildTargetPair(platformTarget: CompileTarget, cliOut: string): Promise<string> {
95
94
  const serverOut = join(dirname(cliOut), `server${exeSuffix(platformTarget)}`)
96
95
  await compile({ cwd, target: platformTarget, outfile: serverOut, buildClient: false })
97
- const result = await Bun.build({
96
+ await buildArtifact({
98
97
  entrypoints: [CLI_ENTRY],
99
98
  target: 'bun',
100
99
  compile: { target: platformTarget, outfile: cliOut },
101
100
  plugins,
102
101
  })
103
- exitOnBuildFailure(result)
104
102
  abideLog.success(`compiled cli + server: ${cliOut}`)
105
103
  return cliOut
106
104
  }
@@ -1,6 +1,6 @@
1
1
  import { clientBuildPlugins } from './clientBuildPlugins.ts'
2
2
  import { abideLog } from './lib/shared/abideLog.ts'
3
- import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
3
+ import { buildArtifact } from './lib/shared/buildArtifact.ts'
4
4
 
5
5
  const ENTRY = new URL('./bundleDisconnectedEntry.ts', import.meta.url).pathname
6
6
  const CSS_ENTRY = new URL('./lib/bundle/disconnected.css', import.meta.url).pathname
@@ -47,7 +47,7 @@ export async function buildDisconnected({
47
47
  the entry (which TS can't type), so it compiles to a standalone artifact we
48
48
  inline alongside the JS — the component carries no <style> of its own.
49
49
  */
50
- const result = await Bun.build({
50
+ const result = await buildArtifact({
51
51
  entrypoints: [ENTRY, CSS_ENTRY],
52
52
  target: 'browser',
53
53
  minify: true,
@@ -55,7 +55,6 @@ export async function buildDisconnected({
55
55
  define: { __ABIDE_DEV__: 'false' },
56
56
  plugins,
57
57
  })
58
- exitOnBuildFailure(result)
59
58
 
60
59
  // Collect the JS bundle + extracted CSS from the in-memory artifacts.
61
60
  let js = ''
package/src/bundleApp.ts CHANGED
@@ -8,9 +8,9 @@ import { serverBinaryFilename } from './lib/bundle/serverBinaryFilename.ts'
8
8
  import { signMacApp } from './lib/bundle/signMacApp.ts'
9
9
  import { webviewLibName } from './lib/bundle/webviewLibName.ts'
10
10
  import { abideLog } from './lib/shared/abideLog.ts'
11
+ import { buildArtifact } from './lib/shared/buildArtifact.ts'
11
12
  import { bundleLayout } from './lib/shared/bundleLayout.ts'
12
13
  import { detectTarget } from './lib/shared/detectTarget.ts'
13
- import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
14
14
  import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
15
15
  import { readPackageJson } from './lib/shared/readPackageJson.ts'
16
16
  import { serverBuildPlugins } from './serverBuildPlugins.ts'
@@ -82,7 +82,7 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
82
82
  // 3. Launcher binary — named after the program so CFBundleExecutable matches.
83
83
  const launcherSuffix = target.includes('windows') ? '.exe' : ''
84
84
  const launcherPath = `${binDir}/${programName}${launcherSuffix}`
85
- const launcherResult = await Bun.build({
85
+ await buildArtifact({
86
86
  entrypoints: [APP_ENTRY],
87
87
  target: 'bun',
88
88
  compile: { target, outfile: launcherPath },
@@ -96,7 +96,6 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
96
96
  */
97
97
  define: { __ABIDE_WORKER_ENTRY__: JSON.stringify(WORKER_ENTRY) },
98
98
  })
99
- exitOnBuildFailure(launcherResult)
100
99
 
101
100
  // 4. Webview lib — built from the vendored source if needed, then copied
102
101
  // beside the binaries (or into Frameworks on macOS) so the bundle is self-contained.
package/src/compile.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { build } from './build.ts'
2
2
  import { abideLog } from './lib/shared/abideLog.ts'
3
+ import { buildArtifact } from './lib/shared/buildArtifact.ts'
3
4
  import { detectTarget } from './lib/shared/detectTarget.ts'
4
5
  import { exeSuffix } from './lib/shared/exeSuffix.ts'
5
- import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
6
6
  import type { CompileTarget } from './lib/shared/types/CompileTarget.ts'
7
7
  import { serverBuildPlugins } from './serverBuildPlugins.ts'
8
8
 
@@ -40,7 +40,7 @@ export async function compile({
40
40
 
41
41
  const outPath = outfile ?? `${cwd}/dist/app${exeSuffix(target)}`
42
42
 
43
- const result = await Bun.build({
43
+ await buildArtifact({
44
44
  entrypoints: [SERVER_ENTRY],
45
45
  target: 'bun',
46
46
  format: 'esm',
@@ -56,8 +56,6 @@ export async function compile({
56
56
  plugins: serverBuildPlugins({ cwd, embedAssets: true }),
57
57
  })
58
58
 
59
- exitOnBuildFailure(result)
60
-
61
59
  abideLog.success(`compiled standalone binary: ${outPath} (target: ${target})`)
62
60
  return outPath
63
61
  }
@@ -16,9 +16,19 @@ export function installDownloads(libPath: string, webviewHandle: Pointer | null)
16
16
  if (process.platform !== 'darwin') {
17
17
  return
18
18
  }
19
- const { symbols, close } = dlopen(libPath, {
20
- abide_install_downloads: { args: [FFIType.ptr], returns: FFIType.void },
21
- })
19
+ /* abide_install_downloads exists only in abide's own webview lib. A vanilla
20
+ ABIDE_WEBVIEW_LIB on darwin lacks it, so dlopen throws — degrade to a no-op
21
+ (downloads simply unhandled) instead of crashing the launcher, matching the
22
+ optional-symbol FFI binders. */
23
+ let symbols: { abide_install_downloads: (handle: Pointer | null) => void }
24
+ let close: () => void
25
+ try {
26
+ ;({ symbols, close } = dlopen(libPath, {
27
+ abide_install_downloads: { args: [FFIType.ptr], returns: FFIType.void },
28
+ }))
29
+ } catch {
30
+ return
31
+ }
22
32
  symbols.abide_install_downloads(webviewHandle)
23
33
  close()
24
34
  }
@@ -30,9 +30,19 @@ export function installMacMenu(
30
30
  if (process.platform !== 'darwin') {
31
31
  return
32
32
  }
33
- const { symbols, close } = dlopen(libPath, {
34
- abide_install_app_menu: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
35
- })
33
+ /* The abide_install_app_menu symbol is compiled only into abide's own webview lib.
34
+ A user who points ABIDE_WEBVIEW_LIB at a vanilla webview.dylib is on darwin but
35
+ lacks the symbol, so dlopen throws — degrade to a no-op (native menu simply absent)
36
+ rather than crashing the launcher, matching bindConnectedFlag/bindRequestNavigate. */
37
+ let symbols: { abide_install_app_menu: (handle: Pointer | null, config: Uint8Array) => void }
38
+ let close: () => void
39
+ try {
40
+ ;({ symbols, close } = dlopen(libPath, {
41
+ abide_install_app_menu: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
42
+ }))
43
+ } catch {
44
+ return
45
+ }
36
46
  const config = JSON.stringify({ appName, fileMenu, menu })
37
47
  symbols.abide_install_app_menu(webviewHandle, new TextEncoder().encode(`${config}\0`))
38
48
  close()
@@ -74,8 +74,36 @@ export async function parseArgvForRpc(
74
74
  }
75
75
  const name = token.name
76
76
  const propType = properties[name]?.type
77
+ /* Coerce one string token to a schema scalar type (or throw). Shared by scalar
78
+ props and array ELEMENTS so `--tags 1 --tags 2` on `z.array(z.number())` yields
79
+ numbers, not strings the server then rejects. */
80
+ const coerceScalar = (raw: string, type: string | undefined): unknown => {
81
+ if (type === 'number' || type === 'integer') {
82
+ // Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
83
+ // not NaN, so the NaN guard alone would silently coerce it to zero.
84
+ const n = raw.trim() === '' ? Number.NaN : Number(raw)
85
+ if (Number.isNaN(n)) {
86
+ throw new Error(`--${name} expects a number, got ${raw}`)
87
+ }
88
+ return n
89
+ }
90
+ if (type === 'boolean') {
91
+ const lowered = raw.trim().toLowerCase()
92
+ if (lowered === 'true' || lowered === '1') {
93
+ return true
94
+ }
95
+ if (lowered === 'false' || lowered === '0') {
96
+ return false
97
+ }
98
+ throw new Error(`--${name} expects true or false, got ${raw}`)
99
+ }
100
+ return raw
101
+ }
77
102
  if (propType === 'boolean') {
78
- args[name] = !token.negated
103
+ /* Bare `--flag` / `--no-flag` toggles; inline `--flag=false` honours the RHS
104
+ instead of always resolving to true. */
105
+ args[name] =
106
+ token.value !== undefined ? coerceScalar(token.value, 'boolean') : !token.negated
79
107
  continue
80
108
  }
81
109
  if (token.missingValue || token.value === undefined) {
@@ -83,18 +111,17 @@ export async function parseArgvForRpc(
83
111
  }
84
112
  const value = token.value
85
113
  if (propType === 'number' || propType === 'integer') {
86
- // Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
87
- // not NaN, so the NaN guard alone would silently coerce it to zero.
88
- const n = value.trim() === '' ? Number.NaN : Number(value)
89
- if (Number.isNaN(n)) {
90
- throw new Error(`--${name} expects a number, got ${value}`)
91
- }
92
- args[name] = n
114
+ args[name] = coerceScalar(value, propType)
93
115
  continue
94
116
  }
95
117
  if (propType === 'array') {
118
+ /* Coerce each repeated element per the schema's `items.type` — a string-only
119
+ array arg silently fails server-side Zod validation for numeric/boolean items. */
120
+ const itemType = (properties[name] as { items?: { type?: string } } | undefined)?.items
121
+ ?.type
122
+ const element = coerceScalar(value, itemType)
96
123
  const existing = args[name]
97
- args[name] = Array.isArray(existing) ? [...existing, value] : [value]
124
+ args[name] = Array.isArray(existing) ? [...existing, element] : [element]
98
125
  continue
99
126
  }
100
127
  args[name] = value
@@ -64,9 +64,11 @@ export function* tokenizeArgvFlags(
64
64
  : undefined
65
65
  const negated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
66
66
  const name = negated ? (negatedName as string) : literalName
67
- // Boolean props and inline `--name=value` consume no following token.
67
+ // Boolean props and inline `--name=value` consume no following token. Pass any
68
+ // inline value through (`--flag=false`) so the parser can honour the RHS instead
69
+ // of the boolean always resolving to true.
68
70
  if (properties[name]?.type === 'boolean') {
69
- yield { name, negated }
71
+ yield { name, negated, value: inlineValue }
70
72
  continue
71
73
  }
72
74
  if (inlineValue !== undefined) {
@@ -1,5 +1,6 @@
1
1
  // node:fs existsSync — cheap sync presence check, mirrors createPublicAssetServer
2
2
  import { existsSync } from 'node:fs'
3
+ import { resolve, sep } from 'node:path'
3
4
  import { Glob } from 'bun'
4
5
  import { mimeForExtension } from '../server/runtime/mimeForExtension.ts'
5
6
  import type { Assets } from '../server/runtime/types/Assets.ts'
@@ -81,8 +82,10 @@ export function createMcpResourceServer({
81
82
  return undefined
82
83
  }
83
84
  const relativePath = uri.slice(URI_PREFIX.length)
84
- // reject `..` traversal in the requested uri before any disk read
85
- if (relativePath.split('/').includes('..')) {
85
+ // reject `..` traversal on EITHER separator a backslash-joined `..\` escapes
86
+ // resourcesDir on Windows (its OS layer treats `\` as a separator regardless of
87
+ // how the JS string was built), so a `/`-only split lets it through.
88
+ if (relativePath.split(/[\\/]/).includes('..')) {
86
89
  return undefined
87
90
  }
88
91
  if (mcpResources) {
@@ -92,6 +95,14 @@ export function createMcpResourceServer({
92
95
  }
93
96
  return contentsFor(relativePath, Bun.gunzipSync(compressed))
94
97
  }
98
+ // Belt-and-braces for the on-disk branch: resolve the target and confirm it
99
+ // stays inside resourcesDir, catching any traversal the segment check missed
100
+ // (absolute paths, platform separator quirks).
101
+ const root = resolve(resourcesDir)
102
+ const target = resolve(root, relativePath)
103
+ if (target !== root && !target.startsWith(root + sep)) {
104
+ return undefined
105
+ }
95
106
  const file = Bun.file(`${resourcesDir}/${relativePath}`)
96
107
  if (!(await file.exists())) {
97
108
  return undefined
@@ -4,6 +4,12 @@ import { messageFromError } from '../shared/messageFromError.ts'
4
4
  import { responseErrorText } from '../shared/responseErrorText.ts'
5
5
  import { streamResponse } from '../shared/streamResponse.ts'
6
6
 
7
+ /* MCP `tools/call` is request/response, not a streaming transport, so a genuinely live
8
+ stream must be bounded: return what arrived within these limits (with a truncation note)
9
+ rather than buffering forever and never answering. */
10
+ const MAX_STREAM_FRAMES = 1000
11
+ const STREAM_TOOL_TIMEOUT_MS = 30_000
12
+
7
13
  // Frames a value as MCP text content — strings verbatim, everything else as JSON.
8
14
  // An undefined body (204 / empty) becomes '' — `JSON.stringify(undefined)` is the
9
15
  // value `undefined`, not a string, which would make an invalid text content block.
@@ -38,26 +44,48 @@ export async function toolResultFromResponse(response: Response): Promise<Record
38
44
 
39
45
  if (isStreamingResponse(response)) {
40
46
  const frames: unknown[] = []
47
+ // Set when WE stop the stream (cap/timeout), so the iterator's resulting end/throw
48
+ // reads as truncation, not a stream error.
49
+ let truncated: string | undefined
50
+ const timer = setTimeout(() => {
51
+ truncated = `stream truncated after ${STREAM_TOOL_TIMEOUT_MS}ms — MCP tools/call is request/response, not a live stream`
52
+ void response.body?.cancel()
53
+ }, STREAM_TOOL_TIMEOUT_MS)
41
54
  try {
42
55
  for await (const frame of streamResponse(response)) {
43
56
  frames.push(frame)
57
+ if (frames.length >= MAX_STREAM_FRAMES) {
58
+ truncated = `stream truncated at ${MAX_STREAM_FRAMES} frames`
59
+ void response.body?.cancel()
60
+ break
61
+ }
44
62
  }
45
63
  } catch (error) {
46
- return {
47
- content: [
48
- { type: 'text', text: frames.map(asText).join('\n') },
49
- {
50
- type: 'text',
51
- text: `stream error: ${messageFromError(error)}`,
52
- },
53
- ],
54
- structuredContent: { frames },
55
- isError: true,
64
+ /* A real mid-stream error (we didn't cancel) surfaces as isError; a throw caused
65
+ by our own cancel falls through to the truncated result below. */
66
+ if (truncated === undefined) {
67
+ clearTimeout(timer)
68
+ return {
69
+ content: [
70
+ { type: 'text', text: frames.map(asText).join('\n') },
71
+ { type: 'text', text: `stream error: ${messageFromError(error)}` },
72
+ ],
73
+ structuredContent: { frames },
74
+ isError: true,
75
+ }
56
76
  }
77
+ } finally {
78
+ clearTimeout(timer)
79
+ }
80
+ const content: { type: 'text'; text: string }[] = [
81
+ { type: 'text', text: frames.map(asText).join('\n') },
82
+ ]
83
+ if (truncated !== undefined) {
84
+ content.push({ type: 'text', text: truncated })
57
85
  }
58
86
  return {
59
- content: [{ type: 'text', text: frames.map(asText).join('\n') }],
60
- structuredContent: { frames },
87
+ content,
88
+ structuredContent: truncated === undefined ? { frames } : { frames, truncated },
61
89
  }
62
90
  }
63
91
 
@@ -137,7 +137,21 @@ export async function parseArgs(
137
137
  needs the schema threaded in here (or a coercing schema adapter at the rpc).
138
138
  */
139
139
  const bodyObject = (body ?? {}) as Record<string, unknown>
140
- const merged = { ...Object.fromEntries(url.searchParams), ...bodyObject }
140
+ /* Collect the query into an object, arraying repeated keys (`?tag=a&tag=b` → `['a','b']`)
141
+ rather than letting `Object.fromEntries` silently keep only the last — mirrors
142
+ splitFormData, so a repeated field reaches the schema as an array, not a dropped value. */
143
+ const queryObject: Record<string, unknown> = {}
144
+ for (const [key, value] of url.searchParams) {
145
+ const existing = queryObject[key]
146
+ if (!(key in queryObject)) {
147
+ queryObject[key] = value
148
+ } else if (Array.isArray(existing)) {
149
+ existing.push(value)
150
+ } else {
151
+ queryObject[key] = [existing, value]
152
+ }
153
+ }
154
+ const merged = { ...queryObject, ...bodyObject }
141
155
  if (Object.keys(merged).length === 0) {
142
156
  return undefined
143
157
  }
@@ -1,3 +1,4 @@
1
+ import { abideLog } from '../../shared/abideLog.ts'
1
2
  import { error } from '../error.ts'
2
3
 
3
4
  /*
@@ -39,7 +40,17 @@ export function runWithRpcTimeout(
39
40
  (late) => {
40
41
  void late.body?.cancel()
41
42
  },
42
- () => {},
43
+ (rejection) => {
44
+ /* The handler outlived its timeout and then rejected. A cooperative
45
+ abort (it honoured the composed signal) is expected and stays quiet;
46
+ any other error is a genuine handler bug and would otherwise leave
47
+ zero server-side trace behind the client's 504 — log it. */
48
+ const aborted =
49
+ (rejection as { name?: string } | undefined)?.name === 'AbortError'
50
+ if (!aborted) {
51
+ abideLog.error(rejection)
52
+ }
53
+ },
43
54
  )
44
55
  }
45
56
  }