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,182 @@
1
+ // Browser positions → project source. The page script only records positions
2
+ // in the scripts the browser ran (URL, line, column). They are resolved here
3
+ // through the source maps that the application's own development server
4
+ // publishes, whatever the bundler: separate or inline maps, and code evaluated
5
+ // from strings with its own map (webpack's "eval" development modes).
6
+ import { parse } from 'acorn';
7
+ import { simple } from 'acorn-walk';
8
+ import { AnyMap, originalPositionFor } from '@jridgewell/trace-mapping';
9
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
10
+ import { isAbsolute, join, relative, sep } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { INTERNAL_HEADER } from './page.mjs';
13
+
14
+ const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)$/;
15
+ const MAX_SCRIPT = 64 * 1024 * 1024;
16
+
17
+ async function defaultFetch(url) {
18
+ const response = await fetch(url, { headers: { [INTERNAL_HEADER]: '1' }, signal: AbortSignal.timeout(15000) });
19
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
20
+ const text = await response.text();
21
+ if (text.length > MAX_SCRIPT) throw new Error('demasiado grande');
22
+ return text;
23
+ }
24
+
25
+ function decodeDataUrl(reference) {
26
+ const comma = reference.indexOf(',');
27
+ return reference.slice(0, comma).includes(';base64')
28
+ ? Buffer.from(reference.slice(comma + 1), 'base64').toString('utf8')
29
+ : decodeURIComponent(reference.slice(comma + 1));
30
+ }
31
+ // Only a comment at the start of a real line counts; the same text inside a
32
+ // string (code evaluated later) belongs to that code, not to the script.
33
+ function lastMapReference(text) {
34
+ let found = null;
35
+ for (const match of text.matchAll(/^\/\/[#@]\s*sourceMappingURL=([^\s'"]+)\s*$/gm)) found = match[1];
36
+ return found;
37
+ }
38
+
39
+ function isFile(path) {
40
+ try { return statSync(path).isFile(); } catch { return false; }
41
+ }
42
+ function sameText(file, text) {
43
+ try { return readFileSync(file, 'utf8') === text; } catch { return false; }
44
+ }
45
+ function inside(root, file) {
46
+ const within = relative(root, file);
47
+ return within !== '' && !within.startsWith(`..${sep}`) && within !== '..' && !isAbsolute(within);
48
+ }
49
+
50
+ export function createResolver({ fetchText = defaultFetch } = {}) {
51
+ const scripts = new Map(); // script URL → Promise<{ map } | null>
52
+ const evaluated = new Map(); // sourceURL of evaluated code → AnyMap
53
+ const indexed = new Set(); // scripts already searched for evaluated code
54
+ const subfolders = new Map();
55
+
56
+ function loadScript(url) {
57
+ if (!scripts.has(url)) {
58
+ scripts.set(url, (async () => {
59
+ const text = await fetchText(url);
60
+ const reference = lastMapReference(text);
61
+ if (!reference) return { text, map: null };
62
+ if (reference.startsWith('data:')) return { text, map: new AnyMap(JSON.parse(decodeDataUrl(reference)), url) };
63
+ const mapUrl = new URL(reference, url);
64
+ if (mapUrl.origin !== new URL(url).origin) return { text, map: null };
65
+ return { text, map: new AnyMap(JSON.parse(await fetchText(mapUrl.href)), mapUrl.href) };
66
+ })().catch(() => null));
67
+ }
68
+ return scripts.get(url);
69
+ }
70
+
71
+ // Code evaluated from string literals, with an inline map and a sourceURL.
72
+ async function indexEvaluated(url) {
73
+ if (indexed.has(url)) return;
74
+ indexed.add(url);
75
+ const loaded = await loadScript(url);
76
+ if (!loaded?.text || !loaded.text.includes('sourceURL=')) return;
77
+ let ast;
78
+ try { ast = parse(loaded.text, { ecmaVersion: 'latest', sourceType: 'script', allowHashBang: true }); }
79
+ catch { try { ast = parse(loaded.text, { ecmaVersion: 'latest', sourceType: 'module', allowHashBang: true }); } catch { return; } }
80
+ simple(ast, {
81
+ CallExpression(node) {
82
+ let argument = node.arguments[0];
83
+ // Trusted Types wrappers: eval(policy("…")), as in Next.js (__webpack_require__.ts).
84
+ if (argument?.type === 'CallExpression' && argument.arguments.length === 1) argument = argument.arguments[0];
85
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'eval' || argument?.type !== 'Literal' || typeof argument.value !== 'string') return;
86
+ const code = argument.value;
87
+ // The last sourceURL is the one the browser uses (webpack writes "[module]" first).
88
+ const name = [...code.matchAll(/^\/\/[#@]\s*sourceURL=(\S+)\s*$/gm)].at(-1)?.[1];
89
+ const reference = lastMapReference(code);
90
+ if (!name || !reference?.startsWith('data:') || evaluated.has(name)) return;
91
+ // No base URL: its sources are absolute paths or bundler names, kept as they are.
92
+ try { evaluated.set(name, new AnyMap(JSON.parse(decodeDataUrl(reference)))); } catch {}
93
+ },
94
+ });
95
+ }
96
+
97
+ function folders(root) {
98
+ if (!subfolders.has(root)) {
99
+ let list = [];
100
+ try {
101
+ list = readdirSync(root, { withFileTypes: true })
102
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules').map(entry => entry.name);
103
+ } catch {}
104
+ subfolders.set(root, list);
105
+ }
106
+ return subfolders.get(root);
107
+ }
108
+
109
+ // A source name from a map, in any bundler's notation, to a file on disk.
110
+ function toFile(source, root) {
111
+ let name = String(source);
112
+ if (name.startsWith('file:')) { try { name = fileURLToPath(name); } catch {} }
113
+ name = name.replace(/^webpack:\/\/[^/]*\//, '').replace(/^webpack-internal:\/\/\/(?:\([^)]*\)\/)?/, '')
114
+ .replace(/^turbopack:\/\/\/\[project\]\//, '').replace(/^https?:\/\/[^/]+/, '').replace(/[?#].*$/, '');
115
+ const library = name.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/)?.[1];
116
+ if (library) return { file: name, project: false, library };
117
+ const candidates = [];
118
+ if (isAbsolute(name)) candidates.push(name);
119
+ const bare = name.replace(/^(\.\.?\/)+/, '').replace(/^\//, '');
120
+ candidates.push(join(root, bare));
121
+ for (const folder of folders(root)) candidates.push(join(root, folder, bare));
122
+ for (const candidate of candidates) {
123
+ if (inside(root, candidate) && isFile(candidate)) {
124
+ let real = candidate;
125
+ try { real = realpathSync(candidate); } catch {}
126
+ return { file: real, project: !real.split(sep).includes('node_modules') };
127
+ }
128
+ }
129
+ return { file: name, project: false };
130
+ }
131
+
132
+ async function resolveFrame(frame, { origin, root, scripts: pageScripts = [] }) {
133
+ if (!frame || !root) return { ...frame, resolved: false };
134
+ // React 18 development builds already give the original file.
135
+ if (frame.file && !frame.url) {
136
+ const file = isAbsolute(frame.file) ? frame.file : join(root, frame.file);
137
+ return { fn: frame.fn, file, line: frame.line, column: frame.column, resolved: true, project: inside(root, file) && isFile(file) };
138
+ }
139
+ let map = null;
140
+ let url;
141
+ try { url = new URL(frame.url); } catch {}
142
+ if (url && (url.protocol === 'http:' || url.protocol === 'https:')) {
143
+ if (!LOOPBACK.test(url.hostname) || (origin && url.origin !== origin)) return { ...frame, resolved: false };
144
+ const loaded = await loadScript(url.href.replace(/#.*$/, ''));
145
+ map = loaded?.map ?? null;
146
+ // A script served exactly as it is on disk, with no map: same lines.
147
+ if (!map && loaded?.text) {
148
+ const { file, project } = toFile(url.pathname, root);
149
+ if (project && sameText(file, loaded.text)) return { fn: frame.fn, file, line: frame.line, column: frame.column, project, resolved: true };
150
+ }
151
+ } else if (/^webpack-internal:/.test(frame.url ?? '')) {
152
+ if (!evaluated.has(frame.url)) {
153
+ for (const script of pageScripts) {
154
+ let scriptUrl;
155
+ try { scriptUrl = new URL(script); } catch { continue; }
156
+ if (!LOOPBACK.test(scriptUrl.hostname) || (origin && scriptUrl.origin !== origin)) continue;
157
+ await indexEvaluated(scriptUrl.href);
158
+ if (evaluated.has(frame.url)) break;
159
+ }
160
+ }
161
+ map = evaluated.get(frame.url) ?? null;
162
+ if (!map) {
163
+ // Without its map, the module is known but the line is not.
164
+ const { file, project, library } = toFile(frame.url, root);
165
+ return { fn: frame.fn, file, line: null, project, library, resolved: false };
166
+ }
167
+ }
168
+ if (!map) return { ...frame, resolved: false };
169
+ const original = originalPositionFor(map, { line: frame.line, column: Math.max(0, (frame.column ?? 1) - 1) });
170
+ if (!original.source || original.line == null) return { ...frame, resolved: false };
171
+ const { file, project, library } = toFile(original.source, root);
172
+ // The stack gives the enclosing function; the map's name at that position
173
+ // is the identifier being called there (for example "fetch").
174
+ return { fn: frame.fn || original.name, file, line: original.line, column: original.column + 1, project, library, resolved: true };
175
+ }
176
+
177
+ async function resolveFrames(frames, context) {
178
+ if (!Array.isArray(frames)) return [];
179
+ return Promise.all(frames.map(frame => resolveFrame(frame, context)));
180
+ }
181
+ return { resolveFrames, resolveFrame };
182
+ }
package/src/page.mjs ADDED
@@ -0,0 +1,220 @@
1
+ // Browser side, served by the observed application's own Node server: HTML
2
+ // pages get a <script src="/__codetac/bar.js">, and the /__codetac/ routes are
3
+ // answered here without ever reaching the application. Works for any server
4
+ // built on node:http (Next.js, Vite, Express...), with no proxy or extension.
5
+ import { readFileSync } from 'node:fs';
6
+ import { splitUrl } from './boundaries.mjs';
7
+
8
+ export const PREFIX = '/__codetac/';
9
+ export const ACTION_HEADER = 'x-codetac-action';
10
+ export const INTERNAL_HEADER = 'x-codetac-internal';
11
+ const ACTION_ID = /^[A-Za-z0-9_-]{6,40}$/;
12
+ const MAX_EVENT_BYTES = 64 * 1024;
13
+ const MAX_BUFFER = 512 * 1024;
14
+ const TAG = '<script src="/__codetac/bar.js" data-codetac=""></script>';
15
+
16
+ let script = null;
17
+ function barScript(options) {
18
+ if (!script) {
19
+ const config = { panel: options.panel, run: options.run };
20
+ script = `window.__CODETAC_CONFIG__=${JSON.stringify(config)};\n${readFileSync(new URL('./browser/bar.js', import.meta.url), 'utf8')}`;
21
+ }
22
+ return script;
23
+ }
24
+
25
+ // Action of an incoming request: the header set by the page script on fetch
26
+ // and XHR, or the short cookie it sets before a full page navigation.
27
+ export function actionOf(request) {
28
+ const header = request.headers[ACTION_HEADER];
29
+ if (typeof header === 'string') {
30
+ const [id, number] = header.split('.');
31
+ if (ACTION_ID.test(id)) return { action: id, actionRequest: /^\d{1,5}$/.test(number ?? '') ? Number(number) : null };
32
+ }
33
+ if (request.headers['sec-fetch-mode'] === 'navigate') {
34
+ const match = String(request.headers.cookie ?? '').match(/(?:^|;\s*)codetac_action=([A-Za-z0-9_-]{6,40})(?:;|$)/);
35
+ if (match) return { action: match[1], actionRequest: null };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // Answers /__codetac/ requests. Returns true when the request was handled.
41
+ export function handleOwnRoute(request, response, runtime, options) {
42
+ const url = request.url ?? '';
43
+ if (!url.startsWith(PREFIX)) return false;
44
+ const path = url.split('?')[0];
45
+ if (path === `${PREFIX}bar.js` && request.method === 'GET') {
46
+ response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
47
+ response.end(barScript(options));
48
+ return true;
49
+ }
50
+ if (path === `${PREFIX}events` && request.method === 'POST') {
51
+ // Only the page itself may report actions: another site open in the
52
+ // browser cannot write into the recording (browsers always send Origin here).
53
+ const origin = request.headers.origin;
54
+ let sameOrigin = !origin;
55
+ try { sameOrigin = sameOrigin || new URL(origin).host === request.headers.host; } catch {}
56
+ if (!sameOrigin) {
57
+ response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
58
+ response.end('CodeTAC: origem recusada.');
59
+ return true;
60
+ }
61
+ let size = 0;
62
+ const chunks = [];
63
+ request.on('data', chunk => {
64
+ size += chunk.length;
65
+ if (size <= MAX_EVENT_BYTES) chunks.push(chunk);
66
+ });
67
+ request.on('end', () => {
68
+ if (size <= MAX_EVENT_BYTES) {
69
+ try { recordBrowserAction(runtime, JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch {}
70
+ }
71
+ response.writeHead(size <= MAX_EVENT_BYTES ? 204 : 413, { 'cache-control': 'no-store' });
72
+ response.end();
73
+ });
74
+ return true;
75
+ }
76
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
77
+ response.end('CodeTAC: rota desconhecida.');
78
+ return true;
79
+ }
80
+
81
+ // Only known fields are kept; URLs lose their query values, and everything
82
+ // passes through the runtime's redaction before being written.
83
+ const text = (value, max = 120) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : undefined;
84
+ const number = value => Number.isFinite(value) ? value : undefined;
85
+ function frames(list) {
86
+ if (!Array.isArray(list)) return undefined;
87
+ return list.slice(0, 16).map(frame => ({ fn: text(frame?.fn, 80), url: text(frame?.url, 500), line: number(frame?.line), column: number(frame?.column),
88
+ file: text(frame?.file, 500) })).filter(frame => frame.url || frame.file);
89
+ }
90
+ function names(list) {
91
+ if (!Array.isArray(list)) return undefined;
92
+ return list.slice(0, 40).map(item => ({ name: text(item?.name, 80), count: number(item?.count), frames: frames(item?.frames) })).filter(item => item.name);
93
+ }
94
+ function pathOf(raw) {
95
+ if (typeof raw !== 'string') return {};
96
+ const { path, queryKeys } = splitUrl(raw);
97
+ return { path, queryKeys };
98
+ }
99
+ export function recordBrowserAction(runtime, event) {
100
+ if (!event || typeof event !== 'object' || !ACTION_ID.test(String(event.actionId))) return;
101
+ const trigger = event.trigger && typeof event.trigger === 'object' ? event.trigger : null;
102
+ const element = trigger?.element ?? {};
103
+ runtime.emit({
104
+ type: 'browser-action', actionId: event.actionId, segment: number(event.segment) ?? 1,
105
+ origin: text(event.origin, 200), page: pathOf(event.page),
106
+ startedAt: number(event.startedAt), durationMs: number(event.durationMs), closedBy: text(event.closedBy, 40),
107
+ trigger: trigger ? {
108
+ event: text(trigger.event, 20),
109
+ element: { tag: text(element.tag, 20), type: text(element.type, 20), role: text(element.role, 30), text: text(element.text, 80),
110
+ label: text(element.label, 80), name: text(element.name, 60), id: text(element.id, 60), href: element.href ? pathOf(element.href).path : undefined },
111
+ component: trigger.component ? { name: text(trigger.component.name, 80), owners: Array.isArray(trigger.component.owners)
112
+ ? trigger.component.owners.slice(0, 6).map(owner => ({ name: text(owner?.name, 80), frames: frames(owner?.frames) })).filter(owner => owner.name) : undefined,
113
+ frames: frames(trigger.component.frames) } : undefined,
114
+ handler: trigger.handler ? { name: text(trigger.handler.name, 80), prop: text(trigger.handler.prop, 30), source: text(trigger.handler.source, 20) } : undefined,
115
+ } : undefined,
116
+ requests: Array.isArray(event.requests) ? event.requests.slice(0, 200).map(item => ({
117
+ n: number(item?.n), kind: text(item?.kind, 20), method: text(item?.method, 10), ...pathOf(item?.url),
118
+ sameOrigin: Boolean(item?.sameOrigin), host: item?.sameOrigin ? undefined : text(item?.host, 200),
119
+ startMs: number(item?.startMs), durationMs: number(item?.durationMs), status: number(item?.status), error: Boolean(item?.error) || undefined,
120
+ frames: frames(item?.frames) })) : [],
121
+ screen: event.screen && typeof event.screen === 'object' ? {
122
+ added: number(event.screen.added), removed: number(event.screen.removed), text: number(event.screen.text),
123
+ attributes: number(event.screen.attributes), title: Boolean(event.screen.title) || undefined,
124
+ stateChanged: names(event.screen.stateChanged), mounted: names(event.screen.mounted), unmounted: names(event.screen.unmounted) } : undefined,
125
+ scripts: Array.isArray(event.scripts) ? event.scripts.slice(0, 80).map(url => text(url, 500)).filter(Boolean) : undefined,
126
+ navigations: Array.isArray(event.navigations) ? event.navigations.slice(0, 20).map(item => ({ kind: text(item?.kind, 20), ...pathOf(item?.to), atMs: number(item?.atMs) })) : undefined,
127
+ });
128
+ }
129
+
130
+ // Top-level HTML documents get the script tag. The response is not
131
+ // compressed (accept-encoding is removed for these requests only), and its
132
+ // length and validators are dropped because the body changes.
133
+ export function wantsPage(request) {
134
+ if (request.method !== 'GET' || request.headers[INTERNAL_HEADER]) return false;
135
+ const destination = request.headers['sec-fetch-dest'];
136
+ if (destination) return destination === 'document';
137
+ return /text\/html/.test(String(request.headers.accept ?? ''));
138
+ }
139
+
140
+ function headerIn(headers, name) {
141
+ if (!headers) return undefined;
142
+ if (Array.isArray(headers)) {
143
+ for (let i = 0; i + 1 < headers.length; i += 2) if (String(headers[i]).toLowerCase() === name) return headers[i + 1];
144
+ return undefined;
145
+ }
146
+ for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return value;
147
+ return undefined;
148
+ }
149
+ const DROP = new Set(['content-length', 'etag', 'last-modified']);
150
+ function withoutLength(headers) {
151
+ if (Array.isArray(headers)) {
152
+ const result = [];
153
+ for (let i = 0; i + 1 < headers.length; i += 2) if (!DROP.has(String(headers[i]).toLowerCase())) result.push(headers[i], headers[i + 1]);
154
+ return result;
155
+ }
156
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => !DROP.has(key.toLowerCase())));
157
+ }
158
+
159
+ export function injectScript(request, response) {
160
+ delete request.headers['accept-encoding'];
161
+ let mode = null; // null: undecided; 'pass'; 'buffer'; 'done'
162
+ const chunks = [];
163
+ const { writeHead, write, end } = response;
164
+
165
+ response.writeHead = function codetacWriteHead(status, ...rest) {
166
+ if (mode === null) {
167
+ const reason = typeof rest[0] === 'string' ? [rest[0]] : [];
168
+ let headers = typeof rest[0] === 'string' ? rest[1] : rest[0];
169
+ const type = headerIn(headers, 'content-type') ?? this.getHeader('content-type');
170
+ const encoding = headerIn(headers, 'content-encoding') ?? this.getHeader('content-encoding');
171
+ // Error pages too (Phase 5): an application that fails still gets the
172
+ // bar, and the failing load has its dossier.
173
+ mode = status >= 200 && status < 600 && status !== 204 && status !== 304 && /text\/html/i.test(String(type ?? '')) && !encoding ? 'buffer' : 'pass';
174
+ if (mode === 'buffer') {
175
+ for (const name of DROP) this.removeHeader(name);
176
+ if (headers) rest = [...reason, withoutLength(headers)];
177
+ }
178
+ }
179
+ return writeHead.call(this, status, ...rest);
180
+ };
181
+ const decide = self => { if (mode === null && !self.headersSent) self.writeHead(self.statusCode); if (mode === null) mode = 'pass'; };
182
+ const toBuffer = (chunk, encoding) => Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk)
183
+ : Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
184
+ // Inserts the tag after <head ...>, or before <body> when there is no head.
185
+ // Fragments (no head/body, as in partial HTML responses) are left intact.
186
+ function take(final) {
187
+ const all = Buffer.concat(chunks);
188
+ const view = all.toString('latin1');
189
+ const head = view.match(/<head(?:\s[^>]*)?>/i);
190
+ let at = head ? head.index + head[0].length : -1;
191
+ if (at < 0 && (final || all.length > MAX_BUFFER)) at = view.search(/<body[\s>]/i);
192
+ if (at < 0) return final || all.length > MAX_BUFFER ? all : null;
193
+ return Buffer.concat([all.subarray(0, at), Buffer.from(TAG), all.subarray(at)]);
194
+ }
195
+
196
+ response.write = function codetacWrite(chunk, encoding, callback) {
197
+ decide(this);
198
+ if (mode !== 'buffer') return write.apply(this, arguments);
199
+ if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
200
+ const buffer = toBuffer(chunk, encoding);
201
+ chunks.push(buffer);
202
+ const output = take(false);
203
+ if (output) { mode = 'done'; return write.call(this, output, callback); }
204
+ if (callback) process.nextTick(callback);
205
+ return true;
206
+ };
207
+ response.end = function codetacEnd(chunk, encoding, callback) {
208
+ if (typeof chunk === 'function') { callback = chunk; chunk = undefined; }
209
+ else if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
210
+ decide(this);
211
+ if (mode !== 'buffer') return chunk == null ? end.call(this, callback) : end.call(this, chunk, encoding, callback);
212
+ if (chunk != null) chunks.push(toBuffer(chunk, encoding));
213
+ mode = 'done';
214
+ return end.call(this, take(true), callback);
215
+ };
216
+ }
217
+
218
+ export function panelUrl() {
219
+ return process.env.CODETAC_PANEL_URL || `http://127.0.0.1:${process.env.CODETAC_PANEL_PORT || 4000}`;
220
+ }