@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.
Files changed (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/package.json +1 -1
  4. package/src/abideResolverPlugin.ts +3 -17
  5. package/src/appEntry.ts +18 -8
  6. package/src/controlServerWorker.ts +8 -1
  7. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  8. package/src/lib/mcp/createMcpServer.ts +8 -0
  9. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  10. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  11. package/src/lib/server/rpc/parseArgs.ts +12 -1
  12. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  13. package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
  14. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  15. package/src/lib/server/runtime/createServer.ts +10 -1
  16. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  17. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  18. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  19. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  20. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  21. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  22. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  23. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  24. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  25. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  26. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  27. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  28. package/src/lib/server/sockets/defineSocket.ts +6 -1
  29. package/src/lib/shared/cache.ts +6 -0
  30. package/src/lib/shared/createPushIterator.ts +7 -2
  31. package/src/lib/shared/emitLogRecord.ts +18 -5
  32. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
  33. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  34. package/src/lib/ui/compile/asOutlet.ts +29 -0
  35. package/src/lib/ui/compile/componentWrapperTag.ts +12 -20
  36. package/src/lib/ui/compile/generateBuild.ts +45 -36
  37. package/src/lib/ui/compile/generateSSR.ts +31 -21
  38. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  39. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  40. package/src/lib/ui/compile/skeletonContext.ts +97 -34
  41. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  42. package/src/lib/ui/createScope.ts +11 -0
  43. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  44. package/src/lib/ui/dom/fillBefore.ts +10 -0
  45. package/src/lib/ui/dom/hydrate.ts +2 -1
  46. package/src/lib/ui/dom/mount.ts +2 -1
  47. package/src/lib/ui/dom/mountSlot.ts +7 -2
  48. package/src/lib/ui/dom/scopeLabel.ts +19 -0
  49. package/src/lib/ui/dom/skeleton.ts +16 -1
  50. package/src/lib/ui/installInspectorBridge.ts +138 -0
  51. package/src/lib/ui/navigate.ts +11 -3
  52. package/src/lib/ui/persist.ts +4 -1
  53. package/src/lib/ui/router.ts +77 -9
  54. package/src/lib/ui/runtime/createDoc.ts +20 -7
  55. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  56. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  57. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  58. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  59. package/src/lib/ui/startClient.ts +6 -0
  60. package/src/lib/ui/types/Scope.ts +3 -0
  61. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -201,7 +201,7 @@ export function abideResolverPlugin({
201
201
  setup(build) {
202
202
  build.onResolve(
203
203
  {
204
- filter: /\/_virtual\/(rpc|sockets|prompts|pages|layouts|errors|app|config|mcp-resources|mcp|assets|public-assets|shell|app-info|cli-manifest|cli-name|cli-chrome|bundle-window|bundle-disconnected-component|bundle-disconnected)\.ts$/,
204
+ filter: /\/_virtual\/(rpc|sockets|prompts|pages|layouts|app|config|mcp-resources|mcp|assets|public-assets|shell|app-info|cli-manifest|cli-name|cli-chrome|bundle-window|bundle-disconnected-component|bundle-disconnected)\.ts$/,
205
205
  },
206
206
  (args) => {
207
207
  const name = fileStem(args.path)
@@ -434,17 +434,6 @@ ${optionLines}
434
434
  })
435
435
  }
436
436
 
437
- if (args.path === 'abide:errors') {
438
- const { errorFiles } = await scanPagesOnce()
439
- return manifestModule({
440
- files: errorFiles,
441
- keyForFile: pageUrlForFile,
442
- importDir: pagesDir,
443
- exportName: 'errors',
444
- label: 'error pages',
445
- })
446
- }
447
-
448
437
  if (args.path === 'abide:app') {
449
438
  const userApp = `${cwd}/src/app.ts`
450
439
  const hasAppModule = await Bun.file(userApp).exists()
@@ -788,7 +777,6 @@ ${encoded.map((entry) => entry.line).join('\n')}
788
777
  type PagesScan = {
789
778
  pageFiles: string[]
790
779
  layoutFiles: string[]
791
- errorFiles: string[]
792
780
  }
793
781
 
794
782
  /*
@@ -796,19 +784,17 @@ Walks src/ui/pages once and classifies each `.abide` leaf by filename: a
796
784
  `page.abide` is a route (its URL is the folder path), a `layout.abide` is a
797
785
  layout that wraps every page at or below its folder (keyed by the same folder
798
786
  URL). Any other `.abide` file (a shared component) is ignored here — free to
799
- live anywhere and be imported relatively. errorFiles stay empty — there is no
800
- framework error resolution.
787
+ live anywhere and be imported relatively.
801
788
  */
802
789
  async function scanPages(pagesDir: string): Promise<PagesScan> {
803
790
  if (!existsSync(pagesDir)) {
804
- return { pageFiles: [], layoutFiles: [], errorFiles: [] }
791
+ return { pageFiles: [], layoutFiles: [] }
805
792
  }
806
793
  const allFiles = await Array.fromAsync(new Glob('**/*.abide').scan({ cwd: pagesDir }))
807
794
  const leafIs = (name: string) => (file: string) => (file.split('/').pop() ?? '') === name
808
795
  return {
809
796
  pageFiles: allFiles.filter(leafIs('page.abide')),
810
797
  layoutFiles: allFiles.filter(leafIs('layout.abide')),
811
- errorFiles: [],
812
798
  }
813
799
  }
814
800
 
package/src/appEntry.ts CHANGED
@@ -80,14 +80,24 @@ the connect screen. Resolving before this means a successful resume opens straig
80
80
  at the app — no connect-screen flash — at the cost of a brief window-less moment
81
81
  while it boots/probes (the OS shows the launching dock icon meanwhile).
82
82
  */
83
- const { origin, target } = await new Promise<{ origin: string; target: string }>((resolve) => {
84
- worker.addEventListener('message', (event: MessageEvent) => {
85
- const data = event.data as { type: string; origin?: string; target?: string }
86
- if (data.type === 'ready' && data.origin && data.target) {
87
- resolve({ origin: data.origin, target: data.target })
88
- }
89
- })
90
- })
83
+ const { origin, target } = await new Promise<{ origin: string; target: string }>(
84
+ (resolve, reject) => {
85
+ worker.addEventListener('message', (event: MessageEvent) => {
86
+ const data = event.data as {
87
+ type: string
88
+ origin?: string
89
+ target?: string
90
+ error?: string
91
+ }
92
+ if (data.type === 'ready' && data.origin && data.target) {
93
+ resolve({ origin: data.origin, target: data.target })
94
+ } else if (data.type === 'error') {
95
+ /* The worker's start() rejected — fail fast instead of hanging on `ready`. */
96
+ reject(new Error(data.error ?? 'control server worker failed to start'))
97
+ }
98
+ })
99
+ },
100
+ )
91
101
 
92
102
  /*
93
103
  The built-in File menu (Start / Disconnect), placed before Edit. Each item is a
@@ -12,6 +12,7 @@ import { binaryDirEnvPath } from './lib/shared/binaryDirEnvPath.ts'
12
12
  import { clearLastConnection } from './lib/shared/clearLastConnection.ts'
13
13
  import { createLivenessWatch } from './lib/shared/createLivenessWatch.ts'
14
14
  import { dataDirEnvPath } from './lib/shared/dataDirEnvPath.ts'
15
+ import { messageFromError } from './lib/shared/messageFromError.ts'
15
16
  import { readEnvFile } from './lib/shared/readEnvFile.ts'
16
17
  import { readLastConnection } from './lib/shared/readLastConnection.ts'
17
18
  import { serializeEnv } from './lib/shared/serializeEnv.ts'
@@ -417,7 +418,13 @@ self.addEventListener('message', (event: MessageEvent) => {
417
418
  | { type: 'window'; handle: number }
418
419
  | { type: 'shutdown' }
419
420
  if (data.type === 'init') {
420
- void start(data.init)
421
+ /*
422
+ A rejected start() would leave the launcher's `await {type:'ready'}`
423
+ hanging forever — surface the failure so it can fail fast instead.
424
+ */
425
+ start(data.init).catch((startError) => {
426
+ self.postMessage({ type: 'error', error: messageFromError(startError) })
427
+ })
421
428
  } else if (data.type === 'window') {
422
429
  webviewHandle = data.handle
423
430
  } else if (data.type === 'shutdown') {
@@ -26,9 +26,12 @@ export async function parseArgvForRpc(
26
26
  /*
27
27
  Stdin override: if a JSON object is piped in, treat it as the
28
28
  starting args bag. `Bun.stdin.text()` reads the whole pipe; if
29
- nothing was piped, the read resolves with an empty string.
29
+ nothing was piped, the read resolves with an empty string. Skip the
30
+ read entirely when argv already supplied args — a non-TTY pipe that
31
+ never sends EOF would otherwise hang the call forever even though the
32
+ args are fully on the command line.
30
33
  */
31
- if (!process.stdin.isTTY) {
34
+ if (!process.stdin.isTTY && argv.length === 0) {
32
35
  const text = await Bun.stdin.text()
33
36
  if (text.trim()) {
34
37
  try {
@@ -81,7 +84,9 @@ export async function parseArgvForRpc(
81
84
  throw new Error(`--${name} requires a value`)
82
85
  }
83
86
  if (propType === 'number' || propType === 'integer') {
84
- const n = Number(value)
87
+ // Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
88
+ // not NaN, so the NaN guard alone would silently coerce it to zero.
89
+ const n = value.trim() === '' ? Number.NaN : Number(value)
85
90
  if (Number.isNaN(n)) {
86
91
  throw new Error(`--${name} expects a number, got ${value}`)
87
92
  }
@@ -42,6 +42,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
42
42
  })
43
43
  }
44
44
  const envelope = await dispatchMcpRequest(request, opts, serverInfo)
45
+ /* A notification yields no envelope — JSON-RPC forbids replying, so
46
+ ack the receipt with an empty 202 instead of a response body. */
47
+ if (envelope === undefined) {
48
+ return new Response(undefined, {
49
+ status: 202,
50
+ headers: { 'Cache-Control': NO_STORE },
51
+ })
52
+ }
45
53
  return Response.json(envelope, { headers: { 'Cache-Control': NO_STORE } })
46
54
  },
47
55
  }
@@ -53,18 +53,36 @@ export async function dispatchMcpRequest(
53
53
  request: Request,
54
54
  opts: McpServerOptions,
55
55
  serverInfo: { name: string; version: string },
56
- ): Promise<JsonRpcResponse> {
56
+ ): Promise<JsonRpcResponse | undefined> {
57
57
  let envelope: JsonRpcRequest
58
58
  try {
59
59
  envelope = (await request.clone().json()) as JsonRpcRequest
60
60
  } catch {
61
61
  return jsonRpcError(null, -32700, 'Parse error')
62
62
  }
63
+ /* A notification has no `id` field at all (absent — `id: 0`/`id: null` are
64
+ valid ids). JSON-RPC 2.0 forbids replying to a notification, so dispatch it
65
+ for its side effect and return undefined to signal "no reply". */
66
+ const isNotification = !('id' in envelope)
63
67
  const id = envelope.id ?? null
64
68
  if (envelope.jsonrpc !== '2.0' || typeof envelope.method !== 'string') {
65
- return jsonRpcError(id, -32600, 'Invalid Request')
69
+ return isNotification ? undefined : jsonRpcError(id, -32600, 'Invalid Request')
66
70
  }
67
71
 
72
+ // Run the method for its side effect, then drop the reply for a notification.
73
+ const response = await dispatchMethod(envelope, id, request, opts, serverInfo)
74
+ return isNotification ? undefined : response
75
+ }
76
+
77
+ /* Resolves a parsed, validated request to its JSON-RPC response. The caller
78
+ discards this for notifications; non-notifications get it verbatim. */
79
+ async function dispatchMethod(
80
+ envelope: JsonRpcRequest,
81
+ id: string | number | null,
82
+ request: Request,
83
+ opts: McpServerOptions,
84
+ serverInfo: { name: string; version: string },
85
+ ): Promise<JsonRpcResponse> {
68
86
  if (opts.authorize) {
69
87
  try {
70
88
  await opts.authorize(request)
@@ -5,7 +5,12 @@ import { responseErrorText } from '../shared/responseErrorText.ts'
5
5
  import { streamResponse } from '../shared/streamResponse.ts'
6
6
 
7
7
  // Frames a value as MCP text content — strings verbatim, everything else as JSON.
8
+ // An undefined body (204 / empty) becomes '' — `JSON.stringify(undefined)` is the
9
+ // value `undefined`, not a string, which would make an invalid text content block.
8
10
  function asText(value: unknown): string {
11
+ if (value === undefined) {
12
+ return ''
13
+ }
9
14
  return typeof value === 'string' ? value : JSON.stringify(value)
10
15
  }
11
16
 
@@ -1,4 +1,5 @@
1
1
  import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
2
+ import { contentTypeOf } from '../../shared/contentTypeOf.ts'
2
3
  import { HttpError } from '../../shared/HttpError.ts'
3
4
  import type { HttpVerb } from '../../shared/types/HttpVerb.ts'
4
5
  import { error } from '../error.ts'
@@ -86,7 +87,7 @@ export async function parseArgs(
86
87
  store.req = bounded
87
88
  }
88
89
  }
89
- const contentType = (bounded.headers.get('content-type') ?? '').toLowerCase()
90
+ const contentType = contentTypeOf(bounded.headers)
90
91
  try {
91
92
  if (contentType.includes('application/json')) {
92
93
  const text = await bounded.text()
@@ -116,6 +117,16 @@ export async function parseArgs(
116
117
  return body
117
118
  }
118
119
 
120
+ /*
121
+ TODO(query-coercion): query params arrive as strings, so a numeric/boolean
122
+ field reaches schema validation as `'2'`/`'true'`. Deferred deliberately:
123
+ parseArgs has no access to the verb's inputSchema (it lives in defineVerb),
124
+ and Standard Schema exposes no type structure to drive type-aware coercion.
125
+ Blind value-shape coercion is unsafe — it would corrupt legitimately
126
+ string-typed fields whose value looks numeric/boolean (ids, zip codes,
127
+ version strings like '1.0'), silently breaking GET validation. A correct fix
128
+ needs the schema threaded in here (or a coercing schema adapter at the verb).
129
+ */
119
130
  const bodyObject = (body ?? {}) as Record<string, unknown>
120
131
  const merged = { ...Object.fromEntries(url.searchParams), ...bodyObject }
121
132
  if (Object.keys(merged).length === 0) {
@@ -20,5 +20,14 @@ export function acceptsGzip(req: Request): boolean {
20
20
  return false
21
21
  }
22
22
  const quality = directive.match(/;\s*q=([\d.]+)/)
23
- return quality === null || Number(quality[1]) > 0
23
+ if (quality === null) {
24
+ return true
25
+ }
26
+ /*
27
+ A malformed q (e.g. `q=.` → NaN) is not an explicit refusal — fall back to
28
+ the default-accept path, matching how an absent q is treated. Only a parsed
29
+ quality of exactly 0 denies compression.
30
+ */
31
+ const parsed = Number(quality[1])
32
+ return Number.isNaN(parsed) || parsed > 0
24
33
  }
@@ -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
- return { verbs, sockets }
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 ? devShell.replace(/(["'])\/_app\//g, `$1${base}/_app/`) : devShell
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.get('content-type') ?? '').toLowerCase()
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.get('content-type') ?? '').toLowerCase()
51
+ const contentType = contentTypeOf(response.headers)
51
52
  if (!isTextual(contentType)) {
52
53
  return undefined
53
54
  }
@@ -46,6 +46,14 @@ export function streamFromIterator<T>(
46
46
  enqueue throws on a closed/errored stream, and an uncaught
47
47
  throw in a timer crashes the process. Guard + self-stop.
48
48
  */
49
+ /*
50
+ Skip the tick under backpressure: a stalled consumer leaves
51
+ desiredSize <= 0, and enqueuing anyway grows the internal
52
+ queue unbounded. null = no HWM set, > 0 = consumer keeping up.
53
+ */
54
+ if (controller.desiredSize !== null && controller.desiredSize <= 0) {
55
+ return
56
+ }
49
57
  try {
50
58
  controller.enqueue(payload)
51
59
  } catch {
@@ -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 socket registries after they're
7
- eager-loaded, so a freshly-booted server lists its whole surface, not just the
8
- verbs hit so far. Pages stay out: they're the human surface, already navigable.
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
  }