jev-devtools 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/THIRD_PARTY_NOTICES.txt +50 -0
- package/lib/server/index.d.ts +42 -0
- package/lib/server/index.js +207 -0
- package/lib/server/recorder.d.ts +30 -0
- package/lib/server/recorder.js +226 -0
- package/lib/src/ConnectedInspector.d.ts +2 -0
- package/lib/src/ConnectedInspector.js +78 -0
- package/lib/src/JevDevtools.d.ts +6 -0
- package/lib/src/JevDevtools.js +58 -0
- package/lib/src/JevDrawer.d.ts +6 -0
- package/lib/src/JevDrawer.js +111 -0
- package/lib/src/TraceDiagram.d.ts +4 -0
- package/lib/src/TraceDiagram.js +73 -0
- package/lib/src/TypeSafeMark.d.ts +1 -0
- package/lib/src/TypeSafeMark.js +6 -0
- package/lib/src/flow-model.d.ts +9 -0
- package/lib/src/flow-model.js +69 -0
- package/lib/src/index.d.ts +9 -0
- package/lib/src/index.js +12 -0
- package/lib/src/inspector.css +993 -0
- package/lib/src/protocol.d.ts +64 -0
- package/lib/src/protocol.js +1 -0
- package/package.json +62 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diego Arias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Jev DevTools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/jev-devtools)
|
|
4
|
+
|
|
5
|
+
A React drawer and Node adapter for inspecting TypeSafe Jev requests with animated diagrams. Includes request history, context, questions, typed answers, transport attempts, and raw snapshots.
|
|
6
|
+
|
|
7
|
+
**Experimental 0.1.x.** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
|
|
8
|
+
|
|
9
|
+
## Demo
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
The full sample recording loops above. [Download the MP4 (530 KB)](https://github.com/diegoag64/jev-devtools/raw/refs/heads/main/docs/assets/jev-devtools-demo.mp4).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Install from npm:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install jev-devtools
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
To try unpublished local changes instead:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# From the jev-devtools repository root:
|
|
27
|
+
npm ci
|
|
28
|
+
npm run pack:devtools
|
|
29
|
+
# From the consuming app:
|
|
30
|
+
npm install /absolute/path/to/jev-devtools-0.1.1.tgz
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Mount once:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { JevDevtools } from 'jev-devtools';
|
|
37
|
+
import 'jev-devtools/styles.css';
|
|
38
|
+
|
|
39
|
+
<JevDevtools enabled={import.meta.env.DEV} defaultOpen={false} />
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Create one shared Node adapter and instrumented client:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { createJevDevtools } from 'jev-devtools/server';
|
|
46
|
+
|
|
47
|
+
const inspector = createJevDevtools({
|
|
48
|
+
enabled: process.env.NODE_ENV === 'development',
|
|
49
|
+
origin: 'http://localhost:3000',
|
|
50
|
+
});
|
|
51
|
+
const jev = inspector.createClient({ apiKey: process.env.TYPESAFE_API_KEY });
|
|
52
|
+
app.use(inspector.middleware); // Mount at root, before fallback handlers.
|
|
53
|
+
// Call jev.systemOne(...) in your application's handlers.
|
|
54
|
+
// Call inspector.dispose() on shutdown.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Only calls through that client are captured. The React component subscribes via same-origin SSE. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe and styles are scoped; no Tailwind or shadcn installation is required.
|
|
58
|
+
|
|
59
|
+
History is bounded to 50 calls in memory by default. API routes and inspector endpoints must share an adapter instance; distributed processes and edge runtimes are not supported. Non-loopback use requires explicit authorization. Captures include bounded/redacted context and answers, not hidden model reasoning.
|
|
60
|
+
|
|
61
|
+
For Next.js App Router, use `getJevDevtools(appKey, optionsFactory)` from `/server` to share an adapter across server reloads, expose `handleNodeRoute` through an optional catch-all Route Handler, and mount the root component in a client boundary. See the [Next.js guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/nextjs.md) and runnable example. No Next dependency is required by this package.
|
|
62
|
+
|
|
63
|
+
[Full integration guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md) · [Repository and playground](https://github.com/diegoag64/jev-devtools)
|
|
64
|
+
|
|
65
|
+
MIT for project code. See bundled LICENSE and THIRD_PARTY_NOTICES.txt for original design attribution and TypeSafe branding.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Jev DevTools — design provenance
|
|
2
|
+
|
|
3
|
+
Palette and typography adapted from Diagram Design:
|
|
4
|
+
https://github.com/cathrynlavery/diagram-design
|
|
5
|
+
Source snapshot: f2f4d49ea8d632f17cf196ba26599277a93f767b
|
|
6
|
+
|
|
7
|
+
MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Cathryn Lavery
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Fonts
|
|
30
|
+
|
|
31
|
+
Instrument Serif, Geist, and Geist Mono are loaded from Google Fonts.
|
|
32
|
+
They are distributed by their upstream authors under the SIL Open Font License.
|
|
33
|
+
No font binaries are bundled with this project.
|
|
34
|
+
https://github.com/google/fonts/tree/main/ofl/instrumentserif
|
|
35
|
+
https://github.com/google/fonts/tree/main/ofl/geist
|
|
36
|
+
https://github.com/google/fonts/tree/main/ofl/geistmono
|
|
37
|
+
|
|
38
|
+
The inspector implementation originated in Diagram Motion Lab:
|
|
39
|
+
https://github.com/diegoag64/diagram-motion-lab
|
|
40
|
+
|
|
41
|
+
TypeSafe branding
|
|
42
|
+
|
|
43
|
+
The TypeSafe logo is reproduced from https://docs.typesafe.ai/ (via the
|
|
44
|
+
original lab's docs replica). The logo and TypeSafe/Jev names remain the
|
|
45
|
+
property of their respective owners; the project MIT license does not grant
|
|
46
|
+
trademark rights or imply endorsement.
|
|
47
|
+
|
|
48
|
+
The playground uses shadcn/ui components. Its MIT notice is preserved in
|
|
49
|
+
apps/playground/SHADCN_LICENSE.md. Those components are not bundled in the
|
|
50
|
+
DevTools package. No TanStack DevTools shell is included.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
+
import { type CaptureOptions } from "./recorder.js";
|
|
4
|
+
export { JevRecorder } from "./recorder.js";
|
|
5
|
+
export type { CaptureOptions } from "./recorder.js";
|
|
6
|
+
export type JevDevtoolsServerOptions = CaptureOptions & {
|
|
7
|
+
/** Explicit opt-in; disabled by default. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
origin: string;
|
|
10
|
+
endpoint?: string;
|
|
11
|
+
/** Required for non-loopback origins. Apply your application's authentication. */
|
|
12
|
+
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
13
|
+
/** Server-derived scope. Defaults to a shared local-development history. */
|
|
14
|
+
getSession?: (request: Request) => string | Promise<string>;
|
|
15
|
+
};
|
|
16
|
+
export type ClientMetadata = {
|
|
17
|
+
label?: string;
|
|
18
|
+
source?: "sample" | "live";
|
|
19
|
+
};
|
|
20
|
+
/** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
|
|
21
|
+
export declare function createJevDevtools(options: JevDevtoolsServerOptions): {
|
|
22
|
+
handle: (request: Request) => Promise<Response>;
|
|
23
|
+
/**
|
|
24
|
+
* For Node framework routes whose Request URL uses an internal hostname.
|
|
25
|
+
* Require the actual Host header to match the configured public authority;
|
|
26
|
+
* never trust forwarded host/protocol headers. Origin checks still apply.
|
|
27
|
+
*/
|
|
28
|
+
handleNodeRoute(request: Request): Promise<Response>;
|
|
29
|
+
middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => Promise<void>;
|
|
30
|
+
createClient(clientOptions: ConstructorParameters<typeof TypeSafeClient>[0], meta?: ClientMetadata): TypeSafeClient;
|
|
31
|
+
/** Needed only when getSession is customized. Wrap the application's request handler. */
|
|
32
|
+
run<T>(request: Request, callback: () => T | Promise<T>): Promise<T>;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
};
|
|
35
|
+
type Adapter = ReturnType<typeof createJevDevtools>;
|
|
36
|
+
/**
|
|
37
|
+
* Reuse one adapter across server module reloads in the same Node realm.
|
|
38
|
+
* Use a stable app-specific key. The first options factory wins until dispose
|
|
39
|
+
* or process restart; restart after editing configuration/auth/redaction callbacks.
|
|
40
|
+
* This does not share history between workers or serverless instances.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getJevDevtools(key: string, createOptions: () => JevDevtoolsServerOptions): Adapter;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
5
|
+
import { JevRecorder } from "./recorder.js";
|
|
6
|
+
export { JevRecorder } from "./recorder.js";
|
|
7
|
+
const json = (data, status = 200) => Response.json(data, { status, headers: { "cache-control": "no-store" } });
|
|
8
|
+
/** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
|
|
9
|
+
export function createJevDevtools(options) {
|
|
10
|
+
const origin = new URL(options.origin).origin;
|
|
11
|
+
const endpoint = (options.endpoint ?? "/api/jev-devtools").replace(/\/$/, "");
|
|
12
|
+
if (!endpoint.startsWith("/") ||
|
|
13
|
+
endpoint.startsWith("//") ||
|
|
14
|
+
/[?#]/.test(endpoint))
|
|
15
|
+
throw new Error("endpoint must be an absolute path without a query or fragment");
|
|
16
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(origin).hostname);
|
|
17
|
+
if (options.enabled && !local && !options.authorize)
|
|
18
|
+
throw new Error("Non-loopback inspector endpoints require authorize");
|
|
19
|
+
const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
|
|
20
|
+
const streams = new Set();
|
|
21
|
+
let disposed = false;
|
|
22
|
+
const enabled = () => options.enabled === true && !disposed;
|
|
23
|
+
const sessionFor = async (request) => {
|
|
24
|
+
const session = (await options.getSession?.(request)) ?? "local";
|
|
25
|
+
if (typeof session !== "string" || !session || session.length > 200)
|
|
26
|
+
throw new Error("Invalid inspector scope");
|
|
27
|
+
return session;
|
|
28
|
+
};
|
|
29
|
+
async function handle(request) {
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
if (!enabled() ||
|
|
32
|
+
!(url.pathname === endpoint || url.pathname.startsWith(endpoint + "/")))
|
|
33
|
+
return json({ error: "Not found" }, 404);
|
|
34
|
+
if (url.origin !== origin ||
|
|
35
|
+
(request.headers.get("origin") &&
|
|
36
|
+
request.headers.get("origin") !== origin) ||
|
|
37
|
+
request.headers.get("sec-fetch-site") === "cross-site")
|
|
38
|
+
return json({ error: "Same-origin requests required" }, 403);
|
|
39
|
+
if (options.authorize && !(await options.authorize(request)))
|
|
40
|
+
return json({ error: "Unauthorized" }, 403);
|
|
41
|
+
const session = await sessionFor(request);
|
|
42
|
+
if (url.pathname === endpoint && request.method === "GET")
|
|
43
|
+
return json({ version: 1, enabled: true });
|
|
44
|
+
if (url.pathname === endpoint + "/clear") {
|
|
45
|
+
if (request.method !== "DELETE")
|
|
46
|
+
return json({ error: "Method not allowed" }, 405);
|
|
47
|
+
if (request.headers.get("origin") !== origin)
|
|
48
|
+
return json({ error: "Same-origin requests required" }, 403);
|
|
49
|
+
recorder.clear(session);
|
|
50
|
+
return json({ ok: true });
|
|
51
|
+
}
|
|
52
|
+
if (url.pathname !== endpoint + "/events")
|
|
53
|
+
return json({ error: "Not found" }, 404);
|
|
54
|
+
if (request.method !== "GET")
|
|
55
|
+
return json({ error: "Method not allowed" }, 405);
|
|
56
|
+
const encoder = new TextEncoder();
|
|
57
|
+
let cleanup = () => { }, flush = () => { };
|
|
58
|
+
const stream = new ReadableStream({
|
|
59
|
+
start(controller) {
|
|
60
|
+
let closed = false, dirty = true;
|
|
61
|
+
flush = () => {
|
|
62
|
+
if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
|
|
63
|
+
return;
|
|
64
|
+
dirty = false;
|
|
65
|
+
controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`));
|
|
66
|
+
};
|
|
67
|
+
const unsubscribe = recorder.subscribe(() => {
|
|
68
|
+
dirty = true;
|
|
69
|
+
flush();
|
|
70
|
+
});
|
|
71
|
+
const heartbeat = setInterval(() => {
|
|
72
|
+
if (!closed && (controller.desiredSize ?? 0) > 0)
|
|
73
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
74
|
+
}, 15_000);
|
|
75
|
+
cleanup = () => {
|
|
76
|
+
if (closed)
|
|
77
|
+
return;
|
|
78
|
+
closed = true;
|
|
79
|
+
unsubscribe();
|
|
80
|
+
clearInterval(heartbeat);
|
|
81
|
+
request.signal.removeEventListener("abort", cleanup);
|
|
82
|
+
streams.delete(cleanup);
|
|
83
|
+
try {
|
|
84
|
+
controller.close();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* Already cancelled by the consumer. */
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
streams.add(cleanup);
|
|
91
|
+
request.signal.addEventListener("abort", cleanup, { once: true });
|
|
92
|
+
if (request.signal.aborted)
|
|
93
|
+
cleanup();
|
|
94
|
+
else
|
|
95
|
+
flush();
|
|
96
|
+
},
|
|
97
|
+
pull() {
|
|
98
|
+
flush();
|
|
99
|
+
},
|
|
100
|
+
cancel() {
|
|
101
|
+
cleanup();
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return new Response(stream, {
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "text/event-stream",
|
|
107
|
+
"cache-control": "no-store",
|
|
108
|
+
"x-accel-buffering": "no",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function middleware(req, res, next) {
|
|
113
|
+
const path = (req.url ?? "").split("?")[0];
|
|
114
|
+
if (path !== endpoint && !path.startsWith(endpoint + "/")) {
|
|
115
|
+
next();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const abort = new AbortController();
|
|
119
|
+
res.once("close", () => abort.abort());
|
|
120
|
+
try {
|
|
121
|
+
const headers = new Headers();
|
|
122
|
+
for (const [key, value] of Object.entries(req.headers))
|
|
123
|
+
if (value !== undefined)
|
|
124
|
+
headers.set(key, Array.isArray(value) ? value.join(",") : value);
|
|
125
|
+
const request = new Request(new URL(req.url, `${new URL(origin).protocol}//${req.headers.host}`), { method: req.method, headers, signal: abort.signal });
|
|
126
|
+
const response = await handle(request);
|
|
127
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
128
|
+
if (response.body)
|
|
129
|
+
await pipeline(Readable.fromWeb(response.body), res);
|
|
130
|
+
else
|
|
131
|
+
res.end();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
if (!res.headersSent && !res.destroyed)
|
|
135
|
+
res
|
|
136
|
+
.writeHead(500, { "content-type": "application/json" })
|
|
137
|
+
.end('{"error":"Inspector unavailable"}');
|
|
138
|
+
else
|
|
139
|
+
res.destroy();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
handle,
|
|
144
|
+
/**
|
|
145
|
+
* For Node framework routes whose Request URL uses an internal hostname.
|
|
146
|
+
* Require the actual Host header to match the configured public authority;
|
|
147
|
+
* never trust forwarded host/protocol headers. Origin checks still apply.
|
|
148
|
+
*/
|
|
149
|
+
handleNodeRoute(request) {
|
|
150
|
+
if (!enabled())
|
|
151
|
+
return handle(request);
|
|
152
|
+
if (request.headers.get("host") !== new URL(origin).host)
|
|
153
|
+
return Promise.resolve(json({ error: "Same-origin requests required" }, 403));
|
|
154
|
+
const url = new URL(request.url);
|
|
155
|
+
return handle(new Request(origin + url.pathname + url.search, request));
|
|
156
|
+
},
|
|
157
|
+
middleware,
|
|
158
|
+
createClient(clientOptions, meta = {}) {
|
|
159
|
+
// A disabled adapter is a normal SDK client: no recording or fetch wrapper.
|
|
160
|
+
if (!enabled())
|
|
161
|
+
return new TypeSafeClient(clientOptions);
|
|
162
|
+
const client = new TypeSafeClient({
|
|
163
|
+
...clientOptions,
|
|
164
|
+
fetch: recorder.transport(clientOptions?.fetch ?? globalThis.fetch),
|
|
165
|
+
});
|
|
166
|
+
return recorder.instrument(client, () => ({
|
|
167
|
+
session: scope.getStore() ?? "local",
|
|
168
|
+
label: meta.label ?? "Jev request",
|
|
169
|
+
source: meta.source ?? "live",
|
|
170
|
+
}));
|
|
171
|
+
},
|
|
172
|
+
/** Needed only when getSession is customized. Wrap the application's request handler. */
|
|
173
|
+
async run(request, callback) {
|
|
174
|
+
return scope.run(await sessionFor(request), callback);
|
|
175
|
+
},
|
|
176
|
+
dispose() {
|
|
177
|
+
disposed = true;
|
|
178
|
+
for (const close of [...streams])
|
|
179
|
+
close();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const sharedAdaptersKey = Symbol.for("jev-devtools/adapters/v1");
|
|
184
|
+
/**
|
|
185
|
+
* Reuse one adapter across server module reloads in the same Node realm.
|
|
186
|
+
* Use a stable app-specific key. The first options factory wins until dispose
|
|
187
|
+
* or process restart; restart after editing configuration/auth/redaction callbacks.
|
|
188
|
+
* This does not share history between workers or serverless instances.
|
|
189
|
+
*/
|
|
190
|
+
export function getJevDevtools(key, createOptions) {
|
|
191
|
+
if (!key.trim())
|
|
192
|
+
throw new Error("A non-empty application key is required");
|
|
193
|
+
const shared = globalThis;
|
|
194
|
+
const adapters = (shared[sharedAdaptersKey] ??= new Map());
|
|
195
|
+
const existing = adapters.get(key);
|
|
196
|
+
if (existing)
|
|
197
|
+
return existing;
|
|
198
|
+
const adapter = createJevDevtools(createOptions());
|
|
199
|
+
const dispose = adapter.dispose;
|
|
200
|
+
adapter.dispose = () => {
|
|
201
|
+
dispose();
|
|
202
|
+
if (adapters.get(key) === adapter)
|
|
203
|
+
adapters.delete(key);
|
|
204
|
+
};
|
|
205
|
+
adapters.set(key, adapter);
|
|
206
|
+
return adapter;
|
|
207
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
2
|
+
import type { Fetch } from "@typesafe-ai/sdk";
|
|
3
|
+
import type { Trace } from "../src/protocol.js";
|
|
4
|
+
export type CaptureOptions = {
|
|
5
|
+
limit?: number;
|
|
6
|
+
redact?: (path: string[], value: unknown) => unknown;
|
|
7
|
+
};
|
|
8
|
+
export declare class JevRecorder {
|
|
9
|
+
private options;
|
|
10
|
+
private traces;
|
|
11
|
+
private listeners;
|
|
12
|
+
private context;
|
|
13
|
+
private limit;
|
|
14
|
+
constructor(options?: CaptureOptions);
|
|
15
|
+
snapshot(session: string): Trace[];
|
|
16
|
+
subscribe(listener: () => void): () => void;
|
|
17
|
+
clear(session: string): void;
|
|
18
|
+
private notify;
|
|
19
|
+
private update;
|
|
20
|
+
transport(fetcher: Fetch): Fetch;
|
|
21
|
+
instrument<T extends TypeSafeClient>(client: T, meta: {
|
|
22
|
+
session: string;
|
|
23
|
+
label: string;
|
|
24
|
+
source: "sample" | "live";
|
|
25
|
+
} | (() => {
|
|
26
|
+
session: string;
|
|
27
|
+
label: string;
|
|
28
|
+
source: "sample" | "live";
|
|
29
|
+
})): T;
|
|
30
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { Image } from "@typesafe-ai/sdk";
|
|
4
|
+
const secretKey = /^(authorization|cookie|set-cookie|api[-_]?key|password|secret|access[-_]?token|refresh[-_]?token)$/i;
|
|
5
|
+
const safeMessage = (value) => value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 500);
|
|
6
|
+
// Snapshot before the caller can mutate its request. Bounded and cycle-safe;
|
|
7
|
+
// image handles become explicit previews/references instead of opaque SDK objects.
|
|
8
|
+
function capture(value, warnings, redact) {
|
|
9
|
+
const seen = new WeakSet();
|
|
10
|
+
let budget = 96_000;
|
|
11
|
+
const visit = (input, path, depth) => {
|
|
12
|
+
if (secretKey.test(path.at(-1) || ""))
|
|
13
|
+
return "[redacted]";
|
|
14
|
+
if (redact)
|
|
15
|
+
input = redact(path, input);
|
|
16
|
+
budget -= 8 + (path.at(-1)?.length ?? 0);
|
|
17
|
+
if (budget <= 0 || depth > 12) {
|
|
18
|
+
warnings.add("Capture size/depth limit reached.");
|
|
19
|
+
return "[truncated]";
|
|
20
|
+
}
|
|
21
|
+
if (input === null || input === undefined)
|
|
22
|
+
return null;
|
|
23
|
+
if (typeof input === "string") {
|
|
24
|
+
const text = input.slice(0, Math.min(budget, 16_000));
|
|
25
|
+
budget -= text.length;
|
|
26
|
+
if (text.length < input.length)
|
|
27
|
+
warnings.add("Long text truncated in capture.");
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
if (typeof input === "boolean")
|
|
31
|
+
return input;
|
|
32
|
+
if (typeof input === "number")
|
|
33
|
+
return Number.isFinite(input) ? input : String(input);
|
|
34
|
+
if (typeof input !== "object")
|
|
35
|
+
return String(input);
|
|
36
|
+
if (seen.has(input))
|
|
37
|
+
return "[circular/repeated reference]";
|
|
38
|
+
seen.add(input);
|
|
39
|
+
if (input instanceof Image) {
|
|
40
|
+
const bytes = input.data, result = {
|
|
41
|
+
kind: "image",
|
|
42
|
+
ref: String(input),
|
|
43
|
+
bytes: bytes instanceof Blob ? bytes.size : bytes.byteLength,
|
|
44
|
+
};
|
|
45
|
+
if (bytes instanceof Uint8Array &&
|
|
46
|
+
bytes.byteLength < 48_000 &&
|
|
47
|
+
budget > bytes.byteLength * 1.4) {
|
|
48
|
+
const format = bytes[0] === 137 && bytes[1] === 80
|
|
49
|
+
? "png"
|
|
50
|
+
: bytes[0] === 255 && bytes[1] === 216
|
|
51
|
+
? "jpeg"
|
|
52
|
+
: null;
|
|
53
|
+
if (format) {
|
|
54
|
+
result.preview = `data:image/${format};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
55
|
+
budget -= bytes.byteLength * 1.4;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!result.preview)
|
|
59
|
+
warnings.add("Image preview omitted (Blob, unsupported format, or size limit).");
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(input)) {
|
|
63
|
+
if (input.length > 150)
|
|
64
|
+
warnings.add("Long collection truncated in capture.");
|
|
65
|
+
return input
|
|
66
|
+
.slice(0, 150)
|
|
67
|
+
.map((v, i) => visit(v, [...path, String(i)], depth + 1));
|
|
68
|
+
}
|
|
69
|
+
const entries = Object.entries(input);
|
|
70
|
+
if (entries.length > 150)
|
|
71
|
+
warnings.add("Long collection truncated in capture.");
|
|
72
|
+
return Object.fromEntries(entries
|
|
73
|
+
.slice(0, 150)
|
|
74
|
+
.map(([key, v]) => [key, visit(v, [...path, key], depth + 1)]));
|
|
75
|
+
};
|
|
76
|
+
return visit(value, [], 0);
|
|
77
|
+
}
|
|
78
|
+
export class JevRecorder {
|
|
79
|
+
options;
|
|
80
|
+
traces = new Map();
|
|
81
|
+
listeners = new Set();
|
|
82
|
+
context = new AsyncLocalStorage();
|
|
83
|
+
limit;
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
this.limit = Math.max(1, Math.min(200, options.limit ?? 50));
|
|
87
|
+
}
|
|
88
|
+
snapshot(session) {
|
|
89
|
+
return structuredClone([...this.traces.values()].filter((t) => t.session === session).reverse());
|
|
90
|
+
}
|
|
91
|
+
subscribe(listener) {
|
|
92
|
+
this.listeners.add(listener);
|
|
93
|
+
return () => {
|
|
94
|
+
this.listeners.delete(listener);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
clear(session) {
|
|
98
|
+
for (const [id, t] of this.traces)
|
|
99
|
+
if (t.session === session)
|
|
100
|
+
this.traces.delete(id);
|
|
101
|
+
this.notify();
|
|
102
|
+
}
|
|
103
|
+
notify() {
|
|
104
|
+
for (const listener of this.listeners) {
|
|
105
|
+
try {
|
|
106
|
+
listener();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* Observers must not affect inference. */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
update(id, update) {
|
|
114
|
+
const t = this.traces.get(id);
|
|
115
|
+
if (t) {
|
|
116
|
+
update(t);
|
|
117
|
+
this.notify();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Use this in TypeSafeClient's supported `fetch` option. It observes attempts
|
|
121
|
+
// only and returns the original Response without reading or changing its body.
|
|
122
|
+
transport(fetcher) {
|
|
123
|
+
return async (url, init) => {
|
|
124
|
+
const id = this.context.getStore();
|
|
125
|
+
if (!id)
|
|
126
|
+
return fetcher(url, init);
|
|
127
|
+
const attempt = {
|
|
128
|
+
number: (this.traces.get(id)?.attempts.length ?? 0) + 1,
|
|
129
|
+
}, started = performance.now();
|
|
130
|
+
this.update(id, (t) => {
|
|
131
|
+
t.attempts.push(attempt);
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetcher(url, init);
|
|
135
|
+
this.update(id, () => {
|
|
136
|
+
attempt.durationMs = performance.now() - started;
|
|
137
|
+
attempt.status = response.status;
|
|
138
|
+
});
|
|
139
|
+
return response;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.update(id, () => {
|
|
143
|
+
attempt.durationMs = performance.now() - started;
|
|
144
|
+
attempt.error =
|
|
145
|
+
error instanceof Error ? error.name : "TransportError";
|
|
146
|
+
});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Preserve the SDK's original APIPromise (including map/withResponse/asResponse)
|
|
152
|
+
// and method receiver. The inspector reads a clone, never the caller's body.
|
|
153
|
+
instrument(client, meta) {
|
|
154
|
+
return new Proxy(client, {
|
|
155
|
+
get: (target, property) => {
|
|
156
|
+
if (property !== "systemOne") {
|
|
157
|
+
const value = Reflect.get(target, property, target);
|
|
158
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
159
|
+
}
|
|
160
|
+
return (request, options) => {
|
|
161
|
+
const id = randomUUID(), started = performance.now(), warnings = new Set();
|
|
162
|
+
let captured;
|
|
163
|
+
try {
|
|
164
|
+
captured = capture({ ...request, model: request.model ?? client.defaultModel }, warnings, this.options.redact);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
captured = {
|
|
168
|
+
model: client.defaultModel,
|
|
169
|
+
state: "[capture unavailable]",
|
|
170
|
+
questions: {},
|
|
171
|
+
};
|
|
172
|
+
warnings.add("Input snapshot failed; request continues normally.");
|
|
173
|
+
}
|
|
174
|
+
this.traces.set(id, {
|
|
175
|
+
version: 1,
|
|
176
|
+
id,
|
|
177
|
+
...(typeof meta === "function" ? meta() : meta),
|
|
178
|
+
startedAt: Date.now(),
|
|
179
|
+
status: "pending",
|
|
180
|
+
request: captured,
|
|
181
|
+
attempts: [],
|
|
182
|
+
captureWarnings: [...warnings],
|
|
183
|
+
});
|
|
184
|
+
while (this.traces.size > this.limit)
|
|
185
|
+
this.traces.delete(this.traces.keys().next().value);
|
|
186
|
+
this.notify();
|
|
187
|
+
const fail = (error) => this.update(id, (t) => {
|
|
188
|
+
const e = error;
|
|
189
|
+
t.status = e.name === "APIUserAbortError" ? "cancelled" : "error";
|
|
190
|
+
t.durationMs = performance.now() - started;
|
|
191
|
+
t.error = {
|
|
192
|
+
name: e.name || "Error",
|
|
193
|
+
message: safeMessage(e.message || "Request failed"),
|
|
194
|
+
status: e.status,
|
|
195
|
+
};
|
|
196
|
+
t.requestId = e.requestId;
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
const promise = this.context.run(id, () => target.systemOne(request, options));
|
|
200
|
+
void promise
|
|
201
|
+
.asResponse()
|
|
202
|
+
.then(async (response) => {
|
|
203
|
+
const data = await response.clone().json();
|
|
204
|
+
this.update(id, (t) => {
|
|
205
|
+
t.status = "success";
|
|
206
|
+
t.durationMs = performance.now() - started;
|
|
207
|
+
t.requestId =
|
|
208
|
+
response.headers.get("x-typesafe-request-id") ?? undefined;
|
|
209
|
+
t.result = capture(data, warnings, this.options.redact);
|
|
210
|
+
t.captureWarnings = [...warnings];
|
|
211
|
+
});
|
|
212
|
+
}, fail)
|
|
213
|
+
.catch((error) => {
|
|
214
|
+
fail(error);
|
|
215
|
+
});
|
|
216
|
+
return promise;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
fail(error);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|