@next-rsc-debug/core 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,9 @@ Framework-independent event protocol and collector for Next RSC Debug.
9
9
  - In-memory ring buffer (default 5000 events)
10
10
  - Event subscription support
11
11
  - URL sanitization utilities
12
+ - Metadata sanitization: sensitive keys stripped, values normalized to be
13
+ JSON-safe, applied centrally in `createEvent`
14
+ - Cache key redaction for email addresses
12
15
  - Analyzer primitives (slow request, duplicate request, error detection)
13
16
  - Warning detection
14
17
 
@@ -33,6 +36,30 @@ const events = getCollector().list();
33
36
  const { warnings } = analyzeEvents(events);
34
37
  ```
35
38
 
39
+ ## Sanitization
40
+
41
+ `createEvent` runs every `metadata` object through `sanitizeMetadata` before the
42
+ event is returned, so sensitive keys and unserializable values cannot reach the
43
+ collector — and from there any SSE stream — regardless of which producer
44
+ supplied them:
45
+
46
+ ```ts
47
+ createEvent({
48
+ type: "cache:hit",
49
+ metadata: {
50
+ key: "post:1",
51
+ authorization: "Bearer secret", // stripped
52
+ at: new Date(), // normalized to an ISO string
53
+ },
54
+ });
55
+ // metadata === { key: "post:1", at: "2026-09-29T…" }
56
+ ```
57
+
58
+ `stripSensitiveQueryParams` is reachable through
59
+ `sanitizeUrl(url, { preserveQuery: true })`, which keeps non-sensitive query
60
+ parameters for display and duplicate detection while still removing sensitive
61
+ ones and embedded credentials.
62
+
36
63
  ## License
37
64
 
38
65
  MIT
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG9D,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,CAAC;AAEjD,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,mBAAmB,GAAG,OAAO,CAAC;AAEzE,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,eAAe,CAAC;IAC1B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAID,wBAAgB,aAAa,CAC3B,MAAM,EAAE,UAAU,EAAE,EACpB,OAAO,GAAE,cAAmB,GAC3B;IAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;CAAE,CA0EzB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAKvE;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE9D;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAEnE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE5D;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAI9D;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE/D;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE9D"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG9D,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,CAAC;AAEjD,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,mBAAmB,GAAG,OAAO,CAAC;AAEzE,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,eAAe,CAAC;IAC1B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAID,wBAAgB,aAAa,CAC3B,MAAM,EAAE,UAAU,EAAE,EACpB,OAAO,GAAE,cAAmB,GAC3B;IAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;CAAE,CA8EzB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAKvE;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE9D;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAEnE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE5D;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAI9D;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE/D;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAE9D"}
package/dist/analyzer.js CHANGED
@@ -29,11 +29,15 @@ export function analyzeEvents(events, options = {}) {
29
29
  });
30
30
  }
31
31
  }
32
- // Duplicate detection: group fetch events by normalized URL.
32
+ // Duplicate detection: group fetch events by normalized URL. Non-sensitive
33
+ // query parameters are preserved so `/api/items?page=1` and
34
+ // `/api/items?page=2` are not reported as duplicates of each other.
33
35
  const fetchGroups = new Map();
34
36
  for (const event of events) {
35
37
  if (event.type === "fetch:end" && event.metadata?.url) {
36
- const url = sanitizeUrl(String(event.metadata.url));
38
+ const url = sanitizeUrl(String(event.metadata.url), {
39
+ preserveQuery: true,
40
+ });
37
41
  const group = fetchGroups.get(url);
38
42
  if (group) {
39
43
  group.push(event);
@@ -83,7 +87,7 @@ export function normalizeFetchUrl(event) {
83
87
  if (event.type !== "fetch:end" || !event.metadata?.url) {
84
88
  return undefined;
85
89
  }
86
- return sanitizeUrl(String(event.metadata.url));
90
+ return sanitizeUrl(String(event.metadata.url), { preserveQuery: true });
87
91
  }
88
92
  export function isFetchEventType(type) {
89
93
  return type === "fetch:start" || type === "fetch:end";
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,gBAAgB,GAChB,WAAW,GACX,SAAS,GACT,aAAa,GACb,WAAW,GACX,WAAW,GACX,YAAY,GACZ,kBAAkB,GAClB,cAAc,GACd,YAAY,GACZ,OAAO,CAAC;AAEZ,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAkBD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,UAAU,CAmBnE"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,gBAAgB,GAChB,WAAW,GACX,SAAS,GACT,aAAa,GACb,WAAW,GACX,WAAW,GACX,YAAY,GACZ,kBAAkB,GAClB,cAAc,GACd,YAAY,GACZ,OAAO,CAAC;AAEZ,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAkBD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,UAAU,CAuBnE"}
package/dist/events.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Every event has a unique ID, timestamp, and type.
5
5
  * Optional fields: duration, requestId, parentId, metadata.
6
6
  */
7
+ import { sanitizeMetadata } from "./sanitize.js";
7
8
  let idCounter = 0;
8
9
  let lastTimestamp = 0;
9
10
  function generateId(prefix) {
@@ -43,8 +44,12 @@ export function createEvent(options) {
43
44
  if (options.duration !== undefined) {
44
45
  event.duration = options.duration;
45
46
  }
46
- if (options.metadata !== undefined) {
47
- event.metadata = options.metadata;
47
+ // Sanitized here, at the single point every event is constructed, so that
48
+ // sensitive keys and unserializable values cannot reach the collector — and
49
+ // from there the SSE stream — no matter which producer supplied them.
50
+ const metadata = sanitizeMetadata(options.metadata);
51
+ if (metadata !== undefined) {
52
+ event.metadata = metadata;
48
53
  }
49
54
  return event;
50
55
  }
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Public API for @next-rsc-debug/core.
3
3
  */
4
4
  export { createEvent, createEventId, createRequestId, createNavigationId, type DebugEvent, type DebugEventType, type CreateEventOptions, } from "./events.js";
5
- export { sanitizeUrl, stripSensitiveQueryParams, isSafeKey, } from "./sanitize.js";
6
- export { RingBuffer, type EventListener, type Unsubscribe, type RingBufferOptions, } from "./ring-buffer.js";
5
+ export { sanitizeUrl, stripSensitiveQueryParams, isSafeKey, sanitizeKey, sanitizeMetadata, type SanitizeUrlOptions, type SanitizeMetadataOptions, } from "./sanitize.js";
6
+ export { RingBuffer, DEFAULT_MAX_EVENTS, type EventListener, type Unsubscribe, type RingBufferOptions, } from "./ring-buffer.js";
7
7
  export { analyzeEvents, type Warning, type WarningType, type WarningSeverity, type AnalyzeOptions, normalizeFetchUrl, isFetchEventType, isNavigationEventType, isRscEventType, isCacheEventType, isActionEventType, isErrorEventType, } from "./analyzer.js";
8
8
  export { getCollector, createCollector, setCollector, resetCollector, type Collector, } from "./collector.js";
9
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,yBAAyB,EACzB,SAAS,GACV,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,UAAU,EACV,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,aAAa,EACb,KAAK,OAAO,EACZ,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,cAAc,EACd,KAAK,SAAS,GACf,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,yBAAyB,EACzB,SAAS,EACT,WAAW,EACX,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,uBAAuB,GAC7B,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,aAAa,EACb,KAAK,OAAO,EACZ,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,cAAc,EACd,KAAK,SAAS,GACf,MAAM,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Public API for @next-rsc-debug/core.
3
3
  */
4
4
  export { createEvent, createEventId, createRequestId, createNavigationId, } from "./events.js";
5
- export { sanitizeUrl, stripSensitiveQueryParams, isSafeKey, } from "./sanitize.js";
6
- export { RingBuffer, } from "./ring-buffer.js";
5
+ export { sanitizeUrl, stripSensitiveQueryParams, isSafeKey, sanitizeKey, sanitizeMetadata, } from "./sanitize.js";
6
+ export { RingBuffer, DEFAULT_MAX_EVENTS, } from "./ring-buffer.js";
7
7
  export { analyzeEvents, normalizeFetchUrl, isFetchEventType, isNavigationEventType, isRscEventType, isCacheEventType, isActionEventType, isErrorEventType, } from "./analyzer.js";
8
8
  export { getCollector, createCollector, setCollector, resetCollector, } from "./collector.js";
@@ -15,6 +15,12 @@ export type Unsubscribe = () => void;
15
15
  export interface RingBufferOptions {
16
16
  max?: number;
17
17
  }
18
+ /**
19
+ * Shared by the server-side ring buffer and the client-side event list, so the
20
+ * two cannot drift apart and leave the UI holding more history than the server
21
+ * is willing to keep.
22
+ */
23
+ export declare const DEFAULT_MAX_EVENTS = 5000;
18
24
  export declare class RingBuffer {
19
25
  private readonly max;
20
26
  private events;
@@ -1 +1 @@
1
- {"version":3,"file":"ring-buffer.d.ts","sourceRoot":"","sources":["../src/ring-buffer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;AACxD,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAID,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,SAAS,CAAuB;gBAE5B,OAAO,GAAE,iBAAsB;IAO3C,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAgB7B,IAAI,IAAI,UAAU,EAAE;IAIpB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,EAAE;IAOnD,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,SAAS,CAAC,QAAQ,EAAE,aAAa,GAAG,WAAW;IAU/C,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,OAAO,IAAI,IAAI;CAIhB"}
1
+ {"version":3,"file":"ring-buffer.d.ts","sourceRoot":"","sources":["../src/ring-buffer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;AACxD,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,SAAS,CAAuB;gBAE5B,OAAO,GAAE,iBAAsB;IAO3C,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAgB7B,IAAI,IAAI,UAAU,EAAE;IAIpB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,EAAE;IAOnD,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,SAAS,CAAC,QAAQ,EAAE,aAAa,GAAG,WAAW;IAU/C,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,OAAO,IAAI,IAAI;CAIhB"}
@@ -9,13 +9,18 @@
9
9
  * - Safe concurrent usage in Node.js.
10
10
  * - No memory leaks.
11
11
  */
12
- const DEFAULT_MAX = 5000;
12
+ /**
13
+ * Shared by the server-side ring buffer and the client-side event list, so the
14
+ * two cannot drift apart and leave the UI holding more history than the server
15
+ * is willing to keep.
16
+ */
17
+ export const DEFAULT_MAX_EVENTS = 5000;
13
18
  export class RingBuffer {
14
19
  max;
15
20
  events = [];
16
21
  listeners = [];
17
22
  constructor(options = {}) {
18
- this.max = options.max ?? DEFAULT_MAX;
23
+ this.max = options.max ?? DEFAULT_MAX_EVENTS;
19
24
  if (!Number.isFinite(this.max) || this.max <= 0) {
20
25
  throw new Error("RingBuffer max must be a positive number");
21
26
  }
@@ -7,7 +7,35 @@
7
7
  * - Never store cookies, auth headers, request bodies, response bodies,
8
8
  * passwords, or tokens.
9
9
  */
10
- export declare function sanitizeUrl(url: string): string;
10
+ export interface SanitizeUrlOptions {
11
+ /**
12
+ * Keep non-sensitive query parameters instead of dropping the whole query
13
+ * string. Use this for display and duplicate detection, where
14
+ * `/api/items?page=1` and `/api/items?page=2` are genuinely different
15
+ * requests. Sensitive parameters are still removed either way.
16
+ *
17
+ * Defaults to `false`, which strips the query string entirely — the more
18
+ * conservative choice.
19
+ */
20
+ preserveQuery?: boolean;
21
+ }
22
+ export declare function sanitizeUrl(url: string, options?: SanitizeUrlOptions): string;
11
23
  export declare function stripSensitiveQueryParams(url: string): string;
12
24
  export declare function isSafeKey(key: string): boolean;
25
+ export declare function sanitizeKey(key: string): string;
26
+ export interface SanitizeMetadataOptions {
27
+ maxDepth?: number;
28
+ maxArrayItems?: number;
29
+ maxStringLength?: number;
30
+ }
31
+ /**
32
+ * Strips sensitive keys and makes every remaining value JSON-safe.
33
+ *
34
+ * Guards two failure modes at once:
35
+ * - leakage: an auth token handed to `debugCacheHit()` would otherwise be
36
+ * persisted and streamed to every connected client
37
+ * - serialization: a circular reference or BigInt in metadata would throw in
38
+ * `JSON.stringify` and break the SSE stream
39
+ */
40
+ export declare function sanitizeMetadata(metadata: Record<string, unknown> | undefined, options?: SanitizeMetadataOptions): Record<string, unknown> | undefined;
13
41
  //# sourceMappingURL=sanitize.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwBH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAyB/C;AAED,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA+B7D;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM9C"}
1
+ {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwBH,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AASD,wBAAgB,WAAW,CACzB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,kBAAuB,GAC/B,MAAM,CAyBR;AAED,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA+B7D;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM9C;AAeD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAe/C;AAUD,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AA2HD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC7C,OAAO,GAAE,uBAA4B,GACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAkBrC"}
package/dist/sanitize.js CHANGED
@@ -28,10 +28,19 @@ const SENSITIVE_QUERY_PARAMS = new Set([
28
28
  "private_key",
29
29
  "client_secret",
30
30
  ]);
31
- export function sanitizeUrl(url) {
31
+ /** Removes `user:password@` and `user@` prefixes from a URL. */
32
+ function stripCredentials(url) {
33
+ return url
34
+ .replace(/^(https?:\/\/)[^:/@\s]+:[^@/\s]+@/, "$1")
35
+ .replace(/^(https?:\/\/)[^@/\s]+@/, "$1");
36
+ }
37
+ export function sanitizeUrl(url, options = {}) {
32
38
  if (typeof url !== "string" || url.length === 0) {
33
39
  return "";
34
40
  }
41
+ if (options.preserveQuery) {
42
+ return stripSensitiveQueryParams(url);
43
+ }
35
44
  let sanitized;
36
45
  try {
37
46
  const parsed = new URL(url);
@@ -46,10 +55,7 @@ export function sanitizeUrl(url) {
46
55
  sanitized = url.split("?")[0].split("#")[0];
47
56
  }
48
57
  // Also strip credentials embedded in the URL (user:pass@host).
49
- sanitized = sanitized.replace(/^(https?:\/\/)[^:/@\s]+:[^@/\s]+@/, "$1");
50
- // Remove userinfo entirely if present (user@host without password).
51
- sanitized = sanitized.replace(/^(https?:\/\/)[^@/\s]+@/, "$1");
52
- return sanitized;
58
+ return stripCredentials(sanitized);
53
59
  }
54
60
  export function stripSensitiveQueryParams(url) {
55
61
  if (typeof url !== "string" || url.length === 0) {
@@ -60,24 +66,24 @@ export function stripSensitiveQueryParams(url) {
60
66
  parsed = new URL(url);
61
67
  }
62
68
  catch {
63
- return url.split("#")[0];
69
+ return stripCredentials(url.split("#")[0]);
64
70
  }
65
71
  const params = parsed.searchParams;
66
72
  const sensitiveKeys = [];
67
73
  for (const key of params.keys()) {
68
- if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
74
+ if (!isSafeKey(key)) {
69
75
  sensitiveKeys.push(key);
70
76
  }
71
77
  }
72
78
  if (sensitiveKeys.length === 0) {
73
- return url.split("#")[0];
79
+ return stripCredentials(url.split("#")[0]);
74
80
  }
75
81
  for (const key of sensitiveKeys) {
76
82
  params.delete(key);
77
83
  }
78
84
  parsed.search = params.toString();
79
85
  parsed.hash = "";
80
- return parsed.toString();
86
+ return stripCredentials(parsed.toString());
81
87
  }
82
88
  export function isSafeKey(key) {
83
89
  if (typeof key !== "string") {
@@ -86,3 +92,157 @@ export function isSafeKey(key) {
86
92
  const lower = key.toLowerCase();
87
93
  return !SENSITIVE_QUERY_PARAMS.has(lower);
88
94
  }
95
+ /* -------------------------------------------------------------------------- */
96
+ /* Cache key redaction */
97
+ /* -------------------------------------------------------------------------- */
98
+ /**
99
+ * Deliberately narrow: only email addresses, which are high-confidence PII
100
+ * with a distinctive shape. Attempting to also detect phone numbers, national
101
+ * ids or opaque user ids would mangle legitimate cache keys, and a wrong guess
102
+ * is worse than no redaction for a debugging tool. Anything beyond this is an
103
+ * application-level decision about which identifiers are safe to log.
104
+ */
105
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
106
+ export function sanitizeKey(key) {
107
+ if (typeof key !== "string" || key.length === 0) {
108
+ return "";
109
+ }
110
+ return key.replace(EMAIL_PATTERN, (match) => {
111
+ const at = match.lastIndexOf("@");
112
+ if (at <= 0) {
113
+ return "[redacted]";
114
+ }
115
+ const local = match.slice(0, at);
116
+ const domain = match.slice(at + 1);
117
+ // Keep enough to correlate events about the same user without retaining
118
+ // the address itself.
119
+ return `${local[0]}***@${domain}`;
120
+ });
121
+ }
122
+ /* -------------------------------------------------------------------------- */
123
+ /* Metadata sanitization */
124
+ /* -------------------------------------------------------------------------- */
125
+ const DEFAULT_MAX_DEPTH = 6;
126
+ const DEFAULT_MAX_ARRAY_ITEMS = 100;
127
+ const DEFAULT_MAX_STRING_LENGTH = 2000;
128
+ function truncate(value, max) {
129
+ if (value.length <= max) {
130
+ return value;
131
+ }
132
+ return `${value.slice(0, max)}… [truncated ${value.length - max} chars]`;
133
+ }
134
+ /**
135
+ * Converts an arbitrary value into something `JSON.stringify` can always
136
+ * handle, and that is safe to broadcast to every connected browser.
137
+ *
138
+ * Applied centrally in `createEvent` rather than at each call site, so that a
139
+ * new producer cannot accidentally bypass it.
140
+ */
141
+ function normalizeValue(value, depth, seen, limits) {
142
+ if (value === null) {
143
+ return null;
144
+ }
145
+ const type = typeof value;
146
+ if (type === "string") {
147
+ return truncate(value, limits.maxStringLength);
148
+ }
149
+ if (type === "boolean") {
150
+ return value;
151
+ }
152
+ if (type === "number") {
153
+ // NaN/Infinity serialize to null, which reads as "missing" downstream.
154
+ return Number.isFinite(value) ? value : String(value);
155
+ }
156
+ if (type === "bigint") {
157
+ // JSON.stringify throws on BigInt.
158
+ return value.toString();
159
+ }
160
+ if (type === "function" || type === "symbol" || type === "undefined") {
161
+ return undefined;
162
+ }
163
+ const obj = value;
164
+ // Path-scoped cycle detection: a repeated reference in a tree that is not
165
+ // actually cyclic is fine, only a reference back into the current path.
166
+ if (seen.has(obj)) {
167
+ return "[Circular]";
168
+ }
169
+ if (depth >= limits.maxDepth) {
170
+ return "[Max depth reached]";
171
+ }
172
+ if (value instanceof Date) {
173
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
174
+ }
175
+ if (value instanceof Error) {
176
+ return {
177
+ name: value.name,
178
+ message: truncate(value.message, limits.maxStringLength),
179
+ };
180
+ }
181
+ seen.add(obj);
182
+ try {
183
+ if (Array.isArray(value)) {
184
+ const items = [];
185
+ for (const item of value.slice(0, limits.maxArrayItems)) {
186
+ const normalized = normalizeValue(item, depth + 1, seen, limits);
187
+ items.push(normalized === undefined ? null : normalized);
188
+ }
189
+ if (value.length > limits.maxArrayItems) {
190
+ items.push(`[${value.length - limits.maxArrayItems} more items]`);
191
+ }
192
+ return items;
193
+ }
194
+ if (value instanceof Map) {
195
+ return normalizeValue(Object.fromEntries(value), depth, seen, limits);
196
+ }
197
+ if (value instanceof Set) {
198
+ return normalizeValue([...value], depth, seen, limits);
199
+ }
200
+ return normalizeRecord(value, depth, seen, limits);
201
+ }
202
+ finally {
203
+ seen.delete(obj);
204
+ }
205
+ }
206
+ function normalizeRecord(source, depth, seen, limits) {
207
+ const out = {};
208
+ for (const [key, raw] of Object.entries(source)) {
209
+ // Applied at every level, so `{ user: { token: "…" } }` is cleaned too.
210
+ if (!isSafeKey(key)) {
211
+ continue;
212
+ }
213
+ const value = normalizeValue(raw, depth + 1, seen, limits);
214
+ if (value === undefined) {
215
+ continue;
216
+ }
217
+ out[key] = value;
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Strips sensitive keys and makes every remaining value JSON-safe.
223
+ *
224
+ * Guards two failure modes at once:
225
+ * - leakage: an auth token handed to `debugCacheHit()` would otherwise be
226
+ * persisted and streamed to every connected client
227
+ * - serialization: a circular reference or BigInt in metadata would throw in
228
+ * `JSON.stringify` and break the SSE stream
229
+ */
230
+ export function sanitizeMetadata(metadata, options = {}) {
231
+ if (metadata === undefined || metadata === null) {
232
+ return undefined;
233
+ }
234
+ if (typeof metadata !== "object" || Array.isArray(metadata)) {
235
+ return undefined;
236
+ }
237
+ const limits = {
238
+ maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
239
+ maxArrayItems: options.maxArrayItems ?? DEFAULT_MAX_ARRAY_ITEMS,
240
+ maxStringLength: options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH,
241
+ };
242
+ // Seed the root into the cycle set. `normalizeValue` relies on the invariant
243
+ // that the object it is about to walk is already on the path, so without this
244
+ // a self-referencing root would be expanded one level before being caught.
245
+ const seen = new WeakSet();
246
+ seen.add(metadata);
247
+ return normalizeRecord(metadata, 0, seen, limits);
248
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@next-rsc-debug/core",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Framework-independent event protocol and collector for Next RSC Debug",
5
5
  "author": "Samith Mendis",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
+ "sideEffects": false,
8
9
  "main": "./dist/index.js",
9
10
  "types": "./dist/index.d.ts",
10
11
  "exports": {
@@ -19,7 +20,8 @@
19
20
  "scripts": {
20
21
  "build": "tsc -p tsconfig.json",
21
22
  "typecheck": "tsc --noEmit -p tsconfig.json",
22
- "test": "vitest run --config vitest.config.ts"
23
+ "test": "vitest run --config vitest.config.ts",
24
+ "clean": "rm -rf dist tsconfig.tsbuildinfo"
23
25
  },
24
26
  "peerDependencies": {
25
27
  "typescript": ">=5.0.0 <6.1.0"