@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.
@@ -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"}
@@ -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":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAC5C,OAAO,EAIL,OAAO,GACR,MAAM,eAAe,CAAA;AAgDtB,8EAA8E;AAC9E,oBAAoB;AACpB,8EAA8E;AAE9E,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAA;AAEjC;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CAAC,OAAqC;IAC5D,MAAM,SAAS,GAAG,OAAO,EAAE,SAAS,IAAI,GAAG,CAAA;IAC3C,MAAM,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,IAAI,CAAC,CAAC,CAAA;IAChE,MAAM,QAAQ,GAAG,OAAO,EAAE,IAAI,IAAI,kBAAkB,CAAA;IACpD,MAAM,OAAO,GAAG,OAAO,EAAE,OAAO,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,aAAa,CAAA;IAC1E,MAAM,WAAW,GAAG,OAAO,EAAE,WAAW,IAAI,KAAK,CAAA;IACjD,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,OAAO,EAAE,cAAc,IAAI,EAAE,CAAC,CAAA;IAC7D,MAAM,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,IAAI,MAAM,CAAC,CAAA;IACrE,MAAM,MAAM,GAAoB,EAAE,CAAA;IAClC,MAAM,WAAW,GAAG,IAAI,GAAG,EAAmC,CAAA;IAC9D,IAAI,YAAY,GAAG,CAAC,CAAA;IACpB,IAAI,SAAqD,CAAA;IAEzD,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;IAE9C,SAAS,QAAQ;QACf,IAAI,SAAS,KAAK,SAAS;YAAE,OAAM;QACnC,aAAa,CAAC,SAAS,CAAC,CAAA;QACxB,SAAS,GAAG,SAAS,CAAA;IACvB,CAAC;IAED,SAAS,SAAS;QAChB,IAAI,SAAS,KAAK,SAAS;YAAE,OAAM;QACnC,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YAC3B,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;gBAC/B,IAAI,CAAC;oBACH,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;gBACzB,CAAC;gBAAC,MAAM,CAAC;oBACP,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;gBAC1B,CAAC;YACH,CAAC;YACD,IAAI,WAAW,CAAC,IAAI,KAAK,CAAC;gBAAE,QAAQ,EAAE,CAAA;QACxC,CAAC,EAAE,cAAc,CAAC,CAEjB;QAAC,SAAoC,CAAC,KAAK,EAAE,EAAE,CAAA;IAClD,CAAC;IAED,SAAS,cAAc,CAAC,IAAqC;QAC3D,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QACxB,IAAI,WAAW,CAAC,IAAI,KAAK,CAAC;YAAE,QAAQ,EAAE,CAAA;IACxC,CAAC;IAED,SAAS,SAAS,CAAC,KAAoB;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;QACjE,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACH,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;YACpB,CAAC;YAAC,MAAM,CAAC;gBACP,cAAc,CAAC,IAAI,CAAC,CAAA;YACtB,CAAC;QACH,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAuB;QAClC,KAAK,CAAC,IAAe;YACnB,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAA;YACrD,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,CAAA;YACxC,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,2BAA2B,CAAC,CAAA;YAC3D,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;gBACzF,OAAM;YACR,CAAC;YACD,IAAI,IAAI,KAAK,QAAQ;gBAAE,OAAM;YAE7B,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC,kBAAkB,CAAC,CAAA;YACrD,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC,yBAAyB,CAAC,CAAA;YAC5D,MAAM,KAAK,GAAkB;gBAC3B,EAAE,EAAE,MAAM,CAAC,EAAE,YAAY,CAAC;gBAC1B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,YAAY,EAAE,IAAI,CAAC,YAAY;gBAC/B,SAAS,EAAE,IAAI,CAAC,SAAS;gBACzB,MAAM;gBACN,IAAI;gBACJ,MAAM;gBACN,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;gBAC1D,SAAS,EAAE,OAAO,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS;gBAChE,SAAS,EAAE,OAAO,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;aACzD,CAAA;YAED,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YAClB,IAAI,MAAM,CAAC,MAAM,GAAG,SAAS;gBAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;YAC1E,SAAS,CAAC,KAAK,CAAC,CAAA;QAClB,CAAC;KACF,CAAA;IAED,OAAO,YAAY,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE;QACtC,8FAA8F;QAC9F,IAAI,CAAC,OAAO;YAAE,OAAO,GAAG,CAAA;QACxB,OAAO,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;YACnD,IAAI,EAAE,iBAAiB;YAEvB,KAAK,CAAC,SAAS,CAAC,OAAgB;gBAC9B,0BAA0B;gBAC1B,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;gBAChC,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;oBAC9B,MAAM,QAAQ,GACZ,GAAG,CAAC,QAAQ,KAAK,WAAW;wBAC5B,GAAG,CAAC,QAAQ,KAAK,WAAW;wBAC5B,GAAG,CAAC,QAAQ,KAAK,OAAO;wBACxB,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAA;oBACrC,IAAI,CAAC,WAAW,IAAI,CAAC,QAAQ,EAAE,CAAC;wBAC9B,OAAO,IAAI,QAAQ,CAAC,gDAAgD,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;oBACxF,CAAC;oBACD,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;oBAC5C,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,GAAG,CAAC,MAAM,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;wBAC5E,OAAO,IAAI,QAAQ,CAAC,oBAAoB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;oBAC5D,CAAC;oBACD,IAAI,OAAO,EAAE,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,MAAM,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;wBAC5E,OAAO,IAAI,QAAQ,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;oBACtD,CAAC;oBACD,IAAI,WAAW,CAAC,IAAI,IAAI,cAAc,EAAE,CAAC;wBACvC,OAAO,IAAI,QAAQ,CAAC,+BAA+B,EAAE;4BACnD,MAAM,EAAE,GAAG;4BACX,OAAO,EAAE,EAAE,aAAa,EAAE,GAAG,EAAE;yBAChC,CAAC,CAAA;oBACJ,CAAC;oBAED,IAAI,gBAA6D,CAAA;oBACjE,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC;wBAChC,KAAK,CAAC,UAAU;4BACd,gBAAgB,GAAG,UAAU,CAAA;4BAC7B,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;4BAC3B,SAAS,EAAE,CAAA;4BACX,0CAA0C;4BAC1C,KAAK,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;gCACzB,IAAI,CAAC;oCACH,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAA;gCACxE,CAAC;gCAAC,MAAM,CAAC;oCACP,MAAK;gCACP,CAAC;4BACH,CAAC;wBACH,CAAC;wBACD,MAAM;4BACJ,IAAI,gBAAgB,KAAK,SAAS;gCAAE,cAAc,CAAC,gBAAgB,CAAC,CAAA;wBACtE,CAAC;qBACF,CAAC,CAAA;oBAEF,sCAAsC;oBACtC,MAAM,QAAQ,GAAG,IAAI,QAAQ,CAAC,MAAM,EAAE;wBACpC,OAAO,EAAE;4BACP,cAAc,EAAE,mBAAmB;4BACnC,eAAe,EAAE,UAAU;4BAC3B,UAAU,EAAE,YAAY;4BACxB,yBAAyB,EAAE,oBAAoB;4BAC/C,wBAAwB,EAAE,SAAS;4BACnC,kBAAkB,EAAE,MAAM;yBAC3B;qBACF,CAAC,CAAA;oBAEF,OAAO,QAAQ,CAAA;gBACjB,CAAC;gBAED,OAAO,SAAS,CAAA;YAClB,CAAC;YAED,YAAY,CAAC,OAAO;gBAClB,CAAC;gBAAC,OAAgE,CAAC,WAAW,EAAE,UAAU,CACxF,OAAO,CACR,CAAA;gBACD,OAAO,SAAS,CAAA;YAClB,CAAC;SACF,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,8EAA8E;AAC9E,wBAAwB;AACxB,8EAA8E;AAE9E;;;;;;;;;;GAUG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAiC,EAAE;IACtE,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,IAAI,kBAAkB,CAAC,CAAA;IACnE,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BA8FkB,QAAQ;;;;;;MAM7B,CAAA;AACN,CAAC"}
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
@@ -0,0 +1,9 @@
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.ts"
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
+ }