@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.
- package/AGENTS.md +196 -215
- package/CHANGELOG.md +24 -0
- package/README.md +75 -69
- package/package.json +1 -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/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/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/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/emitLogRecord.ts +18 -5
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
- package/src/lib/ui/compile/generateBuild.ts +18 -9
- package/src/lib/ui/compile/generateSSR.ts +2 -2
- package/src/lib/ui/createScope.ts +11 -0
- package/src/lib/ui/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/hydrate.ts +2 -1
- package/src/lib/ui/dom/mount.ts +2 -1
- package/src/lib/ui/dom/scopeLabel.ts +15 -0
- package/src/lib/ui/dom/skeleton.ts +13 -1
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/installInspectorBridge.ts +138 -0
- package/src/lib/ui/navigate.ts +11 -3
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +94 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/historyEntries.ts +113 -0
- package/src/lib/ui/runtime/liveScopes.ts +15 -0
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
- 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/README.md
CHANGED
|
@@ -1,153 +1,159 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
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
|
-
-
|
|
8
|
-
-
|
|
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
|
|
13
|
+
bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
+
That one declaration fans out:
|
|
44
43
|
|
|
45
44
|
```text
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(
|
|
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
|
|
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.*`
|
|
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
|
-
|
|
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/
|
|
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({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
86
|
-
import {
|
|
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
|
|
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
|
|
95
|
-
let
|
|
96
|
-
let
|
|
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 (
|
|
101
|
-
await
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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}
|
|
112
|
-
<input bind:value={
|
|
113
|
-
<label><input type="checkbox" bind:checked={
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
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={
|
|
122
|
-
<p class="
|
|
126
|
+
<template if={live}>
|
|
127
|
+
<p class="live">latest: {live.text}</p>
|
|
123
128
|
<template else>
|
|
124
|
-
<p
|
|
129
|
+
<p>no messages yet</p>
|
|
125
130
|
</template>
|
|
126
131
|
</template>
|
|
127
132
|
|
|
128
133
|
<template switch={filter}>
|
|
129
|
-
<template case={'
|
|
130
|
-
<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={
|
|
134
|
-
<p>loading
|
|
135
|
-
<template then="
|
|
139
|
+
<template await={cache(getMessages)({ room })}>
|
|
140
|
+
<p>loading…</p>
|
|
141
|
+
<template then="history">
|
|
136
142
|
<ul>
|
|
137
|
-
<template each={messages} as="
|
|
138
|
-
{
|
|
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="
|
|
143
|
-
<p
|
|
148
|
+
<template catch="error">
|
|
149
|
+
<p>failed: {error.message}</p>
|
|
144
150
|
</template>
|
|
145
151
|
</template>
|
|
146
152
|
|
|
147
153
|
<style>
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
.live {
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
}
|
|
151
157
|
</style>
|
|
152
158
|
```
|
|
153
159
|
|
package/package.json
CHANGED
|
@@ -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.
|
|
@@ -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
|
|
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
|
}
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|