@beignet/devtools 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +464 -0
  3. package/dist/access.d.ts +21 -0
  4. package/dist/access.d.ts.map +1 -0
  5. package/dist/access.js +20 -0
  6. package/dist/access.js.map +1 -0
  7. package/dist/audit.d.ts +10 -0
  8. package/dist/audit.d.ts.map +1 -0
  9. package/dist/audit.js +49 -0
  10. package/dist/audit.js.map +1 -0
  11. package/dist/events.d.ts +143 -0
  12. package/dist/events.d.ts.map +1 -0
  13. package/dist/events.js +20 -0
  14. package/dist/events.js.map +1 -0
  15. package/dist/index.d.ts +114 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +44 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/instrumentation.d.ts +74 -0
  20. package/dist/instrumentation.d.ts.map +1 -0
  21. package/dist/instrumentation.js +293 -0
  22. package/dist/instrumentation.js.map +1 -0
  23. package/dist/persistence.d.ts +30 -0
  24. package/dist/persistence.d.ts.map +1 -0
  25. package/dist/persistence.js +100 -0
  26. package/dist/persistence.js.map +1 -0
  27. package/dist/provider-instrumentation.d.ts +9 -0
  28. package/dist/provider-instrumentation.d.ts.map +1 -0
  29. package/dist/provider-instrumentation.js +25 -0
  30. package/dist/provider-instrumentation.js.map +1 -0
  31. package/dist/provider.d.ts +79 -0
  32. package/dist/provider.d.ts.map +1 -0
  33. package/dist/provider.js +293 -0
  34. package/dist/provider.js.map +1 -0
  35. package/dist/redaction.d.ts +5 -0
  36. package/dist/redaction.d.ts.map +1 -0
  37. package/dist/redaction.js +20 -0
  38. package/dist/redaction.js.map +1 -0
  39. package/dist/routes.d.ts +113 -0
  40. package/dist/routes.d.ts.map +1 -0
  41. package/dist/routes.js +247 -0
  42. package/dist/routes.js.map +1 -0
  43. package/dist/trace-context.d.ts +29 -0
  44. package/dist/trace-context.d.ts.map +1 -0
  45. package/dist/trace-context.js +74 -0
  46. package/dist/trace-context.js.map +1 -0
  47. package/dist/ui.d.ts +14 -0
  48. package/dist/ui.d.ts.map +1 -0
  49. package/dist/ui.js +795 -0
  50. package/dist/ui.js.map +1 -0
  51. package/dist/watchers.d.ts +22 -0
  52. package/dist/watchers.d.ts.map +1 -0
  53. package/dist/watchers.js +171 -0
  54. package/dist/watchers.js.map +1 -0
  55. package/package.json +66 -0
  56. package/src/access.ts +52 -0
  57. package/src/audit.ts +71 -0
  58. package/src/events.ts +193 -0
  59. package/src/index.ts +136 -0
  60. package/src/instrumentation.ts +451 -0
  61. package/src/persistence.ts +163 -0
  62. package/src/provider-instrumentation.ts +50 -0
  63. package/src/provider.ts +375 -0
  64. package/src/redaction.ts +26 -0
  65. package/src/routes.ts +317 -0
  66. package/src/trace-context.ts +115 -0
  67. package/src/ui.ts +807 -0
  68. package/src/watchers.ts +235 -0
package/src/index.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @beignet/devtools
3
+ *
4
+ * Development-time devtools for the Beignet server.
5
+ * Provides an in-memory event buffer and HTTP handlers to expose
6
+ * framework activity (requests, use cases, errors, events, jobs, schedules, providers).
7
+ *
8
+ * This package is intended for development and debugging only.
9
+ * It should not be used in production environments.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { createDevtoolsProvider } from "@beignet/devtools";
14
+ * import { createServer } from "@beignet/core/server";
15
+ *
16
+ * const server = await createServer({
17
+ * ports,
18
+ * providers: [createDevtoolsProvider(), ...otherProviders],
19
+ * });
20
+ *
21
+ * // In your code, log events:
22
+ * ctx.ports.devtools?.log({
23
+ * id: crypto.randomUUID(),
24
+ * type: "request",
25
+ * timestamp: new Date().toISOString(),
26
+ * method: "GET",
27
+ * path: "/api/users",
28
+ * });
29
+ * ```
30
+ */
31
+
32
+ import type { ProviderInstrumentationPort } from "@beignet/core/providers";
33
+ import type {
34
+ DevtoolsEvent,
35
+ DevtoolsEventInput,
36
+ DevtoolsListener,
37
+ } from "./events";
38
+ import type { DevtoolsWatcher, DevtoolsWatcherName } from "./watchers";
39
+
40
+ /**
41
+ * Filter options for querying devtools events.
42
+ */
43
+ export interface DevtoolsFilter {
44
+ /**
45
+ * Filter by event type.
46
+ */
47
+ type?: DevtoolsEvent["type"];
48
+
49
+ /**
50
+ * Filter by request correlation ID.
51
+ */
52
+ requestId?: string;
53
+
54
+ /**
55
+ * Filter by W3C trace ID.
56
+ */
57
+ traceId?: string;
58
+
59
+ /**
60
+ * Limit the number of results returned (default: 200).
61
+ */
62
+ limit?: number;
63
+ }
64
+
65
+ /**
66
+ * Port interface for devtools.
67
+ *
68
+ * Apps and server internals can log framework events here during development.
69
+ * The events are stored in an in-memory buffer and can be retrieved via HTTP handlers.
70
+ */
71
+ export interface DevtoolsPort
72
+ extends ProviderInstrumentationPort<
73
+ DevtoolsEventInput,
74
+ DevtoolsWatcherName,
75
+ DevtoolsEvent
76
+ > {
77
+ /**
78
+ * Log a devtools event into the in-memory buffer.
79
+ *
80
+ * @param event - The event to log
81
+ */
82
+ log(event: DevtoolsEvent): void;
83
+
84
+ /**
85
+ * Record an event and fill common fields such as id and timestamp.
86
+ *
87
+ * @param event - The event payload to record
88
+ * @returns The normalized event that was stored
89
+ */
90
+ record(event: DevtoolsEventInput): DevtoolsEvent;
91
+
92
+ /**
93
+ * Subscribe to event buffer changes. Used by the live SSE endpoint.
94
+ *
95
+ * @param listener - Called when an event is recorded or the buffer is cleared
96
+ * @returns Cleanup function that removes the listener
97
+ */
98
+ subscribe(listener: DevtoolsListener): () => void;
99
+
100
+ /**
101
+ * Retrieve recent events with optional filtering.
102
+ *
103
+ * @param filter - Optional filter to apply (type, requestId, limit)
104
+ * @returns Array of matching events
105
+ */
106
+ getEvents(filter?: DevtoolsFilter): DevtoolsEvent[];
107
+
108
+ /**
109
+ * Clear all buffered events (dev-only).
110
+ */
111
+ clear(): void | Promise<void>;
112
+
113
+ /**
114
+ * Return the watcher configuration installed for this devtools instance.
115
+ */
116
+ getWatchers(): DevtoolsWatcher[];
117
+
118
+ /**
119
+ * Check whether a watcher is enabled.
120
+ */
121
+ isWatcherEnabled(name: DevtoolsWatcherName): boolean;
122
+ }
123
+
124
+ // Re-export everything
125
+ export * from "./access";
126
+ export * from "./audit";
127
+ export * from "./events";
128
+ export * from "./instrumentation";
129
+ export * from "./persistence";
130
+ export * from "./provider";
131
+ export * from "./provider-instrumentation";
132
+ export * from "./redaction";
133
+ export * from "./routes";
134
+ export * from "./trace-context";
135
+ export * from "./ui";
136
+ export * from "./watchers";
@@ -0,0 +1,451 @@
1
+ import type { AnyPorts } from "@beignet/core/ports";
2
+ import type {
3
+ HttpContractConfig,
4
+ HttpRequestLike,
5
+ HttpResponseLike,
6
+ ServerHook,
7
+ } from "@beignet/core/server";
8
+ import type { DevtoolsEventInput, DevtoolsRedactor } from "./events";
9
+ import type { DevtoolsPort } from "./index";
10
+ import { createDevtoolsEvent } from "./provider";
11
+ import {
12
+ createChildDevtoolsTraceContext,
13
+ createDevtoolsTraceContext,
14
+ type DevtoolsTraceContext,
15
+ type DevtoolsTraceContextInput,
16
+ parseTraceparent,
17
+ } from "./trace-context";
18
+ import type { BuiltInDevtoolsWatcherName } from "./watchers";
19
+
20
+ type PortsWithDevtools = AnyPorts & {
21
+ devtools?: DevtoolsPort;
22
+ };
23
+
24
+ type ContextWithPorts = {
25
+ requestId?: string;
26
+ traceId?: string;
27
+ spanId?: string;
28
+ parentSpanId?: string;
29
+ traceparent?: string;
30
+ ports?: {
31
+ devtools?: DevtoolsPort;
32
+ };
33
+ };
34
+
35
+ export interface DevtoolsHooksOptions<Ctx> {
36
+ /**
37
+ * Devtools route prefix. Requests under this path are ignored to avoid
38
+ * filling the timeline with its own polling traffic.
39
+ *
40
+ * @default "/api/devtools"
41
+ */
42
+ basePath?: string;
43
+
44
+ getRequestId?: (args: {
45
+ req: HttpRequestLike;
46
+ ctx?: Ctx;
47
+ response?: HttpResponseLike;
48
+ }) => string | undefined;
49
+
50
+ /**
51
+ * Response header used to expose the resolved request ID.
52
+ *
53
+ * Pass `false` to avoid writing a header.
54
+ *
55
+ * @default "x-request-id"
56
+ */
57
+ requestIdHeader?: string | false;
58
+
59
+ /**
60
+ * W3C trace context header used to correlate devtools events with distributed
61
+ * traces.
62
+ *
63
+ * Pass `false` to avoid writing a header.
64
+ *
65
+ * @default "traceparent"
66
+ */
67
+ traceContextHeader?: string | false;
68
+
69
+ getTraceContext?: (args: {
70
+ req: HttpRequestLike;
71
+ ctx?: Ctx;
72
+ response?: HttpResponseLike;
73
+ }) => DevtoolsTraceContextInput | string | undefined;
74
+
75
+ /**
76
+ * Apply a custom redactor to events produced by these hooks. The default
77
+ * devtools redactor still runs when events are stored.
78
+ */
79
+ redact?: DevtoolsRedactor;
80
+
81
+ shouldCapture?: (args: {
82
+ req: HttpRequestLike;
83
+ ctx?: Ctx;
84
+ contract: HttpContractConfig;
85
+ response: HttpResponseLike;
86
+ error?: unknown;
87
+ }) => boolean;
88
+ }
89
+
90
+ export interface DevtoolsUseCaseRunEvent<Ctx> {
91
+ name: string;
92
+ kind: "command" | "query";
93
+ phase: "start" | "end" | "error";
94
+ durationMs?: number;
95
+ error?: unknown;
96
+ ctx: Ctx;
97
+ }
98
+
99
+ export interface DevtoolsUseCaseObserverOptions<Ctx> {
100
+ getDevtools?: (ctx: Ctx) => DevtoolsPort | undefined;
101
+ getRequestId?: (ctx: Ctx) => string | undefined;
102
+ logErrorEvents?: boolean;
103
+ redact?: DevtoolsRedactor;
104
+ }
105
+
106
+ function getContextRequestId(ctx: unknown): string | undefined {
107
+ if (!ctx || typeof ctx !== "object") return undefined;
108
+ const requestId = (ctx as { requestId?: unknown }).requestId;
109
+ return typeof requestId === "string" ? requestId : undefined;
110
+ }
111
+
112
+ function getContextDevtools(ctx: unknown): DevtoolsPort | undefined {
113
+ if (!ctx || typeof ctx !== "object") return undefined;
114
+ const ports = (ctx as ContextWithPorts).ports;
115
+ return ports?.devtools;
116
+ }
117
+
118
+ function getContextTraceContext(
119
+ ctx: unknown,
120
+ ): DevtoolsTraceContextInput | undefined {
121
+ if (!ctx || typeof ctx !== "object") return undefined;
122
+ const context = ctx as ContextWithPorts;
123
+ if (
124
+ !context.traceId &&
125
+ !context.spanId &&
126
+ !context.parentSpanId &&
127
+ !context.traceparent
128
+ ) {
129
+ return undefined;
130
+ }
131
+ return {
132
+ traceId: context.traceId,
133
+ spanId: context.spanId,
134
+ parentSpanId: context.parentSpanId,
135
+ traceparent: context.traceparent,
136
+ };
137
+ }
138
+
139
+ function getErrorMessage(error: unknown): string {
140
+ if (error instanceof Error) return error.message;
141
+ if (typeof error === "string") return error;
142
+ return "Unknown error";
143
+ }
144
+
145
+ function getErrorStack(error: unknown): string | undefined {
146
+ return error instanceof Error ? error.stack : undefined;
147
+ }
148
+
149
+ function createRequestId(): string {
150
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
151
+ return crypto.randomUUID();
152
+ }
153
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
154
+ }
155
+
156
+ function getPathname(req: HttpRequestLike): string {
157
+ try {
158
+ return new URL(req.url).pathname;
159
+ } catch {
160
+ return req.url;
161
+ }
162
+ }
163
+
164
+ function isDevtoolsPath(pathname: string, basePath: string): boolean {
165
+ const normalizedBase = basePath.replace(/\/+$/, "");
166
+ return (
167
+ pathname === normalizedBase || pathname.startsWith(`${normalizedBase}/`)
168
+ );
169
+ }
170
+
171
+ function isWatcherEnabled(
172
+ devtools: DevtoolsPort,
173
+ name: BuiltInDevtoolsWatcherName,
174
+ ): boolean {
175
+ return devtools.isWatcherEnabled(name);
176
+ }
177
+
178
+ export function createDevtoolsHooks<
179
+ Ctx,
180
+ Ports extends PortsWithDevtools = PortsWithDevtools,
181
+ >(options: DevtoolsHooksOptions<Ctx> = {}): ServerHook<Ctx, Ports> {
182
+ let devtools: DevtoolsPort | undefined;
183
+ const basePath = options.basePath ?? "/api/devtools";
184
+ const requestIdHeader = options.requestIdHeader ?? "x-request-id";
185
+ const traceContextHeader = options.traceContextHeader ?? "traceparent";
186
+ const generatedRequestIds = new WeakMap<HttpRequestLike, string>();
187
+ const traceContexts = new WeakMap<HttpRequestLike, DevtoolsTraceContext>();
188
+
189
+ const resolveRequestId = (args: {
190
+ req: HttpRequestLike;
191
+ ctx?: Ctx;
192
+ response?: HttpResponseLike;
193
+ }) => {
194
+ const headerRequestId =
195
+ requestIdHeader === false
196
+ ? undefined
197
+ : args.req.headers.get(requestIdHeader);
198
+ const existing =
199
+ options.getRequestId?.(args) ??
200
+ getContextRequestId(args.ctx) ??
201
+ headerRequestId ??
202
+ generatedRequestIds.get(args.req);
203
+
204
+ if (existing) return existing;
205
+
206
+ const requestId = createRequestId();
207
+ generatedRequestIds.set(args.req, requestId);
208
+ return requestId;
209
+ };
210
+
211
+ const resolveTraceContext = (args: {
212
+ req: HttpRequestLike;
213
+ ctx?: Ctx;
214
+ response?: HttpResponseLike;
215
+ }) => {
216
+ const configured = options.getTraceContext?.(args);
217
+ const configuredContext =
218
+ typeof configured === "string" ? { traceparent: configured } : configured;
219
+ const contextTrace = getContextTraceContext(args.ctx);
220
+ const headerTraceparent =
221
+ traceContextHeader === false
222
+ ? undefined
223
+ : args.req.headers.get(traceContextHeader);
224
+ const existing = configuredContext ?? contextTrace;
225
+ const existingTraceparent =
226
+ existing?.traceparent ?? parseTraceparent(headerTraceparent)?.traceparent;
227
+
228
+ if (existing) {
229
+ const traceContext = createDevtoolsTraceContext({
230
+ ...existing,
231
+ traceparent: existingTraceparent,
232
+ });
233
+ traceContexts.set(args.req, traceContext);
234
+ return traceContext;
235
+ }
236
+
237
+ const cached = traceContexts.get(args.req);
238
+ if (cached) return cached;
239
+
240
+ const traceContext = createDevtoolsTraceContext({
241
+ traceparent: existingTraceparent,
242
+ });
243
+ traceContexts.set(args.req, traceContext);
244
+ return traceContext;
245
+ };
246
+
247
+ const record = (event: DevtoolsEventInput) => {
248
+ if (!devtools) return;
249
+ const normalized = createDevtoolsEvent(event);
250
+ try {
251
+ devtools.record(options.redact ? options.redact(normalized) : normalized);
252
+ } catch (error) {
253
+ devtools.record({
254
+ type: "error",
255
+ message: "Devtools hook redactor failed",
256
+ details: {
257
+ message: error instanceof Error ? error.message : String(error),
258
+ },
259
+ });
260
+ }
261
+ };
262
+
263
+ return {
264
+ name: "devtools",
265
+ onRequest: ({ ports }) => {
266
+ devtools = ports.devtools;
267
+ return undefined;
268
+ },
269
+ beforeHandle: ({ req, ctx }) => {
270
+ if (!ctx || typeof ctx !== "object" || Array.isArray(ctx)) {
271
+ return undefined;
272
+ }
273
+
274
+ const traceContext = resolveTraceContext({ req, ctx });
275
+ return {
276
+ ctx: {
277
+ ...ctx,
278
+ traceId: traceContext.traceId,
279
+ spanId: traceContext.spanId,
280
+ parentSpanId: traceContext.parentSpanId,
281
+ traceparent: traceContext.traceparent,
282
+ } as Ctx,
283
+ };
284
+ },
285
+ beforeSend: async ({ req, ctx, response }) => {
286
+ if (requestIdHeader === false && traceContextHeader === false) {
287
+ return undefined;
288
+ }
289
+
290
+ const requestId = resolveRequestId({ req, ctx, response });
291
+ const traceContext = resolveTraceContext({ req, ctx, response });
292
+ const headers = {
293
+ ...response.headers,
294
+ ...(requestIdHeader === false ? {} : { [requestIdHeader]: requestId }),
295
+ ...(traceContextHeader === false
296
+ ? {}
297
+ : { [traceContextHeader]: traceContext.traceparent }),
298
+ };
299
+ return {
300
+ ...response,
301
+ headers,
302
+ };
303
+ },
304
+ afterSend: async ({ req, ctx, contract, response, error, durationMs }) => {
305
+ if (!devtools) return;
306
+
307
+ const path = getPathname(req);
308
+ if (isDevtoolsPath(path, basePath)) return;
309
+
310
+ const shouldCaptureRequest = isWatcherEnabled(devtools, "requests");
311
+ const shouldCaptureError =
312
+ Boolean(error) && isWatcherEnabled(devtools, "errors");
313
+
314
+ if (!shouldCaptureRequest && !shouldCaptureError) return;
315
+
316
+ if (
317
+ options.shouldCapture &&
318
+ !options.shouldCapture({ req, ctx, contract, response, error })
319
+ ) {
320
+ return;
321
+ }
322
+
323
+ const requestId = resolveRequestId({ req, ctx, response });
324
+ const traceContext = resolveTraceContext({ req, ctx, response });
325
+
326
+ if (shouldCaptureRequest) {
327
+ record({
328
+ type: "request",
329
+ requestId,
330
+ traceId: traceContext.traceId,
331
+ spanId: traceContext.spanId,
332
+ parentSpanId: traceContext.parentSpanId,
333
+ traceparent: traceContext.traceparent,
334
+ method: req.method,
335
+ path,
336
+ contractName: contract.name,
337
+ status: response.status,
338
+ durationMs,
339
+ details: {
340
+ headers: Object.fromEntries(req.headers.entries()),
341
+ },
342
+ });
343
+ }
344
+
345
+ if (error && shouldCaptureError) {
346
+ record({
347
+ type: "error",
348
+ requestId,
349
+ traceId: traceContext.traceId,
350
+ spanId: traceContext.spanId,
351
+ parentSpanId: traceContext.parentSpanId,
352
+ traceparent: traceContext.traceparent,
353
+ message: getErrorMessage(error),
354
+ stack: getErrorStack(error),
355
+ contractName: contract.name,
356
+ });
357
+ }
358
+ },
359
+ };
360
+ }
361
+
362
+ export function createDevtoolsUseCaseObserver<Ctx>(
363
+ options: DevtoolsUseCaseObserverOptions<Ctx> = {},
364
+ ): (event: DevtoolsUseCaseRunEvent<Ctx>) => void {
365
+ const logErrorEvents = options.logErrorEvents ?? true;
366
+ const activeTraceContexts = new Map<string, DevtoolsTraceContext>();
367
+
368
+ return (event) => {
369
+ const devtools =
370
+ options.getDevtools?.(event.ctx) ?? getContextDevtools(event.ctx);
371
+ if (!devtools) return;
372
+
373
+ const requestId =
374
+ options.getRequestId?.(event.ctx) ?? getContextRequestId(event.ctx);
375
+ const errorMessage =
376
+ event.phase === "error" ? getErrorMessage(event.error) : undefined;
377
+ const shouldCaptureUseCase = isWatcherEnabled(devtools, "useCases");
378
+ const shouldCaptureError =
379
+ event.phase === "error" &&
380
+ logErrorEvents &&
381
+ isWatcherEnabled(devtools, "errors");
382
+
383
+ if (!shouldCaptureUseCase && !shouldCaptureError) return;
384
+
385
+ const parentTraceContext = getContextTraceContext(event.ctx);
386
+ const useCaseKey = [
387
+ requestId ?? "no-request",
388
+ parentTraceContext?.traceId ?? "no-trace",
389
+ event.kind,
390
+ event.name,
391
+ ].join(":");
392
+ const traceContext =
393
+ event.phase === "start"
394
+ ? createChildDevtoolsTraceContext(parentTraceContext ?? {})
395
+ : (activeTraceContexts.get(useCaseKey) ??
396
+ createChildDevtoolsTraceContext(parentTraceContext ?? {}));
397
+
398
+ if (event.phase === "start") {
399
+ activeTraceContexts.set(useCaseKey, traceContext);
400
+ } else {
401
+ activeTraceContexts.delete(useCaseKey);
402
+ }
403
+
404
+ const record = (input: DevtoolsEventInput) => {
405
+ const normalized = createDevtoolsEvent(input);
406
+ try {
407
+ devtools.record(
408
+ options.redact ? options.redact(normalized) : normalized,
409
+ );
410
+ } catch (error) {
411
+ devtools.record({
412
+ type: "error",
413
+ message: "Devtools use case redactor failed",
414
+ details: {
415
+ message: error instanceof Error ? error.message : String(error),
416
+ },
417
+ });
418
+ }
419
+ };
420
+
421
+ if (shouldCaptureUseCase) {
422
+ record({
423
+ type: "usecase",
424
+ requestId,
425
+ traceId: traceContext.traceId,
426
+ spanId: traceContext.spanId,
427
+ parentSpanId: traceContext.parentSpanId,
428
+ traceparent: traceContext.traceparent,
429
+ name: event.name,
430
+ kind: event.kind,
431
+ phase: event.phase,
432
+ durationMs: event.durationMs,
433
+ error: errorMessage,
434
+ });
435
+ }
436
+
437
+ if (shouldCaptureError) {
438
+ record({
439
+ type: "error",
440
+ requestId,
441
+ traceId: traceContext.traceId,
442
+ spanId: traceContext.spanId,
443
+ parentSpanId: traceContext.parentSpanId,
444
+ traceparent: traceContext.traceparent,
445
+ message: errorMessage ?? "Use case failed",
446
+ stack: getErrorStack(event.error),
447
+ useCaseName: event.name,
448
+ });
449
+ }
450
+ };
451
+ }