@nifrajs/devtools 1.4.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/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +303 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/client.ts +9 -0
- package/src/index.ts +366 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export the client script for consumers who only need the browser overlay.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { devtoolsClientScript } from "@nifrajs/devtools/client"
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
export { devtoolsClientScript } from "./index.js";
|
|
10
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export the client script for consumers who only need the browser overlay.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { devtoolsClientScript } from "@nifrajs/devtools/client"
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
export { devtoolsClientScript } from "./index.js";
|
|
10
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/devtools` — interactive DevTools overlay for `nifra dev`.
|
|
3
|
+
*
|
|
4
|
+
* Server-side: a middleware plugin that captures request traces into a ring buffer
|
|
5
|
+
* and streams them via SSE at `/_nifra/devtools`.
|
|
6
|
+
*
|
|
7
|
+
* Client-side: a floating browser overlay that connects to the SSE stream and
|
|
8
|
+
* renders a scrolling log of request traces with ISR status badges.
|
|
9
|
+
*
|
|
10
|
+
* **Zero production overhead** — register conditionally:
|
|
11
|
+
* ```ts
|
|
12
|
+
* if (process.env.NODE_ENV !== "production") app.use(devtools())
|
|
13
|
+
* ```
|
|
14
|
+
* When not registered, routes stay `bare` and the SSE endpoint doesn't exist.
|
|
15
|
+
*/
|
|
16
|
+
export interface DevToolsEvent {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly traceId: string;
|
|
19
|
+
readonly spanId: string;
|
|
20
|
+
readonly parentSpanId?: string | undefined;
|
|
21
|
+
readonly timestamp: number;
|
|
22
|
+
readonly method: string;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly status: number;
|
|
25
|
+
readonly durationMs: number;
|
|
26
|
+
readonly isrStatus?: string | undefined;
|
|
27
|
+
readonly bodyBytes: number;
|
|
28
|
+
}
|
|
29
|
+
export interface DevToolsOptions {
|
|
30
|
+
/** Maximum events to retain in the ring buffer (default 200). */
|
|
31
|
+
readonly maxEvents?: number | undefined;
|
|
32
|
+
/** Maximum simultaneous SSE connections (default 5). */
|
|
33
|
+
readonly maxConnections?: number | undefined;
|
|
34
|
+
/** SSE path (default `/_nifra/devtools`). */
|
|
35
|
+
readonly path?: string | undefined;
|
|
36
|
+
/** Explicit enable switch. Defaults to true only when `NODE_ENV === "development"`. */
|
|
37
|
+
readonly enabled?: boolean | undefined;
|
|
38
|
+
/** Permit non-loopback URL hosts. Default false. */
|
|
39
|
+
readonly allowRemote?: boolean | undefined;
|
|
40
|
+
/** Additional origins permitted to open the stream. Same-origin requests are always allowed. */
|
|
41
|
+
readonly allowedOrigins?: readonly string[] | undefined;
|
|
42
|
+
/** Optional request authorization for the stream. */
|
|
43
|
+
readonly authorize?: ((request: Request) => boolean | Promise<boolean>) | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Interval for SSE keep-alive comment frames, in milliseconds (default 15000). Pings stop
|
|
46
|
+
* idle proxies/dev tunnels from dropping the stream and evict dead connections so they no
|
|
47
|
+
* longer count against `maxConnections`.
|
|
48
|
+
*/
|
|
49
|
+
readonly pingIntervalMs?: number | undefined;
|
|
50
|
+
}
|
|
51
|
+
export interface DevToolsClientOptions {
|
|
52
|
+
/** SSE path configured on the server plugin. */
|
|
53
|
+
readonly path?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* DevTools plugin. Its observation adapter projects the single request span into a
|
|
57
|
+
* `DevToolsEvent`; its middleware only owns the secured SSE transport.
|
|
58
|
+
* When configuring `tracing()` yourself, register it before this plugin so DevTools attaches to
|
|
59
|
+
* that request owner.
|
|
60
|
+
*/
|
|
61
|
+
export declare function devtools(options?: DevToolsOptions | undefined): import("@nifrajs/core").NifraPlugin<import("@nifrajs/core").AnyServer, import("@nifrajs/core").AnyServer>;
|
|
62
|
+
/**
|
|
63
|
+
* Returns a self-contained JavaScript string that creates a floating DevTools
|
|
64
|
+
* overlay in the browser. Inject via `<script>` tag in dev mode.
|
|
65
|
+
*
|
|
66
|
+
* The overlay:
|
|
67
|
+
* - Connects to `/_nifra/devtools` via EventSource
|
|
68
|
+
* - Shows a scrolling log of request traces
|
|
69
|
+
* - Color-coded method badges and status codes
|
|
70
|
+
* - ISR hit/miss/stale indicators
|
|
71
|
+
* - Collapsible with a ⚡ toggle button
|
|
72
|
+
*/
|
|
73
|
+
export declare function devtoolsClientScript(options?: DevToolsClientOptions): string;
|
|
74
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAcH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,wDAAwD;IACxD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC5C,6CAA6C;IAC7C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,uFAAuF;IACvF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACtC,oDAAoD;IACpD,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC1C,gGAAgG;IAChG,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAA;IACvD,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAAA;IACnF;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7C;AAED,MAAM,WAAW,qBAAqB;IACpC,gDAAgD;IAChD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACnC;AAQD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,6GAmK7D;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAuGhF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/devtools` — interactive DevTools overlay for `nifra dev`.
|
|
3
|
+
*
|
|
4
|
+
* Server-side: a middleware plugin that captures request traces into a ring buffer
|
|
5
|
+
* and streams them via SSE at `/_nifra/devtools`.
|
|
6
|
+
*
|
|
7
|
+
* Client-side: a floating browser overlay that connects to the SSE stream and
|
|
8
|
+
* renders a scrolling log of request traces with ISR status badges.
|
|
9
|
+
*
|
|
10
|
+
* **Zero production overhead** — register conditionally:
|
|
11
|
+
* ```ts
|
|
12
|
+
* if (process.env.NODE_ENV !== "production") app.use(devtools())
|
|
13
|
+
* ```
|
|
14
|
+
* When not registered, routes stay `bare` and the SSE endpoint doesn't exist.
|
|
15
|
+
*/
|
|
16
|
+
import { definePlugin } from "@nifrajs/core";
|
|
17
|
+
import { tracing, } from "@nifrajs/otel";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Server middleware
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
/**
|
|
23
|
+
* DevTools plugin. Its observation adapter projects the single request span into a
|
|
24
|
+
* `DevToolsEvent`; its middleware only owns the secured SSE transport.
|
|
25
|
+
* When configuring `tracing()` yourself, register it before this plugin so DevTools attaches to
|
|
26
|
+
* that request owner.
|
|
27
|
+
*/
|
|
28
|
+
export function devtools(options) {
|
|
29
|
+
const maxEvents = options?.maxEvents ?? 200;
|
|
30
|
+
const maxConnections = Math.max(1, options?.maxConnections ?? 5);
|
|
31
|
+
const endpoint = options?.path ?? "/_nifra/devtools";
|
|
32
|
+
const enabled = options?.enabled ?? process.env.NODE_ENV === "development";
|
|
33
|
+
const allowRemote = options?.allowRemote ?? false;
|
|
34
|
+
const allowedOrigins = new Set(options?.allowedOrigins ?? []);
|
|
35
|
+
const pingIntervalMs = Math.max(1, options?.pingIntervalMs ?? 15_000);
|
|
36
|
+
const buffer = [];
|
|
37
|
+
const controllers = new Set();
|
|
38
|
+
let eventCounter = 0;
|
|
39
|
+
let pingTimer;
|
|
40
|
+
const pingFrame = encoder.encode(": ping\n\n");
|
|
41
|
+
function stopPing() {
|
|
42
|
+
if (pingTimer === undefined)
|
|
43
|
+
return;
|
|
44
|
+
clearInterval(pingTimer);
|
|
45
|
+
pingTimer = undefined;
|
|
46
|
+
}
|
|
47
|
+
function startPing() {
|
|
48
|
+
if (pingTimer !== undefined)
|
|
49
|
+
return;
|
|
50
|
+
pingTimer = setInterval(() => {
|
|
51
|
+
for (const ctrl of controllers) {
|
|
52
|
+
try {
|
|
53
|
+
ctrl.enqueue(pingFrame);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
controllers.delete(ctrl);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (controllers.size === 0)
|
|
60
|
+
stopPing();
|
|
61
|
+
}, pingIntervalMs);
|
|
62
|
+
pingTimer.unref?.();
|
|
63
|
+
}
|
|
64
|
+
function dropController(ctrl) {
|
|
65
|
+
controllers.delete(ctrl);
|
|
66
|
+
if (controllers.size === 0)
|
|
67
|
+
stopPing();
|
|
68
|
+
}
|
|
69
|
+
function broadcast(event) {
|
|
70
|
+
const data = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
71
|
+
for (const ctrl of controllers) {
|
|
72
|
+
try {
|
|
73
|
+
ctrl.enqueue(data);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
dropController(ctrl);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const adapter = {
|
|
81
|
+
onEnd(span) {
|
|
82
|
+
const method = span.attributes["http.request.method"];
|
|
83
|
+
const path = span.attributes["url.path"];
|
|
84
|
+
const status = span.attributes["http.response.status_code"];
|
|
85
|
+
if (typeof method !== "string" || typeof path !== "string" || typeof status !== "number") {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (path === endpoint)
|
|
89
|
+
return;
|
|
90
|
+
const isrStatus = span.attributes["nifra.isr.status"];
|
|
91
|
+
const bodyBytes = span.attributes["http.response.body.size"];
|
|
92
|
+
const event = {
|
|
93
|
+
id: String(++eventCounter),
|
|
94
|
+
traceId: span.traceId,
|
|
95
|
+
spanId: span.spanId,
|
|
96
|
+
parentSpanId: span.parentSpanId,
|
|
97
|
+
timestamp: span.startTime,
|
|
98
|
+
method,
|
|
99
|
+
path,
|
|
100
|
+
status,
|
|
101
|
+
durationMs: Math.round((span.durationMs ?? 0) * 100) / 100,
|
|
102
|
+
isrStatus: typeof isrStatus === "string" ? isrStatus : undefined,
|
|
103
|
+
bodyBytes: typeof bodyBytes === "number" ? bodyBytes : 0,
|
|
104
|
+
};
|
|
105
|
+
buffer.push(event);
|
|
106
|
+
if (buffer.length > maxEvents)
|
|
107
|
+
buffer.splice(0, buffer.length - maxEvents);
|
|
108
|
+
broadcast(event);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return definePlugin("devtools", (app) => {
|
|
112
|
+
// Disabled → wire nothing: no tracing pipeline, no SSE middleware, zero per-request overhead.
|
|
113
|
+
if (!enabled)
|
|
114
|
+
return app;
|
|
115
|
+
return app.use(tracing({ adapters: [adapter] })).use({
|
|
116
|
+
name: "devtools-stream",
|
|
117
|
+
async onRequest(request) {
|
|
118
|
+
// Handle the SSE endpoint
|
|
119
|
+
const url = new URL(request.url);
|
|
120
|
+
if (url.pathname === endpoint) {
|
|
121
|
+
const loopback = url.hostname === "localhost" ||
|
|
122
|
+
url.hostname === "127.0.0.1" ||
|
|
123
|
+
url.hostname === "[::1]" ||
|
|
124
|
+
url.hostname.endsWith(".localhost");
|
|
125
|
+
if (!allowRemote && !loopback) {
|
|
126
|
+
return new Response("Nifra DevTools is restricted to loopback hosts", { status: 403 });
|
|
127
|
+
}
|
|
128
|
+
const origin = request.headers.get("origin");
|
|
129
|
+
if (origin !== null && origin !== url.origin && !allowedOrigins.has(origin)) {
|
|
130
|
+
return new Response("Origin not allowed", { status: 403 });
|
|
131
|
+
}
|
|
132
|
+
if (options?.authorize !== undefined && !(await options.authorize(request))) {
|
|
133
|
+
return new Response("Unauthorized", { status: 401 });
|
|
134
|
+
}
|
|
135
|
+
if (controllers.size >= maxConnections) {
|
|
136
|
+
return new Response("Too many DevTools connections", {
|
|
137
|
+
status: 503,
|
|
138
|
+
headers: { "retry-after": "5" },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
let activeController;
|
|
142
|
+
const stream = new ReadableStream({
|
|
143
|
+
start(controller) {
|
|
144
|
+
activeController = controller;
|
|
145
|
+
controllers.add(controller);
|
|
146
|
+
startPing();
|
|
147
|
+
// Send buffered events as initial payload
|
|
148
|
+
for (const evt of buffer) {
|
|
149
|
+
try {
|
|
150
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
cancel() {
|
|
158
|
+
if (activeController !== undefined)
|
|
159
|
+
dropController(activeController);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
// Clean up dead controllers on cancel
|
|
163
|
+
const response = new Response(stream, {
|
|
164
|
+
headers: {
|
|
165
|
+
"content-type": "text/event-stream",
|
|
166
|
+
"cache-control": "no-store",
|
|
167
|
+
connection: "keep-alive",
|
|
168
|
+
"content-security-policy": "default-src 'none'",
|
|
169
|
+
"x-content-type-options": "nosniff",
|
|
170
|
+
"x-nifra-devtools": "true",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
return response;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
},
|
|
177
|
+
beforeHandle(context) {
|
|
178
|
+
;
|
|
179
|
+
context.observation?.addAdapter(adapter);
|
|
180
|
+
return undefined;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Browser client script
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
/**
|
|
189
|
+
* Returns a self-contained JavaScript string that creates a floating DevTools
|
|
190
|
+
* overlay in the browser. Inject via `<script>` tag in dev mode.
|
|
191
|
+
*
|
|
192
|
+
* The overlay:
|
|
193
|
+
* - Connects to `/_nifra/devtools` via EventSource
|
|
194
|
+
* - Shows a scrolling log of request traces
|
|
195
|
+
* - Color-coded method badges and status codes
|
|
196
|
+
* - ISR hit/miss/stale indicators
|
|
197
|
+
* - Collapsible with a ⚡ toggle button
|
|
198
|
+
*/
|
|
199
|
+
export function devtoolsClientScript(options = {}) {
|
|
200
|
+
const endpoint = JSON.stringify(options.path ?? "/_nifra/devtools");
|
|
201
|
+
return `(function(){
|
|
202
|
+
if(typeof window==='undefined')return;
|
|
203
|
+
var MAX=50,events=[],open=false;
|
|
204
|
+
|
|
205
|
+
// Panel container
|
|
206
|
+
var panel=document.createElement('div');
|
|
207
|
+
panel.id='nifra-devtools';
|
|
208
|
+
panel.style.cssText='position:fixed;bottom:12px;right:12px;z-index:99999;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,monospace;font-size:12px;';
|
|
209
|
+
|
|
210
|
+
// Toggle button
|
|
211
|
+
var btn=document.createElement('button');
|
|
212
|
+
btn.textContent='⚡';
|
|
213
|
+
btn.title='Nifra DevTools';
|
|
214
|
+
btn.style.cssText='width:36px;height:36px;border-radius:50%;border:1px solid rgba(255,255,255,0.15);background:rgba(10,10,30,0.9);color:#7c3aed;font-size:16px;cursor:pointer;backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);transition:transform 0.2s;';
|
|
215
|
+
btn.onmouseenter=function(){btn.style.transform='scale(1.1)'};
|
|
216
|
+
btn.onmouseleave=function(){btn.style.transform='scale(1)'};
|
|
217
|
+
|
|
218
|
+
// Log container
|
|
219
|
+
var log=document.createElement('div');
|
|
220
|
+
log.style.cssText='display:none;width:380px;max-height:400px;overflow-y:auto;background:rgba(10,10,30,0.92);backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:8px;margin-bottom:8px;box-shadow:0 8px 32px rgba(0,0,0,0.5);';
|
|
221
|
+
|
|
222
|
+
// Header
|
|
223
|
+
var hdr=document.createElement('div');
|
|
224
|
+
hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:4px 8px 8px;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:6px;';
|
|
225
|
+
hdr.innerHTML='<span style="color:#7c3aed;font-weight:700;font-size:13px;">⚡ Nifra DevTools</span>';
|
|
226
|
+
var counter=document.createElement('span');
|
|
227
|
+
counter.style.cssText='color:#888;font-size:11px;';
|
|
228
|
+
counter.textContent='0 events';
|
|
229
|
+
var clearBtn=document.createElement('button');
|
|
230
|
+
clearBtn.textContent='Clear';
|
|
231
|
+
clearBtn.style.cssText='background:none;border:1px solid rgba(255,255,255,0.1);color:#888;font-size:10px;padding:2px 8px;border-radius:4px;cursor:pointer;margin-left:8px;';
|
|
232
|
+
clearBtn.onclick=function(){events=[];list.innerHTML='';counter.textContent='0 events';};
|
|
233
|
+
var right=document.createElement('span');
|
|
234
|
+
right.appendChild(counter);right.appendChild(clearBtn);
|
|
235
|
+
hdr.appendChild(right);
|
|
236
|
+
log.appendChild(hdr);
|
|
237
|
+
|
|
238
|
+
var list=document.createElement('div');
|
|
239
|
+
log.appendChild(list);
|
|
240
|
+
|
|
241
|
+
panel.appendChild(log);
|
|
242
|
+
panel.appendChild(btn);
|
|
243
|
+
|
|
244
|
+
btn.onclick=function(){
|
|
245
|
+
open=!open;
|
|
246
|
+
log.style.display=open?'block':'none';
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function methodColor(m){
|
|
250
|
+
switch(m){case'GET':return'#2ecc71';case'POST':return'#3b82f6';case'PUT':return'#f59e0b';case'DELETE':return'#ef4444';default:return'#888';}
|
|
251
|
+
}
|
|
252
|
+
function statusColor(s){
|
|
253
|
+
if(s<300)return'#2ecc71';if(s<400)return'#3b82f6';if(s<500)return'#f59e0b';return'#ef4444';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function addEvent(evt){
|
|
257
|
+
events.push(evt);
|
|
258
|
+
if(events.length>MAX)events.shift();
|
|
259
|
+
counter.textContent=events.length+' events';
|
|
260
|
+
|
|
261
|
+
var row=document.createElement('div');
|
|
262
|
+
row.style.cssText='padding:4px 8px;border-bottom:1px solid rgba(255,255,255,0.04);display:flex;align-items:center;gap:8px;';
|
|
263
|
+
|
|
264
|
+
var method=document.createElement('span');
|
|
265
|
+
method.textContent=evt.method;
|
|
266
|
+
method.style.cssText='font-weight:700;font-size:10px;padding:1px 6px;border-radius:3px;color:#fff;background:'+methodColor(evt.method)+';min-width:36px;text-align:center;';
|
|
267
|
+
|
|
268
|
+
var path=document.createElement('span');
|
|
269
|
+
path.textContent=evt.path;
|
|
270
|
+
path.style.cssText='color:#e0e0e0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
271
|
+
|
|
272
|
+
var status=document.createElement('span');
|
|
273
|
+
status.textContent=String(evt.status);
|
|
274
|
+
status.style.cssText='color:'+statusColor(evt.status)+';font-weight:600;';
|
|
275
|
+
|
|
276
|
+
var dur=document.createElement('span');
|
|
277
|
+
dur.textContent=evt.durationMs+'ms';
|
|
278
|
+
dur.style.cssText='color:#888;font-size:11px;min-width:50px;text-align:right;';
|
|
279
|
+
|
|
280
|
+
row.appendChild(method);row.appendChild(path);row.appendChild(status);row.appendChild(dur);
|
|
281
|
+
|
|
282
|
+
if(evt.isrStatus){
|
|
283
|
+
var isr=document.createElement('span');
|
|
284
|
+
isr.textContent=evt.isrStatus;
|
|
285
|
+
var ic=evt.isrStatus==='hit'?'#2ecc71':evt.isrStatus==='stale'?'#f59e0b':'#888';
|
|
286
|
+
isr.style.cssText='font-size:9px;padding:1px 4px;border-radius:3px;background:'+ic+'22;color:'+ic+';border:1px solid '+ic+'44;';
|
|
287
|
+
row.appendChild(isr);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
list.insertBefore(row,list.firstChild);
|
|
291
|
+
if(list.children.length>MAX)list.removeChild(list.lastChild);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Connect SSE
|
|
295
|
+
var es=new EventSource(${endpoint});
|
|
296
|
+
es.onmessage=function(e){
|
|
297
|
+
try{addEvent(JSON.parse(e.data));}catch(err){}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
document.body.appendChild(panel);
|
|
301
|
+
})();`;
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/devtools",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Interactive DevTools overlay for nifra dev — loader traces, ISR status, route metadata.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"bun": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"bun": "./src/client.ts",
|
|
17
|
+
"types": "./dist/client.d.ts",
|
|
18
|
+
"default": "./dist/client.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@nifrajs/otel": "1.4.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@nifrajs/core": "^1.4.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@nifrajs/core": "workspace:*"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/client.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/devtools` — interactive DevTools overlay for `nifra dev`.
|
|
3
|
+
*
|
|
4
|
+
* Server-side: a middleware plugin that captures request traces into a ring buffer
|
|
5
|
+
* and streams them via SSE at `/_nifra/devtools`.
|
|
6
|
+
*
|
|
7
|
+
* Client-side: a floating browser overlay that connects to the SSE stream and
|
|
8
|
+
* renders a scrolling log of request traces with ISR status badges.
|
|
9
|
+
*
|
|
10
|
+
* **Zero production overhead** — register conditionally:
|
|
11
|
+
* ```ts
|
|
12
|
+
* if (process.env.NODE_ENV !== "production") app.use(devtools())
|
|
13
|
+
* ```
|
|
14
|
+
* When not registered, routes stay `bare` and the SSE endpoint doesn't exist.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { definePlugin } from "@nifrajs/core"
|
|
18
|
+
import {
|
|
19
|
+
type ActiveObservation,
|
|
20
|
+
type NifraSpan,
|
|
21
|
+
type ObservationAdapter,
|
|
22
|
+
tracing,
|
|
23
|
+
} from "@nifrajs/otel"
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface DevToolsEvent {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly traceId: string
|
|
32
|
+
readonly spanId: string
|
|
33
|
+
readonly parentSpanId?: string | undefined
|
|
34
|
+
readonly timestamp: number
|
|
35
|
+
readonly method: string
|
|
36
|
+
readonly path: string
|
|
37
|
+
readonly status: number
|
|
38
|
+
readonly durationMs: number
|
|
39
|
+
readonly isrStatus?: string | undefined
|
|
40
|
+
readonly bodyBytes: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DevToolsOptions {
|
|
44
|
+
/** Maximum events to retain in the ring buffer (default 200). */
|
|
45
|
+
readonly maxEvents?: number | undefined
|
|
46
|
+
/** Maximum simultaneous SSE connections (default 5). */
|
|
47
|
+
readonly maxConnections?: number | undefined
|
|
48
|
+
/** SSE path (default `/_nifra/devtools`). */
|
|
49
|
+
readonly path?: string | undefined
|
|
50
|
+
/** Explicit enable switch. Defaults to true only when `NODE_ENV === "development"`. */
|
|
51
|
+
readonly enabled?: boolean | undefined
|
|
52
|
+
/** Permit non-loopback URL hosts. Default false. */
|
|
53
|
+
readonly allowRemote?: boolean | undefined
|
|
54
|
+
/** Additional origins permitted to open the stream. Same-origin requests are always allowed. */
|
|
55
|
+
readonly allowedOrigins?: readonly string[] | undefined
|
|
56
|
+
/** Optional request authorization for the stream. */
|
|
57
|
+
readonly authorize?: ((request: Request) => boolean | Promise<boolean>) | undefined
|
|
58
|
+
/**
|
|
59
|
+
* Interval for SSE keep-alive comment frames, in milliseconds (default 15000). Pings stop
|
|
60
|
+
* idle proxies/dev tunnels from dropping the stream and evict dead connections so they no
|
|
61
|
+
* longer count against `maxConnections`.
|
|
62
|
+
*/
|
|
63
|
+
readonly pingIntervalMs?: number | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DevToolsClientOptions {
|
|
67
|
+
/** SSE path configured on the server plugin. */
|
|
68
|
+
readonly path?: string | undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Server middleware
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const encoder = new TextEncoder()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* DevTools plugin. Its observation adapter projects the single request span into a
|
|
79
|
+
* `DevToolsEvent`; its middleware only owns the secured SSE transport.
|
|
80
|
+
* When configuring `tracing()` yourself, register it before this plugin so DevTools attaches to
|
|
81
|
+
* that request owner.
|
|
82
|
+
*/
|
|
83
|
+
export function devtools(options?: DevToolsOptions | undefined) {
|
|
84
|
+
const maxEvents = options?.maxEvents ?? 200
|
|
85
|
+
const maxConnections = Math.max(1, options?.maxConnections ?? 5)
|
|
86
|
+
const endpoint = options?.path ?? "/_nifra/devtools"
|
|
87
|
+
const enabled = options?.enabled ?? process.env.NODE_ENV === "development"
|
|
88
|
+
const allowRemote = options?.allowRemote ?? false
|
|
89
|
+
const allowedOrigins = new Set(options?.allowedOrigins ?? [])
|
|
90
|
+
const pingIntervalMs = Math.max(1, options?.pingIntervalMs ?? 15_000)
|
|
91
|
+
const buffer: DevToolsEvent[] = []
|
|
92
|
+
const controllers = new Set<ReadableStreamDefaultController>()
|
|
93
|
+
let eventCounter = 0
|
|
94
|
+
let pingTimer: ReturnType<typeof setInterval> | undefined
|
|
95
|
+
|
|
96
|
+
const pingFrame = encoder.encode(": ping\n\n")
|
|
97
|
+
|
|
98
|
+
function stopPing(): void {
|
|
99
|
+
if (pingTimer === undefined) return
|
|
100
|
+
clearInterval(pingTimer)
|
|
101
|
+
pingTimer = undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function startPing(): void {
|
|
105
|
+
if (pingTimer !== undefined) return
|
|
106
|
+
pingTimer = setInterval(() => {
|
|
107
|
+
for (const ctrl of controllers) {
|
|
108
|
+
try {
|
|
109
|
+
ctrl.enqueue(pingFrame)
|
|
110
|
+
} catch {
|
|
111
|
+
controllers.delete(ctrl)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (controllers.size === 0) stopPing()
|
|
115
|
+
}, pingIntervalMs)
|
|
116
|
+
// Keep-alive frames must never keep the process alive.
|
|
117
|
+
;(pingTimer as { unref?: () => void }).unref?.()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function dropController(ctrl: ReadableStreamDefaultController): void {
|
|
121
|
+
controllers.delete(ctrl)
|
|
122
|
+
if (controllers.size === 0) stopPing()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function broadcast(event: DevToolsEvent): void {
|
|
126
|
+
const data = encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
|
|
127
|
+
for (const ctrl of controllers) {
|
|
128
|
+
try {
|
|
129
|
+
ctrl.enqueue(data)
|
|
130
|
+
} catch {
|
|
131
|
+
dropController(ctrl)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const adapter: ObservationAdapter = {
|
|
137
|
+
onEnd(span: NifraSpan) {
|
|
138
|
+
const method = span.attributes["http.request.method"]
|
|
139
|
+
const path = span.attributes["url.path"]
|
|
140
|
+
const status = span.attributes["http.response.status_code"]
|
|
141
|
+
if (typeof method !== "string" || typeof path !== "string" || typeof status !== "number") {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (path === endpoint) return
|
|
145
|
+
|
|
146
|
+
const isrStatus = span.attributes["nifra.isr.status"]
|
|
147
|
+
const bodyBytes = span.attributes["http.response.body.size"]
|
|
148
|
+
const event: DevToolsEvent = {
|
|
149
|
+
id: String(++eventCounter),
|
|
150
|
+
traceId: span.traceId,
|
|
151
|
+
spanId: span.spanId,
|
|
152
|
+
parentSpanId: span.parentSpanId,
|
|
153
|
+
timestamp: span.startTime,
|
|
154
|
+
method,
|
|
155
|
+
path,
|
|
156
|
+
status,
|
|
157
|
+
durationMs: Math.round((span.durationMs ?? 0) * 100) / 100,
|
|
158
|
+
isrStatus: typeof isrStatus === "string" ? isrStatus : undefined,
|
|
159
|
+
bodyBytes: typeof bodyBytes === "number" ? bodyBytes : 0,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
buffer.push(event)
|
|
163
|
+
if (buffer.length > maxEvents) buffer.splice(0, buffer.length - maxEvents)
|
|
164
|
+
broadcast(event)
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return definePlugin("devtools", (app) => {
|
|
169
|
+
// Disabled → wire nothing: no tracing pipeline, no SSE middleware, zero per-request overhead.
|
|
170
|
+
if (!enabled) return app
|
|
171
|
+
return app.use(tracing({ adapters: [adapter] })).use({
|
|
172
|
+
name: "devtools-stream",
|
|
173
|
+
|
|
174
|
+
async onRequest(request: Request): Promise<Response | undefined> {
|
|
175
|
+
// Handle the SSE endpoint
|
|
176
|
+
const url = new URL(request.url)
|
|
177
|
+
if (url.pathname === endpoint) {
|
|
178
|
+
const loopback =
|
|
179
|
+
url.hostname === "localhost" ||
|
|
180
|
+
url.hostname === "127.0.0.1" ||
|
|
181
|
+
url.hostname === "[::1]" ||
|
|
182
|
+
url.hostname.endsWith(".localhost")
|
|
183
|
+
if (!allowRemote && !loopback) {
|
|
184
|
+
return new Response("Nifra DevTools is restricted to loopback hosts", { status: 403 })
|
|
185
|
+
}
|
|
186
|
+
const origin = request.headers.get("origin")
|
|
187
|
+
if (origin !== null && origin !== url.origin && !allowedOrigins.has(origin)) {
|
|
188
|
+
return new Response("Origin not allowed", { status: 403 })
|
|
189
|
+
}
|
|
190
|
+
if (options?.authorize !== undefined && !(await options.authorize(request))) {
|
|
191
|
+
return new Response("Unauthorized", { status: 401 })
|
|
192
|
+
}
|
|
193
|
+
if (controllers.size >= maxConnections) {
|
|
194
|
+
return new Response("Too many DevTools connections", {
|
|
195
|
+
status: 503,
|
|
196
|
+
headers: { "retry-after": "5" },
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let activeController: ReadableStreamDefaultController | undefined
|
|
201
|
+
const stream = new ReadableStream({
|
|
202
|
+
start(controller) {
|
|
203
|
+
activeController = controller
|
|
204
|
+
controllers.add(controller)
|
|
205
|
+
startPing()
|
|
206
|
+
// Send buffered events as initial payload
|
|
207
|
+
for (const evt of buffer) {
|
|
208
|
+
try {
|
|
209
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`))
|
|
210
|
+
} catch {
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
cancel() {
|
|
216
|
+
if (activeController !== undefined) dropController(activeController)
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Clean up dead controllers on cancel
|
|
221
|
+
const response = new Response(stream, {
|
|
222
|
+
headers: {
|
|
223
|
+
"content-type": "text/event-stream",
|
|
224
|
+
"cache-control": "no-store",
|
|
225
|
+
connection: "keep-alive",
|
|
226
|
+
"content-security-policy": "default-src 'none'",
|
|
227
|
+
"x-content-type-options": "nosniff",
|
|
228
|
+
"x-nifra-devtools": "true",
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return response
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return undefined
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
beforeHandle(context) {
|
|
239
|
+
;(context as typeof context & { observation?: ActiveObservation }).observation?.addAdapter(
|
|
240
|
+
adapter,
|
|
241
|
+
)
|
|
242
|
+
return undefined
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Browser client script
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Returns a self-contained JavaScript string that creates a floating DevTools
|
|
254
|
+
* overlay in the browser. Inject via `<script>` tag in dev mode.
|
|
255
|
+
*
|
|
256
|
+
* The overlay:
|
|
257
|
+
* - Connects to `/_nifra/devtools` via EventSource
|
|
258
|
+
* - Shows a scrolling log of request traces
|
|
259
|
+
* - Color-coded method badges and status codes
|
|
260
|
+
* - ISR hit/miss/stale indicators
|
|
261
|
+
* - Collapsible with a ⚡ toggle button
|
|
262
|
+
*/
|
|
263
|
+
export function devtoolsClientScript(options: DevToolsClientOptions = {}): string {
|
|
264
|
+
const endpoint = JSON.stringify(options.path ?? "/_nifra/devtools")
|
|
265
|
+
return `(function(){
|
|
266
|
+
if(typeof window==='undefined')return;
|
|
267
|
+
var MAX=50,events=[],open=false;
|
|
268
|
+
|
|
269
|
+
// Panel container
|
|
270
|
+
var panel=document.createElement('div');
|
|
271
|
+
panel.id='nifra-devtools';
|
|
272
|
+
panel.style.cssText='position:fixed;bottom:12px;right:12px;z-index:99999;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,monospace;font-size:12px;';
|
|
273
|
+
|
|
274
|
+
// Toggle button
|
|
275
|
+
var btn=document.createElement('button');
|
|
276
|
+
btn.textContent='⚡';
|
|
277
|
+
btn.title='Nifra DevTools';
|
|
278
|
+
btn.style.cssText='width:36px;height:36px;border-radius:50%;border:1px solid rgba(255,255,255,0.15);background:rgba(10,10,30,0.9);color:#7c3aed;font-size:16px;cursor:pointer;backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);transition:transform 0.2s;';
|
|
279
|
+
btn.onmouseenter=function(){btn.style.transform='scale(1.1)'};
|
|
280
|
+
btn.onmouseleave=function(){btn.style.transform='scale(1)'};
|
|
281
|
+
|
|
282
|
+
// Log container
|
|
283
|
+
var log=document.createElement('div');
|
|
284
|
+
log.style.cssText='display:none;width:380px;max-height:400px;overflow-y:auto;background:rgba(10,10,30,0.92);backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:8px;margin-bottom:8px;box-shadow:0 8px 32px rgba(0,0,0,0.5);';
|
|
285
|
+
|
|
286
|
+
// Header
|
|
287
|
+
var hdr=document.createElement('div');
|
|
288
|
+
hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:4px 8px 8px;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:6px;';
|
|
289
|
+
hdr.innerHTML='<span style="color:#7c3aed;font-weight:700;font-size:13px;">⚡ Nifra DevTools</span>';
|
|
290
|
+
var counter=document.createElement('span');
|
|
291
|
+
counter.style.cssText='color:#888;font-size:11px;';
|
|
292
|
+
counter.textContent='0 events';
|
|
293
|
+
var clearBtn=document.createElement('button');
|
|
294
|
+
clearBtn.textContent='Clear';
|
|
295
|
+
clearBtn.style.cssText='background:none;border:1px solid rgba(255,255,255,0.1);color:#888;font-size:10px;padding:2px 8px;border-radius:4px;cursor:pointer;margin-left:8px;';
|
|
296
|
+
clearBtn.onclick=function(){events=[];list.innerHTML='';counter.textContent='0 events';};
|
|
297
|
+
var right=document.createElement('span');
|
|
298
|
+
right.appendChild(counter);right.appendChild(clearBtn);
|
|
299
|
+
hdr.appendChild(right);
|
|
300
|
+
log.appendChild(hdr);
|
|
301
|
+
|
|
302
|
+
var list=document.createElement('div');
|
|
303
|
+
log.appendChild(list);
|
|
304
|
+
|
|
305
|
+
panel.appendChild(log);
|
|
306
|
+
panel.appendChild(btn);
|
|
307
|
+
|
|
308
|
+
btn.onclick=function(){
|
|
309
|
+
open=!open;
|
|
310
|
+
log.style.display=open?'block':'none';
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
function methodColor(m){
|
|
314
|
+
switch(m){case'GET':return'#2ecc71';case'POST':return'#3b82f6';case'PUT':return'#f59e0b';case'DELETE':return'#ef4444';default:return'#888';}
|
|
315
|
+
}
|
|
316
|
+
function statusColor(s){
|
|
317
|
+
if(s<300)return'#2ecc71';if(s<400)return'#3b82f6';if(s<500)return'#f59e0b';return'#ef4444';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function addEvent(evt){
|
|
321
|
+
events.push(evt);
|
|
322
|
+
if(events.length>MAX)events.shift();
|
|
323
|
+
counter.textContent=events.length+' events';
|
|
324
|
+
|
|
325
|
+
var row=document.createElement('div');
|
|
326
|
+
row.style.cssText='padding:4px 8px;border-bottom:1px solid rgba(255,255,255,0.04);display:flex;align-items:center;gap:8px;';
|
|
327
|
+
|
|
328
|
+
var method=document.createElement('span');
|
|
329
|
+
method.textContent=evt.method;
|
|
330
|
+
method.style.cssText='font-weight:700;font-size:10px;padding:1px 6px;border-radius:3px;color:#fff;background:'+methodColor(evt.method)+';min-width:36px;text-align:center;';
|
|
331
|
+
|
|
332
|
+
var path=document.createElement('span');
|
|
333
|
+
path.textContent=evt.path;
|
|
334
|
+
path.style.cssText='color:#e0e0e0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
335
|
+
|
|
336
|
+
var status=document.createElement('span');
|
|
337
|
+
status.textContent=String(evt.status);
|
|
338
|
+
status.style.cssText='color:'+statusColor(evt.status)+';font-weight:600;';
|
|
339
|
+
|
|
340
|
+
var dur=document.createElement('span');
|
|
341
|
+
dur.textContent=evt.durationMs+'ms';
|
|
342
|
+
dur.style.cssText='color:#888;font-size:11px;min-width:50px;text-align:right;';
|
|
343
|
+
|
|
344
|
+
row.appendChild(method);row.appendChild(path);row.appendChild(status);row.appendChild(dur);
|
|
345
|
+
|
|
346
|
+
if(evt.isrStatus){
|
|
347
|
+
var isr=document.createElement('span');
|
|
348
|
+
isr.textContent=evt.isrStatus;
|
|
349
|
+
var ic=evt.isrStatus==='hit'?'#2ecc71':evt.isrStatus==='stale'?'#f59e0b':'#888';
|
|
350
|
+
isr.style.cssText='font-size:9px;padding:1px 4px;border-radius:3px;background:'+ic+'22;color:'+ic+';border:1px solid '+ic+'44;';
|
|
351
|
+
row.appendChild(isr);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
list.insertBefore(row,list.firstChild);
|
|
355
|
+
if(list.children.length>MAX)list.removeChild(list.lastChild);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Connect SSE
|
|
359
|
+
var es=new EventSource(${endpoint});
|
|
360
|
+
es.onmessage=function(e){
|
|
361
|
+
try{addEvent(JSON.parse(e.data));}catch(err){}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
document.body.appendChild(panel);
|
|
365
|
+
})();`
|
|
366
|
+
}
|