@abide/abide 0.30.0 → 0.31.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 (44) hide show
  1. package/AGENTS.md +4 -3
  2. package/CHANGELOG.md +26 -0
  3. package/package.json +2 -1
  4. package/src/lib/bundle/disconnected.abide +82 -82
  5. package/src/lib/cli/dispatchCommand.ts +3 -2
  6. package/src/lib/cli/resolveCliTarget.ts +2 -3
  7. package/src/lib/cli/runCli.ts +2 -3
  8. package/src/lib/cli/runSession.ts +2 -3
  9. package/src/lib/mcp/dispatchMcpRequest.ts +3 -2
  10. package/src/lib/mcp/mcpSurface.ts +2 -1
  11. package/src/lib/mcp/toolResultFromResponse.ts +2 -1
  12. package/src/lib/server/rpc/parseArgs.ts +1 -3
  13. package/src/lib/server/runtime/streamFromIterator.ts +3 -1
  14. package/src/lib/server/runtime/warnUnguardedMcp.ts +4 -3
  15. package/src/lib/server/sockets/createSocketDispatcher.ts +5 -7
  16. package/src/lib/shared/cacheEntryFromSnapshot.ts +2 -1
  17. package/src/lib/shared/contentTypeOf.ts +6 -0
  18. package/src/lib/shared/decodeResponse.ts +2 -1
  19. package/src/lib/shared/isCompileTarget.ts +7 -1
  20. package/src/lib/shared/isModuleNotFound.ts +3 -1
  21. package/src/lib/shared/isStreamingResponse.ts +2 -1
  22. package/src/lib/shared/messageFromError.ts +6 -0
  23. package/src/lib/shared/streamResponse.ts +2 -1
  24. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -0
  25. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  26. package/src/lib/ui/compile/VOID_TAGS.ts +4 -3
  27. package/src/lib/ui/compile/compileComponent.ts +12 -2
  28. package/src/lib/ui/compile/compileModule.ts +7 -4
  29. package/src/lib/ui/compile/compileSSR.ts +11 -2
  30. package/src/lib/ui/compile/compileShadow.ts +146 -49
  31. package/src/lib/ui/compile/createShadowLanguageService.ts +2 -1
  32. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  33. package/src/lib/ui/compile/desugarSignals.ts +41 -14
  34. package/src/lib/ui/compile/parseTemplate.ts +21 -26
  35. package/src/lib/ui/compile/prepareNestedScript.ts +4 -2
  36. package/src/lib/ui/derived.ts +25 -4
  37. package/src/lib/ui/dom/awaitBlock.ts +1 -24
  38. package/src/lib/ui/dom/discardBoundary.ts +27 -0
  39. package/src/lib/ui/dom/tryBlock.ts +7 -26
  40. package/src/lib/ui/installHotBridge.ts +2 -0
  41. package/src/lib/ui/linked.ts +34 -0
  42. package/src/lib/ui/router.ts +1 -1
  43. package/src/lib/ui/state.ts +9 -2
  44. package/template/src/ui/pages/page.abide +1 -1
package/AGENTS.md CHANGED
@@ -160,8 +160,9 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
160
160
  ## UI surface — `abide/ui/*` (client-only)
161
161
 
162
162
  ### Reactive primitives — `@readme plumbing` (in scope inside `.abide`, no import)
163
- - `abide/ui/state(initial)` → writable `State<T>` (`.value` getter/setter).
164
- - `abide/ui/derived(compute)` → lazy read-only computed (`.value`); re-derived on resume, never serialized.
163
+ - `abide/ui/state(initial, transform?)` → writable `State<T>` (`.value` getter/setter). Local truth. `transform(next, prev) => T` is a write-coercion gate: each `.value=` stores what it returns (`return prev` rejects via the `Object.is` no-op); construction `initial` is verbatim. Plain `state(x)` is a serializable doc slot; `state(x, transform)` is a non-serializing `.value` cell.
164
+ - `abide/ui/linked(seed, transform?)` → writable `State<T>` seeded reactively from upstream (Angular's `linkedSignal`): owns a local value, reseeds when the `seed` thunk's deps change, edits stay local. `transform` gates reseeds and writes alike. Thunk seed is required (it is the reactivity); seed captured by reference (clone in the thunk for isolation). Non-serializing — reseeds on resume.
165
+ - `abide/ui/derived(compute)` → lazy read-only computed (`.value`); re-derived on resume, never serialized. `derived(compute, set)` → writable lens: `.value` derives from upstream, assigning runs imperative `set(next)` to write *through* to the sources (no local store).
165
166
  - `abide/ui/effect(fn)` → run now, re-run on dependency change; `fn` may return teardown / be async; returns dispose.
166
167
  - `abide/ui/doc(initial?)` → reactive document: immutable tree addressed by path, every change a patch (the substrate under all reactivity / resumability / sync).
167
168
 
@@ -180,7 +181,7 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
180
181
  Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
181
182
  - **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
182
183
  - **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
183
- - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). `prop('name')` reads a typed page param.
184
+ - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
184
185
  - **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
185
186
  - **Reactivity:** write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers it to patches. Deep-field edits wake only that field.
186
187
  - **SSR:** byte-identical HTML string; `renderToStream` ships the shell then streams `<template await>` fragments out of order; `hydrate` adopts static structure in place (control-flow blocks + child components fall back to `mount`/re-render — known gap).
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # abide
2
2
 
3
+ ## 0.31.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`a9f7b3b`](https://github.com/briancray/abide/commit/a9f7b3b09f1db15fa784844a2672b1058dde2b25) - stop the type-check shadow merging a semicolon-less call into the next component ([`7d863d7`](https://github.com/briancray/abide/commit/7d863d7b1f2d4d973a5eafd94cf5002fdeb519c4))
8
+
9
+ ## 0.31.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - linked() reactive cell, writable derived, state write-transform ([`82301ea`](https://github.com/briancray/abide/commit/82301eabcda0866eec471f95048f74079ea93ab7))
14
+
15
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - sharper template type-checking in the shadow ([`f280619`](https://github.com/briancray/abide/commit/f280619778c23200a0f11cf8e6344edf2ea39884))
16
+
17
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - pass component on\*/bind/attach attributes through as props ([`f9a9202`](https://github.com/briancray/abide/commit/f9a9202d8555d7246fcc4338e833551f54ee8968))
18
+
19
+ ### Patch Changes
20
+
21
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - extract messageFromError, contentTypeOf, discardBoundary helpers ([`1bfb721`](https://github.com/briancray/abide/commit/1bfb721a8a8d3c8907bc9d4d0c6d97ddc265ed1e))
22
+
23
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - resolve the chained route on the page proxy ([`ed518c1`](https://github.com/briancray/abide/commit/ed518c1e8232c04496fe3fd04783f750164bc378))
24
+
25
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - share one component analysis across client and SSR back-ends ([`f330664`](https://github.com/briancray/abide/commit/f33066441625c08077b1b413603204ec2afe59eb))
26
+
27
+ - [`ea69bad`](https://github.com/briancray/abide/commit/ea69bad96d516dc6884d8a8c014b47c4864ebbe6) - format .abide <script> bodies via Biome ([`f4fc64e`](https://github.com/briancray/abide/commit/f4fc64ecb838a449dfb669af5fcaf40bcf7fa8ca))
28
+
3
29
  ## 0.30.0
4
30
 
5
31
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.30.0",
3
+ "version": "0.31.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",
@@ -73,6 +73,7 @@
73
73
  "./shared/url": "./src/lib/shared/url.ts",
74
74
  "./shared/createSubscriber": "./src/lib/shared/createSubscriber.ts",
75
75
  "./ui/state": "./src/lib/ui/state.ts",
76
+ "./ui/linked": "./src/lib/ui/linked.ts",
76
77
  "./ui/derived": "./src/lib/ui/derived.ts",
77
78
  "./ui/effect": "./src/lib/ui/effect.ts",
78
79
  "./ui/doc": "./src/lib/ui/doc.ts",
@@ -1,149 +1,149 @@
1
1
  <script>
2
- /*
2
+ /*
3
3
  Default bundle connect screen (abide-ui). The launcher serves this (logo baked in
4
4
  at build time, app title injected at runtime) instead of a blank window; override
5
5
  it by dropping a `src/bundle/disconnected.abide`. It connects to a remote server
6
6
  by URL and boots the embedded server, talking to the launcher's in-process control
7
7
  server: POST /connect and POST /start each reply with a `{ redirect }` to follow.
8
8
  */
9
- const LAST_URL_KEY = 'abide:last-server-url'
9
+ const LAST_URL_KEY = 'abide:last-server-url'
10
10
 
11
- const heading = globalThis.__ABIDE_TITLE__ ?? 'abide app'
12
- const logo = globalThis.__ABIDE_LOGO__
13
- const placeholder = 'https://example.com'
11
+ const heading = globalThis.__ABIDE_TITLE__ ?? 'abide app'
12
+ const logo = globalThis.__ABIDE_LOGO__
13
+ const placeholder = 'https://example.com'
14
14
 
15
- let url = state(localStorage.getItem(LAST_URL_KEY) ?? '')
16
- let error = state('')
15
+ let url = state(localStorage.getItem(LAST_URL_KEY) ?? '')
16
+ let error = state('')
17
17
 
18
- const launchAction = new URLSearchParams(location.search).get('action')
18
+ const launchAction = new URLSearchParams(location.search).get('action')
19
19
 
20
- /* Two phases so the screen never flashes before a redirect. */
21
- let phase = state(launchAction === 'start' ? 'splash' : 'connect')
20
+ /* Two phases so the screen never flashes before a redirect. */
21
+ let phase = state(launchAction === 'start' ? 'splash' : 'connect')
22
22
 
23
- let configFields = state([])
24
- let configValues = state({})
25
- let showConfig = state(false)
26
- let savingConfig = state(false)
23
+ let configFields = state([])
24
+ let configValues = state({})
25
+ let showConfig = state(false)
26
+ let savingConfig = state(false)
27
27
 
28
- /* Interpret the boot intent once on load. */
29
- effect(() => {
28
+ /* Interpret the boot intent once on load. */
29
+ effect(() => {
30
30
  if (launchAction === 'start') {
31
- void start()
32
- return
31
+ void start()
32
+ return
33
33
  }
34
34
  if (launchAction === 'lost') {
35
- error = 'The server stopped responding.'
36
- return
35
+ error = 'The server stopped responding.'
36
+ return
37
37
  }
38
38
  if (launchAction === 'disconnect') {
39
- void fetch('/__abide/disconnect').catch(() => {})
39
+ void fetch('/__abide/disconnect').catch(() => {})
40
40
  }
41
- })
41
+ })
42
42
 
43
- async function connect() {
43
+ async function connect() {
44
44
  const cleaned = url.trim()
45
45
  if (!cleaned) {
46
- return
46
+ return
47
47
  }
48
48
  await postAndFollow('/connect', { url: cleaned }, 'Could not connect', () => {
49
- localStorage.setItem(LAST_URL_KEY, cleaned)
49
+ localStorage.setItem(LAST_URL_KEY, cleaned)
50
50
  })
51
- }
51
+ }
52
52
 
53
- async function postAndFollow(path, body, failure, onSuccess) {
53
+ async function postAndFollow(path, body, failure, onSuccess) {
54
54
  error = ''
55
55
  phase = 'splash'
56
56
  try {
57
- const response = await fetch(path, {
58
- method: 'POST',
59
- ...(body === undefined
60
- ? {}
61
- : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
62
- })
63
- if (!response.ok) {
64
- const reply = await response.json()
65
- throw new Error(reply.error ?? `${path.slice(1)} failed (${response.status})`)
66
- }
67
- const { redirect } = await response.json()
68
- onSuccess?.()
69
- location.href = redirect
57
+ const response = await fetch(path, {
58
+ method: 'POST',
59
+ ...(body === undefined
60
+ ? {}
61
+ : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
62
+ })
63
+ if (!response.ok) {
64
+ const reply = await response.json()
65
+ throw new Error(reply.error ?? `${path.slice(1)} failed (${response.status})`)
66
+ }
67
+ const { redirect } = await response.json()
68
+ onSuccess?.()
69
+ location.href = redirect
70
70
  } catch (cause) {
71
- error = `${failure}: ${String(cause)}`
72
- phase = 'connect'
71
+ error = `${failure}: ${String(cause)}`
72
+ phase = 'connect'
73
73
  }
74
- }
74
+ }
75
75
 
76
- async function start() {
76
+ async function start() {
77
77
  error = ''
78
78
  const config = await loadConfig().catch(() => undefined)
79
79
  if (config) {
80
- configFields = config.fields
81
- configValues = { ...config.values }
82
- phase = 'connect'
83
- showConfig = true
84
- return
80
+ configFields = config.fields
81
+ configValues = { ...config.values }
82
+ phase = 'connect'
83
+ showConfig = true
84
+ return
85
85
  }
86
86
  await boot()
87
- }
87
+ }
88
88
 
89
- async function boot() {
89
+ async function boot() {
90
90
  await postAndFollow('/start', undefined, 'Could not start the server')
91
- }
91
+ }
92
92
 
93
- async function loadConfig() {
93
+ async function loadConfig() {
94
94
  const response = await fetch('/__abide/config')
95
95
  const { schema, values } = await response.json()
96
96
  if (!schema) {
97
- return undefined
97
+ return undefined
98
98
  }
99
99
  return { fields: fieldsFromSchema(schema), values: values ?? {} }
100
- }
100
+ }
101
101
 
102
- function fieldsFromSchema(schema) {
102
+ function fieldsFromSchema(schema) {
103
103
  const properties = schema.properties ?? {}
104
104
  const required = new Set(schema.required ?? [])
105
105
  return Object.entries(properties).map(([key, property]) => ({
106
- key,
107
- label: property.title ?? key,
108
- description: property.description,
109
- inputType: inputType(property),
110
- required: required.has(key),
106
+ key,
107
+ label: property.title ?? key,
108
+ description: property.description,
109
+ inputType: inputType(property),
110
+ required: required.has(key),
111
111
  }))
112
- }
112
+ }
113
113
 
114
- function inputType(property) {
114
+ function inputType(property) {
115
115
  if (property.type === 'boolean') {
116
- return 'checkbox'
116
+ return 'checkbox'
117
117
  }
118
118
  if (property.type === 'number' || property.type === 'integer') {
119
- return 'number'
119
+ return 'number'
120
120
  }
121
121
  if (property.format === 'password') {
122
- return 'password'
122
+ return 'password'
123
123
  }
124
124
  return 'text'
125
- }
125
+ }
126
126
 
127
- async function saveConfig() {
127
+ async function saveConfig() {
128
128
  error = ''
129
129
  savingConfig = true
130
130
  try {
131
- const response = await fetch('/__abide/config', {
132
- method: 'POST',
133
- headers: { 'content-type': 'application/json' },
134
- body: JSON.stringify({ values: configValues }),
135
- })
136
- if (!response.ok) {
137
- throw new Error(`save failed (${response.status})`)
138
- }
139
- showConfig = false
140
- savingConfig = false
141
- await boot()
131
+ const response = await fetch('/__abide/config', {
132
+ method: 'POST',
133
+ headers: { 'content-type': 'application/json' },
134
+ body: JSON.stringify({ values: configValues }),
135
+ })
136
+ if (!response.ok) {
137
+ throw new Error(`save failed (${response.status})`)
138
+ }
139
+ showConfig = false
140
+ savingConfig = false
141
+ await boot()
142
142
  } catch (cause) {
143
- error = `Could not save settings: ${String(cause)}`
144
- savingConfig = false
143
+ error = `Could not save settings: ${String(cause)}`
144
+ savingConfig = false
145
145
  }
146
- }
146
+ }
147
147
  </script>
148
148
 
149
149
  <template if={phase === 'splash'}>
@@ -1,5 +1,6 @@
1
1
  import { decodeResponse } from '../shared/decodeResponse.ts'
2
2
  import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
3
+ import { messageFromError } from '../shared/messageFromError.ts'
3
4
  import { responseErrorText } from '../shared/responseErrorText.ts'
4
5
  import { streamResponse } from '../shared/streamResponse.ts'
5
6
  import { createClient } from './createClient.ts'
@@ -41,7 +42,7 @@ export async function dispatchCommand({
41
42
  try {
42
43
  args = await parseArgvForRpc(argvTail, entry.jsonSchema)
43
44
  } catch (error) {
44
- console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
45
+ console.error(`${programName}: ${messageFromError(error)}`)
45
46
  return 1
46
47
  }
47
48
 
@@ -65,7 +66,7 @@ export async function dispatchCommand({
65
66
  printValue(await decodeResponse(response), true)
66
67
  return 0
67
68
  } catch (error) {
68
- console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
69
+ console.error(`${programName}: ${messageFromError(error)}`)
69
70
  return 1
70
71
  }
71
72
  }
@@ -1,6 +1,7 @@
1
1
  import { probeAbideServer } from '../bundle/probeAbideServer.ts'
2
2
  import { spawnEmbeddedServer } from '../bundle/spawnEmbeddedServer.ts'
3
3
  import { abideLog } from '../shared/abideLog.ts'
4
+ import { messageFromError } from '../shared/messageFromError.ts'
4
5
  import { readLastConnection } from '../shared/readLastConnection.ts'
5
6
  import type { CliTarget } from './types/CliTarget.ts'
6
7
 
@@ -28,9 +29,7 @@ export async function resolveCliTarget(programName: string): Promise<CliTarget |
28
29
  })
29
30
  return { url, child }
30
31
  } catch (error) {
31
- abideLog.warn(
32
- `could not start local instance: ${error instanceof Error ? error.message : String(error)}`,
33
- )
32
+ abideLog.warn(`could not start local instance: ${messageFromError(error)}`)
34
33
  return undefined
35
34
  }
36
35
  }
@@ -1,5 +1,6 @@
1
1
  import { clearLastConnection } from '../shared/clearLastConnection.ts'
2
2
  import { loadEnvFromDataDir } from '../shared/loadEnvFromDataDir.ts'
3
+ import { messageFromError } from '../shared/messageFromError.ts'
3
4
  import { connectToServer } from './connectToServer.ts'
4
5
  import { dispatchCommand } from './dispatchCommand.ts'
5
6
  import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
@@ -146,9 +147,7 @@ export async function runCli({
146
147
  try {
147
148
  target = await startLocalInstance(programName)
148
149
  } catch (error) {
149
- console.error(
150
- `${programName}: ${error instanceof Error ? error.message : String(error)}`,
151
- )
150
+ console.error(`${programName}: ${messageFromError(error)}`)
152
151
  return 1
153
152
  }
154
153
  return runSession({ programName, manifest, footer, target })
@@ -1,4 +1,5 @@
1
1
  import { clearLastConnection } from '../shared/clearLastConnection.ts'
2
+ import { messageFromError } from '../shared/messageFromError.ts'
2
3
  import { connectToServer } from './connectToServer.ts'
3
4
  import { dispatchCommand } from './dispatchCommand.ts'
4
5
  import { printSessionHelp } from './printSessionHelp.ts'
@@ -77,9 +78,7 @@ export async function runSession({
77
78
  try {
78
79
  await swap(await startLocalInstance(programName))
79
80
  } catch (error) {
80
- console.error(
81
- `could not start local instance: ${error instanceof Error ? error.message : String(error)}`,
82
- )
81
+ console.error(`could not start local instance: ${messageFromError(error)}`)
83
82
  }
84
83
  } else if (head === '/disconnect') {
85
84
  await clearLastConnection(programName)
@@ -1,4 +1,5 @@
1
1
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
2
+ import { messageFromError } from '../shared/messageFromError.ts'
2
3
  import { getMcpResourceServer } from './mcpResourceServerSlot.ts'
3
4
  import { buildPrompts, buildTools, callTool, renderPrompt } from './mcpSurface.ts'
4
5
  import type { JsonRpcRequest } from './types/JsonRpcRequest.ts'
@@ -68,7 +69,7 @@ export async function dispatchMcpRequest(
68
69
  try {
69
70
  await opts.authorize(request)
70
71
  } catch (error) {
71
- const message = error instanceof Error ? error.message : String(error)
72
+ const message = messageFromError(error)
72
73
  return jsonRpcError(id, -32001, message)
73
74
  }
74
75
  }
@@ -132,7 +133,7 @@ export async function dispatchMcpRequest(
132
133
  return jsonRpcError(id, -32601, `Method not found: ${envelope.method}`)
133
134
  }
134
135
  } catch (error) {
135
- const message = error instanceof Error ? error.message : String(error)
136
+ const message = messageFromError(error)
136
137
  return jsonRpcError(id, -32603, message)
137
138
  }
138
139
  }
@@ -8,6 +8,7 @@ import { abideLog } from '../shared/abideLog.ts'
8
8
  import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
9
9
  import { forwardHeaders } from '../shared/forwardHeaders.ts'
10
10
  import { jsonSchemaForSchema } from '../shared/jsonSchemaForSchema.ts'
11
+ import { messageFromError } from '../shared/messageFromError.ts'
11
12
  import { annotationsForMethod } from './annotationsForMethod.ts'
12
13
  import { getMcpResourceServer } from './mcpResourceServerSlot.ts'
13
14
  import { toolResultFromResponse } from './toolResultFromResponse.ts'
@@ -193,7 +194,7 @@ function callSocketTool(
193
194
  // publish() validates the payload against the socket schema and throws on failure.
194
195
  entry.socket.publish(args)
195
196
  } catch (error) {
196
- return textResult(error instanceof Error ? error.message : String(error), true)
197
+ return textResult(messageFromError(error), true)
197
198
  }
198
199
  return textResult('ok')
199
200
  }
@@ -1,5 +1,6 @@
1
1
  import { decodeResponse } from '../shared/decodeResponse.ts'
2
2
  import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
3
+ import { messageFromError } from '../shared/messageFromError.ts'
3
4
  import { responseErrorText } from '../shared/responseErrorText.ts'
4
5
  import { streamResponse } from '../shared/streamResponse.ts'
5
6
 
@@ -42,7 +43,7 @@ export async function toolResultFromResponse(response: Response): Promise<Record
42
43
  { type: 'text', text: frames.map(asText).join('\n') },
43
44
  {
44
45
  type: 'text',
45
- text: `stream error: ${error instanceof Error ? error.message : String(error)}`,
46
+ text: `stream error: ${messageFromError(error)}`,
46
47
  },
47
48
  ],
48
49
  structuredContent: { frames },
@@ -112,9 +112,7 @@ export async function parseArgs(
112
112
  }
113
113
 
114
114
  if (!url) {
115
- if (body === undefined) {
116
- return undefined
117
- }
115
+ /* `body` is undefined or a plain object here — return it as-is. */
118
116
  return body
119
117
  }
120
118
 
@@ -1,3 +1,5 @@
1
+ import { messageFromError } from '../../shared/messageFromError.ts'
2
+
1
3
  /*
2
4
  Shared body builder for the streaming respond helpers (`jsonl`, `sse`).
3
5
  Both flow the same shape — pull from an AsyncIterator, encode each frame
@@ -62,7 +64,7 @@ export function streamFromIterator<T>(
62
64
  }
63
65
  controller.enqueue(textEncoder.encode(encoder.encodeFrame(next.value)))
64
66
  } catch (error) {
65
- const message = error instanceof Error ? error.message : String(error)
67
+ const message = messageFromError(error)
66
68
  controller.enqueue(textEncoder.encode(encoder.encodeError(message)))
67
69
  stopKeepalive()
68
70
  controller.close()
@@ -18,9 +18,10 @@ export async function warnUnguardedMcp(): Promise<void> {
18
18
  } catch {
19
19
  return
20
20
  }
21
- const exposed =
22
- Array.from(verbRegistry.values()).filter((entry) => entry.clients.mcp).length +
23
- Array.from(socketRegistry.values()).filter((entry) => entry.clients.mcp).length
21
+ const isMcpExposed = (entry: { clients: { mcp: boolean } }): boolean => entry.clients.mcp
22
+ const exposed = [...verbRegistry.values(), ...socketRegistry.values()].filter(
23
+ isMcpExposed,
24
+ ).length
24
25
  if (exposed === 0) {
25
26
  return
26
27
  }
@@ -1,6 +1,7 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
2
  import { abideLog } from '../../shared/abideLog.ts'
3
3
  import { memoizeByKey } from '../../shared/memoizeByKey.ts'
4
+ import { messageFromError } from '../../shared/messageFromError.ts'
4
5
  import { error } from '../error.ts'
5
6
  import { json } from '../json.ts'
6
7
  import { sse } from '../sse.ts'
@@ -86,7 +87,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
86
87
  abideLog.error(loadError)
87
88
  return {
88
89
  failure: 'load-failed',
89
- message: loadError instanceof Error ? loadError.message : String(loadError),
90
+ message: messageFromError(loadError),
90
91
  }
91
92
  }
92
93
  const entry = lookupSocket(name)
@@ -214,8 +215,8 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
214
215
  */
215
216
  try {
216
217
  entry.socket.publish(frame.message)
217
- } catch (error) {
218
- abideLog.error(error)
218
+ } catch (publishError) {
219
+ abideLog.error(publishError)
219
220
  }
220
221
  /*
221
222
  ws parameter retained for future per-ws auth context (cookies on
@@ -276,10 +277,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
276
277
  // publish() validates against the socket schema and throws on a bad payload.
277
278
  entry.socket.publish(message)
278
279
  } catch (publishError) {
279
- return error(
280
- 422,
281
- publishError instanceof Error ? publishError.message : String(publishError),
282
- )
280
+ return error(422, messageFromError(publishError))
283
281
  }
284
282
  return json({ ok: true })
285
283
  }
@@ -1,3 +1,4 @@
1
+ import { contentTypeOf } from './contentTypeOf.ts'
1
2
  import type { CacheEntry } from './types/CacheEntry.ts'
2
3
  import type { CacheSnapshotEntry } from './types/CacheSnapshotEntry.ts'
3
4
 
@@ -39,7 +40,7 @@ function warmValueFromSnapshot(status: number, headers: Headers, body: string):
39
40
  if (status === 204 || status < 200 || status >= 300) {
40
41
  return undefined
41
42
  }
42
- const contentType = (headers.get('content-type') ?? '').toLowerCase()
43
+ const contentType = contentTypeOf(headers)
43
44
  if (contentType.includes('json')) {
44
45
  /*
45
46
  `.includes('json')` also matches streaming/non-JSON types like
@@ -0,0 +1,6 @@
1
+ /* The `content-type` header, lowercased, or '' when absent — the canonical shape
2
+ every content-type comparison in the codebase reads (case-insensitive match,
3
+ no undefined to guard). */
4
+ export function contentTypeOf(headers: Headers): string {
5
+ return (headers.get('content-type') ?? '').toLowerCase()
6
+ }
@@ -1,3 +1,4 @@
1
+ import { contentTypeOf } from './contentTypeOf.ts'
1
2
  import { HttpError } from './HttpError.ts'
2
3
  import { isStreamingResponse } from './isStreamingResponse.ts'
3
4
 
@@ -31,7 +32,7 @@ export async function decodeResponse(response: Response): Promise<unknown> {
31
32
  if (response.status === 204) {
32
33
  return undefined
33
34
  }
34
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
35
+ const contentType = contentTypeOf(response.headers)
35
36
  if (isStreamingResponse(response)) {
36
37
  throw new Error(
37
38
  `[abide] response at ${response.url} is a stream (${contentType}) — use tail(fn.stream(args)) for a reactive view, or fn.stream(args) for per-call iteration, instead of awaiting the bare call or cache()`,
@@ -1,6 +1,12 @@
1
1
  import type { CompileTarget } from './types/CompileTarget.ts'
2
2
 
3
- /* The canonical cross-compile targets, as a runtime set for validation. */
3
+ /* The canonical cross-compile targets, as a runtime set for validation. Kept
4
+ separate from `detectTarget`'s `HOST_TO_TARGET` on purpose, though the two lists
5
+ coincide today: this is "every target `--target` accepts" (all cross-targets),
6
+ while that map is "targets auto-detectable from a host". A future Bun target that
7
+ no host maps to (a new arch/libc we cross-compile to but don't run on) belongs
8
+ here and not there — deriving one from the other would couple distinct concerns.
9
+ Both stay aligned to `CompileTarget`, the single source of truth. */
4
10
  const COMPILE_TARGETS: readonly CompileTarget[] = [
5
11
  'bun-darwin-arm64',
6
12
  'bun-darwin-x64',
@@ -1,3 +1,5 @@
1
+ import { messageFromError } from './messageFromError.ts'
2
+
1
3
  /*
2
4
  True when an error from a dynamic `import(...)` is a module-resolution
3
5
  failure (the package isn't installed) rather than an error thrown while
@@ -11,6 +13,6 @@ export function isModuleNotFound(error: unknown): boolean {
11
13
  if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
12
14
  return true
13
15
  }
14
- const message = error instanceof Error ? error.message : String(error)
16
+ const message = messageFromError(error)
15
17
  return /cannot find (module|package)|failed to resolve/i.test(message)
16
18
  }
@@ -1,3 +1,4 @@
1
+ import { contentTypeOf } from './contentTypeOf.ts'
1
2
  import { STREAMING_CONTENT_TYPES } from './STREAMING_CONTENT_TYPES.ts'
2
3
 
3
4
  /*
@@ -6,6 +7,6 @@ Content-Type, so callers drain it frame-by-frame instead of buffering.
6
7
  Shared by the CLI print path and the MCP tool dispatcher.
7
8
  */
8
9
  export function isStreamingResponse(response: Response): boolean {
9
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
10
+ const contentType = contentTypeOf(response.headers)
10
11
  return STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))
11
12
  }
@@ -0,0 +1,6 @@
1
+ /* A thrown value's human message: an Error's `.message`, otherwise the value
2
+ coerced to a string. The one place the "how a non-Error surfaces to users"
3
+ rule lives, instead of inlining `x instanceof Error ? x.message : String(x)`. */
4
+ export function messageFromError(error: unknown): string {
5
+ return error instanceof Error ? error.message : String(error)
6
+ }
@@ -1,3 +1,4 @@
1
+ import { contentTypeOf } from './contentTypeOf.ts'
1
2
  import { decodeResponse } from './decodeResponse.ts'
2
3
  import { HttpError } from './HttpError.ts'
3
4
  import { jsonlErrorFrame } from './jsonlErrorFrame.ts'
@@ -29,7 +30,7 @@ export function streamResponse<T>(response: Response): AsyncIterable<T> {
29
30
  if (!response.ok) {
30
31
  return errorIterable<T>(new HttpError(response))
31
32
  }
32
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
33
+ const contentType = contentTypeOf(response.headers)
33
34
  if (contentType.startsWith('text/event-stream')) {
34
35
  return parseSse<T>(response)
35
36
  }
@@ -0,0 +1,7 @@
1
+ /* The callee names the `.abide` compiler recognises as reactive declarations
2
+ (`let x = state(...)`, `linked(...)`, `derived(...)`, `prop(...)`): the shared
3
+ "is this a reactive binding" allowlist read by the desugarer, the nested-script
4
+ scoper, and the type-checking shadow. How each lowers — a serializable doc slot
5
+ vs a `.value` cell — is decided per-site; this is only the membership set, so a
6
+ new primitive is a single edit here. */
7
+ export const REACTIVE_CALLEES: ReadonlySet<string> = new Set(['state', 'linked', 'derived', 'prop'])