api-tracer-kit 1.0.0 → 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 });
package/cli/web/app.css CHANGED
@@ -158,8 +158,7 @@ header {
158
158
  border-right: 1px solid var(--line);
159
159
  }
160
160
  .brand .logo {
161
- /* the app's own wordmark (public/images/conx.svg, the white-ink variant --
162
- conx-dark.svg is dark ink, meant for light backgrounds) */
161
+ /* logo.svg, drawn with currentColor so it inherits the header's ink */
163
162
  height: 26px;
164
163
  width: auto;
165
164
  display: block;
package/dist/axios.cjs CHANGED
@@ -687,6 +687,13 @@ function createReporter(baseUrl) {
687
687
 
688
688
  // src/core/tracer.ts
689
689
  var DEFAULT_REPORT_URL = "http://localhost:4400";
690
+ function warnIgnoredOptions(options) {
691
+ const ignored = Object.keys(options).filter((k) => k !== "onTrace");
692
+ if (!ignored.length) return;
693
+ console.warn(
694
+ `[api-tracer] init() was called again on a tracer that is already running, so these options were ignored: ${ignored.join(", ")}. The configuration from the first init() is still in effect. Configure the tracer in one place, or call destroy() before re-initialising.`
695
+ );
696
+ }
690
697
  var ApiTracer = class {
691
698
  constructor() {
692
699
  this.storage = new MemoryTraceStorage();
@@ -712,6 +719,7 @@ var ApiTracer = class {
712
719
  init(options = {}) {
713
720
  if (this.started) {
714
721
  if (options.onTrace) this.subscribe(options.onTrace);
722
+ warnIgnoredOptions(options);
715
723
  return this;
716
724
  }
717
725
  this.options = {