codetac 0.1.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.
@@ -0,0 +1,124 @@
1
+ // Leitura direta de uma gravação (.codetac/<gravação>/*.jsonl), sem a base
2
+ // do painel: o comando `codetac` segue-a enquanto a app corre, e o
3
+ // diagnóstico resume-a.
4
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { isLibraryFile } from './boundaries.mjs';
7
+
8
+ // Follows the files of a recording and calls `onEvent` for each new event.
9
+ export function follow(directory, onEvent) {
10
+ const offsets = new Map();
11
+ const rests = new Map();
12
+ function poll() {
13
+ if (!existsSync(directory)) return;
14
+ for (const name of readdirSync(directory)) {
15
+ if (!name.endsWith('.jsonl')) continue;
16
+ const path = join(directory, name);
17
+ let size;
18
+ try { size = statSync(path).size; } catch { continue; }
19
+ let offset = offsets.get(path) ?? 0;
20
+ if (size <= offset) continue;
21
+ const fd = openSync(path, 'r');
22
+ try {
23
+ while (offset < size) {
24
+ const buffer = Buffer.alloc(Math.min(size - offset, 4 * 1024 * 1024));
25
+ const read = readSync(fd, buffer, 0, buffer.length, offset);
26
+ if (!read) break;
27
+ offset += read;
28
+ const text = (rests.get(path) ?? '') + buffer.subarray(0, read).toString('utf8');
29
+ const lines = text.split('\n');
30
+ rests.set(path, lines.pop());
31
+ for (const line of lines) {
32
+ if (!line) continue;
33
+ let event;
34
+ try { event = JSON.parse(line); } catch { continue; }
35
+ onEvent(event, name);
36
+ }
37
+ }
38
+ } finally { closeSync(fd); }
39
+ offsets.set(path, offset);
40
+ }
41
+ }
42
+ return { poll };
43
+ }
44
+
45
+ // What a recording shows about the capture, in counts.
46
+ export function createSummary() {
47
+ const summary = {
48
+ starts: [], ports: new Set(), addresses: new Map(), pages: 0, requests: 0, withFunctions: new Set(), withBoundaries: new Set(),
49
+ actions: new Set(), files: 0, functions: 0, emptyFiles: 0, failed: [], limitations: new Map(), processes: new Set(),
50
+ firstAction: null, firstRequest: null, firstPage: null, pagePath: null,
51
+ };
52
+ summary.add = event => {
53
+ if (event.process) summary.processes.add(event.process);
54
+ switch (event.type) {
55
+ case 'capture-start': summary.starts.push({ level: event.level, reason: event.reason, root: event.root, node: event.node }); break;
56
+ case 'listening':
57
+ if (event.port) { summary.ports.add(event.port); summary.addresses.set(event.port, event.address); }
58
+ break;
59
+ case 'page': summary.pages++; summary.pagePath = event.path; break;
60
+ case 'request':
61
+ summary.requests++;
62
+ summary.firstRequest ??= event;
63
+ // The request that follows a page event with the same path loaded that page.
64
+ if (summary.pagePath && event.path === summary.pagePath) { summary.firstPage ??= { ...event, seenAt: Date.now() }; summary.pagePath = null; }
65
+ break;
66
+ case 'browser-action':
67
+ if (!summary.actions.has(event.actionId)) summary.firstAction ??= event;
68
+ summary.actions.add(event.actionId);
69
+ break;
70
+ case 'module':
71
+ if (event.cached || isLibraryFile(event.file ?? '')) break;
72
+ summary.files++;
73
+ summary.functions += event.functions ?? 0;
74
+ if (!event.functions) summary.emptyFiles++;
75
+ break;
76
+ case 'limitation':
77
+ summary.limitations.set(event.reason, (summary.limitations.get(event.reason) ?? 0) + 1);
78
+ if (event.reason === 'module-transform-failed' || event.reason === 'source-map-unreadable') summary.failed.push({ file: event.file, detail: event.detail });
79
+ break;
80
+ case 'enter':
81
+ if (event.requestId && !isLibraryFile(event.file ?? '')) summary.withFunctions.add(event.requestId);
82
+ break;
83
+ case 'boundary':
84
+ if (event.requestId) summary.withBoundaries.add(event.requestId);
85
+ break;
86
+ }
87
+ };
88
+ return summary;
89
+ }
90
+
91
+ export function summarize(directory) {
92
+ const summary = createSummary();
93
+ follow(directory, summary.add).poll();
94
+ return summary;
95
+ }
96
+
97
+ // The recordings of a project, newest first (by the folder's last change).
98
+ export function recordingsOf(workspaceDirectory, root) {
99
+ if (!existsSync(workspaceDirectory)) return [];
100
+ const found = [];
101
+ for (const name of readdirSync(workspaceDirectory)) {
102
+ const folder = join(workspaceDirectory, name);
103
+ if (name === 'projetos' || !statSync(folder).isDirectory()) continue;
104
+ const files = readdirSync(folder).filter(file => file.endsWith('.jsonl'));
105
+ if (!files.length) continue;
106
+ // The first line of any file is its capture-start, with the root.
107
+ let first = null;
108
+ for (const file of files) {
109
+ const fd = openSync(join(folder, file), 'r');
110
+ try {
111
+ const buffer = Buffer.alloc(4096);
112
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
113
+ const line = buffer.subarray(0, read).toString('utf8').split('\n')[0];
114
+ first = JSON.parse(line);
115
+ } catch {} finally { closeSync(fd); }
116
+ if (first) break;
117
+ }
118
+ if (first?.type === 'capture-start' && first.root === root) {
119
+ const changed = Math.max(...files.map(file => statSync(join(folder, file)).mtimeMs));
120
+ found.push({ name, folder, changed });
121
+ }
122
+ }
123
+ return found.sort((a, b) => b.changed - a.changed);
124
+ }
package/src/redact.mjs ADDED
@@ -0,0 +1,35 @@
1
+ const sensitive = /password|secret|token|authorization|api[ _-]?key|credential|private[ _-]?key|session/i;
2
+ const marker = '[REDACTED]';
3
+ // A name (field, parameter, function) that announces a secret.
4
+ export const isSensitiveName = name => sensitive.test(String(name ?? ''));
5
+ const MIN_SECRET_LENGTH = 8;
6
+
7
+ export function createRedactor(env = process.env, maxBytes = 10 * 1024) {
8
+ const secrets = Object.entries(env)
9
+ // Very short values ("1", "true") are flags, not secrets; matching them
10
+ // would destroy unrelated text such as paths and numbers.
11
+ .filter(([key, value]) => sensitive.test(key) && typeof value === 'string' && value.length >= MIN_SECRET_LENGTH)
12
+ .map(([, value]) => value).sort((a, b) => b.length - a.length);
13
+ function text(value) {
14
+ for (const secret of secrets) value = value.split(secret).join(marker);
15
+ value = value
16
+ .replace(/Bearer\s+[^\s"',;]+/gi, `Bearer ${marker}`)
17
+ .replace(/-----BEGIN [\w ]*PRIVATE KEY-----[\s\S]*?-----END [\w ]*PRIVATE KEY-----/g, marker)
18
+ .replace(/\b(?:sk-(?:proj-|ant-)?[\w-]{8,}|gh[pousr]_[\w]{10,}|github_pat_[\w]{10,}|AKIA[A-Z0-9]{16}|AIza[\w-]{20,}|xox[baprs]-[\w-]{10,}|eyJ[\w-]+\.[\w-]+\.[\w-]+)\b/g, marker)
19
+ .replace(/((?:password|secret|token|authorization|api[ _-]?key|credential|private[ _-]?key|session)\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, `$1${marker}`)
20
+ .replace(/\b([\w.+-])[\w.+-]*@([\w-])[\w.-]*\.[a-z]{2,}\b/gi, '$1***@$2***')
21
+ .replace(/(?<![\w])\+?\d[\d ()-]{7,}\d(?![\w])/g, match => `${match.slice(0, 2)}***${match.slice(-2)}`);
22
+ if (Buffer.byteLength(value) > maxBytes) {
23
+ value = Buffer.from(value).subarray(0, maxBytes).toString('utf8').replace(/\uFFFD$/, '') + '[TRUNCATED]';
24
+ }
25
+ return value;
26
+ }
27
+ return function redact(value) {
28
+ if (typeof value === 'string') return text(value);
29
+ if (Array.isArray(value)) return value.map(redact);
30
+ if (value && typeof value === 'object') return Object.fromEntries(
31
+ Object.entries(value).map(([key, item]) => [text(key), sensitive.test(key) ? marker : redact(item)]),
32
+ );
33
+ return value;
34
+ };
35
+ }
@@ -0,0 +1,94 @@
1
+ import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { resolve, relative, sep, join } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { createRuntime } from './runtime.mjs';
6
+ import { transform, runtimeKey } from './transform.mjs';
7
+ import { installBoundaries, isLibraryFile } from './boundaries.mjs';
8
+ import { panelUrl } from './page.mjs';
9
+ import { dataDirectory, install as workspace } from './home.mjs';
10
+ const root = realpathSync(resolve(process.env.CODETAC_ROOT || process.cwd()));
11
+ // Output goes to CodeTAC's data folder (home.mjs), never into the observed app.
12
+ const label = process.env.CODETAC_RUN || 'manual';
13
+ if (!/^[a-zA-Z0-9_-]{1,80}$/.test(label)) throw new Error('CODETAC_RUN: use apenas letras, números, _ ou - (máximo 80).');
14
+ const runtime = createRuntime(join(dataDirectory(), label));
15
+ globalThis[Symbol.for(runtimeKey)] = runtime;
16
+ // Minimal mode (Phase 5): requests, boundaries and the page bar, without
17
+ // following the project's functions. The `codetac` command falls back to it
18
+ // when the application does not start with the fine instrumentation; it is
19
+ // also used when this Node cannot register module hooks.
20
+ const minimal = process.env.CODETAC_LEVEL === 'minimo' || typeof registerHooks !== 'function';
21
+ const reason = process.env.CODETAC_LEVEL === 'minimo' ? (process.env.CODETAC_MINIMO_MOTIVO || 'pedido no arranque')
22
+ : minimal ? `o Node ${process.version} não permite registar hooks de módulos` : undefined;
23
+ runtime.emit({ type: 'capture-start', root, node: process.version, level: minimal ? 'minimo' : 'normal', reason });
24
+ // The page script is injected into HTML pages unless CODETAC_PAGE=0.
25
+ installBoundaries(runtime, root, { panel: panelUrl(), run: label, inject: process.env.CODETAC_PAGE !== '0' });
26
+ const transformed = new Map();
27
+ const produced = new Set();
28
+
29
+ function observed(filename) {
30
+ if (filename.startsWith(join(workspace, 'src') + sep)) return false;
31
+ // Known libraries are transformed only to mark their boundary functions.
32
+ if (isLibraryFile(filename)) return true;
33
+ if (minimal) return false;
34
+ const within = relative(root, filename);
35
+ return !(within.startsWith(`..${sep}`) || within === '..' || within.startsWith(sep)
36
+ || within.split(sep).includes('node_modules'));
37
+ }
38
+
39
+ // Returns the instrumented source and format, or null to keep the original.
40
+ function instrument(source, filename, format) {
41
+ // Development servers may reload unchanged bundles (require cache cleared);
42
+ // reuse the transformation instead of parsing the same code again.
43
+ const cached = transformed.get(filename);
44
+ if (cached?.input === source) {
45
+ runtime.emit({ type: 'module', file: filename, functions: cached.count, cached: true });
46
+ return cached;
47
+ }
48
+ const input = source;
49
+ try {
50
+ // Another loader (tsx, ts-node...) may report a .ts file as plain
51
+ // "module"/"commonjs" while its source still carries type syntax.
52
+ if (format.endsWith('-typescript') || /\.[cm]?ts$/.test(filename)) {
53
+ source = stripTypeScriptTypes(source, { mode: 'strip', sourceUrl: pathToFileURL(filename).href });
54
+ format = format.replace('-typescript', '');
55
+ }
56
+ const output = transform(source, filename, format, root, runtime.register);
57
+ runtime.emit({ type: 'module', file: filename, functions: output.count });
58
+ for (const diagnostic of output.diagnostics) runtime.emit({ type: 'limitation', ...diagnostic });
59
+ const entry = { input, format, source: output.source, count: output.count };
60
+ transformed.set(filename, entry);
61
+ produced.add(output.source);
62
+ return entry;
63
+ } catch (error) {
64
+ // Parser messages hold a position, not source values; they are redacted anyway.
65
+ runtime.emit({ type: 'limitation', reason: 'module-transform-failed', file: filename, detail: String(error?.message).slice(0, 200) });
66
+ return null;
67
+ }
68
+ }
69
+
70
+ if (typeof registerHooks === 'function') registerHooks({
71
+ load(url, context, nextLoad) {
72
+ const result = nextLoad(url, context);
73
+ if (!url.startsWith('file:')) return result;
74
+ const filename = fileURLToPath(url);
75
+ if (!observed(filename)
76
+ || !['module', 'commonjs', 'module-typescript', 'commonjs-typescript'].includes(result.format)) return result;
77
+ const source = result.source == null ? readFileSync(filename, 'utf8') : Buffer.from(result.source).toString('utf8');
78
+ const entry = instrument(source, filename, result.format);
79
+ return entry ? { ...result, format: entry.format, source: entry.source } : result;
80
+ },
81
+ });
82
+
83
+ // Loaders that compile CommonJS themselves (for example through
84
+ // Module._extensions) bypass the load hook, but every CommonJS module still
85
+ // goes through Module.prototype._compile.
86
+ const Module = createRequire(import.meta.url)('node:module');
87
+ const compile = Module.prototype._compile;
88
+ Module.prototype._compile = function codetacCompile(content, filename, ...rest) {
89
+ if (typeof content === 'string' && typeof filename === 'string' && !produced.has(content) && observed(filename)) {
90
+ const entry = instrument(content, filename, 'commonjs');
91
+ if (entry) return compile.call(this, entry.source, filename, ...rest);
92
+ }
93
+ return compile.call(this, content, filename, ...rest);
94
+ };
@@ -0,0 +1,353 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import fs, { mkdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { threadId, isMainThread } from 'node:worker_threads';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { createRedactor, isSensitiveName } from './redact.mjs';
7
+ import { SKIP, libraryPoints, splitUrl } from './boundaries.mjs';
8
+
9
+ // Events are buffered and written in batches. The order inside a file is the
10
+ // order of `sequence`; the buffer is flushed on size, on a short timer, on
11
+ // normal exit and on SIGINT/SIGTERM/SIGHUP. A SIGKILL loses the last batch.
12
+ const FLUSH_BYTES = 256 * 1024;
13
+ // CodeTAC's own batches must not appear as writes of the application: they
14
+ // are written with open/write/close, which the file boundary does not wrap
15
+ // (fs.appendFileSync goes through the wrapped fs.writeFileSync).
16
+ const { openSync, writeSync, closeSync } = fs;
17
+ function appendFileSync(file, text, { mode }) {
18
+ const fd = openSync(file, 'a', mode);
19
+ try {
20
+ const buffer = Buffer.from(text);
21
+ let offset = 0;
22
+ while (offset < buffer.length) offset += writeSync(fd, buffer, offset);
23
+ } finally { closeSync(fd); }
24
+ }
25
+ const FLUSH_MS = 25;
26
+
27
+ export function createRuntime(directory, { flushBytes = FLUSH_BYTES, flushMs = FLUSH_MS } = {}) {
28
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
29
+ const file = join(directory, `${process.pid}-${threadId}-${randomUUID()}.jsonl`);
30
+ const context = new AsyncLocalStorage();
31
+ const redact = createRedactor();
32
+ const processId = `${process.pid}:${threadId}`;
33
+ const prefix = `{"version":1,"process":${JSON.stringify(processId)},"sequence":`;
34
+ const idPrefix = JSON.stringify(`${processId}:`).slice(0, -1);
35
+ const functions = [];
36
+ let buffer = [];
37
+ let bufferedBytes = 0;
38
+ let timer = null;
39
+ let sequence = 0;
40
+ let span = 0;
41
+ let requests = 0;
42
+ let boundaries = 0;
43
+ let disabled = false;
44
+ let pendingEnd = null;
45
+
46
+ function flush() {
47
+ if (timer) { clearTimeout(timer); timer = null; }
48
+ if (!buffer.length || disabled) return;
49
+ const chunk = buffer.join('');
50
+ buffer = [];
51
+ bufferedBytes = 0;
52
+ try {
53
+ appendFileSync(file, chunk, { mode: 0o600 });
54
+ } catch {
55
+ disabled = true;
56
+ process.stderr.write('[CodeTAC] Gravação indisponível; a aplicação continua sem captura.\n');
57
+ }
58
+ }
59
+ function write(line) {
60
+ if (disabled) return;
61
+ buffer.push(line);
62
+ bufferedBytes += line.length;
63
+ if (bufferedBytes >= flushBytes) flush();
64
+ else if (!timer) { timer = setTimeout(flush, flushMs); timer.unref(); }
65
+ }
66
+ // `body` is a JSON object fragment without braces, already redacted.
67
+ function line(body) {
68
+ return `${prefix}${++sequence},"timeNs":${Number(process.hrtime.bigint())},${body}}\n`;
69
+ }
70
+ function emit(event) {
71
+ if (disabled) return;
72
+ write(line(JSON.stringify(redact(event)).slice(1, -1)));
73
+ }
74
+ // Static function metadata is redacted and serialised once, not per call.
75
+ // Parameter names stay out of the per-call event; they only name the
76
+ // values recorded in detail mode.
77
+ function prepare(meta) {
78
+ const { params, ...recorded } = meta;
79
+ return { async: Boolean(meta.async), enter: JSON.stringify(redact(recorded)).slice(1, -1),
80
+ file: meta.file, line: meta.line, name: meta.function, params: params ?? [] };
81
+ }
82
+ function register(meta) {
83
+ const info = prepare(meta);
84
+ functions.push(info);
85
+ detailed.push(wanted(info));
86
+ return functions.length - 1;
87
+ }
88
+
89
+ // Detail on request (Phase 4): the panel writes detalhe.json in this
90
+ // recording's folder; the functions it names record their arguments,
91
+ // return value and executed lines from the next call on. Everything else
92
+ // only pays a null check.
93
+ const detailed = [];
94
+ let wish = { functions: new Set(), files: new Set() };
95
+ function wanted(info) {
96
+ // A function is named by file, line and name: several functions can start on one line.
97
+ return wish.files.has(info.file) || wish.functions.has(`${info.file}:${info.line}:${info.name}`) || wish.functions.has(`${info.file}:${info.line}:`);
98
+ }
99
+ const detailFile = join(directory, 'detalhe.json');
100
+ function readDetail() {
101
+ let spec = {};
102
+ try { spec = JSON.parse(readFileSync(detailFile, 'utf8')); } catch {}
103
+ wish = { functions: new Set((spec.functions ?? []).map(item => `${item.file}:${item.line}:${item.function ?? ''}`)), files: new Set(spec.files ?? []) };
104
+ for (let index = 0; index < functions.length; index++) detailed[index] = wanted(functions[index]);
105
+ }
106
+ readDetail();
107
+ try { fs.watchFile(detailFile, { interval: 400, persistent: false }, readDetail); } catch {}
108
+
109
+ function capture() {
110
+ return { args: undefined, lines: new Set(), a(values) { this.args = values; }, l(line, last = line) { for (let n = line; n <= last; n++) this.lines.add(n); } };
111
+ }
112
+ function detailEvent(id, request, info, captured, outcome) {
113
+ const event = { type: 'detail', id: JSON.parse(id), requestId: request === 'null' ? null : JSON.parse(request),
114
+ // Parameter names are values of this event, so the redaction by field
115
+ // name cannot see them: sensitive parameters are hidden here, and so is
116
+ // what a function with a sensitive name returns (a password hash…).
117
+ args: info.params.map((name, index) => ({ name, value: isSensitiveName(name) ? '[REDACTED]' : preview(captured.args?.[index]) })),
118
+ lines: [...captured.lines].sort((a, b) => a - b) };
119
+ if (outcome.error) event.threw = preview(outcome.value);
120
+ else event.returned = isSensitiveName(info.name) ? '[REDACTED]' : preview(outcome.value);
121
+ emit(event);
122
+ }
123
+
124
+ function run(meta, callback) {
125
+ const info = typeof meta === 'number' ? functions[meta] : prepare(meta);
126
+ const captured = typeof meta === 'number' && detailed[meta] ? capture() : null;
127
+ const parent = context.getStore();
128
+ const number = ++span;
129
+ const id = `${idPrefix}${number}"`;
130
+ const request = parent ? parent.request : 'null';
131
+ write(line(`"type":"enter","id":${id},"parentId":${parent ? parent.id : 'null'},"requestId":${request},${info.enter}`));
132
+ const start = process.hrtime.bigint();
133
+ const finish = (error, value) => {
134
+ if (captured) { try { detailEvent(id, request, info, captured, { error, value }); } catch {} }
135
+ write(line(`"type":"exit","id":${id},"requestId":${request},"error":${error},"durationNs":${Number(process.hrtime.bigint() - start)}`));
136
+ };
137
+ return context.run({ id, request }, () => {
138
+ try {
139
+ const result = callback(captured);
140
+ if (info.async) {
141
+ return result.then(value => { finish(false, value); return value; }, error => { finish(true, error); throw error; });
142
+ }
143
+ finish(false, result);
144
+ return result;
145
+ } catch (error) {
146
+ finish(true, error);
147
+ throw error;
148
+ }
149
+ });
150
+ }
151
+
152
+ // Store ids are kept as JSON literals (see run); boundaries decode them.
153
+ function current() {
154
+ const store = context.getStore();
155
+ return { parentId: store && store.id !== 'null' ? JSON.parse(store.id) : null,
156
+ requestId: store && store.request !== 'null' ? JSON.parse(store.request) : null };
157
+ }
158
+ // Starts a boundary and returns end(extra), which records it once.
159
+ function startBoundary(details) {
160
+ const id = `${processId}:b${++boundaries}`;
161
+ const where = current();
162
+ emit({ type: 'boundary', id, ...where, ...details });
163
+ const start = process.hrtime.bigint();
164
+ let done = false;
165
+ return (extra = {}) => {
166
+ if (done) return;
167
+ done = true;
168
+ emit({ type: 'boundary-end', id, requestId: where.requestId, durationNs: Number(process.hrtime.bigint() - start), error: false, ...extra });
169
+ };
170
+ }
171
+ function library(index, self, args, name, body) {
172
+ const point = libraryPoints[index];
173
+ let details;
174
+ try { details = point.before ? point.before(args, self, name, api) : {}; } catch { details = {}; }
175
+ const end = details === SKIP ? () => {} : startBoundary({ kind: point.kind, library: point.library, function: name, ...details });
176
+ const previous = pendingEnd;
177
+ pendingEnd = end;
178
+ let result;
179
+ try { result = body(); }
180
+ catch (error) { end({ error: true }); throw error; }
181
+ finally { pendingEnd = previous; }
182
+ try { return point.after ? point.after(result, end, self, name, api) : (end({}), result); }
183
+ catch { end({}); return result; }
184
+ }
185
+ // Wraps a callback argument of the library call being entered.
186
+ function callback(fn) {
187
+ const end = pendingEnd;
188
+ if (!end || typeof fn !== 'function') return fn;
189
+ return function (error, ...rest) {
190
+ end(error ? { error: true } : {});
191
+ return fn.call(this, error, ...rest);
192
+ };
193
+ }
194
+ function request(req, res, handle, action = null) {
195
+ const parent = context.getStore();
196
+ const requestId = `${processId}:r${++requests}`;
197
+ const { path, queryKeys } = splitUrl(req.url ?? '/');
198
+ emit({ type: 'request', requestId, parentId: current().parentId, method: req.method, path, queryKeys, at: Date.now(),
199
+ action: action?.action, actionRequest: action?.actionRequest ?? undefined });
200
+ const start = process.hrtime.bigint();
201
+ let done = false;
202
+ // Cookies the response sets or deletes: names only, never values. Headers
203
+ // given directly to writeHead are not visible through getHeader.
204
+ let written = null;
205
+ const writeHead = res.writeHead;
206
+ if (typeof writeHead === 'function') {
207
+ res.writeHead = function (...args) {
208
+ const headers = args.find((item, index) => index > 0 && item && typeof item === 'object');
209
+ if (headers) written = Array.isArray(headers) ? headers : Object.entries(headers);
210
+ return writeHead.apply(this, args);
211
+ };
212
+ }
213
+ const end = () => {
214
+ if (done) return;
215
+ done = true;
216
+ let cookies;
217
+ try { cookies = cookieNames(res.getHeader?.('set-cookie'), written); } catch {}
218
+ emit({ type: 'request-end', requestId, status: res.statusCode, aborted: !res.writableFinished,
219
+ durationNs: Number(process.hrtime.bigint() - start), cookies });
220
+ };
221
+ res.once('finish', end);
222
+ res.once('close', end);
223
+ return context.run({ id: parent ? parent.id : 'null', request: JSON.stringify(requestId) }, handle);
224
+ }
225
+ function currentRequest() {
226
+ const store = context.getStore();
227
+ return store && store.request !== 'null' ? store.request : null;
228
+ }
229
+ const api = { run, emit, register, flush, file, startBoundary, library, callback, request, currentRequest };
230
+
231
+ process.on('exit', flush);
232
+ // Signals are only delivered to the main thread.
233
+ // CodeTAC's listener runs first and removes itself before the others run,
234
+ // so handlers that exit only when they are alone (tsx, for example) still
235
+ // see themselves alone. With no other listener, the default termination is
236
+ // restored by raising the signal again.
237
+ for (const signal of isMainThread ? ['SIGINT', 'SIGTERM', 'SIGHUP'] : []) {
238
+ const onSignal = () => {
239
+ process.removeListener(signal, onSignal);
240
+ flush();
241
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
242
+ };
243
+ process.prependListener(signal, onSignal);
244
+ }
245
+ return api;
246
+ }
247
+
248
+ // Set-Cookie headers → [{ name, cleared }]. A cookie is cleared when it
249
+ // expires immediately (Max-Age=0 or less, or an Expires date in the past).
250
+ export function cookieNames(header, written) {
251
+ const values = [header].flat().filter(value => typeof value === 'string');
252
+ if (written) {
253
+ // writeHead(status, [name, value, name, value…]) or entries of an object.
254
+ const pairs = written.length && !Array.isArray(written[0]) ? written.flatMap((item, index) => index % 2 ? [] : [[item, written[index + 1]]]) : written;
255
+ for (const [name, value] of pairs) if (String(name).toLowerCase() === 'set-cookie') values.push(...[value].flat().filter(item => typeof item === 'string'));
256
+ }
257
+ if (!values.length) return undefined;
258
+ const result = new Map();
259
+ for (const value of values) {
260
+ const [pair, ...attributes] = value.split(';');
261
+ const name = pair.split('=')[0].trim();
262
+ if (!name) continue;
263
+ const maxAge = attributes.map(item => item.trim().match(/^max-age\s*=\s*(-?\d+)/i)?.[1]).find(item => item != null);
264
+ const expires = attributes.map(item => item.trim().match(/^expires\s*=\s*(.+)$/i)?.[1]).find(Boolean);
265
+ const cleared = maxAge != null ? Number(maxAge) <= 0 : expires ? Date.parse(expires) < Date.now() : false;
266
+ result.set(name, { name, cleared });
267
+ }
268
+ return [...result.values()];
269
+ }
270
+
271
+ // A readable copy of a value for the detail view: plain JSON, bounded in
272
+ // depth, length and size. Getters are not called (they could change state);
273
+ // class instances keep their class name. Redaction happens when it is emitted.
274
+ const LIMITS = { depth: 4, items: 20, keys: 30, nodes: 300, text: 2000 };
275
+ export function preview(value) {
276
+ let nodes = 0;
277
+ const seen = new WeakSet();
278
+ function walk(item, depth) {
279
+ if (++nodes > LIMITS.nodes) return '[…]';
280
+ if (item === null || typeof item === 'boolean') return item;
281
+ if (typeof item === 'number') return Number.isFinite(item) ? item : String(item);
282
+ if (typeof item === 'string') return item.length > LIMITS.text ? `${item.slice(0, LIMITS.text)}[TRUNCATED ${item.length}]` : item;
283
+ if (item === undefined) return { $tipo: 'undefined' };
284
+ if (typeof item === 'bigint') return `${item}n`;
285
+ if (typeof item === 'symbol') return { $tipo: 'symbol', descricao: item.description ?? '' };
286
+ if (typeof item === 'function') return { $tipo: 'função', nome: item.name || '(anónima)' };
287
+ if (seen.has(item)) return { $tipo: 'circular' };
288
+ if (depth >= LIMITS.depth) return { $tipo: Array.isArray(item) ? `lista (${item.length})` : className(item) ?? 'objeto', $resumido: true };
289
+ seen.add(item);
290
+ try {
291
+ if (item instanceof Error) return { $tipo: 'erro', nome: item.name, mensagem: String(item.message ?? '') };
292
+ if (item instanceof Date) return { $tipo: 'data', valor: Number.isNaN(item.getTime()) ? 'inválida' : item.toISOString() };
293
+ if (item instanceof Promise) return { $tipo: 'promessa' };
294
+ const known = summary(item);
295
+ if (known) return known;
296
+ if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer) return { $tipo: className(item) ?? 'bytes', bytes: item.byteLength };
297
+ if (item instanceof Map) return { $tipo: 'Map', tamanho: item.size, entradas: [...item].slice(0, LIMITS.items).map(([key, entry]) => [walk(key, depth + 1), walk(entry, depth + 1)]) };
298
+ if (item instanceof Set) return { $tipo: 'Set', tamanho: item.size, valores: [...item].slice(0, LIMITS.items).map(entry => walk(entry, depth + 1)) };
299
+ if (Array.isArray(item)) {
300
+ const out = item.slice(0, LIMITS.items).map(entry => walk(entry, depth + 1));
301
+ if (item.length > LIMITS.items) out.push({ $mais: item.length - LIMITS.items });
302
+ return out;
303
+ }
304
+ const out = {};
305
+ const name = className(item);
306
+ if (name) out.$classe = name;
307
+ // Fields starting with "_" of class instances are internals (streams,
308
+ // emitters, frameworks), not the application's data.
309
+ const keys = Object.keys(item).filter(key => !(name && key.startsWith('_')));
310
+ for (const key of keys.slice(0, LIMITS.keys)) {
311
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
312
+ out[key] = descriptor && 'value' in descriptor ? walk(descriptor.value, depth + 1) : { $tipo: 'getter' };
313
+ }
314
+ if (keys.length > LIMITS.keys) out.$mais = keys.length - LIMITS.keys;
315
+ return out;
316
+ } catch {
317
+ return { $tipo: 'ilegível' };
318
+ }
319
+ }
320
+ return walk(value, 0);
321
+ }
322
+ // HTTP objects of Node, fetch and frameworks are summarised: method, path
323
+ // without query values and header names, never header values (cookies,
324
+ // authorization) nor sockets and buffers.
325
+ function summary(item) {
326
+ const name = className(item);
327
+ if (!name) return null;
328
+ const headerNames = headers => {
329
+ try {
330
+ if (typeof headers?.keys === 'function') return [...headers.keys()].slice(0, 40);
331
+ return Object.keys(headers ?? {}).slice(0, 40);
332
+ } catch { return []; }
333
+ };
334
+ const path = url => { try { const parsed = new URL(String(url), 'http://x'); return parsed.pathname + (parsed.search ? '?' + [...parsed.searchParams.keys()].map(key => `${key}=…`).join('&') : ''); } catch { return '[?]'; } };
335
+ if (name === 'IncomingMessage' || (typeof item.method === 'string' && 'headers' in item && ('url' in item))) {
336
+ return { $classe: name, metodo: item.method, caminho: path(item.url), cabecalhos: headerNames(item.headers) };
337
+ }
338
+ if (name === 'ServerResponse') return { $classe: name, estado: item.statusCode, cabecalhos: headerNames(item.getHeaders?.()) };
339
+ if (name === 'Response') return { $classe: name, estado: item.status, cabecalhos: headerNames(item.headers) };
340
+ if (name === 'Headers') return { $classe: name, nomes: headerNames(item) };
341
+ if (/^(Socket|TLSSocket|Server|Agent|EventEmitter|Readable|Writable|Duplex|Transform|PassThrough|WriteStream|ReadStream|Pool|PoolConnection|Connection|Database|DatabaseSync|Statement|StatementSync)$/.test(name)) {
342
+ return { $classe: name, $resumido: true };
343
+ }
344
+ return null;
345
+ }
346
+ function className(item) {
347
+ try {
348
+ const proto = Object.getPrototypeOf(item);
349
+ if (proto === null) return null;
350
+ const name = proto.constructor?.name;
351
+ return name && name !== 'Object' ? name : null;
352
+ } catch { return null; }
353
+ }