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 +14 -0
- package/README.md +62 -34
- package/dist/bin.cjs +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-AR23I2QK.js → chunk-OXOZQLZ3.js} +83 -2
- package/dist/{chunk-4OVLT3PO.js → chunk-RO6CIYR2.js} +168 -5
- package/dist/index.cjs +242 -6
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/register.cjs +242 -6
- package/dist/register.js +2 -2
- package/docs/guide.md +4 -1
- package/docs/sdk-flow-diagram.png +0 -0
- package/package.json +4 -2
- package/docs/github-sdk.png +0 -0
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
|
-

|
|
4
4
|
|
|
5
5
|
# Manifest for Node.js
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**The API resilience layer for your Node.js apps.**
|
|
8
8
|
|
|
9
9
|
[](https://github.com/mnfst/manifest-node/actions/workflows/ci.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/manifest)
|
|
@@ -16,15 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
## What is Manifest
|
|
18
18
|
|
|
19
|
-
Manifest is
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-

|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
import { manifest } from 'manifest';
|
|
51
|
+
2. Install the SDK:
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
```
|
|
53
|
+
```sh
|
|
54
|
+
npm install manifest
|
|
55
|
+
```
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
3. Call `manifest()` from the first import of the file that starts your app, before any client is constructed:
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
```js
|
|
60
|
+
import { manifest } from 'manifest';
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
manifest(); // Once, at startup.
|
|
63
|
+
// Keep making your API calls as usual.
|
|
64
|
+
```
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
TypeScript, ESM and CommonJS. Zero dependencies.
|
|
67
|
+
|
|
68
|
+
4. Set your key in the environment of your app:
|
|
68
69
|
|
|
69
|
-
|
|
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
|
-
|
|
88
|
+
### n8n
|
|
76
89
|
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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 }), //
|
|
129
|
+
body: JSON.stringify({ limit: 500 }), // rejected by the API
|
|
102
130
|
});
|
|
103
|
-
console.log(res.status);
|
|
131
|
+
console.log(res.status);
|
|
104
132
|
```
|
|
105
133
|
|
|
106
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|
|
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 {
|