flugrekorder 1.1.0 → 1.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 +354 -72
- package/dist/flugrekorder.d.mts +49 -24
- package/dist/flugrekorder.d.ts +49 -24
- package/dist/flugrekorder.global.js +422 -175
- package/dist/flugrekorder.global.js.map +1 -1
- package/dist/flugrekorder.js +425 -176
- package/dist/flugrekorder.js.map +1 -1
- package/dist/flugrekorder.mjs +421 -176
- package/dist/flugrekorder.mjs.map +1 -1
- package/package.json +13 -25
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
[](https://github.com/rspieker/flugrekorder/actions/workflows/tests.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
+
> Following a hunch but not sure if you need a microscope, a periscope, or a telescope? Zoom in, peek around, look beyond — all at once.
|
|
8
|
+
|
|
7
9
|
> A tireless, impartial, punctilious, incurious spectator. It witnesses every interaction. It takes note. It understands nothing. Remarkably, this is a feature.
|
|
8
10
|
|
|
9
11
|
Wraps any object, function, or array in a transparent `Proxy` and emits a structured `Rekording` for every Reflect trap that fires — get, set, apply, construct, and all others.
|
|
@@ -13,6 +15,25 @@ The recorder has no knowledge of what it wraps. If a dependency adds new methods
|
|
|
13
15
|
|
|
14
16
|
---
|
|
15
17
|
|
|
18
|
+
## When to use this
|
|
19
|
+
|
|
20
|
+
- A side effect came knocking. Where did it come from? Was it invited? When? By whom?
|
|
21
|
+
- Here, we observe the code in its digital habitat. Undisturbed. Unmodified. Doing — what?
|
|
22
|
+
- The scriptures tell one story. The scribes tell another.
|
|
23
|
+
- You want a record. Not a theory, not a phantom — a record.
|
|
24
|
+
- When the whole district looks suspicious, bring them all to the archives, every soul, every mouse, every elephant. They all give a statement. They don't get a choice.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Why not…
|
|
29
|
+
|
|
30
|
+
- **`console.log`** — you have to know where to put it first.
|
|
31
|
+
- **Mocks and spies** — they replace the real thing; you're no longer observing, you're performing.
|
|
32
|
+
- **Monkey-patching** — one property at a time, brittle, and you'll miss what you didn't think to patch.
|
|
33
|
+
- **A debugger** — interactive, ephemeral, and gone the moment you step past it.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
16
37
|
## Installation
|
|
17
38
|
|
|
18
39
|
```sh
|
|
@@ -26,7 +47,7 @@ npm install flugrekorder
|
|
|
26
47
|
```ts
|
|
27
48
|
import { create, type Rekording } from 'flugrekorder';
|
|
28
49
|
|
|
29
|
-
const records: Rekording
|
|
50
|
+
const records: Array<Rekording> = [];
|
|
30
51
|
const p = create({ greet: (name: string) => `hello, ${name}` }, {
|
|
31
52
|
callback: (r) => records.push(r),
|
|
32
53
|
});
|
|
@@ -40,6 +61,224 @@ console.log(records.map((r) => `${r.id} ${r.trap}`));
|
|
|
40
61
|
|
|
41
62
|
---
|
|
42
63
|
|
|
64
|
+
## Quick useful patterns
|
|
65
|
+
|
|
66
|
+
### Watch sort rewrite your array
|
|
67
|
+
|
|
68
|
+
`sort` reads every element to compare, then writes every position back. Most developers think of it as a single operation. It isn't.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { create, format } from 'flugrekorder';
|
|
72
|
+
|
|
73
|
+
const todos = [
|
|
74
|
+
'🔲 document quick examples',
|
|
75
|
+
'🔲 release next version',
|
|
76
|
+
'✅ build flugrekorder',
|
|
77
|
+
];
|
|
78
|
+
let tracked!: typeof todos;
|
|
79
|
+
tracked = create(todos, {
|
|
80
|
+
only: ['get', 'set', 'apply'],
|
|
81
|
+
callback: (r) => console.log(format(r, tracked)),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
tracked[0] = tracked[0].replace('🔲', '✅');
|
|
85
|
+
tracked.sort();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Output:
|
|
89
|
+
```
|
|
90
|
+
0 → 🔲 document quick examples
|
|
91
|
+
0 = ✅ document quick examples
|
|
92
|
+
sort → sort
|
|
93
|
+
length → 3
|
|
94
|
+
0 → ✅ document quick examples
|
|
95
|
+
1 → 🔲 release next version
|
|
96
|
+
2 → ✅ build flugrekorder
|
|
97
|
+
0 = ✅ build flugrekorder
|
|
98
|
+
1 = ✅ document quick examples
|
|
99
|
+
2 = 🔲 release next version
|
|
100
|
+
sort()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The read phase (`→`) and write phase (`=`) are visible in sequence. After marking index `0` as done, `sort` reads all three items — including `✅ build flugrekorder` sitting at index `2` — then rewrites every position. The completed item from index `2` lands at `0` because `✅` sorts before `🔲`.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### What hint JavaScript passes when it coerces your object
|
|
108
|
+
|
|
109
|
+
Every time JavaScript converts an object to a primitive, it calls `Symbol.toPrimitive` with a hint — `'string'`, `'number'`, or `'default'`. Track a `Date` to see which context passes which:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { create, format } from 'flugrekorder';
|
|
113
|
+
|
|
114
|
+
const date = new Date('2025-01-15T12:00:00Z');
|
|
115
|
+
let tracked!: typeof date;
|
|
116
|
+
tracked = create(date, {
|
|
117
|
+
only: ['get', 'apply'],
|
|
118
|
+
callback: (r) => console.log(format(r, tracked)),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
`${tracked}`; // template literal
|
|
122
|
+
tracked + ''; // + operator
|
|
123
|
+
+tracked; // unary plus
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Output:
|
|
127
|
+
```
|
|
128
|
+
Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
|
|
129
|
+
Symbol(Symbol.toPrimitive)(string)
|
|
130
|
+
Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
|
|
131
|
+
Symbol(Symbol.toPrimitive)(default)
|
|
132
|
+
Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
|
|
133
|
+
Symbol(Symbol.toPrimitive)(number)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`tracked + ''` passes `'default'`, not `'string'` — most developers expect string-hint here. For `Date` both resolve to the same string representation, but that is a `Date`-specific choice, not a JavaScript guarantee.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### What survives `JSON.stringify` — and what doesn't
|
|
141
|
+
|
|
142
|
+
`toJSON` lets an object control its own serialisation. Track one to see exactly what it reads, what it returns, and which values quietly disappear on the way to JSON.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { create, format } from 'flugrekorder';
|
|
146
|
+
|
|
147
|
+
const config: Record<string, Record<string, unknown>> = {
|
|
148
|
+
db: {
|
|
149
|
+
host: 'localhost',
|
|
150
|
+
port: 27017,
|
|
151
|
+
database: 'example',
|
|
152
|
+
timeout: Infinity,
|
|
153
|
+
toJSON() {
|
|
154
|
+
return {
|
|
155
|
+
dsn: `${this['protocol'] ?? 'mongodb'}://${this['host']}:${this['port']}/${this['database']}`,
|
|
156
|
+
timeout: this['timeout'],
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let tracked!: typeof config;
|
|
163
|
+
tracked = create(config, {
|
|
164
|
+
only: ['get', 'apply'],
|
|
165
|
+
callback: (r) => console.log(format(r, tracked)),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
console.log(JSON.stringify(tracked));
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Output:
|
|
172
|
+
```
|
|
173
|
+
toJSON → undefined
|
|
174
|
+
db → db
|
|
175
|
+
db.toJSON → db.toJSON
|
|
176
|
+
db.protocol → undefined
|
|
177
|
+
db.host → localhost
|
|
178
|
+
db.port → 27017
|
|
179
|
+
db.database → example
|
|
180
|
+
db.timeout → Infinity
|
|
181
|
+
db.toJSON(db)
|
|
182
|
+
db.toJSON().dsn → mongodb://localhost:27017/example
|
|
183
|
+
db.toJSON().timeout → Infinity
|
|
184
|
+
|
|
185
|
+
{"db":{"dsn":"mongodb://localhost:27017/example","timeout":null}}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`protocol` was read but never set — `toJSON` had a hidden dependency. `timeout` recorded faithfully as `Infinity` throughout, then silently coerced to `null` in the final JSON because `Infinity` is not valid JSON.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### How many comparisons does `sort` need?
|
|
193
|
+
|
|
194
|
+
Sorting feels atomic — pass an array, get it back in order. But `sort` calls your comparator repeatedly, and the number of calls — and their order — is what the algorithm actually looks like. Writing every call to disk is an easy way to count them and examine the sequence afterwards.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { createWriteStream } from 'node:fs';
|
|
198
|
+
import { create } from 'flugrekorder';
|
|
199
|
+
|
|
200
|
+
const words = ['banana', 'apple', 'elderberry', 'cherry', 'date', 'fig'];
|
|
201
|
+
|
|
202
|
+
let compare!: (a: string, b: string) => number;
|
|
203
|
+
compare = create((a: string, b: string) => a.localeCompare(b), {
|
|
204
|
+
only: ['apply'],
|
|
205
|
+
stream: createWriteStream('comparisons.ndjson'),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
words.sort(compare);
|
|
209
|
+
console.log(words);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Output:
|
|
213
|
+
```
|
|
214
|
+
[ 'apple', 'banana', 'cherry', 'date', 'elderberry', 'fig' ]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`comparisons.ndjson` — 9 lines written, first two shown:
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{"id":"#2","trap":"apply","origin":{"trap":"apply","source":"#1"},"args":[{"$proxy":"#1"},null,["apple","banana"]],"result":-1,"timestamp":1748091234567}
|
|
221
|
+
{"id":"#3","trap":"apply","origin":{"trap":"apply","source":"#1"},"args":[{"$proxy":"#1"},null,["elderberry","apple"]],"result":1,"timestamp":1748091234568}
|
|
222
|
+
...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
For 6 words, V8 made 9 comparisons — not 15 (the bubble sort worst case for 6 elements). The pattern is binary insertion sort: each word is binary-searched into position in the already-sorted prefix. `args[2]` holds the pair being compared; `result` is the comparator's return value. Swap the input for a larger or reverse-sorted list and the count — and the pattern — change.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
### Request logging
|
|
230
|
+
|
|
231
|
+
An HTTP server handles requests concurrently. `callback` accumulates records per request in memory — `stream` writes directly to disk as each trap fires. Custom IDs make records from concurrent requests separable: every record carries its request's namespace.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { createServer } from 'node:http';
|
|
235
|
+
import { createWriteStream } from 'node:fs';
|
|
236
|
+
import { create } from 'flugrekorder';
|
|
237
|
+
|
|
238
|
+
let n = 0;
|
|
239
|
+
const log = createWriteStream('requests.ndjson');
|
|
240
|
+
|
|
241
|
+
const server = createServer((req, res) => {
|
|
242
|
+
let seq = 0;
|
|
243
|
+
const rid = `req${++n}`;
|
|
244
|
+
let r!: typeof req;
|
|
245
|
+
r = create(req, { id: () => `${rid}:${++seq}`, stream: log });
|
|
246
|
+
|
|
247
|
+
if (r.url === '/hello') {
|
|
248
|
+
res.end(`Hello, ${r.method}!`);
|
|
249
|
+
} else {
|
|
250
|
+
res.statusCode = 404;
|
|
251
|
+
res.end(`not found: ${r.method}: ${r.url}`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await new Promise<void>(resolve => server.listen(3000, resolve));
|
|
256
|
+
console.log((await fetch('http://localhost:3000/hello')).status);
|
|
257
|
+
console.log((await fetch('http://localhost:3000/missing')).status);
|
|
258
|
+
server.close();
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Output:
|
|
262
|
+
```
|
|
263
|
+
200
|
|
264
|
+
404
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`requests.ndjson` — 5 lines written:
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{"id":"req1:2","trap":"get","origin":{"trap":"get","parent":"req1:1","key":"url"},"args":[{"$unwrap":{"$proxy":"req1:1"}},"url",{"$proxy":"req1:1"}],"result":"/hello","timestamp":1748091234100}
|
|
271
|
+
{"id":"req1:3","trap":"get","origin":{"trap":"get","parent":"req1:1","key":"method"},"args":[{"$unwrap":{"$proxy":"req1:1"}},"method",{"$proxy":"req1:1"}],"result":"GET","timestamp":1748091234101}
|
|
272
|
+
{"id":"req2:2","trap":"get","origin":{"trap":"get","parent":"req2:1","key":"url"},"args":[{"$unwrap":{"$proxy":"req2:1"}},"url",{"$proxy":"req2:1"}],"result":"/missing","timestamp":1748091234150}
|
|
273
|
+
{"id":"req2:3","trap":"get","origin":{"trap":"get","parent":"req2:1","key":"method"},"args":[{"$unwrap":{"$proxy":"req2:1"}},"method",{"$proxy":"req2:1"}],"result":"GET","timestamp":1748091234151}
|
|
274
|
+
{"id":"req2:4","trap":"get","origin":{"trap":"get","parent":"req2:1","key":"url"},"args":[{"$unwrap":{"$proxy":"req2:1"}},"url",{"$proxy":"req2:1"}],"result":"/missing","timestamp":1748091234151}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Certainly not the average request log, but a full picture of what happened when, where and why.
|
|
278
|
+
`req1:*` and `req2:*` are distinct namespaces in the same file. `args[0]` carries `$unwrap` — the raw request object (not the proxy) — because that is what the Reflect `get` trap receives as its target. `args[2]` is the proxy receiver. The 404 handler reads `url` twice and `method` once — exactly as written. Under concurrent load the records interleave by timestamp, but `grep req1` always isolates one request.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
43
282
|
## API
|
|
44
283
|
|
|
45
284
|
### `create(target, options)`
|
|
@@ -49,7 +288,7 @@ Wraps `target` in a recording proxy and returns it. The proxy is transparent —
|
|
|
49
288
|
```ts
|
|
50
289
|
import { create, type Rekording } from 'flugrekorder';
|
|
51
290
|
|
|
52
|
-
const records: Rekording
|
|
291
|
+
const records: Array<Rekording> = [];
|
|
53
292
|
const p = create(target, { callback: (r) => records.push(r) });
|
|
54
293
|
```
|
|
55
294
|
|
|
@@ -61,7 +300,11 @@ const p = create(target, { callback: (r) => records.push(r) });
|
|
|
61
300
|
| `stream` | `Writable` | — | Node.js Writable; records are written as newline-delimited JSON. Mutually exclusive with `callback`. |
|
|
62
301
|
| `id` | `number \| (() => string)` | `0` | Starting integer for the auto-incrementing ID sequence, or a custom generator. IDs take the form `#1`, `#2`, … unless overridden. |
|
|
63
302
|
| `recursive` | `boolean` | `true` | When `false`, only the root target is proxied. Values returned from traps are passed through as-is. |
|
|
64
|
-
| `only` | `string
|
|
303
|
+
| `only` | `Array<string>` | all traps | Allowlist of Reflect trap names to record. Traps not listed pass straight through to `Reflect` without emitting a record. |
|
|
304
|
+
| `depth` | `number` | `Infinity` | Maximum depth for nested object serialization. Deeper values are replaced with `[…]`. |
|
|
305
|
+
| `redact` | `Redactor \| Array<Redactor>` | — | Function(s) to redact sensitive values. Return `true` to replace with `"[redacted]"`, `false` to keep as-is, or a string for custom replacement. |
|
|
306
|
+
| `truncate` | `number` | `Infinity` | Maximum string length before truncation. Longer strings are truncated with `…`. |
|
|
307
|
+
| `bind` | `boolean` | `undefined` | Controls how flugrekorder handles classes with `#` private fields. See [Private fields](#private-fields-bind). |
|
|
65
308
|
|
|
66
309
|
One of `callback` or `stream` is required.
|
|
67
310
|
|
|
@@ -127,8 +370,8 @@ import { create, getPath } from 'flugrekorder';
|
|
|
127
370
|
|
|
128
371
|
const p = create({ a: { b: { fn: () => ({ v: 1 }) } } }, { callback: () => {} });
|
|
129
372
|
|
|
130
|
-
getPath(p);
|
|
131
|
-
getPath(p.a);
|
|
373
|
+
getPath(p); // ''
|
|
374
|
+
getPath(p.a); // 'a'
|
|
132
375
|
getPath(p.a.b.fn); // 'a.b.fn'
|
|
133
376
|
getPath(p.a.b.fn()); // 'a.b.fn()'
|
|
134
377
|
```
|
|
@@ -158,7 +401,7 @@ Looks up a proxy by its recorded ID within the same graph as `proxy`. Useful for
|
|
|
158
401
|
```ts
|
|
159
402
|
import { create, getProxyById, type Rekording } from 'flugrekorder';
|
|
160
403
|
|
|
161
|
-
const records: Rekording
|
|
404
|
+
const records: Array<Rekording> = [];
|
|
162
405
|
const p = create({ nested: { x: 1 } }, { callback: (r) => records.push(r) });
|
|
163
406
|
|
|
164
407
|
p.nested; // triggers a get, result is { $proxy: '#2' }
|
|
@@ -176,17 +419,18 @@ Every emitted record has this structure:
|
|
|
176
419
|
```ts
|
|
177
420
|
type Rekording = {
|
|
178
421
|
id: string; // e.g. '#3'
|
|
179
|
-
trap: string; // Reflect trap name: 'get', 'set', 'apply', …
|
|
422
|
+
trap: string; // Reflect trap name: 'get', 'set', 'apply', … or a synthetic variant ('apply:native', 'apply:structure', 'apply:private')
|
|
180
423
|
origin: {
|
|
181
424
|
trap: 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor';
|
|
182
425
|
parent: string; // ID of the proxy this trap fired on
|
|
183
426
|
key: string; // property name (symbols are serialised to their string form)
|
|
184
427
|
} | {
|
|
185
428
|
trap: 'apply' | 'construct';
|
|
186
|
-
source: string;
|
|
187
|
-
} | null;
|
|
188
|
-
args: Serialized
|
|
189
|
-
result: Serialized;
|
|
429
|
+
source: string; // ID of the function/constructor proxy that was called
|
|
430
|
+
} | null; // null for the root proxy
|
|
431
|
+
args: Array<Serialized>; // trap arguments
|
|
432
|
+
result: Serialized; // return value
|
|
433
|
+
timestamp: number; // Date.now() at the moment the trap fired
|
|
190
434
|
};
|
|
191
435
|
```
|
|
192
436
|
|
|
@@ -257,98 +501,136 @@ Promises cannot be proxied directly — native `.then()` checks for the `[[Promi
|
|
|
257
501
|
|
|
258
502
|
Trap specs use two wrapping modes. `wrap` creates a new proxy for any proxiable value not already in the graph — used for results, where a newly returned object should be recorded. `wrapKnown` only wraps values that are already in the graph — used for call arguments, where passing a plain object to a proxied function should not silently create a new proxy out of it.
|
|
259
503
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
## Examples
|
|
504
|
+
### Native boundary crossings (`apply:native`, `apply:structure`, `apply:private`)
|
|
263
505
|
|
|
264
|
-
|
|
506
|
+
Some native code rejects a `Proxy` as `this` or as an argument — V8 checks the real target at the C++ level before any JS runs. flugrekorder catches these failures, retries with the unwrapped real target, and records the crossing with a synthetic trap name so it remains visible in the rekording.
|
|
265
507
|
|
|
266
|
-
|
|
267
|
-
import { createWriteStream } from 'node:fs';
|
|
268
|
-
import { create } from 'flugrekorder';
|
|
508
|
+
**`apply:native`** — a C++ method threw `TypeError: Illegal invocation` because `this` was a Proxy. The real target is substituted for `this` and the call is retried. The unwrapped `this` appears as `{ $unwrap: { $proxy: "<id>" } }` in `args[1]`.
|
|
269
509
|
|
|
270
|
-
|
|
271
|
-
const tracked = create(myService, { stream: log });
|
|
510
|
+
**`apply:structure`** — a function internally passed a proxied argument to the [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) (`structuredClone`, `postMessage`, `history.pushState`, …), which threw `DataCloneError`. flugrekorder unwraps any proxy arguments in the call and retries. Unwrapped args appear as `{ $unwrap: { $proxy: "<id>" } }` in `args[2]`.
|
|
272
511
|
|
|
273
|
-
|
|
274
|
-
await tracked.processOrder(orderId);
|
|
275
|
-
```
|
|
512
|
+
**Remaining gap** — a *direct* `structuredClone(proxy)` call that never passes through a flugrekorder trap cannot be intercepted; V8 resolves the proxy before any JS runs. Use `structuredClone(getTarget(proxy))` at the call site as a workaround.
|
|
276
513
|
|
|
277
|
-
###
|
|
514
|
+
### Private fields (`bind`)
|
|
278
515
|
|
|
279
|
-
|
|
280
|
-
import { create, type Rekording } from 'flugrekorder';
|
|
516
|
+
JavaScript `#` private fields are stored on the real instance. Accessing them with `this` as a Proxy throws `TypeError: Cannot read private member … from an object whose class did not declare it` — the engine enforces the class boundary at the field level.
|
|
281
517
|
|
|
282
|
-
|
|
283
|
-
const p = create(myApi, {
|
|
284
|
-
callback: (r) => records.push(r),
|
|
285
|
-
only: ['apply'],
|
|
286
|
-
});
|
|
518
|
+
The `bind` option controls how flugrekorder responds:
|
|
287
519
|
|
|
288
|
-
|
|
520
|
+
**`undefined` (default)** — flugrekorder tries normally. If a method or getter throws the private-field TypeError, it retries with the real target as `this`. Method calls record as `apply:private` instead of `apply` to signal the crossing; getter reads record transparently as `get`. This is the zero-friction path — no configuration needed, no crashes.
|
|
289
521
|
|
|
290
|
-
|
|
291
|
-
console.log(records.length); // 1
|
|
292
|
-
```
|
|
522
|
+
**`bind: true`** — flugrekorder pre-binds all methods to the real target at `get` time, before any call is attempted. Calls record as plain `apply` and never throw. The trade-off: any property access that happens *inside* a method (e.g. `this.name`) runs on the real target, bypassing the proxy. Those internal reads and writes are not recorded.
|
|
293
523
|
|
|
294
|
-
|
|
524
|
+
**`bind: false`** — flugrekorder never retries. If a method throws due to a `#` private field, the error propagates unchanged. Use this when you know the class has no `#` fields and want to ensure unexpected private-field errors surface rather than being silently retried.
|
|
295
525
|
|
|
296
526
|
```ts
|
|
297
|
-
|
|
527
|
+
class Counter {
|
|
528
|
+
#count = 0;
|
|
529
|
+
increment() { this.#count++; }
|
|
530
|
+
get value() { return this.#count; }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// default: automatic retry, apply:private in recording
|
|
534
|
+
const p = create(new Counter(), { callback });
|
|
535
|
+
p.increment(); // records trap: 'apply:private'
|
|
536
|
+
p.value; // records trap: 'get'
|
|
537
|
+
|
|
538
|
+
// bind: true: pre-bound, plain apply in recording, internals invisible
|
|
539
|
+
const q = create(new Counter(), { bind: true, callback });
|
|
540
|
+
q.increment(); // records trap: 'apply'
|
|
541
|
+
q.value; // records trap: 'get'
|
|
542
|
+
```
|
|
298
543
|
|
|
299
|
-
|
|
300
|
-
const p = create(myService, { callback: (r) => records.push(r) });
|
|
544
|
+
**The inverse** — a class that uses `#` private fields exclusively for its internal state is naturally opaque to flugrekorder: method calls are recorded but what happens inside them is not. This is an effective opt-out for library authors who want their implementation details to remain unobservable regardless of whether a caller wraps their object.
|
|
301
545
|
|
|
302
|
-
|
|
546
|
+
---
|
|
303
547
|
|
|
304
|
-
|
|
305
|
-
records
|
|
306
|
-
.filter((r) => r.trap === 'apply')
|
|
307
|
-
.forEach((r) => {
|
|
308
|
-
if (r.origin && 'source' in r.origin) {
|
|
309
|
-
const fn = getProxyById(r.origin.source, p);
|
|
310
|
-
if (fn) console.log('called:', getPath(fn));
|
|
311
|
-
}
|
|
312
|
-
});
|
|
313
|
-
```
|
|
548
|
+
## Types
|
|
314
549
|
|
|
315
|
-
###
|
|
550
|
+
### `Proxiable`
|
|
551
|
+
Any value that can be proxied: objects or functions.
|
|
316
552
|
|
|
317
553
|
```ts
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// Prefix IDs with a session token for correlation across multiple recordings
|
|
321
|
-
const session = crypto.randomUUID();
|
|
322
|
-
let n = 0;
|
|
323
|
-
const p = create(target, {
|
|
324
|
-
callback: (r) => console.log(r.id),
|
|
325
|
-
id: () => `${session}:${++n}`,
|
|
326
|
-
});
|
|
554
|
+
type Proxiable = object | Function;
|
|
327
555
|
```
|
|
328
556
|
|
|
329
|
-
|
|
557
|
+
### `Redactor`
|
|
558
|
+
Function that decides how to handle sensitive values during serialization.
|
|
330
559
|
|
|
331
|
-
|
|
560
|
+
```ts
|
|
561
|
+
type Redactor = (
|
|
562
|
+
key: string | symbol,
|
|
563
|
+
value: unknown,
|
|
564
|
+
target: object,
|
|
565
|
+
) => string | boolean | null;
|
|
566
|
+
```
|
|
332
567
|
|
|
333
568
|
```ts
|
|
334
|
-
|
|
569
|
+
/**
|
|
570
|
+
* @param key - Property key being serialized
|
|
571
|
+
* @param value - Value at that key
|
|
572
|
+
* @param target - Parent object containing the key
|
|
573
|
+
* @returns `true` to replace with `"[redacted]"`, `false` to keep as-is,
|
|
574
|
+
* a string for custom replacement, or `null` to drop the key
|
|
575
|
+
*/
|
|
576
|
+
const hideSecrets: Redactor = (key) => key === 'password';
|
|
577
|
+
const redactSecrets: Redactor = (key, value) => key === 'password' && '*'.repeat(String(value).length);
|
|
578
|
+
const dropSecrets: Redactor = (key) => key === 'password' ? null : false;
|
|
579
|
+
```
|
|
335
580
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
| { readonly $proxy: string }
|
|
339
|
-
| Serialized[]
|
|
340
|
-
| { [key: string]: Serialized };
|
|
581
|
+
### `Origin`
|
|
582
|
+
Describes how a proxy was created. Every proxied value carries its `Origin` so you can trace where it came from in the proxy tree. `null` for the root proxy.
|
|
341
583
|
|
|
342
|
-
|
|
584
|
+
```ts
|
|
585
|
+
type Origin =
|
|
343
586
|
| { trap: 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor'; parent: string; key: string | symbol }
|
|
344
587
|
| { trap: 'apply' | 'construct'; source: string }
|
|
345
588
|
| null;
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
| Field | Type | Description |
|
|
592
|
+
|-------|------|-------------|
|
|
593
|
+
| `trap` | `string` | The trap that created this proxy |
|
|
594
|
+
| `parent` | `string` | Proxy ID of the parent that returned this value |
|
|
595
|
+
| `key` | `string \| symbol` | Property key accessed |
|
|
596
|
+
| `source` | `string` | Proxy ID of the function that returned this value |
|
|
346
597
|
|
|
347
|
-
|
|
598
|
+
### `Rekording`
|
|
599
|
+
A single recorded interaction from a proxy trap. Every trap firing produces one `Rekording` emitted to your callback or stream.
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
type Rekording = {
|
|
348
603
|
id: string;
|
|
349
604
|
trap: string;
|
|
350
|
-
origin:
|
|
351
|
-
args: Serialized
|
|
605
|
+
origin: Origin;
|
|
606
|
+
args: Array<Serialized>;
|
|
352
607
|
result: Serialized;
|
|
608
|
+
timestamp: number;
|
|
353
609
|
};
|
|
354
610
|
```
|
|
611
|
+
|
|
612
|
+
| Field | Type | Description |
|
|
613
|
+
|-------|------|-------------|
|
|
614
|
+
| `id` | `string` | Unique identifier for this record |
|
|
615
|
+
| `trap` | `string` | Reflect trap name (`get`, `set`, `apply`, etc.) |
|
|
616
|
+
| `origin` | `Origin` | How this proxy was created |
|
|
617
|
+
| `args` | `Array<Serialized>` | Arguments passed to the trap |
|
|
618
|
+
| `result` | `Serialized` | Return value from the trap |
|
|
619
|
+
| `timestamp` | `number` | When the trap fired |
|
|
620
|
+
|
|
621
|
+
### `Serialized`
|
|
622
|
+
Serialized recorded values can be:
|
|
623
|
+
- Primitives: `string`, `number`, `boolean`, `bigint`, `null`, `undefined`
|
|
624
|
+
- Proxy reference: `{ $proxy: string }` — a proxiable value known to the graph, referenced by ID
|
|
625
|
+
- Raw target reference: `{ $unwrap: { $proxy: string } }` — the real target behind a proxy, recorded when a native boundary crossing required unwrapping (see [`apply:native`](#native-boundary-crossings-and-applynative))
|
|
626
|
+
- Array of `Serialized`
|
|
627
|
+
- Plain object with `Serialized` values
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
type Serialized =
|
|
631
|
+
| string | number | boolean | bigint | null | undefined
|
|
632
|
+
| { readonly $proxy: string }
|
|
633
|
+
| { readonly $unwrap: { readonly $proxy: string } }
|
|
634
|
+
| Array<Serialized>
|
|
635
|
+
| { [key: string]: Serialized };
|
|
636
|
+
```
|
package/dist/flugrekorder.d.mts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { Writable } from 'node:stream';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
type Proxiable = object | Function;
|
|
3
|
+
type Proxiable = object;
|
|
5
4
|
/** Reflect traps that operate on a property — carry a parent ID and key in their origin. */
|
|
6
5
|
type PropertyTrap = 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor';
|
|
7
|
-
/** Reflect traps that invoke a callable — carry a source ID in their origin. */
|
|
8
|
-
type CallTrap = 'apply' | 'construct';
|
|
6
|
+
/** Reflect traps that invoke a callable — carry a source ID in their origin. Includes synthetic variants for native boundary crossings. */
|
|
7
|
+
type CallTrap = 'apply' | 'construct' | 'apply:native' | 'construct:native' | 'apply:structure' | 'apply:private';
|
|
9
8
|
/** Describes how a proxy was created — which trap fired, on which parent, and under which key or source. Null for root proxies. */
|
|
10
9
|
type Origin = {
|
|
11
10
|
trap: PropertyTrap;
|
|
@@ -24,12 +23,24 @@ type SerializedOrigin = {
|
|
|
24
23
|
trap: CallTrap;
|
|
25
24
|
source: string;
|
|
26
25
|
} | null;
|
|
27
|
-
/** A JSON-safe representation of any value. Proxiable values are replaced with `{ $proxy: id }` tags. */
|
|
26
|
+
/** A JSON-safe representation of any value. Proxiable values are replaced with `{ $proxy: id }` tags. Raw targets (proxiable values known to the graph but passed unwrapped) are tagged `{ $unwrap: { $proxy: id } }`. */
|
|
28
27
|
type Serialized = string | number | boolean | bigint | null | undefined | {
|
|
29
28
|
readonly $proxy: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly $unwrap: {
|
|
31
|
+
readonly $proxy: string;
|
|
32
|
+
};
|
|
30
33
|
} | Array<Serialized> | {
|
|
31
34
|
[key: string]: Serialized;
|
|
32
35
|
};
|
|
36
|
+
/** Returns true for any value that can meaningfully be wrapped in a Proxy. */
|
|
37
|
+
declare function isProxiable(value: unknown): value is Proxiable;
|
|
38
|
+
/**
|
|
39
|
+
* Determines whether and how a key/value pair should be redacted during serialization.
|
|
40
|
+
* Return `false` to keep as-is, `true` to replace with `"[redacted]"`,
|
|
41
|
+
* a string to use as a custom replacement, or `null` to drop the key entirely.
|
|
42
|
+
*/
|
|
43
|
+
type Redactor = (key: string | symbol, value: unknown, target: object) => string | boolean | null;
|
|
33
44
|
/** A single recorded interaction — one Reflect trap firing on one proxy. */
|
|
34
45
|
type Rekording = {
|
|
35
46
|
id: string;
|
|
@@ -37,25 +48,14 @@ type Rekording = {
|
|
|
37
48
|
origin: SerializedOrigin;
|
|
38
49
|
args: Array<Serialized>;
|
|
39
50
|
result: Serialized;
|
|
51
|
+
timestamp: number;
|
|
40
52
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
callback?: never;
|
|
48
|
-
} | {
|
|
49
|
-
callback: (record: Rekording) => void;
|
|
50
|
-
stream?: never;
|
|
51
|
-
});
|
|
52
|
-
/**
|
|
53
|
-
* Wraps `target` in a transparent recording proxy.
|
|
54
|
-
* Every Reflect trap that fires emits a `Rekording` to `callback` or `stream`.
|
|
55
|
-
*/
|
|
56
|
-
declare function create<T extends Proxiable>(target: T, options: CreateOptions): T;
|
|
57
|
-
/** Returns `true` if `value` is a proxy created by this module. */
|
|
58
|
-
declare function isFlugrekorder(value: unknown): value is Proxiable;
|
|
53
|
+
|
|
54
|
+
/** Converts a `Rekording` to a human-readable string.
|
|
55
|
+
* When `proxy` is supplied, `{ $proxy: id }` tags in args and results resolve to dotted paths.
|
|
56
|
+
* When omitted, raw IDs are shown instead. */
|
|
57
|
+
declare function format(rekording: Rekording, proxy?: Proxiable): string;
|
|
58
|
+
|
|
59
59
|
/** Returns the original unwrapped target of a proxy, or `null` for non-proxies. */
|
|
60
60
|
declare function getTarget(pxy: Proxiable): Proxiable | null;
|
|
61
61
|
/**
|
|
@@ -81,4 +81,29 @@ declare function getAncestors(pxy: Proxiable): Array<{
|
|
|
81
81
|
*/
|
|
82
82
|
declare function getPath(pxy: Proxiable): string;
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
type CommonCreateOptions = {
|
|
85
|
+
id?: number | (() => string);
|
|
86
|
+
recursive?: boolean;
|
|
87
|
+
only?: Array<string>;
|
|
88
|
+
filter?: (rekording: Rekording) => boolean;
|
|
89
|
+
depth?: number;
|
|
90
|
+
redact?: Redactor | Array<Redactor>;
|
|
91
|
+
truncate?: number;
|
|
92
|
+
bind?: boolean;
|
|
93
|
+
};
|
|
94
|
+
type StreamCreateOptions = CommonCreateOptions & {
|
|
95
|
+
stream: Writable;
|
|
96
|
+
};
|
|
97
|
+
type CallbackCreateOptions = CommonCreateOptions & {
|
|
98
|
+
callback: (record: Rekording) => void;
|
|
99
|
+
};
|
|
100
|
+
type CreateOptions = StreamCreateOptions | CallbackCreateOptions;
|
|
101
|
+
/**
|
|
102
|
+
* Wraps `target` in a transparent recording proxy.
|
|
103
|
+
* Every Reflect trap that fires emits a `Rekording` to `callback` or `stream`.
|
|
104
|
+
*/
|
|
105
|
+
declare function create<T extends Proxiable>(target: T, options: CreateOptions): T;
|
|
106
|
+
/** Returns `true` if `value` is a proxy created by this module. */
|
|
107
|
+
declare function isFlugrekorder(value: unknown): value is Proxiable;
|
|
108
|
+
|
|
109
|
+
export { type CallTrap, type Origin, type PropertyTrap, type Proxiable, type Redactor, type Rekording, type Serialized, create, format, getAncestors, getOrigin, getPath, getProxyById, getTarget, isFlugrekorder, isProxiable };
|