manifest 7.2.1 → 7.4.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/CONTRACT.md CHANGED
@@ -63,7 +63,7 @@ Reports are best effort, bounded, and observable through Node warnings with code
63
63
 
64
64
  ## Tracked requests
65
65
 
66
- Every call the SDK sees and does not send to `POST /v1/heal`, whatever its status (2xx, 3xx, 401, 402, 403, 429, 5xx, and a 4xx that is not sent for healing: after a redirect, while the project is disabled, or while all eight heal slots are busy), is recorded and sent in batches to `POST /v1/requests`:
66
+ Every call the SDK sees and does not send to `POST /v1/heal`, whatever its status (2xx, 3xx, 401, 402, 403, 429, 5xx, and a 4xx that is not sent for healing: after a redirect, while the project is disabled, or while all eight heal slots are busy), is recorded and sent in batches to `POST /v1/requests`. So is every call made through the `undici` package directly or through a `fetch` reference saved before `manifest()` ran: the SDK observes those on undici's diagnostics channels, so they are tracked, never healed. The SDK's own calls to Manifest are never tracked.
67
67
 
68
68
  ```json
69
69
  {"requests":[{"traceId":"a-uuid","method":"POST","url":"https://example.com/orders","statusCode":200,"responseTimeMs":84,"occurredAt":"2026-09-23T10:14:07.512Z"}]}
package/README.md CHANGED
@@ -65,7 +65,7 @@ The prompt adds the one-line install to your entry file and stops to let you pas
65
65
 
66
66
  TypeScript, ESM and CommonJS. Zero dependencies.
67
67
 
68
- 4. Set your key in the environment of your app:
68
+ 4. Set your key in the environment of your app, or in the project's `.env`:
69
69
 
70
70
  ```sh
71
71
  export MNFST_KEY='your-project-key'
@@ -77,7 +77,7 @@ The prompt adds the one-line install to your entry file and stops to let you pas
77
77
  npx manifest doctor
78
78
  ```
79
79
 
80
- It resolves the installed SDK version, masks and validates the key, checks that Manifest loads before your app, and prints the runtime coverage.
80
+ It reads the key from the shell, then from the project's `.env.local` or `.env`. It resolves the installed SDK version, masks and validates the key, checks that Manifest loads before your app, and prints the runtime coverage.
81
81
 
82
82
  Some clients keep the `fetch` they saw when they were built, and a client built at import time runs before your call. Where that happens, or where the start command is not yours to change, preload the SDK instead:
83
83
 
@@ -133,6 +133,23 @@ console.log(res.status);
133
133
 
134
134
  The failed request appears in your [Manifest dashboard](https://dashboard.manifest.build), grouped with others like it in an issue. Once Manifest has a patch for that error, the next request that fails the same way is repaired and retried: `onHeal` reports `patched` or `unverified` with the retry's status code, and your app receives the answer to the retry.
135
135
 
136
+ ## Choosing which calls reach Manifest
137
+
138
+ Keep calls out of Manifest entirely: they are neither repaired nor tracked, and nothing about them leaves your app. Each entry is a domain or a domain with a path:
139
+
140
+ ```sh
141
+ MNFST_ALLOWLIST=stripe.com # only Stripe
142
+ MNFST_ALLOWLIST=stripe.com/v1/payment_intents # only this Stripe endpoint
143
+ MNFST_DENYLIST=stripe.com/v1/charges,internal.example.com # never these
144
+ ```
145
+
146
+ - A domain covers its subdomains, with or without a path: `stripe.com` and `stripe.com/v1/charges` both match `api.stripe.com`.
147
+ - A path matches whole segments: `/v1/charges` covers `/v1/charges/ch_123`, not `/v1/charges_export`. Paths are case-sensitive.
148
+ - A scheme, port, query or fragment in an entry is ignored. `*` in a path is not supported yet: the entry is skipped with a warning, and an allowlist made only of skipped entries lets nothing through.
149
+ - The denylist wins over the allowlist. With no allowlist, every call is eligible.
150
+
151
+ Or in code: `manifest({ denylist: ['stripe.com/v1/charges'] })`. An option overrides its environment variable. When Manifest starts from a preload (`node -r manifest/register`), set the environment variables: a later `manifest()` call cannot change the configuration.
152
+
136
153
  ## More
137
154
 
138
155
  [Documentation](https://docs.manifest.build) · [Configuration, limits & development](docs/guide.md) · [API contract](CONTRACT.md) · [Python SDK](https://github.com/mnfst/manifest-python) · [PHP SDK](https://github.com/mnfst/manifest-php) · [Website](https://manifest.build)
package/dist/bin.cjs CHANGED
@@ -25,15 +25,51 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
 
26
26
  // src/cli.ts
27
27
  var import_node_module = require("module");
28
- var import_node_fs = require("fs");
28
+ var import_node_fs2 = require("fs");
29
29
  var import_promises = require("fs/promises");
30
- var import_node_path = __toESM(require("path"), 1);
30
+ var import_node_path2 = __toESM(require("path"), 1);
31
31
 
32
32
  // src/wire.ts
33
- var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
33
+ var isObject = (value2) => value2 !== null && typeof value2 === "object" && !Array.isArray(value2);
34
34
 
35
35
  // src/api.ts
36
- var VERSION = "7.2.1";
36
+ var VERSION = "7.4.0";
37
+
38
+ // src/dotenv.ts
39
+ var import_node_fs = require("fs");
40
+ var import_node_path = __toESM(require("path"), 1);
41
+ var FILES = [".env.local", ".env"];
42
+ var LINE = /^\s*(?:export\s+)?(MNFST_KEY|MNFST_URL)\s*=(.*)$/;
43
+ function value(raw) {
44
+ const trimmed = raw.trim();
45
+ const quote = trimmed[0];
46
+ if (quote === '"' || quote === "'") {
47
+ const end = trimmed.indexOf(quote, 1);
48
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end);
49
+ }
50
+ const hash = trimmed.indexOf(" #");
51
+ return (hash === -1 ? trimmed : trimmed.slice(0, hash)).trim();
52
+ }
53
+ function readDotEnv(cwd) {
54
+ const found = {};
55
+ for (const file of FILES) {
56
+ let contents;
57
+ try {
58
+ contents = (0, import_node_fs.readFileSync)(import_node_path.default.join(cwd, file), "utf8");
59
+ } catch {
60
+ continue;
61
+ }
62
+ for (const line of contents.split(/\r?\n/)) {
63
+ const match = LINE.exec(line);
64
+ if (!match) continue;
65
+ const name = match[1];
66
+ if (found[name] !== void 0) continue;
67
+ const parsed = value(match[2] ?? "");
68
+ if (parsed) found[name] = parsed;
69
+ }
70
+ }
71
+ return found;
72
+ }
37
73
 
38
74
  // src/cli.ts
39
75
  var DEFAULT_URL = "https://api.manifest.build";
@@ -139,17 +175,17 @@ async function probe(url, key, doFetch) {
139
175
  }
140
176
  function installedVersion(cwd) {
141
177
  try {
142
- const entry = (0, import_node_module.createRequire)(import_node_path.default.join(cwd, "package.json")).resolve(
178
+ const entry = (0, import_node_module.createRequire)(import_node_path2.default.join(cwd, "package.json")).resolve(
143
179
  "manifest"
144
180
  );
145
- let dir = import_node_path.default.dirname(entry);
181
+ let dir = import_node_path2.default.dirname(entry);
146
182
  while (true) {
147
- const manifest = import_node_path.default.join(dir, "package.json");
148
- if ((0, import_node_fs.existsSync)(manifest)) {
149
- const pkg = JSON.parse((0, import_node_fs.readFileSync)(manifest, "utf8"));
183
+ const manifest = import_node_path2.default.join(dir, "package.json");
184
+ if ((0, import_node_fs2.existsSync)(manifest)) {
185
+ const pkg = JSON.parse((0, import_node_fs2.readFileSync)(manifest, "utf8"));
150
186
  if (pkg.name === "manifest" && pkg.version) return pkg.version;
151
187
  }
152
- const parent = import_node_path.default.dirname(dir);
188
+ const parent = import_node_path2.default.dirname(dir);
153
189
  if (parent === dir) return void 0;
154
190
  dir = parent;
155
191
  }
@@ -158,10 +194,10 @@ function installedVersion(cwd) {
158
194
  }
159
195
  }
160
196
  async function nearestPackage(cwd) {
161
- let dir = import_node_path.default.resolve(cwd);
197
+ let dir = import_node_path2.default.resolve(cwd);
162
198
  while (true) {
163
- const file = import_node_path.default.join(dir, "package.json");
164
- if ((0, import_node_fs.existsSync)(file)) {
199
+ const file = import_node_path2.default.join(dir, "package.json");
200
+ if ((0, import_node_fs2.existsSync)(file)) {
165
201
  try {
166
202
  return {
167
203
  dir,
@@ -171,7 +207,7 @@ async function nearestPackage(cwd) {
171
207
  return void 0;
172
208
  }
173
209
  }
174
- const parent = import_node_path.default.dirname(dir);
210
+ const parent = import_node_path2.default.dirname(dir);
175
211
  if (parent === dir) return void 0;
176
212
  dir = parent;
177
213
  }
@@ -195,9 +231,9 @@ async function loadCheck(cwd) {
195
231
  };
196
232
  const scripts = isObject(found.pkg.scripts) ? found.pkg.scripts : {};
197
233
  const values = Object.values(scripts).filter(
198
- (value) => typeof value === "string"
234
+ (value2) => typeof value2 === "string"
199
235
  );
200
- if (values.some((value) => value.includes("manifest/register")))
236
+ if (values.some((value2) => value2.includes("manifest/register")))
201
237
  return {
202
238
  label,
203
239
  status: "ok",
@@ -209,8 +245,8 @@ async function loadCheck(cwd) {
209
245
  };
210
246
  if ("next" in dependencies) {
211
247
  for (const relative of instrumentationFiles) {
212
- const file = import_node_path.default.join(found.dir, relative);
213
- if (!(0, import_node_fs.existsSync)(file)) continue;
248
+ const file = import_node_path2.default.join(found.dir, relative);
249
+ if (!(0, import_node_fs2.existsSync)(file)) continue;
214
250
  if (/manifest/.test(await (0, import_promises.readFile)(file, "utf8")))
215
251
  return {
216
252
  label,
@@ -248,19 +284,23 @@ function sdkCheck(cwd, injected) {
248
284
  };
249
285
  return { label, status: "ok", detail: `manifest ${version}` };
250
286
  }
287
+ function nonBlank(value2) {
288
+ return value2 && value2.trim() !== "" ? value2 : void 0;
289
+ }
251
290
  async function runDoctor(options = {}) {
252
291
  const cwd = options.cwd ?? process.cwd();
253
292
  const env = options.env ?? process.env;
254
293
  const doFetch = options.fetch ?? globalThis.fetch;
255
294
  const checks = [sdkCheck(cwd, options.sdkVersion)];
256
- const rawUrl = options.url ?? env.MNFST_URL ?? DEFAULT_URL;
295
+ const dotenv = readDotEnv(cwd);
296
+ const rawUrl = options.url ?? nonBlank(env.MNFST_URL) ?? dotenv.MNFST_URL ?? DEFAULT_URL;
257
297
  const url = normalizeBase(rawUrl);
258
- const key = env.MNFST_KEY;
298
+ const key = nonBlank(env.MNFST_KEY) ?? dotenv.MNFST_KEY;
259
299
  if (!key) {
260
300
  checks.push({
261
301
  label: "MNFST_KEY set",
262
302
  status: "fail",
263
- detail: "MNFST_KEY is not set"
303
+ detail: "MNFST_KEY is not set in the environment or the project's .env"
264
304
  });
265
305
  } else {
266
306
  checks.push({ label: "MNFST_KEY set", status: "ok", detail: maskKey(key) });
@@ -332,7 +372,7 @@ function render(report) {
332
372
  lines.push("");
333
373
  lines.push("Runtime coverage");
334
374
  lines.push(
335
- " Node.js runtime fetch, http.request, https.request, http.get are patched"
375
+ " Node.js runtime fetch, http.request, https.request, http.get are patched; undici is tracked"
336
376
  );
337
377
  lines.push(
338
378
  " Edge runtime not supported \u2014 middleware.ts and Edge route handlers are never covered"
@@ -347,9 +387,9 @@ function parseArgs(argv) {
347
387
  if (arg === "-h" || arg === "--help") args.help = true;
348
388
  else if (arg === "-v" || arg === "--version") args.version = true;
349
389
  else if (arg === "--url") {
350
- const value = argv[++index];
351
- if (!value) throw new Error("--url needs a value");
352
- args.url = value;
390
+ const value2 = argv[++index];
391
+ if (!value2) throw new Error("--url needs a value");
392
+ args.url = value2;
353
393
  } else if (arg.startsWith("--url=")) args.url = arg.slice("--url=".length);
354
394
  else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
355
395
  else if (args.command === void 0) args.command = arg;
package/dist/bin.js CHANGED
@@ -2,13 +2,51 @@
2
2
  import {
3
3
  VERSION,
4
4
  isObject
5
- } from "./chunk-OXOZQLZ3.js";
5
+ } from "./chunk-TX6KHYCJ.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { createRequire } from "module";
9
- import { existsSync, readFileSync } from "fs";
9
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
10
10
  import { readFile } from "fs/promises";
11
+ import path2 from "path";
12
+
13
+ // src/dotenv.ts
14
+ import { readFileSync } from "fs";
11
15
  import path from "path";
16
+ var FILES = [".env.local", ".env"];
17
+ var LINE = /^\s*(?:export\s+)?(MNFST_KEY|MNFST_URL)\s*=(.*)$/;
18
+ function value(raw) {
19
+ const trimmed = raw.trim();
20
+ const quote = trimmed[0];
21
+ if (quote === '"' || quote === "'") {
22
+ const end = trimmed.indexOf(quote, 1);
23
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end);
24
+ }
25
+ const hash = trimmed.indexOf(" #");
26
+ return (hash === -1 ? trimmed : trimmed.slice(0, hash)).trim();
27
+ }
28
+ function readDotEnv(cwd) {
29
+ const found = {};
30
+ for (const file of FILES) {
31
+ let contents;
32
+ try {
33
+ contents = readFileSync(path.join(cwd, file), "utf8");
34
+ } catch {
35
+ continue;
36
+ }
37
+ for (const line of contents.split(/\r?\n/)) {
38
+ const match = LINE.exec(line);
39
+ if (!match) continue;
40
+ const name = match[1];
41
+ if (found[name] !== void 0) continue;
42
+ const parsed = value(match[2] ?? "");
43
+ if (parsed) found[name] = parsed;
44
+ }
45
+ }
46
+ return found;
47
+ }
48
+
49
+ // src/cli.ts
12
50
  var DEFAULT_URL = "https://api.manifest.build";
13
51
  var REQUEST_TIMEOUT_MS = 1e4;
14
52
  var symbols = {
@@ -112,17 +150,17 @@ async function probe(url, key, doFetch) {
112
150
  }
113
151
  function installedVersion(cwd) {
114
152
  try {
115
- const entry = createRequire(path.join(cwd, "package.json")).resolve(
153
+ const entry = createRequire(path2.join(cwd, "package.json")).resolve(
116
154
  "manifest"
117
155
  );
118
- let dir = path.dirname(entry);
156
+ let dir = path2.dirname(entry);
119
157
  while (true) {
120
- const manifest = path.join(dir, "package.json");
158
+ const manifest = path2.join(dir, "package.json");
121
159
  if (existsSync(manifest)) {
122
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
160
+ const pkg = JSON.parse(readFileSync2(manifest, "utf8"));
123
161
  if (pkg.name === "manifest" && pkg.version) return pkg.version;
124
162
  }
125
- const parent = path.dirname(dir);
163
+ const parent = path2.dirname(dir);
126
164
  if (parent === dir) return void 0;
127
165
  dir = parent;
128
166
  }
@@ -131,9 +169,9 @@ function installedVersion(cwd) {
131
169
  }
132
170
  }
133
171
  async function nearestPackage(cwd) {
134
- let dir = path.resolve(cwd);
172
+ let dir = path2.resolve(cwd);
135
173
  while (true) {
136
- const file = path.join(dir, "package.json");
174
+ const file = path2.join(dir, "package.json");
137
175
  if (existsSync(file)) {
138
176
  try {
139
177
  return {
@@ -144,7 +182,7 @@ async function nearestPackage(cwd) {
144
182
  return void 0;
145
183
  }
146
184
  }
147
- const parent = path.dirname(dir);
185
+ const parent = path2.dirname(dir);
148
186
  if (parent === dir) return void 0;
149
187
  dir = parent;
150
188
  }
@@ -168,9 +206,9 @@ async function loadCheck(cwd) {
168
206
  };
169
207
  const scripts = isObject(found.pkg.scripts) ? found.pkg.scripts : {};
170
208
  const values = Object.values(scripts).filter(
171
- (value) => typeof value === "string"
209
+ (value2) => typeof value2 === "string"
172
210
  );
173
- if (values.some((value) => value.includes("manifest/register")))
211
+ if (values.some((value2) => value2.includes("manifest/register")))
174
212
  return {
175
213
  label,
176
214
  status: "ok",
@@ -182,7 +220,7 @@ async function loadCheck(cwd) {
182
220
  };
183
221
  if ("next" in dependencies) {
184
222
  for (const relative of instrumentationFiles) {
185
- const file = path.join(found.dir, relative);
223
+ const file = path2.join(found.dir, relative);
186
224
  if (!existsSync(file)) continue;
187
225
  if (/manifest/.test(await readFile(file, "utf8")))
188
226
  return {
@@ -221,19 +259,23 @@ function sdkCheck(cwd, injected) {
221
259
  };
222
260
  return { label, status: "ok", detail: `manifest ${version}` };
223
261
  }
262
+ function nonBlank(value2) {
263
+ return value2 && value2.trim() !== "" ? value2 : void 0;
264
+ }
224
265
  async function runDoctor(options = {}) {
225
266
  const cwd = options.cwd ?? process.cwd();
226
267
  const env = options.env ?? process.env;
227
268
  const doFetch = options.fetch ?? globalThis.fetch;
228
269
  const checks = [sdkCheck(cwd, options.sdkVersion)];
229
- const rawUrl = options.url ?? env.MNFST_URL ?? DEFAULT_URL;
270
+ const dotenv = readDotEnv(cwd);
271
+ const rawUrl = options.url ?? nonBlank(env.MNFST_URL) ?? dotenv.MNFST_URL ?? DEFAULT_URL;
230
272
  const url = normalizeBase(rawUrl);
231
- const key = env.MNFST_KEY;
273
+ const key = nonBlank(env.MNFST_KEY) ?? dotenv.MNFST_KEY;
232
274
  if (!key) {
233
275
  checks.push({
234
276
  label: "MNFST_KEY set",
235
277
  status: "fail",
236
- detail: "MNFST_KEY is not set"
278
+ detail: "MNFST_KEY is not set in the environment or the project's .env"
237
279
  });
238
280
  } else {
239
281
  checks.push({ label: "MNFST_KEY set", status: "ok", detail: maskKey(key) });
@@ -305,7 +347,7 @@ function render(report) {
305
347
  lines.push("");
306
348
  lines.push("Runtime coverage");
307
349
  lines.push(
308
- " Node.js runtime fetch, http.request, https.request, http.get are patched"
350
+ " Node.js runtime fetch, http.request, https.request, http.get are patched; undici is tracked"
309
351
  );
310
352
  lines.push(
311
353
  " Edge runtime not supported \u2014 middleware.ts and Edge route handlers are never covered"
@@ -320,9 +362,9 @@ function parseArgs(argv) {
320
362
  if (arg === "-h" || arg === "--help") args.help = true;
321
363
  else if (arg === "-v" || arg === "--version") args.version = true;
322
364
  else if (arg === "--url") {
323
- const value = argv[++index];
324
- if (!value) throw new Error("--url needs a value");
325
- args.url = value;
365
+ const value2 = argv[++index];
366
+ if (!value2) throw new Error("--url needs a value");
367
+ args.url = value2;
326
368
  } else if (arg.startsWith("--url=")) args.url = arg.slice("--url=".length);
327
369
  else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
328
370
  else if (args.command === void 0) args.command = arg;
@@ -15,7 +15,7 @@ import {
15
15
  trackedUrl,
16
16
  travelingBody,
17
17
  warn
18
- } from "./chunk-OXOZQLZ3.js";
18
+ } from "./chunk-TX6KHYCJ.js";
19
19
 
20
20
  // src/http.ts
21
21
  import http from "http";
@@ -140,6 +140,85 @@ var CallBuffer = class {
140
140
  }
141
141
  };
142
142
 
143
+ // src/undici.ts
144
+ import { AsyncLocalStorage } from "async_hooks";
145
+ import { subscribe, unsubscribe } from "diagnostics_channel";
146
+ var handling = new AsyncLocalStorage();
147
+ var handled = (work) => handling.run(true, work);
148
+ function installUndici(runtime) {
149
+ const manifestOrigin = new URL(runtime.options.url).origin;
150
+ const inFlight = /* @__PURE__ */ new WeakMap();
151
+ const onCreate = (message) => {
152
+ const { request } = message;
153
+ if (handling.getStore() || String(request.origin) === manifestOrigin) return;
154
+ inFlight.set(request, { startedAt: Date.now(), started: performance.now() });
155
+ };
156
+ const onHeaders = (message) => {
157
+ const { request, response } = message;
158
+ const started = inFlight.get(request);
159
+ if (!started) return;
160
+ inFlight.delete(request);
161
+ let url;
162
+ try {
163
+ url = new URL(request.path ?? "/", String(request.origin)).href;
164
+ } catch {
165
+ return;
166
+ }
167
+ if (runtime.excluded(url)) return;
168
+ runtime.track(
169
+ request.method ?? "GET",
170
+ () => url,
171
+ response.statusCode,
172
+ started.startedAt,
173
+ performance.now() - started.started
174
+ );
175
+ };
176
+ subscribe("undici:request:create", onCreate);
177
+ subscribe("undici:request:headers", onHeaders);
178
+ return () => {
179
+ unsubscribe("undici:request:create", onCreate);
180
+ unsubscribe("undici:request:headers", onHeaders);
181
+ };
182
+ }
183
+
184
+ // src/filter.ts
185
+ var HOST = /^(?:[a-z0-9_-]+(?:\.[a-z0-9_-]+)*|\[[0-9a-f:.]+\])$/;
186
+ function parseRule(entry) {
187
+ const rest = entry.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[?#]/)[0] ?? "";
188
+ const slash = rest.indexOf("/");
189
+ const authority = (slash < 0 ? rest : rest.slice(0, slash)).toLowerCase();
190
+ const path = slash < 0 ? "" : rest.slice(slash).replace(/\/+$/, "");
191
+ const host = authority.replace(/^\*\./, "").replace(/:\d+$/, "").replace(/^\.+|\.+$/g, "");
192
+ if (!HOST.test(host) || path.includes("*")) return null;
193
+ return { host: host.replace(/^\[|\]$/g, ""), path: path || null };
194
+ }
195
+ var entries = (value) => (typeof value === "string" ? value.split(",") : value ?? []).map((e) => String(e).trim()).filter(Boolean);
196
+ function resolveFilter(options, env) {
197
+ const invalid = [];
198
+ const pick = (option, name) => {
199
+ const given = entries(option).length > 0 ? entries(option) : entries(env[name]);
200
+ const rules = given.map((e) => parseRule(e) ?? (invalid.push(e), null)).filter((r) => r !== null);
201
+ return { given: given.length > 0, rules };
202
+ };
203
+ const allow = pick(options.allowlist, "MNFST_ALLOWLIST");
204
+ const deny = pick(options.denylist, "MNFST_DENYLIST");
205
+ return { filter: { allow: allow.given ? allow.rules : null, deny: deny.rules }, invalid };
206
+ }
207
+ var covers = (rule, host, path) => (host === rule.host || host.endsWith("." + rule.host)) && (rule.path === null || path === rule.path || path.startsWith(rule.path + "/"));
208
+ function isExcluded(filter, url) {
209
+ let host;
210
+ let path;
211
+ try {
212
+ const parsed = new URL(url);
213
+ host = parsed.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
214
+ path = parsed.pathname || "/";
215
+ } catch {
216
+ return false;
217
+ }
218
+ if (filter.deny.some((r) => covers(r, host, path))) return true;
219
+ return filter.allow !== null && !filter.allow.some((r) => covers(r, host, path));
220
+ }
221
+
143
222
  // src/runtime.ts
144
223
  var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
145
224
  var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
@@ -151,7 +230,7 @@ var Runtime = class {
151
230
  this.original = original;
152
231
  this.api = api ?? new HealApi(original, options.key, options.url);
153
232
  this.tracker = new CallBuffer(
154
- (batch, signal) => this.api.sendRequests(batch, signal),
233
+ (batch, signal) => handled(() => this.api.sendRequests(batch, signal)),
155
234
  { immediate: isServerless() }
156
235
  );
157
236
  }
@@ -180,7 +259,16 @@ var Runtime = class {
180
259
  } catch {
181
260
  }
182
261
  }
183
- fetch = async (input, init) => {
262
+ /** A call kept out of Manifest by the allowlist or denylist: never healed, never tracked. */
263
+ excluded(url) {
264
+ return this.options.filter ? isExcluded(this.options.filter, url) : false;
265
+ }
266
+ // Marked as handled, so the undici channels do not count this call (or its
267
+ // retry) a second time.
268
+ fetch = (input, init) => handled(() => this.send(input, init));
269
+ async send(input, init) {
270
+ const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
271
+ if (this.excluded(target)) return this.original(input, init);
184
272
  const request = new Request(input, init);
185
273
  const extras = { ...init };
186
274
  delete extras.body;
@@ -195,7 +283,7 @@ var Runtime = class {
195
283
  return response;
196
284
  }
197
285
  return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
198
- };
286
+ }
199
287
  async handleResponse(request, response, body, responseTimeMs, extras = {}) {
200
288
  if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
201
289
  return this.repair(request, extras, response, body, responseTimeMs);
@@ -331,6 +419,7 @@ function wrapRequest(original, protocol, runtime) {
331
419
  const emit = request.emit.bind(request);
332
420
  request.emit = ((event, ...values) => {
333
421
  if (event !== "response") return emit(event, ...values);
422
+ if (excluded(runtime, request, protocol)) return emit(event, ...values);
334
423
  const response = values[0];
335
424
  if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
336
425
  runtime.track(
@@ -358,6 +447,13 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
358
447
  const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
359
448
  return incomingResponse(healed, clientRequest);
360
449
  }
450
+ function excluded(runtime, request, protocol) {
451
+ try {
452
+ return runtime.excluded(requestUrl(request, protocol).toString());
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
361
457
  function requestUrl(request, protocol) {
362
458
  const authority = String(request.getHeader("host") ?? request.host);
363
459
  return new URL(request.path, `${protocol}//${authority}`);
@@ -496,10 +592,12 @@ function manifest(options = {}) {
496
592
  );
497
593
  }
498
594
  if (!url.pathname.endsWith("/")) url.pathname += "/";
499
- const resolved = { ...options, key, url: url.toString() };
595
+ const { filter, invalid } = resolveFilter(options, process.env);
596
+ if (invalid.length > 0) warn(`Ignoring unreadable allowlist/denylist entries: ${invalid.join(", ")}`);
597
+ const resolved = { ...options, key, url: url.toString(), filter };
500
598
  const existing = globals[STATE];
501
599
  if (existing) {
502
- if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
600
+ if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal || JSON.stringify(existing.options.filter) !== JSON.stringify(filter)) {
503
601
  warn(
504
602
  "Manifest is already configured; changing configuration requires a process restart"
505
603
  );
@@ -509,6 +607,7 @@ function manifest(options = {}) {
509
607
  const runtime = new Runtime(resolved, globalThis.fetch.bind(globalThis));
510
608
  globalThis.fetch = runtime.fetch;
511
609
  installHttp(runtime);
610
+ installUndici(runtime);
512
611
  globals[STATE] = runtime;
513
612
  runtime.api.hello(`node-${process.versions.node}`);
514
613
  process.once("beforeExit", () => {
@@ -314,7 +314,7 @@ function keepAlive(promise) {
314
314
  }
315
315
 
316
316
  // src/api.ts
317
- var VERSION = "7.2.1";
317
+ var VERSION = "7.4.0";
318
318
  var MAX_HEALS_IN_FLIGHT = 8;
319
319
  var warn = (message) => process.emitWarning(message, { code: "MNFST" });
320
320
  var HealApi = class {
package/dist/index.cjs CHANGED
@@ -361,7 +361,7 @@ function keepAlive(promise) {
361
361
  }
362
362
 
363
363
  // src/api.ts
364
- var VERSION = "7.2.1";
364
+ var VERSION = "7.4.0";
365
365
  var MAX_HEALS_IN_FLIGHT = 8;
366
366
  var warn = (message) => process.emitWarning(message, { code: "MNFST" });
367
367
  var HealApi = class {
@@ -660,6 +660,85 @@ var CallBuffer = class {
660
660
  }
661
661
  };
662
662
 
663
+ // src/undici.ts
664
+ var import_node_async_hooks = require("async_hooks");
665
+ var import_node_diagnostics_channel = require("diagnostics_channel");
666
+ var handling = new import_node_async_hooks.AsyncLocalStorage();
667
+ var handled = (work) => handling.run(true, work);
668
+ function installUndici(runtime) {
669
+ const manifestOrigin = new URL(runtime.options.url).origin;
670
+ const inFlight = /* @__PURE__ */ new WeakMap();
671
+ const onCreate = (message) => {
672
+ const { request } = message;
673
+ if (handling.getStore() || String(request.origin) === manifestOrigin) return;
674
+ inFlight.set(request, { startedAt: Date.now(), started: performance.now() });
675
+ };
676
+ const onHeaders = (message) => {
677
+ const { request, response } = message;
678
+ const started = inFlight.get(request);
679
+ if (!started) return;
680
+ inFlight.delete(request);
681
+ let url;
682
+ try {
683
+ url = new URL(request.path ?? "/", String(request.origin)).href;
684
+ } catch {
685
+ return;
686
+ }
687
+ if (runtime.excluded(url)) return;
688
+ runtime.track(
689
+ request.method ?? "GET",
690
+ () => url,
691
+ response.statusCode,
692
+ started.startedAt,
693
+ performance.now() - started.started
694
+ );
695
+ };
696
+ (0, import_node_diagnostics_channel.subscribe)("undici:request:create", onCreate);
697
+ (0, import_node_diagnostics_channel.subscribe)("undici:request:headers", onHeaders);
698
+ return () => {
699
+ (0, import_node_diagnostics_channel.unsubscribe)("undici:request:create", onCreate);
700
+ (0, import_node_diagnostics_channel.unsubscribe)("undici:request:headers", onHeaders);
701
+ };
702
+ }
703
+
704
+ // src/filter.ts
705
+ var HOST = /^(?:[a-z0-9_-]+(?:\.[a-z0-9_-]+)*|\[[0-9a-f:.]+\])$/;
706
+ function parseRule(entry) {
707
+ const rest = entry.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[?#]/)[0] ?? "";
708
+ const slash = rest.indexOf("/");
709
+ const authority = (slash < 0 ? rest : rest.slice(0, slash)).toLowerCase();
710
+ const path = slash < 0 ? "" : rest.slice(slash).replace(/\/+$/, "");
711
+ const host = authority.replace(/^\*\./, "").replace(/:\d+$/, "").replace(/^\.+|\.+$/g, "");
712
+ if (!HOST.test(host) || path.includes("*")) return null;
713
+ return { host: host.replace(/^\[|\]$/g, ""), path: path || null };
714
+ }
715
+ var entries = (value) => (typeof value === "string" ? value.split(",") : value ?? []).map((e) => String(e).trim()).filter(Boolean);
716
+ function resolveFilter(options, env) {
717
+ const invalid = [];
718
+ const pick = (option, name) => {
719
+ const given = entries(option).length > 0 ? entries(option) : entries(env[name]);
720
+ const rules = given.map((e) => parseRule(e) ?? (invalid.push(e), null)).filter((r) => r !== null);
721
+ return { given: given.length > 0, rules };
722
+ };
723
+ const allow = pick(options.allowlist, "MNFST_ALLOWLIST");
724
+ const deny = pick(options.denylist, "MNFST_DENYLIST");
725
+ return { filter: { allow: allow.given ? allow.rules : null, deny: deny.rules }, invalid };
726
+ }
727
+ var covers = (rule, host, path) => (host === rule.host || host.endsWith("." + rule.host)) && (rule.path === null || path === rule.path || path.startsWith(rule.path + "/"));
728
+ function isExcluded(filter, url) {
729
+ let host;
730
+ let path;
731
+ try {
732
+ const parsed = new URL(url);
733
+ host = parsed.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
734
+ path = parsed.pathname || "/";
735
+ } catch {
736
+ return false;
737
+ }
738
+ if (filter.deny.some((r) => covers(r, host, path))) return true;
739
+ return filter.allow !== null && !filter.allow.some((r) => covers(r, host, path));
740
+ }
741
+
663
742
  // src/runtime.ts
664
743
  var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
665
744
  var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
@@ -671,7 +750,7 @@ var Runtime = class {
671
750
  this.original = original;
672
751
  this.api = api ?? new HealApi(original, options.key, options.url);
673
752
  this.tracker = new CallBuffer(
674
- (batch, signal) => this.api.sendRequests(batch, signal),
753
+ (batch, signal) => handled(() => this.api.sendRequests(batch, signal)),
675
754
  { immediate: isServerless() }
676
755
  );
677
756
  }
@@ -700,7 +779,16 @@ var Runtime = class {
700
779
  } catch {
701
780
  }
702
781
  }
703
- fetch = async (input, init) => {
782
+ /** A call kept out of Manifest by the allowlist or denylist: never healed, never tracked. */
783
+ excluded(url) {
784
+ return this.options.filter ? isExcluded(this.options.filter, url) : false;
785
+ }
786
+ // Marked as handled, so the undici channels do not count this call (or its
787
+ // retry) a second time.
788
+ fetch = (input, init) => handled(() => this.send(input, init));
789
+ async send(input, init) {
790
+ const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
791
+ if (this.excluded(target)) return this.original(input, init);
704
792
  const request = new Request(input, init);
705
793
  const extras = { ...init };
706
794
  delete extras.body;
@@ -715,7 +803,7 @@ var Runtime = class {
715
803
  return response;
716
804
  }
717
805
  return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
718
- };
806
+ }
719
807
  async handleResponse(request, response, body, responseTimeMs, extras = {}) {
720
808
  if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
721
809
  return this.repair(request, extras, response, body, responseTimeMs);
@@ -851,6 +939,7 @@ function wrapRequest(original, protocol, runtime) {
851
939
  const emit = request.emit.bind(request);
852
940
  request.emit = ((event, ...values) => {
853
941
  if (event !== "response") return emit(event, ...values);
942
+ if (excluded(runtime, request, protocol)) return emit(event, ...values);
854
943
  const response = values[0];
855
944
  if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
856
945
  runtime.track(
@@ -878,6 +967,13 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
878
967
  const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
879
968
  return incomingResponse(healed, clientRequest);
880
969
  }
970
+ function excluded(runtime, request, protocol) {
971
+ try {
972
+ return runtime.excluded(requestUrl(request, protocol).toString());
973
+ } catch {
974
+ return false;
975
+ }
976
+ }
881
977
  function requestUrl(request, protocol) {
882
978
  const authority = String(request.getHeader("host") ?? request.host);
883
979
  return new URL(request.path, `${protocol}//${authority}`);
@@ -1016,10 +1112,12 @@ function manifest(options = {}) {
1016
1112
  );
1017
1113
  }
1018
1114
  if (!url.pathname.endsWith("/")) url.pathname += "/";
1019
- const resolved = { ...options, key, url: url.toString() };
1115
+ const { filter, invalid } = resolveFilter(options, process.env);
1116
+ if (invalid.length > 0) warn(`Ignoring unreadable allowlist/denylist entries: ${invalid.join(", ")}`);
1117
+ const resolved = { ...options, key, url: url.toString(), filter };
1020
1118
  const existing = globals[STATE];
1021
1119
  if (existing) {
1022
- if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
1120
+ if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal || JSON.stringify(existing.options.filter) !== JSON.stringify(filter)) {
1023
1121
  warn(
1024
1122
  "Manifest is already configured; changing configuration requires a process restart"
1025
1123
  );
@@ -1029,6 +1127,7 @@ function manifest(options = {}) {
1029
1127
  const runtime = new Runtime(resolved, globalThis.fetch.bind(globalThis));
1030
1128
  globalThis.fetch = runtime.fetch;
1031
1129
  installHttp(runtime);
1130
+ installUndici(runtime);
1032
1131
  globals[STATE] = runtime;
1033
1132
  runtime.api.hello(`node-${process.versions.node}`);
1034
1133
  process.once("beforeExit", () => {
package/dist/index.d.cts CHANGED
@@ -2,6 +2,13 @@ interface ManifestOptions {
2
2
  key?: string;
3
3
  url?: string;
4
4
  onHeal?: (event: HealEvent) => void | Promise<void>;
5
+ /**
6
+ * Only these calls reach Manifest. Each entry is a domain (`stripe.com`, subdomains included)
7
+ * or a domain with a path (`stripe.com/v1/charges`). Default: `MNFST_ALLOWLIST`.
8
+ */
9
+ allowlist?: string[] | string;
10
+ /** These calls never reach Manifest; same entries as `allowlist`, and it wins. Default: `MNFST_DENYLIST`. */
11
+ denylist?: string[] | string;
5
12
  }
6
13
  interface HealEvent {
7
14
  url: string;
@@ -12,7 +19,7 @@ interface HealEvent {
12
19
  operations?: unknown[];
13
20
  }
14
21
 
15
- declare const VERSION = "7.2.1";
22
+ declare const VERSION = "7.4.0";
16
23
 
17
24
  /** Install once, before libraries capture their own reference to global fetch. */
18
25
  declare function manifest(options?: ManifestOptions): void;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,13 @@ interface ManifestOptions {
2
2
  key?: string;
3
3
  url?: string;
4
4
  onHeal?: (event: HealEvent) => void | Promise<void>;
5
+ /**
6
+ * Only these calls reach Manifest. Each entry is a domain (`stripe.com`, subdomains included)
7
+ * or a domain with a path (`stripe.com/v1/charges`). Default: `MNFST_ALLOWLIST`.
8
+ */
9
+ allowlist?: string[] | string;
10
+ /** These calls never reach Manifest; same entries as `allowlist`, and it wins. Default: `MNFST_DENYLIST`. */
11
+ denylist?: string[] | string;
5
12
  }
6
13
  interface HealEvent {
7
14
  url: string;
@@ -12,7 +19,7 @@ interface HealEvent {
12
19
  operations?: unknown[];
13
20
  }
14
21
 
15
- declare const VERSION = "7.2.1";
22
+ declare const VERSION = "7.4.0";
16
23
 
17
24
  /** Install once, before libraries capture their own reference to global fetch. */
18
25
  declare function manifest(options?: ManifestOptions): void;
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  manifest
3
- } from "./chunk-RO6CIYR2.js";
3
+ } from "./chunk-6RNG7PBO.js";
4
4
  import {
5
5
  VERSION
6
- } from "./chunk-OXOZQLZ3.js";
6
+ } from "./chunk-TX6KHYCJ.js";
7
7
  export {
8
8
  VERSION,
9
9
  manifest
package/dist/register.cjs CHANGED
@@ -348,7 +348,7 @@ function keepAlive(promise) {
348
348
  }
349
349
 
350
350
  // src/api.ts
351
- var VERSION = "7.2.1";
351
+ var VERSION = "7.4.0";
352
352
  var MAX_HEALS_IN_FLIGHT = 8;
353
353
  var warn = (message) => process.emitWarning(message, { code: "MNFST" });
354
354
  var HealApi = class {
@@ -647,6 +647,85 @@ var CallBuffer = class {
647
647
  }
648
648
  };
649
649
 
650
+ // src/undici.ts
651
+ var import_node_async_hooks = require("async_hooks");
652
+ var import_node_diagnostics_channel = require("diagnostics_channel");
653
+ var handling = new import_node_async_hooks.AsyncLocalStorage();
654
+ var handled = (work) => handling.run(true, work);
655
+ function installUndici(runtime) {
656
+ const manifestOrigin = new URL(runtime.options.url).origin;
657
+ const inFlight = /* @__PURE__ */ new WeakMap();
658
+ const onCreate = (message) => {
659
+ const { request } = message;
660
+ if (handling.getStore() || String(request.origin) === manifestOrigin) return;
661
+ inFlight.set(request, { startedAt: Date.now(), started: performance.now() });
662
+ };
663
+ const onHeaders = (message) => {
664
+ const { request, response } = message;
665
+ const started = inFlight.get(request);
666
+ if (!started) return;
667
+ inFlight.delete(request);
668
+ let url;
669
+ try {
670
+ url = new URL(request.path ?? "/", String(request.origin)).href;
671
+ } catch {
672
+ return;
673
+ }
674
+ if (runtime.excluded(url)) return;
675
+ runtime.track(
676
+ request.method ?? "GET",
677
+ () => url,
678
+ response.statusCode,
679
+ started.startedAt,
680
+ performance.now() - started.started
681
+ );
682
+ };
683
+ (0, import_node_diagnostics_channel.subscribe)("undici:request:create", onCreate);
684
+ (0, import_node_diagnostics_channel.subscribe)("undici:request:headers", onHeaders);
685
+ return () => {
686
+ (0, import_node_diagnostics_channel.unsubscribe)("undici:request:create", onCreate);
687
+ (0, import_node_diagnostics_channel.unsubscribe)("undici:request:headers", onHeaders);
688
+ };
689
+ }
690
+
691
+ // src/filter.ts
692
+ var HOST = /^(?:[a-z0-9_-]+(?:\.[a-z0-9_-]+)*|\[[0-9a-f:.]+\])$/;
693
+ function parseRule(entry) {
694
+ const rest = entry.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[?#]/)[0] ?? "";
695
+ const slash = rest.indexOf("/");
696
+ const authority = (slash < 0 ? rest : rest.slice(0, slash)).toLowerCase();
697
+ const path = slash < 0 ? "" : rest.slice(slash).replace(/\/+$/, "");
698
+ const host = authority.replace(/^\*\./, "").replace(/:\d+$/, "").replace(/^\.+|\.+$/g, "");
699
+ if (!HOST.test(host) || path.includes("*")) return null;
700
+ return { host: host.replace(/^\[|\]$/g, ""), path: path || null };
701
+ }
702
+ var entries = (value) => (typeof value === "string" ? value.split(",") : value ?? []).map((e) => String(e).trim()).filter(Boolean);
703
+ function resolveFilter(options, env) {
704
+ const invalid = [];
705
+ const pick = (option, name) => {
706
+ const given = entries(option).length > 0 ? entries(option) : entries(env[name]);
707
+ const rules = given.map((e) => parseRule(e) ?? (invalid.push(e), null)).filter((r) => r !== null);
708
+ return { given: given.length > 0, rules };
709
+ };
710
+ const allow = pick(options.allowlist, "MNFST_ALLOWLIST");
711
+ const deny = pick(options.denylist, "MNFST_DENYLIST");
712
+ return { filter: { allow: allow.given ? allow.rules : null, deny: deny.rules }, invalid };
713
+ }
714
+ var covers = (rule, host, path) => (host === rule.host || host.endsWith("." + rule.host)) && (rule.path === null || path === rule.path || path.startsWith(rule.path + "/"));
715
+ function isExcluded(filter, url) {
716
+ let host;
717
+ let path;
718
+ try {
719
+ const parsed = new URL(url);
720
+ host = parsed.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
721
+ path = parsed.pathname || "/";
722
+ } catch {
723
+ return false;
724
+ }
725
+ if (filter.deny.some((r) => covers(r, host, path))) return true;
726
+ return filter.allow !== null && !filter.allow.some((r) => covers(r, host, path));
727
+ }
728
+
650
729
  // src/runtime.ts
651
730
  var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
652
731
  var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
@@ -658,7 +737,7 @@ var Runtime = class {
658
737
  this.original = original;
659
738
  this.api = api ?? new HealApi(original, options.key, options.url);
660
739
  this.tracker = new CallBuffer(
661
- (batch, signal) => this.api.sendRequests(batch, signal),
740
+ (batch, signal) => handled(() => this.api.sendRequests(batch, signal)),
662
741
  { immediate: isServerless() }
663
742
  );
664
743
  }
@@ -687,7 +766,16 @@ var Runtime = class {
687
766
  } catch {
688
767
  }
689
768
  }
690
- fetch = async (input, init) => {
769
+ /** A call kept out of Manifest by the allowlist or denylist: never healed, never tracked. */
770
+ excluded(url) {
771
+ return this.options.filter ? isExcluded(this.options.filter, url) : false;
772
+ }
773
+ // Marked as handled, so the undici channels do not count this call (or its
774
+ // retry) a second time.
775
+ fetch = (input, init) => handled(() => this.send(input, init));
776
+ async send(input, init) {
777
+ const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
778
+ if (this.excluded(target)) return this.original(input, init);
691
779
  const request = new Request(input, init);
692
780
  const extras = { ...init };
693
781
  delete extras.body;
@@ -702,7 +790,7 @@ var Runtime = class {
702
790
  return response;
703
791
  }
704
792
  return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
705
- };
793
+ }
706
794
  async handleResponse(request, response, body, responseTimeMs, extras = {}) {
707
795
  if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
708
796
  return this.repair(request, extras, response, body, responseTimeMs);
@@ -838,6 +926,7 @@ function wrapRequest(original, protocol, runtime) {
838
926
  const emit = request.emit.bind(request);
839
927
  request.emit = ((event, ...values) => {
840
928
  if (event !== "response") return emit(event, ...values);
929
+ if (excluded(runtime, request, protocol)) return emit(event, ...values);
841
930
  const response = values[0];
842
931
  if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
843
932
  runtime.track(
@@ -865,6 +954,13 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
865
954
  const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
866
955
  return incomingResponse(healed, clientRequest);
867
956
  }
957
+ function excluded(runtime, request, protocol) {
958
+ try {
959
+ return runtime.excluded(requestUrl(request, protocol).toString());
960
+ } catch {
961
+ return false;
962
+ }
963
+ }
868
964
  function requestUrl(request, protocol) {
869
965
  const authority = String(request.getHeader("host") ?? request.host);
870
966
  return new URL(request.path, `${protocol}//${authority}`);
@@ -1003,10 +1099,12 @@ function manifest(options = {}) {
1003
1099
  );
1004
1100
  }
1005
1101
  if (!url.pathname.endsWith("/")) url.pathname += "/";
1006
- const resolved = { ...options, key, url: url.toString() };
1102
+ const { filter, invalid } = resolveFilter(options, process.env);
1103
+ if (invalid.length > 0) warn(`Ignoring unreadable allowlist/denylist entries: ${invalid.join(", ")}`);
1104
+ const resolved = { ...options, key, url: url.toString(), filter };
1007
1105
  const existing = globals[STATE];
1008
1106
  if (existing) {
1009
- if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
1107
+ if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal || JSON.stringify(existing.options.filter) !== JSON.stringify(filter)) {
1010
1108
  warn(
1011
1109
  "Manifest is already configured; changing configuration requires a process restart"
1012
1110
  );
@@ -1016,6 +1114,7 @@ function manifest(options = {}) {
1016
1114
  const runtime = new Runtime(resolved, globalThis.fetch.bind(globalThis));
1017
1115
  globalThis.fetch = runtime.fetch;
1018
1116
  installHttp(runtime);
1117
+ installUndici(runtime);
1019
1118
  globals[STATE] = runtime;
1020
1119
  runtime.api.hello(`node-${process.versions.node}`);
1021
1120
  process.once("beforeExit", () => {
package/dist/register.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  manifest
3
- } from "./chunk-RO6CIYR2.js";
4
- import "./chunk-OXOZQLZ3.js";
3
+ } from "./chunk-6RNG7PBO.js";
4
+ import "./chunk-TX6KHYCJ.js";
5
5
 
6
6
  // src/register.ts
7
7
  manifest();
package/docs/guide.md CHANGED
@@ -6,13 +6,15 @@
6
6
 
7
7
  Call `manifest()` once at startup, before other libraries save a reference to `fetch`, `node:http` or `node:https`. Where a client is built at import time and would capture the original `fetch` first, [preload the register entry](#preloading) instead.
8
8
 
9
- It reads `MNFST_KEY` and `MNFST_URL` and takes no options.
9
+ It reads `MNFST_KEY`, `MNFST_URL`, `MNFST_ALLOWLIST` and `MNFST_DENYLIST`; every option is optional.
10
10
 
11
11
  | Option | Environment | Default |
12
12
  | --- | --- | --- |
13
13
  | `key` | `MNFST_KEY` | Disabled with a warning if missing |
14
14
  | `url` | `MNFST_URL` | `https://api.manifest.build` |
15
15
  | `onHeal` | — | Optional local callback |
16
+ | `allowlist` | `MNFST_ALLOWLIST` | Every call eligible ([entries](../README.md#choosing-which-calls-reach-manifest)) |
17
+ | `denylist` | `MNFST_DENYLIST` | None excluded |
16
18
 
17
19
  Explicit options take precedence. Reconfiguration requires a restart. ESM and CommonJS imports share one process-wide installation; CommonJS uses `const { manifest } = require('manifest')`.
18
20
 
@@ -75,6 +77,8 @@ Run the doctor from the project directory. It checks the install without guessin
75
77
  npx manifest doctor
76
78
  ```
77
79
 
80
+ It reads `MNFST_KEY` and `MNFST_URL` from the environment first, then from the project's `.env.local` or `.env` (the first file that sets a variable wins) — the files Next.js and `dotenv` keep them in.
81
+
78
82
  ```
79
83
  ✅ SDK installed manifest 7.0.0
80
84
  ✅ MNFST_KEY set mnfst_proj_…DZDw
@@ -84,7 +88,7 @@ npx manifest doctor
84
88
  ⚠️ Requests received no requests received yet
85
89
 
86
90
  Runtime coverage
87
- Node.js runtime fetch, http.request, https.request, http.get are patched
91
+ Node.js runtime fetch, http.request, https.request, http.get are patched; undici is tracked
88
92
  Edge runtime not supported — middleware.ts and Edge route handlers are never covered
89
93
  ```
90
94
 
@@ -106,8 +110,9 @@ You can also verify by hand: send a JSON request that your test API rejects with
106
110
  - One retry per capture. Same-origin URL and header repairs are supported by the SDK; the current app returns structured body repairs.
107
111
  - Successful calls and successful retries remain streamed. Failed responses retain their bytes, status, headers, URL and redirect metadata.
108
112
  - Every call that is not healed, whatever its status, is tracked as metadata only and sent in batches, off the request path (see "Data sent to Manifest").
113
+ - Calls made through the `undici` package directly (its `fetch` or `request`, as `@vercel/blob` does) and through a `fetch` reference saved before initialization are **tracked, not healed**: the SDK observes them on undici's diagnostics channels, which can see a response but not replace it.
109
114
 
110
- **Not covered:** browser JavaScript, HTTP/2, directly imported `undici.fetch`, and `fetch` references saved before initialization (see `manifest/register` above). Those transports need separate integration. This SDK does not claim to intercept every Node HTTP client.
115
+ **Not covered:** browser JavaScript and HTTP/2. Those transports need separate integration. This SDK does not claim to intercept every Node HTTP client.
111
116
 
112
117
  ## Limits and failure behavior
113
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "manifest",
3
- "version": "7.2.1",
3
+ "version": "7.4.0",
4
4
  "description": "Repair eligible failed API calls made by Node HTTP clients.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -57,7 +57,8 @@
57
57
  "axios": "^1.7.0",
58
58
  "tsup": "^8.5.0",
59
59
  "tsx": "^4.20.0",
60
- "typescript": "^5.9.0"
60
+ "typescript": "^5.9.0",
61
+ "undici": "^5.29.0"
61
62
  },
62
63
  "repository": {
63
64
  "type": "git",