@abide/abide 0.36.0 → 0.38.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +44 -0
- package/package.json +1 -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/buildInFlightSnapshot.ts +35 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
- package/src/lib/server/runtime/createServer.ts +10 -1
- package/src/lib/server/runtime/gzipResponse.ts +2 -1
- package/src/lib/server/runtime/inFlightRequests.ts +13 -0
- package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
- package/src/lib/server/runtime/streamFromIterator.ts +8 -0
- package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
- package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
- package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
- package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
- 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/shared/emitLogRecord.ts +18 -5
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/asOutlet.ts +29 -0
- package/src/lib/ui/compile/componentWrapperTag.ts +12 -20
- package/src/lib/ui/compile/generateBuild.ts +45 -36
- package/src/lib/ui/compile/generateSSR.ts +31 -21
- 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 -34
- package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
- package/src/lib/ui/createScope.ts +11 -0
- package/src/lib/ui/dom/appendSnippet.ts +60 -20
- package/src/lib/ui/dom/fillBefore.ts +10 -0
- package/src/lib/ui/dom/hydrate.ts +2 -1
- package/src/lib/ui/dom/mount.ts +2 -1
- package/src/lib/ui/dom/mountSlot.ts +7 -2
- package/src/lib/ui/dom/scopeLabel.ts +19 -0
- package/src/lib/ui/dom/skeleton.ts +16 -1
- package/src/lib/ui/installInspectorBridge.ts +138 -0
- package/src/lib/ui/navigate.ts +11 -3
- package/src/lib/ui/persist.ts +4 -1
- package/src/lib/ui/router.ts +77 -9
- package/src/lib/ui/runtime/createDoc.ts +20 -7
- package/src/lib/ui/runtime/historyEntries.ts +113 -0
- package/src/lib/ui/runtime/liveScopes.ts +15 -0
- package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
- package/src/lib/ui/seedStreamedResolution.ts +10 -1
- package/src/lib/ui/startClient.ts +6 -0
- package/src/lib/ui/types/Scope.ts +3 -0
- package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
package/AGENTS.md
CHANGED
|
@@ -100,7 +100,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
100
100
|
|
|
101
101
|
- `abide/server/AppModule` — the type of `src/app.ts`'s optional hooks: `init` (boot + cleanup), `handle` (middleware), `handleError`, `health` (merges into `/__abide/health`), `forwardHeaders`.
|
|
102
102
|
- `abide/server/agent` — `agent(engine, messages)`: run a model engine against the current request's MCP surface (caller auth forwarded into every tool call), yielding neutral `AgentFrame`s to wrap in `jsonl`/`sse`.
|
|
103
|
-
- `abide/server/InspectorContext` — the capability bundle (`app`, `loadSurface`, `cacheSnapshot`, `onRecord`) handed to `@abide/inspector` when `ABIDE_ENABLE_INSPECTOR=true`.
|
|
103
|
+
- `abide/server/InspectorContext` — the capability bundle (`app`, `loadSurface`, `cacheSnapshot`, `inFlightSnapshot`, `onRecord`) handed to `@abide/inspector` when `ABIDE_ENABLE_INSPECTOR=true`.
|
|
104
104
|
|
|
105
105
|
### Prompts — @documentation plumbing
|
|
106
106
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`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.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [`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))
|
|
12
|
+
|
|
13
|
+
- [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - harden rpc/runtime/socket edge cases ([`39581f4`](https://github.com/briancray/abide/commit/39581f46601b71ccad6c6112e37c55a03b069068))
|
|
14
|
+
|
|
15
|
+
- [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - propagate control-server worker start failure ([`3bb529b`](https://github.com/briancray/abide/commit/3bb529b00dc939c73212215cf6ae027b1078c053))
|
|
16
|
+
|
|
17
|
+
- [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - handle JSON-RPC notifications without replying ([`4315aa1`](https://github.com/briancray/abide/commit/4315aa11c7b834607e315e6403bc41c4c9bd6a8c))
|
|
18
|
+
|
|
19
|
+
- [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - drop unused error-page manifest ([`7f4f78a`](https://github.com/briancray/abide/commit/7f4f78af9829dca10a6062dd76b6e7fb9fddf739))
|
|
20
|
+
|
|
21
|
+
- [`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))
|
|
22
|
+
|
|
23
|
+
- [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - avoid stdin hang and reject blank numeric flags ([`f1d47ef`](https://github.com/briancray/abide/commit/f1d47ef731235d7a4c4ebb727a83bb642102aac0))
|
|
24
|
+
|
|
25
|
+
- [`2efd4ad`](https://github.com/briancray/abide/commit/2efd4ad1e572c46a0611d3d1d5cd1a02f80da629) - parse unquoted attributes and skip destructured binding rewrites ([`ff66d57`](https://github.com/briancray/abide/commit/ff66d57f5362af68f06d0e62dfc78d5f95322f99))
|
|
26
|
+
|
|
27
|
+
- [`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.
|
|
28
|
+
|
|
29
|
+
- [`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.
|
|
30
|
+
|
|
31
|
+
- [`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.
|
|
32
|
+
|
|
33
|
+
## 0.37.0
|
|
34
|
+
|
|
35
|
+
### Minor Changes
|
|
36
|
+
|
|
37
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - manual scroll restoration + hash-only navigation ([`1b71ef1`](https://github.com/briancray/abide/commit/1b71ef195186c0cee5a36527307f5f981833b546))
|
|
38
|
+
|
|
39
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - lead every tsv line with a wall-clock column ([`5784d47`](https://github.com/briancray/abide/commit/5784d47ddd2d52696fb9db111092c6f571270086))
|
|
40
|
+
|
|
41
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - always-`abide-` component wrapper tag ([`711de46`](https://github.com/briancray/abide/commit/711de46f8b5210cbbff5c73cd7aceda410d87f47))
|
|
42
|
+
|
|
43
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - inspector in-flight requests + prompts surface ([`823392d`](https://github.com/briancray/abide/commit/823392d0e644b3a84661293f86b28ca10d9a9de9))
|
|
44
|
+
|
|
45
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - inspector client scope/router bridge ([`c25b45e`](https://github.com/briancray/abide/commit/c25b45e778f6f17eeaf0875a4b7ecdd63fccb978))
|
|
46
|
+
|
|
3
47
|
## 0.36.0
|
|
4
48
|
|
|
5
49
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { inFlightRequests } from './inFlightRequests.ts'
|
|
2
|
+
import type { InspectorInFlightRequest } from './types/InspectorInFlightRequest.ts'
|
|
3
|
+
import type { InspectorInFlightSnapshot } from './types/InspectorInFlightSnapshot.ts'
|
|
4
|
+
import type { RequestStore } from './types/RequestStore.ts'
|
|
5
|
+
|
|
6
|
+
function projectStore(store: RequestStore, now: number): InspectorInFlightRequest {
|
|
7
|
+
return {
|
|
8
|
+
trace: store.trace.traceId,
|
|
9
|
+
method: store.req.method,
|
|
10
|
+
path: `${store.url.pathname}${store.url.search}`,
|
|
11
|
+
/* store.start is Bun.nanoseconds() at scope entry; elapsed is current at the read. */
|
|
12
|
+
elapsedMs: (now - store.start) / 1e6,
|
|
13
|
+
route: store.route,
|
|
14
|
+
params: store.params,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
Snapshots the request scopes currently executing their handler. Read at call
|
|
20
|
+
time so it reflects the in-flight set as it stands; returns empty when the
|
|
21
|
+
inspector hasn't installed the tracking Set or the server is idle. Sorted by
|
|
22
|
+
elapsed ascending — newest (least elapsed) first, so a long-running request
|
|
23
|
+
sinks to the bottom as its elapsed grows.
|
|
24
|
+
*/
|
|
25
|
+
export function buildInFlightSnapshot(): InspectorInFlightSnapshot {
|
|
26
|
+
const tracked = inFlightRequests.tracked
|
|
27
|
+
if (!tracked) {
|
|
28
|
+
return { requests: [] }
|
|
29
|
+
}
|
|
30
|
+
const now = Bun.nanoseconds()
|
|
31
|
+
const requests = Array.from(tracked, (store) => projectStore(store, now)).sort(
|
|
32
|
+
(left, right) => left.elapsedMs - right.elapsedMs,
|
|
33
|
+
)
|
|
34
|
+
return { requests }
|
|
35
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
|
|
2
|
+
import { promptRegistry } from '../prompts/promptRegistry.ts'
|
|
2
3
|
import { verbRegistry } from '../rpc/verbRegistry.ts'
|
|
3
4
|
import { socketOperations } from '../sockets/socketOperations.ts'
|
|
4
5
|
import { socketRegistry } from '../sockets/socketRegistry.ts'
|
|
@@ -33,5 +34,12 @@ export function buildInspectorSurface(): InspectorSurface {
|
|
|
33
34
|
restUrl: operation.restUrl,
|
|
34
35
|
})),
|
|
35
36
|
}))
|
|
36
|
-
|
|
37
|
+
/* jsonSchema is already JSON Schema (built from the frontmatter `arguments`
|
|
38
|
+
list by the resolver plugin), so it's passed through, not re-projected. */
|
|
39
|
+
const prompts = Array.from(promptRegistry.values()).map((entry) => ({
|
|
40
|
+
name: entry.prompt.name,
|
|
41
|
+
description: entry.prompt.description,
|
|
42
|
+
inputSchema: entry.jsonSchema,
|
|
43
|
+
}))
|
|
44
|
+
return { verbs, sockets, prompts }
|
|
37
45
|
}
|
|
@@ -186,6 +186,13 @@ export async function createServer({
|
|
|
186
186
|
// In dev, append the live-reload client to the shell so every rendered
|
|
187
187
|
// page reconnects to /__abide/dev and reloads after a restart.
|
|
188
188
|
const devShell = dev ? shell.replace('</body>', `${DEV_RELOAD_CLIENT_SCRIPT}</body>`) : shell
|
|
189
|
+
// When the inspector is enabled, flag the client so startClient installs the
|
|
190
|
+
// BroadcastChannel bridge that streams scope + router state to the inspector
|
|
191
|
+
// page. Independent of dev — the inspector can run on a build server too.
|
|
192
|
+
const inspectedShell =
|
|
193
|
+
process.env.ABIDE_ENABLE_INSPECTOR === 'true'
|
|
194
|
+
? devShell.replace('</body>', `<script>window.__abideInspect=true</script></body>`)
|
|
195
|
+
: devShell
|
|
189
196
|
/*
|
|
190
197
|
Mount base from APP_URL's pathname (e.g. https://foo.com/v2 → /v2). Install
|
|
191
198
|
the server-side resolver so url() prefixes SSR-generated links, and rewrite
|
|
@@ -197,7 +204,9 @@ export async function createServer({
|
|
|
197
204
|
setBaseResolver(() => base)
|
|
198
205
|
// Rebase the shell's rooted `/_app/` entry refs onto the mount base, matching
|
|
199
206
|
// either quote style so a custom app.html using single quotes still rewrites.
|
|
200
|
-
const activeShell = base
|
|
207
|
+
const activeShell = base
|
|
208
|
+
? inspectedShell.replace(/(["'])\/_app\//g, `$1${base}/_app/`)
|
|
209
|
+
: inspectedShell
|
|
201
210
|
/*
|
|
202
211
|
Boot-path disk scans run concurrently — they share no data, and under
|
|
203
212
|
`abide dev` the worker-swap window is bounded by exactly this boot.
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RequestStore } from './types/RequestStore.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The set of request scopes currently executing their handler, for the inspector's
|
|
5
|
+
in-flight view. `tracked` stays undefined until the inspector mounts and swaps in
|
|
6
|
+
a live Set — so the request path adds nothing (no allocation, no membership
|
|
7
|
+
churn) when the inspector is off. runWithRequestScope adds a store on scope entry
|
|
8
|
+
and removes it when the handler settles; buildInFlightSnapshot reads it. Mirrors
|
|
9
|
+
the on-demand, opt-in shape of the log/socket tap slots.
|
|
10
|
+
*/
|
|
11
|
+
export const inFlightRequests: { tracked: Set<RequestStore> | undefined } = {
|
|
12
|
+
tracked: undefined,
|
|
13
|
+
}
|
|
@@ -4,7 +4,9 @@ import { requestScopeSlot } from '../../shared/requestScopeSlot.ts'
|
|
|
4
4
|
import { socketTapSlot } from '../../shared/socketTapSlot.ts'
|
|
5
5
|
import type { LogRecord } from '../../shared/types/LogRecord.ts'
|
|
6
6
|
import { buildCacheSnapshot } from './buildCacheSnapshot.ts'
|
|
7
|
+
import { buildInFlightSnapshot } from './buildInFlightSnapshot.ts'
|
|
7
8
|
import { buildInspectorSurface } from './buildInspectorSurface.ts'
|
|
9
|
+
import { inFlightRequests } from './inFlightRequests.ts'
|
|
8
10
|
import { ensureRegistriesLoaded } from './registryManifests.ts'
|
|
9
11
|
import type { InspectorContext } from './types/InspectorContext.ts'
|
|
10
12
|
|
|
@@ -63,6 +65,10 @@ export async function maybeMountInspector(app: {
|
|
|
63
65
|
)
|
|
64
66
|
return undefined
|
|
65
67
|
}
|
|
68
|
+
/* Arm in-flight tracking now the inspector is mounting: runWithRequestScope
|
|
69
|
+
fills this Set per request, kept process-wide (the events tap teardown is
|
|
70
|
+
per-connection; the Set outlives any single feed). */
|
|
71
|
+
inFlightRequests.tracked = new Set()
|
|
66
72
|
const context: InspectorContext = {
|
|
67
73
|
app,
|
|
68
74
|
loadSurface: async () => {
|
|
@@ -70,6 +76,7 @@ export async function maybeMountInspector(app: {
|
|
|
70
76
|
return buildInspectorSurface()
|
|
71
77
|
},
|
|
72
78
|
cacheSnapshot: buildCacheSnapshot,
|
|
79
|
+
inFlightSnapshot: buildInFlightSnapshot,
|
|
73
80
|
/*
|
|
74
81
|
One process-wide tap each for the log and socket chokepoints; the
|
|
75
82
|
inspector owns both and fans out to its readers. Socket frames arrive
|
|
@@ -5,6 +5,7 @@ import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
|
5
5
|
import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
|
|
6
6
|
import { logClosingRecord } from '../../shared/logClosingRecord.ts'
|
|
7
7
|
import type { AppModule } from '../AppModule.ts'
|
|
8
|
+
import { inFlightRequests } from './inFlightRequests.ts'
|
|
8
9
|
import { internalErrorResponse } from './internalErrorResponse.ts'
|
|
9
10
|
import { requestContext } from './requestContext.ts'
|
|
10
11
|
import type { RequestStore } from './types/RequestStore.ts'
|
|
@@ -34,6 +35,10 @@ export function runWithRequestScope(
|
|
|
34
35
|
start: Bun.nanoseconds(),
|
|
35
36
|
}
|
|
36
37
|
return requestContext.run(store, async () => {
|
|
38
|
+
/* Register the live handler for the inspector's in-flight view; the Set is
|
|
39
|
+
absent (a no-op) unless the inspector mounted. Removed once the handler
|
|
40
|
+
settles — its compute is done even if a streamed body keeps piping. */
|
|
41
|
+
inFlightRequests.tracked?.add(store)
|
|
37
42
|
let response: Response
|
|
38
43
|
try {
|
|
39
44
|
response = await body(store)
|
|
@@ -44,6 +49,8 @@ export function runWithRequestScope(
|
|
|
44
49
|
abideLog.error(error)
|
|
45
50
|
response = internalErrorResponse(error)
|
|
46
51
|
}
|
|
52
|
+
} finally {
|
|
53
|
+
inFlightRequests.tracked?.delete(store)
|
|
47
54
|
}
|
|
48
55
|
/*
|
|
49
56
|
Flush any cookies the handler set onto the outgoing response. Only when
|
|
@@ -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 {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LogRecord } from '../../../shared/types/LogRecord.ts'
|
|
2
2
|
import type { InspectorCacheSnapshot } from './InspectorCacheSnapshot.ts'
|
|
3
|
+
import type { InspectorInFlightSnapshot } from './InspectorInFlightSnapshot.ts'
|
|
3
4
|
import type { InspectorSurface } from './InspectorSurface.ts'
|
|
4
5
|
|
|
5
6
|
/*
|
|
@@ -19,6 +20,9 @@ export type InspectorContext = {
|
|
|
19
20
|
/* Snapshots the persistent (global) cache store — current entries with their
|
|
20
21
|
lifecycle state, retention, tags, and a value preview. */
|
|
21
22
|
cacheSnapshot: () => InspectorCacheSnapshot
|
|
23
|
+
/* Snapshots the requests whose handler is executing right now — the live
|
|
24
|
+
counterpart to the closing records onRecord emits once a request settles. */
|
|
25
|
+
inFlightSnapshot: () => InspectorInFlightSnapshot
|
|
22
26
|
/*
|
|
23
27
|
Subscribes to the unified event stream: every emitted log record (the log's
|
|
24
28
|
structured form, trace context and all) plus published socket frames shaped
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
One in-flight request projected for the inspector — the serializable facts the
|
|
3
|
+
In-flight tab renders for a handler that's currently executing. The held Request
|
|
4
|
+
and CacheStore aren't included; what an operator wants is which request it is,
|
|
5
|
+
how long it's been running, and where it landed in routing.
|
|
6
|
+
*/
|
|
7
|
+
export type InspectorInFlightRequest = {
|
|
8
|
+
/* The W3C trace id, so the row links to the same trace the Logs/Traces tabs group by. */
|
|
9
|
+
trace: string
|
|
10
|
+
/* HTTP method of the inbound request. */
|
|
11
|
+
method: string
|
|
12
|
+
/* Request path with query string (app-space, as logged). */
|
|
13
|
+
path: string
|
|
14
|
+
/* Ms the handler has been running, measured from scope entry to snapshot time. */
|
|
15
|
+
elapsedMs: number
|
|
16
|
+
/* The matched page route pattern, once routing has landed; undefined on rpc/socket requests or before a match. */
|
|
17
|
+
route: string | undefined
|
|
18
|
+
/* The matched route's decoded params, when a page route landed. */
|
|
19
|
+
params: Record<string, string> | undefined
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InspectorInFlightRequest } from './InspectorInFlightRequest.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The requests executing their handler at snapshot time — a point-in-time read of
|
|
5
|
+
the in-flight set, the live counterpart to the closing records the Logs tab
|
|
6
|
+
shows once a request settles. Empty when the server is idle.
|
|
7
|
+
*/
|
|
8
|
+
export type InspectorInFlightSnapshot = {
|
|
9
|
+
requests: InspectorInFlightRequest[]
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
One declared MCP prompt projected for the inspector: the serializable facts the
|
|
3
|
+
Surface tab renders. The render closure isn't included — what an operator wants
|
|
4
|
+
is the prompt's name, what it's for, and the arguments it interpolates, the same
|
|
5
|
+
trio `prompts/list` advertises. Prompts are MCP-only, so there are no client
|
|
6
|
+
surface flags to show.
|
|
7
|
+
*/
|
|
8
|
+
export type InspectorPrompt = {
|
|
9
|
+
/* The prompt's name (stamped from its file path under src/mcp/prompts/). */
|
|
10
|
+
name: string
|
|
11
|
+
/* The frontmatter description, when the prompt declared one. */
|
|
12
|
+
description: string | undefined
|
|
13
|
+
/* The argument shape as JSON Schema (from the frontmatter `arguments` list); undefined when it takes none. */
|
|
14
|
+
inputSchema: Record<string, unknown> | undefined
|
|
15
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import type { InspectorPrompt } from './InspectorPrompt.ts'
|
|
1
2
|
import type { InspectorSocket } from './InspectorSocket.ts'
|
|
2
3
|
import type { InspectorVerb } from './InspectorVerb.ts'
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
The app's machine surface projected for the inspector — the static catalog the
|
|
6
|
-
UI renders. Built by reading the verb and
|
|
7
|
-
eager-loaded, so a freshly-booted server lists its whole surface, not
|
|
8
|
-
verbs hit so far. Pages stay out: they're the human surface, already
|
|
7
|
+
UI renders. Built by reading the verb, socket, and prompt registries after
|
|
8
|
+
they're eager-loaded, so a freshly-booted server lists its whole surface, not
|
|
9
|
+
just the verbs hit so far. Pages stay out: they're the human surface, already
|
|
10
|
+
navigable.
|
|
9
11
|
*/
|
|
10
12
|
export type InspectorSurface = {
|
|
11
13
|
verbs: InspectorVerb[]
|
|
12
14
|
sockets: InspectorSocket[]
|
|
15
|
+
prompts: InspectorPrompt[]
|
|
13
16
|
}
|