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/CHANGELOG.md +17 -0
- package/README.md +77 -342
- package/cli/bin/api-tracer.mjs +93 -2
- package/cli/setup.mjs +182 -0
- package/cli/test.mjs +97 -0
- package/cli/web/app.css +1 -2
- package/dist/axios.cjs +8 -0
- package/dist/axios.cjs.map +1 -1
- package/dist/axios.js +8 -0
- package/dist/axios.js.map +1 -1
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +8 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +8 -0
- package/dist/react.js.map +1 -1
- package/dist/ui.cjs +8 -0
- package/dist/ui.cjs.map +1 -1
- package/dist/ui.js +8 -0
- package/dist/ui.js.map +1 -1
- package/docs/architecture.md +136 -0
- package/docs/configuration.md +275 -0
- package/docs/console.md +428 -0
- package/docs/frameworks.md +192 -0
- package/docs/getting-started.md +120 -0
- package/docs/security.md +143 -0
- package/docs/tracer.md +330 -0
- package/docs/troubleshooting.md +194 -0
- package/package.json +2 -1
|
@@ -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.
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
There is no required configuration. `api-tracer scan` works in a project it has
|
|
4
|
+
never seen: it runs every scanner preset over your codebase and keeps whichever
|
|
5
|
+
actually reads it, then reads your base URLs and auth header out of the source.
|
|
6
|
+
|
|
7
|
+
A config file exists for what guessing cannot cover — a bespoke service shape, a
|
|
8
|
+
base URL that is not written down, a sign-in flow worth automating.
|
|
9
|
+
|
|
10
|
+
## The file
|
|
11
|
+
|
|
12
|
+
One of these in your project root, checked in order:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
api-tracer.config.mjs
|
|
16
|
+
api-tracer.config.js
|
|
17
|
+
api-tracer.config.json
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use `.mjs` unless your project is ESM already — a `.js` file with
|
|
21
|
+
`export default` will not load inside a CommonJS package.
|
|
22
|
+
|
|
23
|
+
Generate one filled in with what the scan worked out:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx api-tracer init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It prints what each preset found before writing, so you can see the evidence:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
1,284 source files scanned
|
|
33
|
+
service-object 325
|
|
34
|
+
fetch-direct 1
|
|
35
|
+
axios-direct 0
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Every field
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
export default {
|
|
42
|
+
// shown in the console header and in Postman exports
|
|
43
|
+
name: 'My API',
|
|
44
|
+
|
|
45
|
+
// which preset reads this codebase; null or absent means guess
|
|
46
|
+
preset: 'service-object',
|
|
47
|
+
|
|
48
|
+
// for the service-object preset: the object key holding the path
|
|
49
|
+
pathKey: 'subUrl',
|
|
50
|
+
|
|
51
|
+
// where to look for API calls, relative to the project root
|
|
52
|
+
sources: ['src', 'app', 'lib', 'services', 'api'],
|
|
53
|
+
|
|
54
|
+
// never walked
|
|
55
|
+
ignore: ['node_modules', 'dist', 'build', 'coverage', '.git', '.next', 'out', 'vendor'],
|
|
56
|
+
|
|
57
|
+
extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'],
|
|
58
|
+
|
|
59
|
+
// environment -> base URL; guessed from the source when absent
|
|
60
|
+
baseUrls: {
|
|
61
|
+
dev: 'https://dev-api.example.com',
|
|
62
|
+
stage: 'https://stage-api.example.com',
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// the header the API authenticates with
|
|
66
|
+
auth: { header: 'Authorization' },
|
|
67
|
+
|
|
68
|
+
// how to read pass/fail out of a 2xx body; false turns it off
|
|
69
|
+
envelope: { codeFields: ['status', 'code'], okField: 'success', failFrom: 400 },
|
|
70
|
+
|
|
71
|
+
// the module an endpoint belongs to; the filename by default
|
|
72
|
+
moduleOf: (file, root) => file.split('/').at(-2),
|
|
73
|
+
|
|
74
|
+
// your own scanner, when no preset fits
|
|
75
|
+
parse: (src, config) => [],
|
|
76
|
+
|
|
77
|
+
// an optional multi-step sign-in chain
|
|
78
|
+
login: null,
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Scanner presets
|
|
83
|
+
|
|
84
|
+
### `service-object`
|
|
85
|
+
|
|
86
|
+
One exported function per endpoint, building a request object:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
export const getPatients = (params) => {
|
|
90
|
+
const request = { subUrl: `/v1/patients.json`, params };
|
|
91
|
+
return get(request);
|
|
92
|
+
};
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Recognised verbs: `get`, `get2`, `getRequest`, `post`, `postRequest`, `put`,
|
|
96
|
+
`patch`, `delete`, `deletee`, `del`, `destroy`. The request argument may be
|
|
97
|
+
named `request`, `config`, `options` or `req`. Change the path key with
|
|
98
|
+
`pathKey` if yours is not `subUrl`.
|
|
99
|
+
|
|
100
|
+
### `axios-direct`
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
export const listUsers = () => axios.get('/api/users', { params: { page: 1 } });
|
|
104
|
+
export const addUser = (body) => api.post(`/api/users/${body.id}`, body);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Any identifier works, not just `axios` — `api`, `http`, `client`.
|
|
108
|
+
|
|
109
|
+
### `fetch-direct`
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
export const load = () => fetch('/api/things');
|
|
113
|
+
export const save = (b) => fetch('/api/things', { method: 'POST', body: JSON.stringify(b) });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Path placeholders
|
|
117
|
+
|
|
118
|
+
All three read `${expr}`, `:name` and `{name}` as holes, so Express- and
|
|
119
|
+
OpenAPI-style paths work as well as template literals.
|
|
120
|
+
|
|
121
|
+
### Why regexes and not a parser
|
|
122
|
+
|
|
123
|
+
A parser would need a dependency per language and per flavour of syntax, and
|
|
124
|
+
would still not understand a template literal assembled from three variables. A
|
|
125
|
+
scanner that reads 90% of a codebase and says what it could not read is more
|
|
126
|
+
useful than a parser that refuses to start. Live recording covers the rest: a
|
|
127
|
+
call the scan cannot explain is adopted as an endpoint of its own.
|
|
128
|
+
|
|
129
|
+
Commented-out lines are blanked before scanning, with line numbers preserved.
|
|
130
|
+
Services routinely keep the previous URL directly above the live one, and
|
|
131
|
+
reading the first match would silently take the dead path.
|
|
132
|
+
|
|
133
|
+
### Your own scanner
|
|
134
|
+
|
|
135
|
+
When no preset fits, supply `parse` rather than forking the tool. It is called
|
|
136
|
+
once per file with the source (comments already stripped) and the config, and
|
|
137
|
+
returns one object per endpoint:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
export default {
|
|
141
|
+
parse(src, config) {
|
|
142
|
+
return [...src.matchAll(/route\("(\w+)",\s*"([^"]+)"\)/g)].map((m) => ({
|
|
143
|
+
name: m[2].replace(/\W+/g, '_'), // the endpoint's function name
|
|
144
|
+
method: m[1].toUpperCase(), // GET, POST, ...
|
|
145
|
+
subUrl: m[2], // the path template
|
|
146
|
+
usesParams: m[1] === 'get', // sends a query string
|
|
147
|
+
usesData: m[1] !== 'get', // sends a body
|
|
148
|
+
absolute: /^https?:\/\//.test(m[2]),
|
|
149
|
+
chunk: src.slice(m.index, m.index + 300), // scanned for auth quirks
|
|
150
|
+
index: m.index, // for the source line number
|
|
151
|
+
}));
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`parse` takes precedence over `preset`. Throwing on one file skips that file
|
|
157
|
+
rather than failing the scan.
|
|
158
|
+
|
|
159
|
+
## Base URLs and the auth header
|
|
160
|
+
|
|
161
|
+
Both are read out of your source by default, so your environments stay in sync
|
|
162
|
+
with the app rather than being copied into a config that drifts.
|
|
163
|
+
|
|
164
|
+
Base URLs are found by looking for an object mapping environment names to URLs:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
const apiBaseUrls = {
|
|
168
|
+
dev: 'https://dev-api.example.com',
|
|
169
|
+
prod: 'https://api.example.com',
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Recognised environment names: `dev`, `develop`, `development`, `stage`,
|
|
174
|
+
`staging`, `uat`, `qa`, `test`, `prod`, `production`, `local`. Failing that, the
|
|
175
|
+
first API-looking URL becomes `dev`.
|
|
176
|
+
|
|
177
|
+
The auth header is found by looking for a named constant — the strongest signal,
|
|
178
|
+
because a codebase that uses the header in more than one place tends to write it
|
|
179
|
+
that way:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
const authTokenKey = 'AUTH_TOKEN';
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
then for header names used as object keys or header lookups.
|
|
186
|
+
|
|
187
|
+
Set either explicitly in the config to override. Consider commenting out a
|
|
188
|
+
production base URL in a generated config, so nobody replays writes against live
|
|
189
|
+
data by selecting the wrong environment.
|
|
190
|
+
|
|
191
|
+
## Sign-in flows
|
|
192
|
+
|
|
193
|
+
Plenty of APIs need more than one call to hand out a usable token. Rather than
|
|
194
|
+
make everyone paste one, describe the chain and the console will drive it.
|
|
195
|
+
|
|
196
|
+
Each step declares the fields to ask for, the call to make, and what to do with
|
|
197
|
+
the answer:
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
export default {
|
|
201
|
+
login: {
|
|
202
|
+
// optional: a short-lived signed JWT sent with every step
|
|
203
|
+
jwt: {
|
|
204
|
+
secret: process.env.API_SIGNING_KEY,
|
|
205
|
+
secretProd: process.env.API_SIGNING_KEY_PROD,
|
|
206
|
+
headerName: 'ACCESS_TOKEN',
|
|
207
|
+
timestampPrefix: 'MyApp-',
|
|
208
|
+
ttlSeconds: 600,
|
|
209
|
+
// or build the payload yourself
|
|
210
|
+
payload: ({ now, secretCode, env }) => ({ iat: now, exp: now + 600 }),
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
steps: [
|
|
214
|
+
{
|
|
215
|
+
name: 'credentials',
|
|
216
|
+
title: 'Sign in with your email and password.',
|
|
217
|
+
fields: [
|
|
218
|
+
{ name: 'email', label: 'Email', type: 'email' },
|
|
219
|
+
{ name: 'password', label: 'Password', type: 'password' },
|
|
220
|
+
],
|
|
221
|
+
// the scanned endpoint id, so a path change in the app is picked up;
|
|
222
|
+
// `path` is the fallback when there is no catalog entry
|
|
223
|
+
endpointId: 'session.loginApi',
|
|
224
|
+
path: '/users/login.json',
|
|
225
|
+
body: (input) => ({ user: input }),
|
|
226
|
+
then: (data) => ({
|
|
227
|
+
state: { apiToken: data.api_token, phone: data.phone },
|
|
228
|
+
next: 'code',
|
|
229
|
+
}),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'code',
|
|
233
|
+
title: 'Enter the code we sent you.',
|
|
234
|
+
fields: [{ name: 'otp', label: 'Code', inputmode: 'numeric' }],
|
|
235
|
+
path: '/users/verify_otp.json',
|
|
236
|
+
headers: (state) => ({ SECRET_TOKEN: state.apiToken }),
|
|
237
|
+
body: (input, state) => ({ otp: input.otp, phone: state.phone }),
|
|
238
|
+
then: (data) => ({
|
|
239
|
+
token: data.auth_token,
|
|
240
|
+
owner: { name: data.name, email: data.email },
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### The step contract
|
|
249
|
+
|
|
250
|
+
| Field | |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `name` | the step's id, used as `next` from the previous step |
|
|
253
|
+
| `title` | shown above the fields |
|
|
254
|
+
| `fields` | `{ name, label?, type?, inputmode?, placeholder?, options? }`. `options` renders a select. |
|
|
255
|
+
| `endpointId` | a scanned endpoint whose path to use |
|
|
256
|
+
| `path` | the path to POST to, when `endpointId` is absent or unmatched |
|
|
257
|
+
| `headers(state)` | extra headers for this step |
|
|
258
|
+
| `body(input, state)` | the request body from the submitted fields and the carried state |
|
|
259
|
+
| `then(data, state, input)` | what to do with the response |
|
|
260
|
+
|
|
261
|
+
`then` returns any of:
|
|
262
|
+
|
|
263
|
+
| | |
|
|
264
|
+
| --- | --- |
|
|
265
|
+
| `state` | merged into the carried state for later steps |
|
|
266
|
+
| `next` | the name of the step to show next |
|
|
267
|
+
| `choices` | options for a select on the next step |
|
|
268
|
+
| `token` | the auth token — ends the flow and stores it |
|
|
269
|
+
| `owner` | `{ name, email }`, shown on a shared console so people know whose session they are replaying as |
|
|
270
|
+
|
|
271
|
+
The password is forwarded once and is never stored, logged, or written to disk.
|
|
272
|
+
Only the resulting token is kept.
|
|
273
|
+
|
|
274
|
+
A project with no `login` config gets the paste-a-token form, which is all most
|
|
275
|
+
APIs need.
|