@zerotal/devtools 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
package/src/tracing.ts CHANGED
@@ -19,10 +19,27 @@
19
19
  *
20
20
  * Console patching is handled separately via startConsoleCapture() since
21
21
  * console.log is a hook (interception) not a broadcast event.
22
+ *
23
+ * ## This package's cast boundary
24
+ *
25
+ * Listed under `boundaries` in `cast-baseline.json`, so the per-file cast ratchet
26
+ * does not apply here. That is deliberate and confined to this file, for two
27
+ * invariants nothing in the type system can express:
28
+ *
29
+ * 1. **`HttpContext` is read structurally.** `ctx.session`, `ctx.user` and
30
+ * `ctx._routeDef` are contributed by packages devtools does not import — that
31
+ * independence is the whole design, and it is why an app without the session
32
+ * middleware simply has no `session` to read. Every such read is guarded and
33
+ * falls back to empty; a missing property is the ordinary case, not an error.
34
+ * 2. **`console` is patched by name.** Replacing `console[level]` at runtime is
35
+ * interception, not a typed call, so the index has to be untyped.
36
+ *
37
+ * Nothing else in `@zerotal/devtools` is exempt. A cast that wants to live
38
+ * somewhere other than this file is a cast to remove.
22
39
  */
23
40
 
24
41
  import { FrameworkEvents, RequestContext } from "@zerotal/core";
25
- import type { RequestHandled, RequestFailed } from "@zerotal/core";
42
+ import type { RequestHandled, RequestFailed, OutgoingRequestCompleted } from "@zerotal/core";
26
43
  import type { HttpContext } from "@zerotal/core";
27
44
  import type {
28
45
  QuerySpan,
@@ -31,19 +48,26 @@ import type {
31
48
  MailEntry,
32
49
  CacheEntry,
33
50
  JobEntry,
51
+ ExceptionInfo,
34
52
  RequestTrace,
35
53
  TraceChannelDescriptor,
36
54
  TraceChannelEntry,
37
55
  } from "./RequestTrace.ts";
38
56
  import { traceStore } from "./TraceStore.ts";
39
- import { redactBindings, type RedactionOptions } from "./redaction.ts";
57
+ import { captureCallSite, parseStack } from "./callsite.ts";
58
+ import { redactBindings, redactCacheKey, redactValue, type RedactionOptions } from "./redaction.ts";
40
59
 
41
60
  // ── Per-context event buffers ─────────────────────────────────────────────────
42
61
  // Events are buffered for the full request lifetime (including phases that run
43
62
  // before DevtoolsInjectionMiddleware, e.g. AuthMiddleware loading the user).
44
63
  // Buffers are GC'd with the HttpContext via WeakMap.
45
64
 
46
- type _BufLog = { level: LogEntry["level"]; args: string[]; absMs: number };
65
+ type _BufLog = {
66
+ level: LogEntry["level"];
67
+ args: string[];
68
+ absMs: number;
69
+ source?: LogEntry["source"];
70
+ };
47
71
  type _BufMail = Omit<MailEntry, "offsetMs"> & { absMs: number };
48
72
  type _BufCache = Omit<CacheEntry, "offsetMs"> & { absMs: number };
49
73
  type _BufJob = Omit<JobEntry, "offsetMs"> & { absMs: number };
@@ -82,7 +106,7 @@ export function _resetChannels(): void {
82
106
  _channels.clear();
83
107
  }
84
108
 
85
- // ── Redaction ─────────────────────────────────────────────────────────────────
109
+ // ── Capture settings ──────────────────────────────────────────────────────────
86
110
 
87
111
  let _redaction: RedactionOptions = {};
88
112
 
@@ -91,6 +115,31 @@ export function _setRedaction(options: RedactionOptions): void {
91
115
  _redaction = options;
92
116
  }
93
117
 
118
+ /**
119
+ * Whether to walk the stack for each query and log line.
120
+ *
121
+ * A field rather than a config read per event: this runs on the hot path of a
122
+ * request running forty queries, and resolving config forty times to answer the
123
+ * same question would cost more than the walk it is guarding.
124
+ */
125
+ let _captureSource = true;
126
+
127
+ /** @internal — set by DevtoolsProvider from the app's `devtools` config. */
128
+ export function _setCaptureSource(enabled: boolean): void {
129
+ _captureSource = enabled;
130
+ }
131
+
132
+ /**
133
+ * Request headers recorded beyond the built-in safe list, lower-cased.
134
+ * `"*"` records every header the redaction rules do not mask.
135
+ */
136
+ let _extraHeaders = new Set<string>();
137
+
138
+ /** @internal — set by DevtoolsProvider from the app's `devtools` config. */
139
+ export function _setHeaderAllowlist(headers: string[]): void {
140
+ _extraHeaders = new Set(headers.map((h) => h.toLowerCase()));
141
+ }
142
+
94
143
  // ── The sink feature packages contribute to ───────────────────────────────────
95
144
 
96
145
  /**
@@ -122,15 +171,32 @@ export interface TraceSink {
122
171
  bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void;
123
172
  }
124
173
 
174
+ // Everything below masks on the way *in*. Redacting in a renderer would protect
175
+ // nothing: by the time a panel draws a row, the unredacted copy has already been
176
+ // streamed to the browser and written to `.zerotal/devtools.sqlite`, where it
177
+ // sits for a day. The sink is the last point where "not recorded" is still true.
125
178
  export const traceSink: TraceSink = {
126
179
  channel(descriptor: TraceChannelDescriptor): void {
127
180
  _channels.set(descriptor.id, descriptor);
128
181
  },
129
182
  record(ctx: object, channel: string, entry: Record<string, unknown>): void {
130
- _bufPush(_ctxChannels, ctx, { channel, entry, absMs: Date.now() });
183
+ _bufPush(_ctxChannels, ctx, {
184
+ channel,
185
+ entry: redactValue(entry, _redaction) as Record<string, unknown>,
186
+ absMs: Date.now(),
187
+ });
131
188
  },
132
189
  bufferQuery(ctx: object, q: QuerySpan): void {
133
- _bufPush(_ctxQueries, ctx, { ...q, bindings: redactBindings(q.sql, q.bindings, _redaction) });
190
+ // The call site is captured here rather than at the emit site because here
191
+ // is the only place that knows whether anyone is recording. `skip` is 0: the
192
+ // frames above are this method and the ORM's bridge, both of which the
193
+ // framework filter drops anyway.
194
+ const source = _captureSource && !q.source ? captureCallSite() : q.source;
195
+ _bufPush(_ctxQueries, ctx, {
196
+ ...q,
197
+ bindings: redactBindings(q.sql, q.bindings, _redaction),
198
+ ...(source ? { source } : {}),
199
+ });
134
200
  },
135
201
  bufferWarning(ctx: object, w: NPlusOneWarning): void {
136
202
  _bufPush(_ctxWarnings, ctx, w);
@@ -139,7 +205,7 @@ export const traceSink: TraceSink = {
139
205
  _bufPush(_ctxMail, ctx, { ...m, absMs: Date.now() });
140
206
  },
141
207
  bufferCache(ctx: object, c: Omit<CacheEntry, "offsetMs">): void {
142
- _bufPush(_ctxCache, ctx, { ...c, absMs: Date.now() });
208
+ _bufPush(_ctxCache, ctx, { ...c, key: redactCacheKey(c.key, _redaction), absMs: Date.now() });
143
209
  },
144
210
  bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void {
145
211
  _bufPush(_ctxJobs, ctx, { ...j, absMs: Date.now() });
@@ -158,17 +224,38 @@ function _cleanupBuffers(ctx: object): void {
158
224
 
159
225
  // ── Trace builder ─────────────────────────────────────────────────────────────
160
226
 
227
+ /**
228
+ * Request headers recorded without being asked.
229
+ *
230
+ * An allowlist rather than a denylist because a trace is *persisted*: `cookie`
231
+ * and `authorization` are the request's credentials, and a header nobody thought
232
+ * to deny is a header on disk for a day. The cost is that the custom header you
233
+ * are actually debugging is invisible, which is what `devtools.headers` opens.
234
+ */
161
235
  const SAFE_HEADERS = new Set([
162
236
  "accept",
163
237
  "content-type",
238
+ "content-length",
164
239
  "user-agent",
165
240
  "referer",
241
+ "origin",
242
+ "accept-language",
166
243
  "x-request-id",
167
244
  "x-forwarded-for",
168
245
  "x-inertia",
169
246
  "x-inertia-version",
247
+ "x-requested-with",
170
248
  ]);
171
249
 
250
+ /** Never recorded, whatever the allowlist says — these *are* the credentials. */
251
+ const NEVER_HEADERS = new Set(["cookie", "set-cookie", "authorization", "proxy-authorization"]);
252
+
253
+ function _recordHeader(name: string): boolean {
254
+ const key = name.toLowerCase();
255
+ if (NEVER_HEADERS.has(key)) return false;
256
+ return SAFE_HEADERS.has(key) || _extraHeaders.has("*") || _extraHeaders.has(key);
257
+ }
258
+
172
259
  const INTERNAL_PREFIXES = ["/__flow/", "/__zerotal/", "/__dev/"];
173
260
 
174
261
  function _isInternal(path: string): boolean {
@@ -189,7 +276,34 @@ function _offset(absMs: number, startMs: number): number {
189
276
  return Math.max(0, absMs - startMs);
190
277
  }
191
278
 
192
- function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): RequestTrace {
279
+ /**
280
+ * What is in the session — the key names, never the values.
281
+ *
282
+ * "Is the CSRF token there, did the flash survive the redirect, is the user id
283
+ * set" are the session questions a request inspector is asked, and all three are
284
+ * answered by the keys. The values are the request's real state — the user's id,
285
+ * the token itself, whatever a form flashed — and this trace is written to disk
286
+ * for a day.
287
+ *
288
+ * Read structurally: devtools imports no feature package, and an app without the
289
+ * session middleware has no `ctx.session` at all.
290
+ */
291
+ function _sessionKeys(ctx: HttpContext): string[] {
292
+ try {
293
+ const session = (ctx as unknown as Record<string, unknown>)["session"] as
294
+ { _data?: Record<string, unknown> } | undefined;
295
+ return session?._data ? Object.keys(session._data).sort() : [];
296
+ } catch {
297
+ return [];
298
+ }
299
+ }
300
+
301
+ function _buildTrace(
302
+ ctx: HttpContext,
303
+ startMs: number,
304
+ durationMs: number,
305
+ exception: ExceptionInfo | null,
306
+ ): RequestTrace {
193
307
  const queryParams: Record<string, string> = {};
194
308
  ctx.url.searchParams.forEach((v, k) => {
195
309
  queryParams[k] = v;
@@ -197,7 +311,14 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
197
311
 
198
312
  const headers: Record<string, string> = {};
199
313
  ctx.request.headers.forEach((v, k) => {
200
- if (SAFE_HEADERS.has(k.toLowerCase())) headers[k] = v;
314
+ if (_recordHeader(k)) headers[k] = v;
315
+ });
316
+
317
+ // The response half of the exchange: its status line and the headers it set.
318
+ // A request tab that shows only what came in answers half the question.
319
+ const responseHeaders: Record<string, string> = {};
320
+ ctx.response?.headers.forEach((v, k) => {
321
+ if (_recordHeader(k)) responseHeaders[k] = v;
201
322
  });
202
323
 
203
324
  const ctxRecord = ctx as unknown as Record<string, unknown>;
@@ -222,14 +343,18 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
222
343
  memory: _heapUsed(),
223
344
  queryParams,
224
345
  headers,
346
+ responseHeaders,
347
+ session: _sessionKeys(ctx),
225
348
  route: rd ? { pattern: rd.pattern, controller: rd.controller, action: rd.action } : null,
226
349
  auth: user ? { id: user["id"], name: user["name"], email: user["email"] } : null,
350
+ exception,
227
351
  queries: _ctxQueries.get(ctx) ?? [],
228
352
  warnings: _ctxWarnings.get(ctx) ?? [],
229
353
  logs: (_ctxLogs.get(ctx) ?? []).map((l) => ({
230
354
  level: l.level,
231
355
  args: l.args,
232
356
  offsetMs: _offset(l.absMs, startMs),
357
+ ...(l.source ? { source: l.source } : {}),
233
358
  })),
234
359
  mail: (_ctxMail.get(ctx) ?? []).map(({ absMs, ...rest }) => ({
235
360
  ...rest,
@@ -261,27 +386,71 @@ export function startDevtoolsTracing(): void {
261
386
 
262
387
  // Both successful and failed requests finalise the trace. Failed requests still
263
388
  // carry the rendered error response on ctx, so the trace records the error status
264
- // code like any other outcome. Everything else on the trace is buffered by
265
- // feature packages through `traceSink`.
389
+ // code like any other outcome and now the message with it, which used to be
390
+ // dropped here, leaving a red 500 in the panel with nothing to read next to it.
391
+ // Everything else on the trace is buffered by feature packages through `traceSink`.
266
392
  _unsubs = [
267
393
  FrameworkEvents.on<RequestHandled>("RequestHandled", (e) =>
268
- _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs),
269
- ),
270
- FrameworkEvents.on<RequestFailed>("RequestFailed", (e) =>
271
- _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs),
394
+ _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs, null),
272
395
  ),
396
+ FrameworkEvents.on<RequestFailed>("RequestFailed", (e) => {
397
+ const frames = parseStack(e.stack);
398
+ _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs, {
399
+ message: e.error,
400
+ status: e.status,
401
+ ...(e.type ? { type: e.type } : {}),
402
+ ...(frames.length ? { frames } : {}),
403
+ });
404
+ }),
405
+ // Outgoing calls, recorded here rather than by a bridge in the package that
406
+ // owns them: the owner is `@zerotal/core`, and core cannot know about the
407
+ // channel API because devtools is what depends on core. Devtools already
408
+ // subscribes to core's events, so this costs no new dependency in either
409
+ // direction.
410
+ FrameworkEvents.on<OutgoingRequestCompleted>("OutgoingRequestCompleted", (e) => {
411
+ // The event carries no context — the client does not take one — so the
412
+ // request is read from the ambient scope it was called in.
413
+ const ctx = RequestContext.tryGet();
414
+ if (!ctx) return;
415
+ traceSink.record(ctx, "http", {
416
+ method: e.method,
417
+ url: e.url,
418
+ host: e.host,
419
+ status: e.status || "—",
420
+ durationMs: e.durationMs,
421
+ failed: !e.ok,
422
+ });
423
+ }),
273
424
  ];
425
+
426
+ // Declared here rather than in a satellite for the same reason. `order` puts
427
+ // it beside the other per-request feeds rather than at the back of the strip.
428
+ traceSink.channel({
429
+ id: "http",
430
+ label: "Outgoing",
431
+ badge: "method",
432
+ title: "url",
433
+ meta: ["status", "durationMs", "host"],
434
+ warn: "failed",
435
+ order: 35,
436
+ render: "table",
437
+ });
274
438
  }
275
439
 
276
440
  /** Merge buffered events into a trace and push it to the store (once per request). */
277
- function _finaliseTrace(ctx: HttpContext, startMs: number, durationMs: number): void {
441
+ function _finaliseTrace(
442
+ ctx: HttpContext,
443
+ startMs: number,
444
+ durationMs: number,
445
+ exception: ExceptionInfo | null,
446
+ ): void {
278
447
  // Internal framework paths are noise — skip them
279
448
  if (_isInternal(ctx.url.pathname)) {
280
449
  _cleanupBuffers(ctx);
281
450
  return;
282
451
  }
283
452
 
284
- const trace = _buildTrace(ctx, startMs, durationMs);
453
+ const trace = _buildTrace(ctx, startMs, durationMs, exception);
285
454
  _cleanupBuffers(ctx);
286
455
  traceStore().push(trace);
287
456
  }
@@ -298,6 +467,28 @@ const LOG_LEVELS = ["log", "debug", "info", "warn", "error"] as const;
298
467
  let _origConsole: Partial<Record<string, unknown>> = {};
299
468
  let _consoleCaptured = false;
300
469
 
470
+ /**
471
+ * One logged argument, as the line the panel shows.
472
+ *
473
+ * Objects are redacted before they are serialised, not after: `console.log(user)`
474
+ * during a debug session used to write the whole record — password hash included
475
+ * — to disk for a day. Redacting also makes the value safe to serialise at all,
476
+ * since the walk replaces cycles; a circular argument used to throw a
477
+ * `Converting circular structure to JSON` out of this patch and into the caller's
478
+ * `console.log`.
479
+ */
480
+ function _formatLogArg(value: unknown): string {
481
+ if (typeof value === "string") return value;
482
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
483
+ const safe = redactValue(value, _redaction);
484
+ try {
485
+ return JSON.stringify(safe) ?? String(safe);
486
+ } catch {
487
+ // BigInt, a throwing toJSON — the log line is not worth failing the request.
488
+ return String(safe);
489
+ }
490
+ }
491
+
301
492
  /** @internal — patch console.* to capture log lines per request context */
302
493
  export function startConsoleCapture(): void {
303
494
  // Idempotent: a second start() without an intervening stop() would otherwise
@@ -319,16 +510,14 @@ export function startConsoleCapture(): void {
319
510
  orig(...args);
320
511
  const ctx = RequestContext.tryGet();
321
512
  if (!ctx) return;
513
+ // One frame to skip: this wrapper is standing between the caller and the
514
+ // stack, and without dropping it every log line would point at devtools.
515
+ const source = _captureSource ? captureCallSite(1) : null;
322
516
  _bufPush(_ctxLogs, ctx, {
323
517
  level,
324
- args: args.map((a) =>
325
- typeof a === "string"
326
- ? a
327
- : a instanceof Error
328
- ? `${a.name}: ${a.message}`
329
- : (JSON.stringify(a, null, 0) ?? String(a)),
330
- ),
518
+ args: args.map(_formatLogArg),
331
519
  absMs: Date.now(),
520
+ ...(source ? { source } : {}),
332
521
  });
333
522
  };
334
523
  }