@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +38 -0
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +3 -17
- package/src/appEntry.ts +18 -8
- package/src/controlServerWorker.ts +8 -1
- package/src/lib/cli/parseArgvForRpc.ts +8 -3
- package/src/lib/mcp/createMcpServer.ts +8 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
- package/src/lib/mcp/toolResultFromResponse.ts +5 -0
- package/src/lib/server/rpc/parseArgs.ts +12 -1
- package/src/lib/server/runtime/acceptsGzip.ts +10 -1
- package/src/lib/server/runtime/gzipResponse.ts +2 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
- package/src/lib/server/runtime/streamFromIterator.ts +8 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cache.ts +6 -0
- package/src/lib/shared/createPushIterator.ts +7 -2
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/asOutlet.ts +30 -0
- package/src/lib/ui/compile/compileModule.ts +13 -9
- package/src/lib/ui/compile/generateBuild.ts +83 -72
- package/src/lib/ui/compile/generateSSR.ts +56 -37
- package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
- package/src/lib/ui/compile/isControlFlow.ts +3 -1
- package/src/lib/ui/compile/isTextLeaf.ts +1 -1
- package/src/lib/ui/compile/parseTemplate.ts +10 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
- package/src/lib/ui/compile/skeletonContext.ts +97 -35
- package/src/lib/ui/compile/skeletonable.ts +3 -2
- package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
- package/src/lib/ui/dom/appendSnippet.ts +60 -20
- package/src/lib/ui/dom/commentData.ts +14 -0
- package/src/lib/ui/dom/disposeRange.ts +21 -0
- package/src/lib/ui/dom/fillBefore.ts +10 -0
- package/src/lib/ui/dom/fillBoundary.ts +38 -0
- package/src/lib/ui/dom/fillRange.ts +30 -0
- package/src/lib/ui/dom/hydrate.ts +11 -21
- package/src/lib/ui/dom/mount.ts +16 -25
- package/src/lib/ui/dom/mountChild.ts +27 -14
- package/src/lib/ui/dom/mountRange.ts +45 -0
- package/src/lib/ui/dom/mountSlot.ts +7 -2
- package/src/lib/ui/dom/outlet.ts +62 -0
- package/src/lib/ui/dom/scopeLabel.ts +13 -7
- package/src/lib/ui/dom/skeleton.ts +11 -27
- package/src/lib/ui/dom/withScope.ts +33 -0
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/persist.ts +4 -1
- package/src/lib/ui/renderChain.ts +23 -15
- package/src/lib/ui/router.ts +78 -38
- package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
- package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
- package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
- package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
- package/src/lib/ui/runtime/createDoc.ts +20 -7
- package/src/lib/ui/runtime/hotReplace.ts +14 -10
- package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
- package/src/lib/ui/runtime/types/Route.ts +6 -5
- package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
- package/src/lib/ui/seedStreamedResolution.ts +10 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
- 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.
|
|
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|
|
|
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.
|
|
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: []
|
|
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 }>(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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 = `
|
|
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,
|
|
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
|