@abide/abide 0.35.0 → 0.37.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 (50) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +75 -69
  4. package/package.json +1 -1
  5. package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
  6. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  7. package/src/lib/server/runtime/createServer.ts +10 -1
  8. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  9. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  10. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  11. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  12. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  13. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  14. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  15. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  16. package/src/lib/shared/cache.ts +10 -3
  17. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  18. package/src/lib/shared/emitLogRecord.ts +18 -5
  19. package/src/lib/shared/withCacheManaged.ts +18 -0
  20. package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
  21. package/src/lib/ui/compile/generateBuild.ts +18 -9
  22. package/src/lib/ui/compile/generateSSR.ts +2 -2
  23. package/src/lib/ui/createScope.ts +11 -0
  24. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  25. package/src/lib/ui/dom/each.ts +8 -15
  26. package/src/lib/ui/dom/eachAsync.ts +8 -9
  27. package/src/lib/ui/dom/hydrate.ts +2 -1
  28. package/src/lib/ui/dom/mount.ts +2 -1
  29. package/src/lib/ui/dom/scopeLabel.ts +15 -0
  30. package/src/lib/ui/dom/skeleton.ts +13 -1
  31. package/src/lib/ui/dom/switchBlock.ts +7 -3
  32. package/src/lib/ui/dom/tryBlock.ts +16 -5
  33. package/src/lib/ui/dom/when.ts +7 -3
  34. package/src/lib/ui/installInspectorBridge.ts +138 -0
  35. package/src/lib/ui/navigate.ts +11 -3
  36. package/src/lib/ui/remoteProxy.ts +32 -6
  37. package/src/lib/ui/router.ts +94 -10
  38. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  39. package/src/lib/ui/runtime/abortNode.ts +22 -0
  40. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  41. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  42. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  43. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  44. package/src/lib/ui/runtime/runNode.ts +9 -0
  45. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  46. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  47. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  48. package/src/lib/ui/startClient.ts +6 -0
  49. package/src/lib/ui/types/Scope.ts +3 -0
  50. package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
package/README.md CHANGED
@@ -1,153 +1,159 @@
1
1
  # abide
2
2
 
3
- **Write one function. Get a typed HTTP endpoint, a CLI, an MCP tool, and an OpenAPI operation — from the same line of code.**
3
+ **One typed RPC declaration fans out to an HTTP route, a typed browser proxy, a CLI subcommand, an MCP tool, and an OpenAPI operation — built for humans and machines.**
4
4
 
5
- abide is a type-safe isomorphic framework built on web standards and Bun. You declare a verb once; the bundler swaps the runtime per side and projects that one declaration onto every surface humans hit it over HTTP or the CLI, machines hit it over MCP, and you control which surfaces each verb exposes. It's a framework built for humans *and* machines.
5
+ abide is an isomorphic framework on Bun: the same callable has the same name and behaviour on both sides, and the bundler swaps the runtime per side a server handler in `src/server`, a `fetch` proxy in the browser. Server-render and hydrate the same `.abide` components, broadcast over multiplexed sockets, and expose every read to an agent all in a single Bun runtime.
6
6
 
7
- - Zero runtime dependencies
8
- - A single runtime (Bun) in every mode dev, build, and compiled binary
7
+ - One dependency (`typescript`, for the type-checking shadow); `tailwindcss` + `bun-plugin-tailwind` are optional peers.
8
+ - Runs on Bun `>= 1.3.0`. No bundler config, no server framework, no client router to wire up.
9
9
 
10
10
  ## Quick start
11
11
 
12
12
  ```sh
13
- bunx abide scaffold my-app # scaffolds, installs, and starts the dev server
13
+ bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
14
14
  ```
15
15
 
16
- To see every surface live, clone the repo and run the kitchen-sink example — each page is runnable and documented:
16
+ Or read the exhaustive demo:
17
17
 
18
18
  ```sh
19
19
  git clone https://github.com/briancray/abide
20
20
  cd abide && bun install
21
- cd examples/kitchen-sink && bun run dev
21
+ cd examples/kitchen-sink && bun dev
22
22
  ```
23
23
 
24
+ The whole public surface — every export, CLI command, route, env var, and the `.abide` grammar — is mapped in [`AGENTS.md`](./AGENTS.md).
25
+
24
26
  ## RPCs
25
27
 
26
- An RPC is a handler wrapped by its HTTP method — one export per file under `src/server/rpc/`. The file path is the URL; the schema validates args and projects the MCP tool, CLI flags, and OpenAPI operation. Standard Schema is the contract: zod, valibot, and arktype work unadapted.
28
+ An RPC is one export per file under `src/server/rpc/`. The file path is the URL (`getMessages.ts` `/rpc/getMessages`), and a [Standard Schema](https://standardschema.dev) (zod / valibot / arktype, unadapted) validates the args and projects every other surface.
27
29
 
28
30
  ```ts
29
31
  // src/server/rpc/getMessages.ts
30
32
  import { GET } from '@abide/abide/server/GET'
31
33
  import { json } from '@abide/abide/server/json'
32
34
  import { z } from 'zod'
35
+ import { history } from '../../store.ts'
33
36
 
34
- // query args arrive as strings — coerce in the schema
35
- const inputSchema = z.object({ room: z.string(), limit: z.coerce.number().default(50) })
37
+ const inputSchema = z.object({ room: z.string() })
36
38
 
37
- export const getMessages = GET(
38
- async ({ room, limit }) => json(await db.recentMessages(room, limit)),
39
- { inputSchema },
40
- )
39
+ export const getMessages = GET(({ room }) => json({ messages: history(room) }), { inputSchema })
41
40
  ```
42
41
 
43
- One declared verb fans out to every surface — this is the whole premise:
42
+ That one declaration fans out:
44
43
 
45
44
  ```text
46
- export const getMessages = GET(fn, { inputSchema })
47
-
48
- ┌───────────────┬────────────┼──────────────┬────────────────┐
49
- SSR call browser fetch MCP tool CLI subcommand OpenAPI op
50
- cache(fn)() fetch /rpc/... getMessages app get-messages /openapi.json
51
- (in-process) (typed proxy) (read-only+schema) (schema→flags) (described)
45
+ getMessages (one declaration)
46
+
47
+ ┌──────────┬─────────┼─────────┬──────────────┐
48
+ ▼ ▼ ▼ ▼ ▼
49
+ SSR call browser MCP tool CLI sub- OpenAPI
50
+ cache(fn)() fetch (read-only) command operation
52
51
  ```
53
52
 
54
- The same callable is consumed differently per side `cache(getMessages)({ room })` in-process, the swapped `fetch` in the browser, `.raw(args)` for a `Response`, `.stream(args)` for `tail()`. A schema gates the machine surfaces: it unlocks the CLI and (for read-only verbs) MCP; a mutating verb never auto-exposes to MCP it needs an explicit `clients: { mcp: true }`. Per-verb `timeout` (504, on every surface) is distinct from the client-side `ABIDE_CLIENT_TIMEOUT`.
53
+ The `inputSchema` unlocks the CLI and because a `GET`/`HEAD` is read-only an MCP tool. A mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to an agent; opt in with `clients: { mcp: true }`. Consume the verb four ways, all typed against the declaration: `cache(getMessages)({ room })` for an SSR-memoized in-process read, the bundler-swapped `getMessages({ room })` `fetch` proxy in the browser, `.raw(args)` for the untouched `Response`, and `.stream(args)` for a frame-by-frame `jsonl`/`sse` body.
55
54
 
56
- > Query args travel as strings — use `z.coerce.*` so numbers and booleans validate.
55
+ > Query args travel as strings — use `z.coerce.*` for numbers and booleans. The per-verb `timeout` option fires a `504` on every surface and is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
57
56
 
58
57
  ## Sockets
59
58
 
60
- One broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — every socket multiplexes onto one ws at `/__abide/sockets`. A `schema` validates publishes, infers the frame type, and flips on the MCP/CLI read faces.
59
+ A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — `for await` it on the server, or read it reactively with `tail()` in a component and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
61
60
 
62
61
  ```ts
63
- // src/server/sockets/chat.ts
62
+ // src/server/sockets/messages.ts
64
63
  import { socket } from '@abide/abide/server/socket'
65
64
  import { z } from 'zod'
66
65
 
67
- const schema = z.object({ id: z.string(), from: z.string(), text: z.string(), at: z.number() })
68
-
69
- // retain the last 100 frames, evict any older than an hour
70
- export const chat = socket({ schema, tail: 100, ttl: 3_600_000 })
71
- export type ChatMessage = z.infer<typeof schema>
66
+ const schema = z.object({
67
+ id: z.string(),
68
+ room: z.string(),
69
+ from: z.string(),
70
+ text: z.string(),
71
+ at: z.number(),
72
+ })
73
+
74
+ // retain the last 100 frames; lazily evict any older than an hour
75
+ export const messages = socket({ schema, tail: 100, ttl: 3_600_000 })
76
+ export type Message = z.infer<typeof schema>
72
77
  ```
73
78
 
74
- For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/<name>` `GET` returns the retained tail, `POST` publishes (gated by `clientPublish`, off by default).
79
+ The schema validates every publish and flips on the read surfaces (a `messages-tail` MCP tool / CLI command). For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/messages`: `GET` returns the retained tail, `POST` publishes gated by `clientPublish` (default `false`, so browsers publish through a validating verb instead).
75
80
 
76
81
  ## Components
77
82
 
78
- Components are `.abide` files valid HTML with `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. Reactive state is reached through `scope()` (`scope().state(v)`, `scope().computed(fn)`); props are read by destructuring `props()` (`const { room } = props()`), and `effect` is in scope without import. This page reads the verb above through `cache()`, tails the socket live, and exercises most of the template grammar:
83
+ A component is an `.abide` file: valid HTML with a `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. `scope()` is the sole reactive surface; `props`, `effect`, and `html` are in scope without import. This one imports the verb and socket above:
79
84
 
80
85
  ```html
81
86
  <script>
82
87
  import { cache } from '@abide/abide/shared/cache'
83
88
  import { tail } from '@abide/abide/ui/tail'
84
89
  import { getMessages } from '$server/rpc/getMessages.ts'
85
- import { publishChat } from '$server/rpc/publishChat.ts'
86
- import { chat } from '$server/sockets/chat.ts'
90
+ import { sendMessage } from '$server/rpc/sendMessage.ts'
91
+ import { messages } from '$server/sockets/messages.ts'
87
92
  import Avatar from '$ui/Avatar.abide'
88
93
 
89
- const { room } = props() // typed via src/.abide/routes.d.ts
90
- const history = scope().computed(() => cache(getMessages)({ room })) // SSR snapshot + reactive refetch
91
- const latest = scope().computed(() => tail(chat)) // re-renders on every new frame
94
+ const { room } = props<{ room: string }>()
92
95
 
93
- let from = scope().state('alice')
94
- let text = scope().state('')
95
- let filter = scope().state('all') // all | mine | others
96
- let onlyUnread = scope().state(false)
96
+ let from = scope().state('alice') // a writable cell — read/assign as a plain var
97
+ let draft = scope().state('')
98
+ let pinned = scope().state(false)
99
+ let filter = scope().state('all')
100
+ const live = scope().computed(() => tail(messages)) // re-renders on every new frame
97
101
 
98
102
  async function send(event: SubmitEvent) {
99
103
  event.preventDefault()
100
- if (text.trim() === '') return
101
- await publishChat({ from, text }) // server validates, then broadcasts
102
- text = ''
104
+ if (draft.trim() === '') return
105
+ await sendMessage({ room, from, text: draft }) // a mutating verb
106
+ draft = ''
103
107
  }
104
108
  </script>
105
109
 
106
- <template name="message" args={m}> <!-- snippet: a reusable builder -->
107
- <li class="row"><Avatar name={m.from} /> <strong>{m.from}</strong>: {m.text}</li>
110
+ <!-- a named snippet: a reusable builder, rendered like a function -->
111
+ <template name="bubble" args={msg}>
112
+ <li><Avatar name={msg.from} /> <b>{msg.from}</b>: {msg.text}</li>
108
113
  </template>
109
114
 
115
+ <h1>#{room}</h1>
116
+
110
117
  <form onsubmit={send}>
111
- <input bind:value={from} placeholder="you" />
112
- <input bind:value={text} placeholder="say something…" />
113
- <label><input type="checkbox" bind:checked={onlyUnread} /> unread only</label>
114
- <fieldset>
115
- <label><input type="radio" bind:group={filter} value="all" /> all</label>
116
- <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
117
- </fieldset>
118
- <button type="submit" disabled={text.trim() === ''}>send</button>
118
+ <input bind:value={from} />
119
+ <input bind:value={draft} placeholder="message" />
120
+ <label><input type="checkbox" bind:checked={pinned} /> pin</label>
121
+ <label><input type="radio" bind:group={filter} value="all" /> all</label>
122
+ <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
123
+ <button type="submit" disabled={draft.trim() === ''}>send</button>
119
124
  </form>
120
125
 
121
- <template if={latest}>
122
- <p class="ping">latest from <strong>{latest.from}</strong></p>
126
+ <template if={live}>
127
+ <p class="live">latest: {live.text}</p>
123
128
  <template else>
124
- <p class="ping muted">no live messages yet</p>
129
+ <p>no messages yet</p>
125
130
  </template>
126
131
  </template>
127
132
 
128
133
  <template switch={filter}>
129
- <template case={'mine'}><p>showing your messages</p></template>
130
- <template default><p>showing every message</p></template>
134
+ <template case={'all'}><p>showing everyone</p></template>
135
+ <template case={'mine'}><p>showing {from}</p></template>
136
+ <template default><p>—</p></template>
131
137
  </template>
132
138
 
133
- <template await={history}>
134
- <p>loading history…</p>
135
- <template then="messages">
139
+ <template await={cache(getMessages)({ room })}>
140
+ <p>loading…</p>
141
+ <template then="history">
136
142
  <ul>
137
- <template each={messages} as="m" key="m.id">
138
- {message(m)} <!-- render the snippet -->
143
+ <template each={history.messages} as="msg" key="msg.id">
144
+ {bubble(msg)}
139
145
  </template>
140
146
  </ul>
141
147
  </template>
142
- <template catch="err">
143
- <p class="error">{err.message}</p>
148
+ <template catch="error">
149
+ <p>failed: {error.message}</p>
144
150
  </template>
145
151
  </template>
146
152
 
147
153
  <style>
148
- .row { display: flex; gap: 0.5rem; }
149
- .muted { color: #94a3b8; }
150
- .error { color: #dc2626; }
154
+ .live {
155
+ font-weight: 600;
156
+ }
151
157
  </style>
152
158
  ```
153
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.35.0",
3
+ "version": "0.37.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",
@@ -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.
@@ -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,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
  }
@@ -24,6 +24,7 @@ import type { CacheStore } from './types/CacheStore.ts'
24
24
  import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
25
25
  import type { RemoteFunction } from './types/RemoteFunction.ts'
26
26
  import type { Subscribable } from './types/Subscribable.ts'
27
+ import { withCacheManaged } from './withCacheManaged.ts'
27
28
 
28
29
  type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
29
30
  type Producer<Args, Return> = (args?: Args) => Promise<Return>
@@ -309,8 +310,12 @@ function invokeProducer<Args, Return>(
309
310
  }
310
311
  /* Miss: time the producer run — where a request's time actually goes (an
311
312
  external fetch, a computation). Producer path only; the remote path must
312
- keep its own promise so getRemoteMeta can read the recorded Request. */
313
- const promise = cacheLog.trace<Return>(`cache ${key}`, () => producer(args))
313
+ keep its own promise so getRemoteMeta can read the recorded Request. The
314
+ producer runs cache-managed so a bare RPC inside it isn't scope-bound — the
315
+ cache coalesces and owns this flight. */
316
+ const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
317
+ withCacheManaged(() => producer(args)),
318
+ )
314
319
  registerEntry(store, key, promise, options, undefined, () => producer(args))
315
320
  return promise
316
321
  }
@@ -326,7 +331,9 @@ function invokeRemote<Args>(
326
331
  if (existing) {
327
332
  return shareable(existing.promise as Promise<Response>)
328
333
  }
329
- const promise = rawFn(args as Args)
334
+ /* Cache-managed: the shared flight isn't bound to the reader that triggered the
335
+ miss, so its scope disposing can't abort a request other readers still join. */
336
+ const promise = withCacheManaged(() => rawFn(args as Args))
330
337
  const request = getRemoteMeta(promise)
331
338
  if (!request) {
332
339
  throw new Error(
@@ -0,0 +1,10 @@
1
+ /*
2
+ Set while cache() synchronously invokes the underlying remote/producer, so the
3
+ client's currentAbortSignal skips scope-binding for cache-managed calls. The cache
4
+ coalesces one in-flight request across every reader and owns its lifetime (it
5
+ evicts the entry when the request rejects), so a single reader navigating away must
6
+ not abort a flight the others still depend on. A plain boolean — cache invokes the
7
+ underlying call synchronously and never re-enters across an await. Harmless on the
8
+ server, where there is no reactive observer to bind anyway.
9
+ */
10
+ export const cacheManagedSlot: { active: boolean } = { active: false }
@@ -66,6 +66,15 @@ function formatElapsed(ms: number): string {
66
66
  return `+${ms.toFixed(2)}ms`
67
67
  }
68
68
 
69
+ /* `14:23:01.072` — local wall-clock to the ms. Every record carries `ts`, so the
70
+ tsv line always leads with one; `+elapsedMs` still trails as the request-relative
71
+ timing — the two are different axes (when it happened vs how long it took). */
72
+ function formatClock(ts: number): string {
73
+ const date = new Date(ts)
74
+ const pad = (value: number, width = 2): string => String(value).padStart(width, '0')
75
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
76
+ }
77
+
69
78
  /*
70
79
  Builds the record from the emission's own fields plus the ambient request
71
80
  scope (trace, elapsed, verb+path), then renders it through the active
@@ -123,11 +132,12 @@ function consoleFor(level: LogRecord['level']): (...args: unknown[]) => void {
123
132
 
124
133
  /*
125
134
  The unified tsv line (the default format): tab-separated
126
- `<trace8> <verb path> [channel] <message> +0.00ms`. Inside a
127
- request scope the trace column leads and the elapsed-at-emission timing
128
- trails; a closing record emitted outside one (asset hits sidestep the scope)
129
- pads a blank trace column and trails its serve duration instead, so request
130
- lines stay aligned whatever produced them. Every record speaks on a channel
135
+ `<clock> <trace8> <verb path> [channel] <message> +0.00ms`. The
136
+ wall-clock leads every line (each record carries `ts`); inside a request scope
137
+ the trace column follows and the elapsed-at-emission timing trails; a closing
138
+ record emitted outside one (asset hits sidestep the scope) pads a blank trace
139
+ column and trails its serve duration instead, so request lines stay aligned
140
+ whatever produced them. Every record speaks on a channel
131
141
  (the app name, 'abide', or a diagnostic channel), shown as a dim `[name]`
132
142
  tag opening the message field. The verb+path pair is one field — it's the
133
143
  line's anchor unit — and the tag folds into the message field so field
@@ -136,6 +146,9 @@ positions stay stable for cut/awk consumers.
136
146
  function printTsv(record: LogRecord, voice?: LogVoice): void {
137
147
  const fields: string[] = []
138
148
  const closing = record.status !== undefined
149
+ /* Wall-clock leads every line — a stable first column, unlike the conditional
150
+ trace field, and the axis `+elapsedMs` can't give (when, not how-long). */
151
+ fields.push(dim(formatClock(record.ts)))
139
152
  if (record.trace) {
140
153
  fields.push(dim(record.trace.slice(0, 8)))
141
154
  } else if (closing) {
@@ -0,0 +1,18 @@
1
+ import { cacheManagedSlot } from './cacheManagedSlot.ts'
2
+
3
+ /*
4
+ Runs `invoke` with cacheManagedSlot flagged, so any RPC fired synchronously inside
5
+ it (the cache's underlying remote/producer call) skips reactive scope-binding — the
6
+ cache, not the calling reader, owns the shared flight's lifetime. Save/restore keeps
7
+ it correct under nesting (a producer that itself reads cache). The fetch is fired
8
+ synchronously and the flag clears before the await, so it never spans the network.
9
+ */
10
+ export function withCacheManaged<T>(invoke: () => T): T {
11
+ const previous = cacheManagedSlot.active
12
+ cacheManagedSlot.active = true
13
+ try {
14
+ return invoke()
15
+ } finally {
16
+ cacheManagedSlot.active = previous
17
+ }
18
+ }