api-tracer-kit 1.0.1 → 1.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.
package/cli/setup.mjs ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `api-tracer setup` — wire the tracer into an application.
3
+ *
4
+ * Writing this file by hand is three decisions people get wrong: where the
5
+ * entry point is, which environment flag the bundler will actually fold, and
6
+ * that `init()` must be called from exactly one place. So the command makes all
7
+ * three, and is safe to re-run.
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join, relative, dirname } from 'node:path';
11
+
12
+ /** the frameworks we can tell apart, and how each one gates a dev-only import */
13
+ const FRAMEWORKS = {
14
+ cra: {
15
+ label: 'Create React App',
16
+ /*
17
+ * REACT_APP_ENV, not NODE_ENV: a dev or stage deploy is a production build,
18
+ * so gating on NODE_ENV would compile the tracer out of exactly the
19
+ * environments it is meant for. Compared directly rather than through a
20
+ * variable so DefinePlugin substitutes the literal and the branch folds.
21
+ */
22
+ gate: `const isEnabled =\n process.env.REACT_APP_ENV === "dev" || process.env.REACT_APP_ENV === "stage";`,
23
+ envLabel: 'process.env.REACT_APP_ENV',
24
+ consoleUrl: `process.env.REACT_APP_API_CONSOLE_URL ||\n process.env.REACT_APP_TRACKER_URL ||\n "http://localhost:4400"`,
25
+ entries: ['src/index.js', 'src/index.jsx', 'src/index.tsx', 'src/index.ts'],
26
+ },
27
+ vite: {
28
+ label: 'Vite',
29
+ gate: `const isEnabled = import.meta.env.DEV;`,
30
+ envLabel: 'import.meta.env.MODE',
31
+ consoleUrl: `import.meta.env.VITE_API_CONSOLE_URL || "http://localhost:4400"`,
32
+ entries: ['src/main.ts', 'src/main.tsx', 'src/main.js', 'src/main.jsx'],
33
+ },
34
+ next: {
35
+ label: 'Next.js',
36
+ gate: `const isEnabled = process.env.NODE_ENV === "development";`,
37
+ envLabel: 'process.env.NODE_ENV',
38
+ consoleUrl: `process.env.NEXT_PUBLIC_API_CONSOLE_URL || "http://localhost:4400"`,
39
+ entries: ['app/layout.tsx', 'app/layout.jsx', 'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.js'],
40
+ },
41
+ node: {
42
+ label: 'Node / plain JavaScript',
43
+ gate: `const isEnabled = process.env.NODE_ENV !== "production";`,
44
+ envLabel: 'process.env.NODE_ENV',
45
+ consoleUrl: `process.env.API_CONSOLE_URL || "http://localhost:4400"`,
46
+ entries: ['src/index.js', 'src/index.ts', 'index.js', 'index.mjs', 'src/main.js'],
47
+ },
48
+ };
49
+
50
+ /** which framework this project is, from what it actually depends on */
51
+ export function detectFramework(root) {
52
+ let pkg = {};
53
+ try {
54
+ pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
55
+ } catch {
56
+ return 'node';
57
+ }
58
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
59
+ if (deps.next) return 'next';
60
+ if (deps['react-scripts']) return 'cra';
61
+ if (deps.vite) return 'vite';
62
+ return 'node';
63
+ }
64
+
65
+ export const usesAxios = (root) => {
66
+ try {
67
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
68
+ return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.axios);
69
+ } catch {
70
+ return false;
71
+ }
72
+ };
73
+
74
+ export const usesTypeScript = (root) =>
75
+ existsSync(join(root, 'tsconfig.json'));
76
+
77
+ /** the first entry file that exists, so the import lands somewhere that runs */
78
+ export function findEntry(root, framework) {
79
+ for (const candidate of FRAMEWORKS[framework].entries) {
80
+ if (existsSync(join(root, candidate))) return candidate;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * The module that does the wiring. It is a file rather than a line in the entry
87
+ * point so that the gate, the console URL and the axios hand-over live together
88
+ * and are obvious to the next person.
89
+ */
90
+ export function recorderSource({ framework, axios, envelope }) {
91
+ const f = FRAMEWORKS[framework];
92
+ const importAxios = axios ? 'import axios from "axios";\n' : '';
93
+ const attachAxios = axios
94
+ ? ` apiTracer.useAxios(axios);\n`
95
+ : '';
96
+ const transports = axios
97
+ ? ` // axios is attached below; the XHR it rides on must not be traced twice\n transports: [],\n`
98
+ : '';
99
+ const envelopeLine = envelope
100
+ ? ` // this API answers 200 and puts the real verdict in the envelope\n envelope: { codeFields: ["status", "code"], okField: "success", failFrom: 400 },\n`
101
+ : '';
102
+
103
+ return `/**
104
+ * Streams every API call this app makes to the API console.
105
+ *
106
+ * Generated by \`api-tracer setup\`. Imported once from the entry point.
107
+ *
108
+ * This is the only place the tracer is configured. \`init()\` is idempotent: a
109
+ * second call from somewhere else keeps this configuration and warns about the
110
+ * options it ignored, which is a confusing way to find out you have two.
111
+ *
112
+ * Credentials never leave the browser -- header values and credential-looking
113
+ * fields are replaced before anything is posted.
114
+ */
115
+ ${importAxios}import { apiTracer } from "api-tracer-kit";
116
+
117
+ const CONSOLE_URL =
118
+ ${f.consoleUrl};
119
+
120
+ ${f.gate}
121
+
122
+ if (isEnabled) {
123
+ apiTracer.init({
124
+ reportTo: CONSOLE_URL,
125
+ ${envelopeLine}${transports} });
126
+ ${attachAxios} // eslint-disable-next-line no-console
127
+ console.info(\`[api-tracer] \${${f.envLabel}}: streaming API calls to \${CONSOLE_URL}\`);
128
+ }
129
+
130
+ export default isEnabled;
131
+ `;
132
+ }
133
+
134
+ /** adds the import to the entry file, after the last import already there */
135
+ export function addImport(source, specifier) {
136
+ if (source.includes(specifier)) return { source, added: false };
137
+
138
+ const lines = source.split('\n');
139
+ let last = -1;
140
+ for (let i = 0; i < lines.length; i++) {
141
+ if (/^\s*(import\s|const\s+\w+\s*=\s*require\()/.test(lines[i])) last = i;
142
+ // stop at the first real statement: a later import is inside a function
143
+ if (last !== -1 && /^\s*(function|class|export default|ReactDOM|createRoot)/.test(lines[i])) break;
144
+ }
145
+
146
+ const line = `import "${specifier}";`;
147
+ const comment = '// dev only: streams API calls to the console, dropped from a production build';
148
+ if (last === -1) lines.unshift(comment, line, '');
149
+ else lines.splice(last + 1, 0, comment, line);
150
+
151
+ return { source: lines.join('\n'), added: true };
152
+ }
153
+
154
+ /** the tracer must be configured in one place; find anyone else doing it */
155
+ export function findExistingInits(root, files) {
156
+ const hits = [];
157
+ for (const file of files) {
158
+ try {
159
+ const text = readFileSync(join(root, file), 'utf8');
160
+ if (/\bapiTracer\s*\.\s*init\s*\(|\binitApiTracer\s*\(/.test(text)) hits.push(file);
161
+ } catch {
162
+ /* unreadable file is not a conflict */
163
+ }
164
+ }
165
+ return hits;
166
+ }
167
+
168
+ export function ensureGitignore(root) {
169
+ const file = join(root, '.gitignore');
170
+ let text = '';
171
+ try {
172
+ text = readFileSync(file, 'utf8');
173
+ } catch {
174
+ /* no .gitignore yet */
175
+ }
176
+ if (/^\.api-tracer\/?$/m.test(text)) return false;
177
+ const addition = `${text.endsWith('\n') || !text ? '' : '\n'}\n# api-tracer-kit console captures: real request and response bodies\n.api-tracer/\n`;
178
+ writeFileSync(file, text + addition);
179
+ return true;
180
+ }
181
+
182
+ export { FRAMEWORKS };
package/cli/test.mjs CHANGED
@@ -14,6 +14,7 @@ import { guessAuth, guessBaseUrls, guessPreset, collectFiles, defaults } from '.
14
14
  import { importHar, importCurl, matchEndpoint, parseQuery, toComparablePath } from './import.mjs';
15
15
  import { shapeOf, diffShapes, summarizeDrift } from './shape.mjs';
16
16
  import { buildReport, toMarkdown } from './report.mjs';
17
+ import { addImport, detectFramework, findEntry, findExistingInits, recorderSource, usesAxios, usesTypeScript } from './setup.mjs';
17
18
 
18
19
  let checks = 0;
19
20
  const check = (name, fn) => {
@@ -335,6 +336,102 @@ check('the markdown export renders without a run history', () => {
335
336
  assert.match(md, /Standard `X_AUTH`/);
336
337
  });
337
338
 
339
+ /* --- setup: wiring the tracer into an app ----------------------------- */
340
+
341
+ const app = (deps, files) => {
342
+ const dir = mkdtempSync(join(tmpdir(), 'api-tracer-setup-'));
343
+ mkdirSync(join(dir, 'src'), { recursive: true });
344
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: deps }));
345
+ for (const [name, text] of Object.entries(files)) {
346
+ mkdirSync(join(dir, name.split('/').slice(0, -1).join('/') || '.'), { recursive: true });
347
+ writeFileSync(join(dir, name), text);
348
+ }
349
+ return dir;
350
+ };
351
+
352
+ check('the framework is detected from what the project depends on', () => {
353
+ assert.equal(detectFramework(app({ 'react-scripts': '5' }, {})), 'cra');
354
+ assert.equal(detectFramework(app({ vite: '5' }, {})), 'vite');
355
+ assert.equal(detectFramework(app({ next: '14' }, {})), 'next');
356
+ assert.equal(detectFramework(app({}, {})), 'node');
357
+ // Next ships React too, so the more specific answer has to win
358
+ assert.equal(detectFramework(app({ next: '14', react: '18' }, {})), 'next');
359
+ });
360
+
361
+ check('axios and TypeScript are detected rather than assumed', () => {
362
+ assert.equal(usesAxios(app({ axios: '1' }, {})), true);
363
+ assert.equal(usesAxios(app({}, {})), false);
364
+ assert.equal(usesTypeScript(app({}, { 'tsconfig.json': '{}' })), true);
365
+ assert.equal(usesTypeScript(app({}, {})), false);
366
+ });
367
+
368
+ check('the entry file is the first one that actually exists', () => {
369
+ assert.equal(findEntry(app({}, { 'src/index.js': '' }), 'cra'), 'src/index.js');
370
+ assert.equal(findEntry(app({}, { 'src/main.tsx': '' }), 'vite'), 'src/main.tsx');
371
+ assert.equal(findEntry(app({}, { 'pages/_app.tsx': '' }), 'next'), 'pages/_app.tsx');
372
+ assert.equal(findEntry(app({}, {}), 'cra'), null, 'a project with no entry says so');
373
+ });
374
+
375
+ check('the CRA gate folds away in a production build', () => {
376
+ const src = recorderSource({ framework: 'cra', axios: true, envelope: true });
377
+ // compared directly, not through a variable, so DefinePlugin can substitute it
378
+ assert.match(src, /process\.env\.REACT_APP_ENV === "dev"/);
379
+ assert.ok(!/NODE_ENV/.test(src), 'NODE_ENV would compile it out of dev and stage deploys');
380
+ });
381
+
382
+ check('axios is attached only when the project uses it', () => {
383
+ assert.match(recorderSource({ framework: 'cra', axios: true }), /useAxios\(axios\)/);
384
+ assert.match(recorderSource({ framework: 'cra', axios: true }), /transports: \[\]/);
385
+ const without = recorderSource({ framework: 'cra', axios: false });
386
+ assert.ok(!/useAxios/.test(without));
387
+ assert.ok(!/from "axios"/.test(without), 'no import of a package that is not there');
388
+ });
389
+
390
+ check('each framework gets the gate and env convention that works for it', () => {
391
+ assert.match(recorderSource({ framework: 'vite', axios: false }), /import\.meta\.env\.DEV/);
392
+ assert.match(recorderSource({ framework: 'vite', axios: false }), /VITE_API_CONSOLE_URL/);
393
+ assert.match(recorderSource({ framework: 'next', axios: false }), /NEXT_PUBLIC_API_CONSOLE_URL/);
394
+ assert.match(recorderSource({ framework: 'node', axios: false }), /NODE_ENV !== "production"/);
395
+ });
396
+
397
+ check('the envelope is included unless the API is said not to need it', () => {
398
+ assert.match(recorderSource({ framework: 'cra', axios: false, envelope: true }), /envelope:/);
399
+ assert.ok(!/envelope:/.test(recorderSource({ framework: 'cra', axios: false, envelope: false })));
400
+ });
401
+
402
+ check('the import goes in after the last top-level import', () => {
403
+ const entry = 'import React from "react";\nimport App from "./App";\n\nReactDOM.render(<App />, root);\n';
404
+ const { source, added } = addImport(entry, './apiTracer');
405
+ assert.equal(added, true);
406
+ const lines = source.split('\n');
407
+ assert.match(lines[2], /^\/\//, 'a comment explains why it is there');
408
+ assert.equal(lines[3], 'import "./apiTracer";');
409
+ assert.ok(source.indexOf('import "./apiTracer"') < source.indexOf('ReactDOM.render'));
410
+ });
411
+
412
+ check('running setup twice does not import twice', () => {
413
+ const entry = 'import React from "react";\n';
414
+ const once = addImport(entry, './apiTracer').source;
415
+ const twice = addImport(once, './apiTracer');
416
+ assert.equal(twice.added, false);
417
+ assert.equal(twice.source, once);
418
+ });
419
+
420
+ check('an entry file with no imports still gets one', () => {
421
+ const { source, added } = addImport('console.log("hi");\n', './apiTracer');
422
+ assert.equal(added, true);
423
+ assert.match(source.split('\n')[1], /^import "/);
424
+ });
425
+
426
+ check('a stray init() somewhere else is found and reported', () => {
427
+ const dir = app({}, {
428
+ 'src/services/index.js': 'import { apiTracer } from "api-tracer-kit";\napiTracer.init();\n',
429
+ 'src/clean.js': 'export const x = 1;\n',
430
+ });
431
+ const found = findExistingInits(dir, ['src/services/index.js', 'src/clean.js', 'src/missing.js']);
432
+ assert.deepEqual(found, ['src/services/index.js'], 'the exact mistake that leaves a tracer reporting nowhere');
433
+ });
434
+
338
435
  rmSync(project, { recursive: true, force: true });
339
436
  rmSync(other, { recursive: true, force: true });
340
437
  rmSync(fetchy, { recursive: true, force: true });
@@ -0,0 +1,136 @@
1
+ # Architecture
2
+
3
+ ```
4
+ api-tracer-kit
5
+ ├── src/ the runtime tracer (TypeScript)
6
+ │ ├── core/
7
+ │ │ ├── tracer.ts the instance: options, storage, subscribers, adapters
8
+ │ │ ├── types.ts the public data model
9
+ │ │ ├── env.ts runtime detection and trace ids
10
+ │ │ ├── global.ts the cross-bundle registry
11
+ │ │ ├── redact.ts credential redaction
12
+ │ │ ├── body.ts reading bodies without breaking them
13
+ │ │ ├── url.ts absolute URLs, axios-style query serialization
14
+ │ │ └── envelope.ts reading a verdict out of a 2xx body
15
+ │ ├── adapters/
16
+ │ │ ├── fetch.ts global fetch
17
+ │ │ ├── xhr.ts XMLHttpRequest prototype
18
+ │ │ ├── axios.ts an axios instance and everything it creates
19
+ │ │ └── suppress.ts stops axios-on-XHR being counted twice
20
+ │ ├── storage/memory.ts the capped in-memory default
21
+ │ ├── transport/report.ts posting finished traces to a console
22
+ │ ├── ui/index.tsx the React panel
23
+ │ ├── index.ts api-tracer-kit
24
+ │ ├── axios.ts api-tracer-kit/axios
25
+ │ └── react.ts api-tracer-kit/react
26
+ └── cli/ the console (plain ESM, no build step)
27
+ ├── bin/api-tracer.mjs the command
28
+ ├── presets.mjs how to find endpoints in a codebase
29
+ ├── config.mjs defaults, and guessing what was not configured
30
+ ├── scan.mjs source -> endpoint catalog
31
+ ├── server.mjs forwards calls, replays, exports, records
32
+ ├── import.mjs HAR and cURL, matched back to the catalog
33
+ ├── shape.mjs response shapes and drift diffing
34
+ ├── report.mjs the report model and its Markdown export
35
+ └── web/ the dashboard: no framework, no build
36
+ ```
37
+
38
+ ## The two halves
39
+
40
+ The **tracer** is framework-free and knows nothing about any application. It
41
+ records what happened and hands it to whoever asked.
42
+
43
+ The **console** knows nothing about any application either: the catalog supplies
44
+ the endpoints, base URLs, auth header and envelope convention, and the config
45
+ file supplies the sign-in flow. Everything project-specific lives in generated
46
+ data or in your config, never in the code.
47
+
48
+ They are useful separately. The tracer with no console is an in-app panel and a
49
+ subscriber API. The console with no tracer still scans your source, still lets
50
+ you fire endpoints, and still imports a HAR.
51
+
52
+ ## Design constraints
53
+
54
+ Three rules shaped most of the code.
55
+
56
+ ### It must never change what the application sees
57
+
58
+ Every adapter is written around this. Responses are read from a `clone()` after
59
+ the original has been returned, never before. Request bodies that are streams
60
+ are described rather than consumed. Errors are rethrown as they arrived. XHR is
61
+ observed with `addEventListener`, never by taking `onload` — that property
62
+ belongs to the caller. A subscriber that throws is caught, because a bad
63
+ listener must not be able to break a request.
64
+
65
+ Where a body cannot be read safely — a stream, a huge payload, a binary blob —
66
+ it is recorded as omitted with a reason, rather than read anyway.
67
+
68
+ ### It must never grow without limit
69
+
70
+ The default store is a capped ring. Bodies over a size limit are recorded as
71
+ omitted rather than copied. The reporter gives up after six consecutive
72
+ failures. A tracer that leaks is worse than no tracer.
73
+
74
+ ### It must be removable
75
+
76
+ `destroy()` restores every patched global and every axios adapter, including
77
+ instances created through the patched `create()`. `fetch` is only restored if
78
+ nothing else has patched it since — clobbering another tool's interceptor on the
79
+ way out would be worse than leaving ours in place.
80
+
81
+ ## Two problems worth explaining
82
+
83
+ ### The cross-bundle registry
84
+
85
+ A package with several entry points is bundled once per entry. So
86
+ `api-tracer-kit` and `api-tracer-kit/axios` would each get their own copy of the
87
+ module graph — and their own "shared" tracer, which is not shared at all.
88
+ `useAxios()` would attach to an instance `getTraces()` never reads from. Two
89
+ versions of the package in one dependency tree cause the same thing.
90
+
91
+ `src/core/global.ts` anchors the instance on
92
+ `globalThis[Symbol.for('api-tracer-kit.registry')]`, the one place every copy can
93
+ agree on.
94
+
95
+ ### Counting an axios call once
96
+
97
+ In a browser axios rides on `XMLHttpRequest`, so a call would be seen by both
98
+ adapters. axios builds and opens its XHR **synchronously** inside its adapter, so
99
+ the axios adapter raises a counter around the adapter call: it is still up when
100
+ `open` and `send` run, and down again before anything else can start a request.
101
+ That makes the suppression exact rather than time-based.
102
+
103
+ The axios adapter wraps the instance's *adapter* rather than only its
104
+ interceptors, because the adapter is the one place both the request and the raw
105
+ response are available — and it is where the XHR is created, which is what makes
106
+ the suppression possible. The property is installed as an accessor, so an
107
+ application that later assigns `axios.defaults.adapter` replaces what the wrapper
108
+ calls rather than the wrapper itself.
109
+
110
+ ## The scanner
111
+
112
+ Regex-based, per preset, and honest about it. A parser would need a dependency
113
+ per language and per flavour of syntax and would still not understand a template
114
+ literal assembled from three variables.
115
+
116
+ Which preset applies is decided by **running every preset over a sample of the
117
+ codebase and keeping whichever finds the most**. Guessing by looking for marker
118
+ strings was tried first and was wrong often enough to matter — a project can
119
+ import axios and still not use it for its API layer. Running the presets asks
120
+ the only question that counts: which one actually reads this code?
121
+
122
+ The sample is weighted towards paths that look like an API layer, so the guess
123
+ is made on the code that matters rather than the first hundred components in
124
+ alphabetical order.
125
+
126
+ What the scanner misses, live recording covers: a call no endpoint explains is
127
+ adopted as an endpoint of its own rather than dropped.
128
+
129
+ ## The dashboard
130
+
131
+ Vanilla JavaScript, no framework, no build step — it is served as static files
132
+ straight from the package. JSON editors are syntax highlighted by a coloured
133
+ layer behind a transparent textarea, so even that needs no editor library.
134
+
135
+ This is deliberate. A dev tool that needs its own toolchain to be maintained is
136
+ a dev tool nobody maintains.