manifest 7.1.0 → 7.2.1

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
@@ -60,3 +60,17 @@ Each captured failure permits one retry. A retry response, including another fai
60
60
  HTTP status must be 200–599. Transport failures use a fixed message without exception prose. HTTP status zero is not a wire status. Transport failures and unattempted retries are inconclusive evidence; neither can verify or invalidate a patch. The server determines the verdict from the raw evidence, with the first accepted report winning.
61
61
 
62
62
  Reports are best effort, bounded, and observable through Node warnings with code `MNFST`. The SDK sends the failed retry's raw body so the app can distinguish recurrence from a newly revealed issue. It does not assert `succeeded` or `failed` itself.
63
+
64
+ ## Tracked requests
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`:
67
+
68
+ ```json
69
+ {"requests":[{"traceId":"a-uuid","method":"POST","url":"https://example.com/orders","statusCode":200,"responseTimeMs":84,"occurredAt":"2026-09-23T10:14:07.512Z"}]}
70
+ ```
71
+
72
+ Metadata only. The URL carries scheme, host, port and path: no query string, userinfo or fragment. No headers and no request or response body are sent, and a response body is never read to record a call. A call sent to `/v1/heal` is not also tracked.
73
+
74
+ Recording is an in-memory append and never delays or fails the caller's request. A batch is sent when 500 calls are queued or every five seconds, at most once per second, one send at a time, up to 500 calls per request, with a five-second deadline. At most 5,000 calls are buffered; newer calls are dropped past that. A network error, timeout, 429 or 5xx is retried once, then the batch is dropped; any other answer, including 404 from a server without the route, drops it. HTTP 403 with `{"error":"project_disabled"}` suspends sending for five minutes, as for healing. Methods are upper-cased; a record whose method exceeds 16 characters or whose URL exceeds 4,096 is not sent. Buffered calls are sent once when the event loop drains (`beforeExit`), for at most two seconds: a send still in flight then is aborted and the rest is dropped, so a script never hangs on an unreachable server. A process killed first can lose them.
75
+
76
+ On a serverless platform (`VERCEL`, `AWS_LAMBDA_FUNCTION_NAME` or `K_SERVICE` set), the function freezes as soon as it responds, so neither the interval nor the exit flush would run. There, each call is sent as soon as it is recorded, still at most once per second and one send at a time, and the send and every outcome report are handed to the platform's `waitUntil` (Vercel's request context, or Next.js's own), so the function stays alive until they are delivered. Where no `waitUntil` exists, the send still starts during the invocation.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <div align="center">
2
2
 
3
- ![Manifest SDK Architecture](./docs/github-sdk.png)
3
+ ![Manifest SDK Architecture](https://raw.githubusercontent.com/mnfst/manifest-node/main/docs/github-sdk.png)
4
4
 
5
5
  # Manifest for Node.js
6
6
 
7
- **Turn 🔴 4xx API errors into 🟢 2xx in real time.**
7
+ **The API resilience layer for your Node.js apps.**
8
8
 
9
9
  [![CI](https://github.com/mnfst/manifest-node/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mnfst/manifest-node/actions/workflows/ci.yml)
10
10
  [![npm version](https://img.shields.io/npm/v/manifest?label=npm)](https://www.npmjs.com/package/manifest)
@@ -16,15 +16,17 @@
16
16
 
17
17
  ## What is Manifest
18
18
 
19
- Manifest is a self-healing layer that fixes and retries failed API requests on the fly.
19
+ Manifest is the API resilience layer for your apps and agents. It works with every API they call: external services, your internal APIs and MCP tools.
20
20
 
21
- * 🎯 **Fix failures automatically** before they impact your users.
22
- * 🔔 **Get notified of root causes** so you can fix them permanently.
23
- * 🔌 **Works across your stack** with internal APIs, external services, and agent tools.
21
+ * 🗺️ **See every API your app depends on**, and how reliable each one is.
22
+ * 🎯 **Repair failed API requests on the fly**, so your app keeps working.
23
+ * 🛠️ **Know what to fix in your code**, with a prompt for your coding agent.
24
24
 
25
25
  ## How it works
26
26
 
27
- ![How Manifest heals a failed request: a 400 reaches Manifest, drops to a patch from the knowledge base or the healing agents, and is retried once, returning a 200 OK](./docs/sdk-flow-diagram.png)
27
+ ![How the SDK works: every call Manifest does not heal is recorded in the background as metadata (method, URL, status, timing, no body); a failure Manifest can heal is sent with its error, patched, and retried once, returning a 200 OK](./docs/sdk-flow-diagram.png)
28
+
29
+ Every call your app makes is reported to Manifest as metadata only (method, URL without its query string, status and timing), in the background. A failure Manifest can heal is sent in full, so it can be repaired. [What is sent](docs/guide.md#data-sent-to-manifest).
28
30
 
29
31
  ## Prerequisites
30
32
 
@@ -44,47 +46,73 @@ The prompt adds the one-line install to your entry file and stops to let you pas
44
46
 
45
47
  ### Start with code
46
48
 
47
- ```sh
48
- npm install manifest
49
- ```
49
+ 1. Create a project in your [Manifest dashboard](https://dashboard.manifest.build) and copy its project key.
50
50
 
51
- ```js
52
- import { manifest } from 'manifest';
51
+ 2. Install the SDK:
53
52
 
54
- manifest(); // Once, at startup.
55
- // Keep making your API calls as usual.
56
- ```
53
+ ```sh
54
+ npm install manifest
55
+ ```
57
56
 
58
- TypeScript, ESM and CommonJS. Zero dependencies.
57
+ 3. Call `manifest()` from the first import of the file that starts your app, before any client is constructed:
59
58
 
60
- ## Setup
59
+ ```js
60
+ import { manifest } from 'manifest';
61
61
 
62
- 1. Create a project in your [Manifest dashboard](https://dashboard.manifest.build) and copy its project key.
63
- 2. Set the key as an environment variable:
62
+ manifest(); // Once, at startup.
63
+ // Keep making your API calls as usual.
64
+ ```
64
65
 
65
- ```sh
66
- export MNFST_KEY='your-project-key'
67
- ```
66
+ TypeScript, ESM and CommonJS. Zero dependencies.
67
+
68
+ 4. Set your key in the environment of your app:
68
69
 
69
- Call `manifest()` from the first import of the file that starts your app, before any client is constructed. 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:
70
+ ```sh
71
+ export MNFST_KEY='your-project-key'
72
+ ```
73
+
74
+ 5. Restart your app, then check the install from your project directory:
75
+
76
+ ```sh
77
+ npx manifest doctor
78
+ ```
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.
81
+
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:
70
83
 
71
84
  ```sh
72
85
  node -r manifest/register app.js
73
86
  ```
74
87
 
75
- Self-healing is enabled by default in your project settings.
88
+ ### n8n
76
89
 
77
- Verify the install from your project directory:
90
+ Manifest runs in a self-hosted n8n with Docker Compose, through this SDK. n8n Cloud cannot load it. Run the steps in this order:
78
91
 
79
- ```sh
80
- npx manifest doctor
81
- ```
92
+ 1. Install the SDK from the folder of your `docker-compose.yml`:
93
+
94
+ ```sh
95
+ docker compose exec n8n npm install --prefix /home/node/.n8n/manifest manifest
96
+ ```
97
+
98
+ 2. Add these two lines to the `environment` of your n8n service, and of every worker service in queue mode:
99
+
100
+ ```yaml
101
+ MNFST_KEY: your-project-key
102
+ NODE_OPTIONS: --require /home/node/.n8n/manifest/node_modules/manifest/dist/register.cjs
103
+ ```
104
+
105
+ 3. Restart n8n:
106
+
107
+ ```sh
108
+ docker compose up -d
109
+ ```
82
110
 
83
- It resolves the installed SDK version, masks and validates the key against the handshake endpoint, checks that Manifest loads before your app, and prints the runtime coverage.
111
+ n8n does not start when `NODE_OPTIONS` names a file that is not installed yet, so keep this order.
84
112
 
85
113
  ## Try it
86
114
 
87
- Send a request that would normally fail. Manifest catches it, repairs it, and retries:
115
+ Send a request that fails with a 4xx error, such as a value the API rejects:
88
116
 
89
117
  ```js
90
118
  import { manifest } from 'manifest';
@@ -98,13 +126,13 @@ manifest({
98
126
  const res = await fetch('https://api.example.com/orders', {
99
127
  method: 'POST',
100
128
  headers: { 'content-type': 'application/json' },
101
- body: JSON.stringify({ limit: 500 }), // Invalid? Manifest fixes it and retries.
129
+ body: JSON.stringify({ limit: 500 }), // rejected by the API
102
130
  });
103
- console.log(res.status); // See the 200 OK response.
131
+ console.log(res.status);
104
132
  ```
105
133
 
106
- Check your [Manifest dashboard](https://dashboard.manifest.build) to see all repairs and insights.
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.
107
135
 
108
136
  ## More
109
137
 
110
- [Configuration, limits & development](docs/guide.md) · [API contract](CONTRACT.md) · [Python SDK](https://github.com/mnfst/manifest-python) · [Website](https://manifest.build)
138
+ [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
@@ -33,7 +33,7 @@ var import_node_path = __toESM(require("path"), 1);
33
33
  var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
34
34
 
35
35
  // src/api.ts
36
- var VERSION = "7.1.0";
36
+ var VERSION = "7.2.1";
37
37
 
38
38
  // src/cli.ts
39
39
  var DEFAULT_URL = "https://api.manifest.build";
package/dist/bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  VERSION,
4
4
  isObject
5
- } from "./chunk-AR23I2QK.js";
5
+ } from "./chunk-OXOZQLZ3.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { createRequire } from "module";
@@ -37,6 +37,19 @@ function safeUrl(raw) {
37
37
  url.search = new URLSearchParams(pairs).toString();
38
38
  return url.toString();
39
39
  }
40
+ function trackedUrl(raw) {
41
+ try {
42
+ const url = new URL(raw);
43
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
44
+ url.username = "";
45
+ url.password = "";
46
+ url.search = "";
47
+ url.hash = "";
48
+ return url.toString();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
40
53
  function safeHeaders(headers) {
41
54
  return Object.fromEntries([...headers].map(([key, value]) => [
42
55
  key,
@@ -280,8 +293,29 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
280
293
  return { response, body: errorBody(captured.bytes), truncated: !captured.complete || Buffer.byteLength(Buffer.from(captured.bytes).toString("utf8")) > limit, complete: captured.complete };
281
294
  }
282
295
 
296
+ // src/serverless.ts
297
+ var CONTEXTS = [/* @__PURE__ */ Symbol.for("@vercel/request-context"), /* @__PURE__ */ Symbol.for("@next/request-context")];
298
+ function isServerless(env = process.env) {
299
+ return Boolean(env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.K_SERVICE);
300
+ }
301
+ function keepAlive(promise) {
302
+ try {
303
+ const globals = globalThis;
304
+ for (const symbol of CONTEXTS) {
305
+ const waitUntil = globals[symbol]?.get?.()?.waitUntil;
306
+ if (typeof waitUntil === "function") {
307
+ waitUntil(promise.catch(() => {
308
+ }));
309
+ return;
310
+ }
311
+ }
312
+ } catch {
313
+ }
314
+ }
315
+
283
316
  // src/api.ts
284
- var VERSION = "7.1.0";
317
+ var VERSION = "7.2.1";
318
+ var MAX_HEALS_IN_FLIGHT = 8;
285
319
  var warn = (message) => process.emitWarning(message, { code: "MNFST" });
286
320
  var HealApi = class {
287
321
  constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
@@ -303,6 +337,14 @@ var HealApi = class {
303
337
  enabled() {
304
338
  return performance.now() >= this.disabledUntil;
305
339
  }
340
+ /**
341
+ * Whether a heal call would be sent right now: the project is not disabled
342
+ * and a heal slot is free. A healable failure that cannot be sent is tracked
343
+ * instead, so it is never lost from both ledgers.
344
+ */
345
+ canHeal() {
346
+ return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
347
+ }
306
348
  headers() {
307
349
  return {
308
350
  authorization: `Bearer ${this.key}`,
@@ -311,7 +353,7 @@ var HealApi = class {
311
353
  };
312
354
  }
313
355
  async heal(capture, signal) {
314
- if (!this.enabled() || this.inFlight >= 8) return null;
356
+ if (!this.canHeal()) return null;
315
357
  this.inFlight++;
316
358
  const controller = new AbortController();
317
359
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -386,6 +428,41 @@ var HealApi = class {
386
428
  }).catch(() => {
387
429
  }).finally(() => clearTimeout(timer));
388
430
  }
431
+ /**
432
+ * Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
433
+ * retry could help (network error, timeout, 429, 5xx), so the buffer retries
434
+ * once; any other answer, including 404 from a backend that predates the
435
+ * route, drops the batch quietly. Uses `rawFetch`, so the send is never
436
+ * tracked or healed by our own patch.
437
+ */
438
+ async sendRequests(calls, signal) {
439
+ if (!this.enabled() || calls.length === 0) return;
440
+ const controller = new AbortController();
441
+ const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
442
+ try {
443
+ const response = await this.rawFetch(new URL("v1/requests", this.url), {
444
+ method: "POST",
445
+ headers: this.headers(),
446
+ body: JSON.stringify({ requests: calls }),
447
+ signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
448
+ redirect: "error"
449
+ });
450
+ if (response.status === 403) {
451
+ const body = await response.json().catch(() => null);
452
+ if (isObject(body) && body.error === "project_disabled") {
453
+ this.disabledUntil = performance.now() + 3e5;
454
+ }
455
+ return;
456
+ }
457
+ void response.body?.cancel().catch(() => {
458
+ });
459
+ if (response.status === 429 || response.status >= 500) {
460
+ throw new Error(`tracked calls refused (${response.status})`);
461
+ }
462
+ } finally {
463
+ clearTimeout(timer);
464
+ }
465
+ }
389
466
  report(id, outcome) {
390
467
  if (!id) return;
391
468
  if (this.pending.size >= 64) {
@@ -395,6 +472,7 @@ var HealApi = class {
395
472
  const promise = this.send(id, outcome);
396
473
  this.pending.add(promise);
397
474
  void promise.then(() => this.pending.delete(promise));
475
+ keepAlive(promise);
398
476
  }
399
477
  async send(id, outcome) {
400
478
  const controller = new AbortController();
@@ -425,6 +503,7 @@ var HealApi = class {
425
503
  export {
426
504
  isObject,
427
505
  safeUrl,
506
+ trackedUrl,
428
507
  safeHeaders,
429
508
  travelingBody,
430
509
  mergeBody,
@@ -434,6 +513,8 @@ export {
434
513
  REQUEST_LIMIT,
435
514
  captureRequest,
436
515
  captureResponse,
516
+ isServerless,
517
+ keepAlive,
437
518
  VERSION,
438
519
  warn,
439
520
  HealApi
@@ -5,14 +5,17 @@ import {
5
5
  captureRequest,
6
6
  captureResponse,
7
7
  isObject,
8
+ isServerless,
9
+ keepAlive,
8
10
  mergeBody,
9
11
  parseRequestBody,
10
12
  safeHeaders,
11
13
  safeUrl,
12
14
  serializeRequestBody,
15
+ trackedUrl,
13
16
  travelingBody,
14
17
  warn
15
- } from "./chunk-AR23I2QK.js";
18
+ } from "./chunk-OXOZQLZ3.js";
16
19
 
17
20
  // src/http.ts
18
21
  import http from "http";
@@ -23,6 +26,121 @@ import { createBrotliDecompress, createUnzip } from "zlib";
23
26
 
24
27
  // src/runtime.ts
25
28
  import { randomUUID } from "crypto";
29
+
30
+ // src/tracking.ts
31
+ var MAX_BUFFER = 5e3;
32
+ var MAX_BATCH = 500;
33
+ var CallBuffer = class {
34
+ constructor(send, options = {}) {
35
+ this.send = send;
36
+ this.flushAt = options.flushAt ?? 500;
37
+ this.minGapMs = options.minGapMs ?? 1e3;
38
+ this.immediate = options.immediate ?? false;
39
+ this.keepAlive = options.keepAlive ?? keepAlive;
40
+ this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
41
+ this.timer.unref();
42
+ }
43
+ send;
44
+ queue = [];
45
+ inFlight = null;
46
+ lastSentAt = -Infinity;
47
+ /** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
48
+ deadline = Infinity;
49
+ controller = new AbortController();
50
+ timer;
51
+ flushAt;
52
+ minGapMs;
53
+ immediate;
54
+ keepAlive;
55
+ draining = null;
56
+ record(call) {
57
+ if (this.queue.length >= MAX_BUFFER) return;
58
+ this.queue.push(call);
59
+ if (this.immediate) this.drain();
60
+ else if (this.queue.length >= this.flushAt) void this.kick();
61
+ }
62
+ size() {
63
+ return this.queue.length;
64
+ }
65
+ /** Resolves when the send in flight, if any, has settled. */
66
+ async idle() {
67
+ await this.inFlight;
68
+ }
69
+ /**
70
+ * Send everything buffered now, one batch at a time through the same
71
+ * in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
72
+ * up at the deadline: the send in flight is aborted, nothing is retried, and
73
+ * what is left is dropped, so a script never hangs on an unreachable server.
74
+ */
75
+ async flush(deadlineMs = Infinity) {
76
+ this.deadline = performance.now() + deadlineMs;
77
+ const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
78
+ try {
79
+ while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
80
+ await (this.inFlight ?? this.start());
81
+ }
82
+ } finally {
83
+ if (abortAt) clearTimeout(abortAt);
84
+ if (this.controller.signal.aborted) this.controller = new AbortController();
85
+ this.deadline = Infinity;
86
+ }
87
+ }
88
+ stop() {
89
+ clearInterval(this.timer);
90
+ }
91
+ /**
92
+ * Serverless: the function may freeze as soon as its response is sent, before
93
+ * any timer fires. So send now, through `flush()` (same gap, same single send
94
+ * in flight), and let the platform wait for it. Calls recorded meanwhile ride
95
+ * the same drain; one that lands as it settles starts the next.
96
+ */
97
+ drain() {
98
+ if (this.draining) return;
99
+ const run = this.flush().finally(() => {
100
+ this.draining = null;
101
+ if (this.queue.length > 0) this.drain();
102
+ });
103
+ this.draining = run;
104
+ this.keepAlive(run);
105
+ }
106
+ kick() {
107
+ if (this.inFlight || this.queue.length === 0) return;
108
+ if (performance.now() - this.lastSentAt < this.minGapMs) return;
109
+ void this.start();
110
+ }
111
+ start() {
112
+ this.inFlight = this.sendOne().finally(() => {
113
+ this.inFlight = null;
114
+ });
115
+ return this.inFlight;
116
+ }
117
+ async sendOne() {
118
+ const batch = this.queue.splice(0, MAX_BATCH);
119
+ for (let attempt = 0; attempt < 2; attempt++) {
120
+ if (attempt > 0 && performance.now() >= this.deadline) return;
121
+ await this.waitForGap();
122
+ this.lastSentAt = performance.now();
123
+ try {
124
+ await this.send(batch, this.controller.signal);
125
+ return;
126
+ } catch {
127
+ }
128
+ if (this.controller.signal.aborted) {
129
+ this.controller = new AbortController();
130
+ return;
131
+ }
132
+ }
133
+ }
134
+ // Referenced on purpose: this wait only happens while a send or an exit
135
+ // flush is under way, and an unref'd wait would let the process exit with
136
+ // the batch unsent. It never outlasts the flush deadline.
137
+ async waitForGap() {
138
+ const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
139
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
140
+ }
141
+ };
142
+
143
+ // src/runtime.ts
26
144
  var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
27
145
  var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
28
146
  var neverBodied = (method) => method === "GET" || method === "HEAD";
@@ -32,20 +150,50 @@ var Runtime = class {
32
150
  this.options = options;
33
151
  this.original = original;
34
152
  this.api = api ?? new HealApi(original, options.key, options.url);
153
+ this.tracker = new CallBuffer(
154
+ (batch, signal) => this.api.sendRequests(batch, signal),
155
+ { immediate: isServerless() }
156
+ );
35
157
  }
36
158
  options;
37
159
  original;
38
160
  api;
161
+ /** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
162
+ tracker;
163
+ /**
164
+ * Record a call that is not being healed. An in-memory append: never awaited,
165
+ * never throws, reads nothing of the response.
166
+ */
167
+ track(method, url, statusCode, startedAt, responseTimeMs) {
168
+ try {
169
+ const reported = trackedUrl(url());
170
+ const verb = method.toUpperCase();
171
+ if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
172
+ this.tracker.record({
173
+ traceId: randomUUID(),
174
+ method: verb,
175
+ url: reported,
176
+ statusCode,
177
+ responseTimeMs: Math.round(responseTimeMs),
178
+ occurredAt: new Date(startedAt).toISOString()
179
+ });
180
+ } catch {
181
+ }
182
+ }
39
183
  fetch = async (input, init) => {
40
184
  const request = new Request(input, init);
41
185
  const extras = { ...init };
42
186
  delete extras.body;
43
187
  delete extras.headers;
44
188
  const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
189
+ const startedAt = Date.now();
45
190
  const started = performance.now();
46
191
  const response = await this.original(request, extras);
47
192
  const responseTimeMs = performance.now() - started;
48
- if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
193
+ if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
194
+ this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
195
+ return response;
196
+ }
49
197
  return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
50
198
  };
51
199
  async handleResponse(request, response, body, responseTimeMs, extras = {}) {
@@ -175,6 +323,7 @@ function wrapRequest(original, protocol, runtime) {
175
323
  return ((...received) => {
176
324
  const args = [...received];
177
325
  const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
326
+ const startedAt = Date.now();
178
327
  const started = performance.now();
179
328
  const request = original(...args);
180
329
  const capture = captureBody(request);
@@ -183,7 +332,14 @@ function wrapRequest(original, protocol, runtime) {
183
332
  request.emit = ((event, ...values) => {
184
333
  if (event !== "response") return emit(event, ...values);
185
334
  const response = values[0];
186
- if (!eligible(response.statusCode ?? 0) || !runtime.api.enabled()) {
335
+ if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
336
+ runtime.track(
337
+ request.method,
338
+ () => requestUrl(request, protocol).toString(),
339
+ response.statusCode ?? 0,
340
+ startedAt,
341
+ performance.now() - started
342
+ );
187
343
  return emit(event, ...values);
188
344
  }
189
345
  void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
@@ -202,6 +358,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
202
358
  const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
203
359
  return incomingResponse(healed, clientRequest);
204
360
  }
361
+ function requestUrl(request, protocol) {
362
+ const authority = String(request.getHeader("host") ?? request.host);
363
+ return new URL(request.path, `${protocol}//${authority}`);
364
+ }
205
365
  function webRequest(request, protocol, captured, signal) {
206
366
  const headers = new Headers();
207
367
  for (const name of request.getHeaderNames()) {
@@ -210,8 +370,7 @@ function webRequest(request, protocol, captured, signal) {
210
370
  if (item !== void 0) headers.append(name, String(item));
211
371
  }
212
372
  }
213
- const authority = String(request.getHeader("host") ?? request.host);
214
- const url = new URL(request.path, `${protocol}//${authority}`);
373
+ const url = requestUrl(request, protocol);
215
374
  const method = request.method;
216
375
  return new Request(url, {
217
376
  method,
@@ -320,6 +479,7 @@ function cancellation(request, external) {
320
479
 
321
480
  // src/index.ts
322
481
  var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
482
+ var EXIT_FLUSH_MS = 2e3;
323
483
  var globals = globalThis;
324
484
  function manifest(options = {}) {
325
485
  const key = options.key || process.env.MNFST_KEY;
@@ -351,6 +511,9 @@ function manifest(options = {}) {
351
511
  installHttp(runtime);
352
512
  globals[STATE] = runtime;
353
513
  runtime.api.hello(`node-${process.versions.node}`);
514
+ process.once("beforeExit", () => {
515
+ if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
516
+ });
354
517
  }
355
518
 
356
519
  export {