@next-rsc-debug/core 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +7 -3
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/ring-buffer.d.ts +6 -0
- package/dist/ring-buffer.d.ts.map +1 -1
- package/dist/ring-buffer.js +7 -2
- package/dist/sanitize.d.ts +29 -1
- package/dist/sanitize.d.ts.map +1 -1
- package/dist/sanitize.js +169 -9
- package/package.json +4 -2
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
|
package/dist/analyzer.d.ts.map
CHANGED
|
@@ -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,
|
|
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";
|
package/dist/events.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
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
|
-
|
|
47
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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";
|
package/dist/ring-buffer.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/ring-buffer.js
CHANGED
|
@@ -9,13 +9,18 @@
|
|
|
9
9
|
* - Safe concurrent usage in Node.js.
|
|
10
10
|
* - No memory leaks.
|
|
11
11
|
*/
|
|
12
|
-
|
|
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 ??
|
|
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
|
}
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -7,7 +7,35 @@
|
|
|
7
7
|
* - Never store cookies, auth headers, request bodies, response bodies,
|
|
8
8
|
* passwords, or tokens.
|
|
9
9
|
*/
|
|
10
|
-
export
|
|
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
|
package/dist/sanitize.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwBH,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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"
|