manifest 7.1.0 → 7.2.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 +12 -0
- package/README.md +4 -2
- package/dist/bin.cjs +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-4OVLT3PO.js → chunk-GC5H22R2.js} +142 -5
- package/dist/{chunk-AR23I2QK.js → chunk-NHEF7GUP.js} +60 -2
- package/dist/index.cjs +197 -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 +197 -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,15 @@ 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 or frozen first (serverless) can lose them.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
# Manifest for Node.js
|
|
6
6
|
|
|
@@ -24,7 +24,9 @@ Manifest is a self-healing layer that fixes and retries failed API requests on t
|
|
|
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
|
|
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.
|
|
36
|
+
var VERSION = "7.2.0";
|
|
37
37
|
|
|
38
38
|
// src/cli.ts
|
|
39
39
|
var DEFAULT_URL = "https://api.manifest.build";
|
package/dist/bin.js
CHANGED
|
@@ -10,9 +10,10 @@ import {
|
|
|
10
10
|
safeHeaders,
|
|
11
11
|
safeUrl,
|
|
12
12
|
serializeRequestBody,
|
|
13
|
+
trackedUrl,
|
|
13
14
|
travelingBody,
|
|
14
15
|
warn
|
|
15
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-NHEF7GUP.js";
|
|
16
17
|
|
|
17
18
|
// src/http.ts
|
|
18
19
|
import http from "http";
|
|
@@ -23,6 +24,100 @@ import { createBrotliDecompress, createUnzip } from "zlib";
|
|
|
23
24
|
|
|
24
25
|
// src/runtime.ts
|
|
25
26
|
import { randomUUID } from "crypto";
|
|
27
|
+
|
|
28
|
+
// src/tracking.ts
|
|
29
|
+
var MAX_BUFFER = 5e3;
|
|
30
|
+
var MAX_BATCH = 500;
|
|
31
|
+
var CallBuffer = class {
|
|
32
|
+
constructor(send, options = {}) {
|
|
33
|
+
this.send = send;
|
|
34
|
+
this.flushAt = options.flushAt ?? 500;
|
|
35
|
+
this.minGapMs = options.minGapMs ?? 1e3;
|
|
36
|
+
this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
|
|
37
|
+
this.timer.unref();
|
|
38
|
+
}
|
|
39
|
+
send;
|
|
40
|
+
queue = [];
|
|
41
|
+
inFlight = null;
|
|
42
|
+
lastSentAt = -Infinity;
|
|
43
|
+
/** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
|
|
44
|
+
deadline = Infinity;
|
|
45
|
+
controller = new AbortController();
|
|
46
|
+
timer;
|
|
47
|
+
flushAt;
|
|
48
|
+
minGapMs;
|
|
49
|
+
record(call) {
|
|
50
|
+
if (this.queue.length >= MAX_BUFFER) return;
|
|
51
|
+
this.queue.push(call);
|
|
52
|
+
if (this.queue.length >= this.flushAt) void this.kick();
|
|
53
|
+
}
|
|
54
|
+
size() {
|
|
55
|
+
return this.queue.length;
|
|
56
|
+
}
|
|
57
|
+
/** Resolves when the send in flight, if any, has settled. */
|
|
58
|
+
async idle() {
|
|
59
|
+
await this.inFlight;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Send everything buffered now, one batch at a time through the same
|
|
63
|
+
* in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
|
|
64
|
+
* up at the deadline: the send in flight is aborted, nothing is retried, and
|
|
65
|
+
* what is left is dropped, so a script never hangs on an unreachable server.
|
|
66
|
+
*/
|
|
67
|
+
async flush(deadlineMs = Infinity) {
|
|
68
|
+
this.deadline = performance.now() + deadlineMs;
|
|
69
|
+
const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
|
|
70
|
+
try {
|
|
71
|
+
while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
|
|
72
|
+
await (this.inFlight ?? this.start());
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
if (abortAt) clearTimeout(abortAt);
|
|
76
|
+
if (this.controller.signal.aborted) this.controller = new AbortController();
|
|
77
|
+
this.deadline = Infinity;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
stop() {
|
|
81
|
+
clearInterval(this.timer);
|
|
82
|
+
}
|
|
83
|
+
kick() {
|
|
84
|
+
if (this.inFlight || this.queue.length === 0) return;
|
|
85
|
+
if (performance.now() - this.lastSentAt < this.minGapMs) return;
|
|
86
|
+
void this.start();
|
|
87
|
+
}
|
|
88
|
+
start() {
|
|
89
|
+
this.inFlight = this.sendOne().finally(() => {
|
|
90
|
+
this.inFlight = null;
|
|
91
|
+
});
|
|
92
|
+
return this.inFlight;
|
|
93
|
+
}
|
|
94
|
+
async sendOne() {
|
|
95
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
96
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
97
|
+
if (attempt > 0 && performance.now() >= this.deadline) return;
|
|
98
|
+
await this.waitForGap();
|
|
99
|
+
this.lastSentAt = performance.now();
|
|
100
|
+
try {
|
|
101
|
+
await this.send(batch, this.controller.signal);
|
|
102
|
+
return;
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
if (this.controller.signal.aborted) {
|
|
106
|
+
this.controller = new AbortController();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Referenced on purpose: this wait only happens while a send or an exit
|
|
112
|
+
// flush is under way, and an unref'd wait would let the process exit with
|
|
113
|
+
// the batch unsent. It never outlasts the flush deadline.
|
|
114
|
+
async waitForGap() {
|
|
115
|
+
const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
|
|
116
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// src/runtime.ts
|
|
26
121
|
var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
|
|
27
122
|
var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
|
|
28
123
|
var neverBodied = (method) => method === "GET" || method === "HEAD";
|
|
@@ -32,20 +127,47 @@ var Runtime = class {
|
|
|
32
127
|
this.options = options;
|
|
33
128
|
this.original = original;
|
|
34
129
|
this.api = api ?? new HealApi(original, options.key, options.url);
|
|
130
|
+
this.tracker = new CallBuffer((batch, signal) => this.api.sendRequests(batch, signal));
|
|
35
131
|
}
|
|
36
132
|
options;
|
|
37
133
|
original;
|
|
38
134
|
api;
|
|
135
|
+
/** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
|
|
136
|
+
tracker;
|
|
137
|
+
/**
|
|
138
|
+
* Record a call that is not being healed. An in-memory append: never awaited,
|
|
139
|
+
* never throws, reads nothing of the response.
|
|
140
|
+
*/
|
|
141
|
+
track(method, url, statusCode, startedAt, responseTimeMs) {
|
|
142
|
+
try {
|
|
143
|
+
const reported = trackedUrl(url());
|
|
144
|
+
const verb = method.toUpperCase();
|
|
145
|
+
if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
|
|
146
|
+
this.tracker.record({
|
|
147
|
+
traceId: randomUUID(),
|
|
148
|
+
method: verb,
|
|
149
|
+
url: reported,
|
|
150
|
+
statusCode,
|
|
151
|
+
responseTimeMs: Math.round(responseTimeMs),
|
|
152
|
+
occurredAt: new Date(startedAt).toISOString()
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
39
157
|
fetch = async (input, init) => {
|
|
40
158
|
const request = new Request(input, init);
|
|
41
159
|
const extras = { ...init };
|
|
42
160
|
delete extras.body;
|
|
43
161
|
delete extras.headers;
|
|
44
162
|
const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
|
|
163
|
+
const startedAt = Date.now();
|
|
45
164
|
const started = performance.now();
|
|
46
165
|
const response = await this.original(request, extras);
|
|
47
166
|
const responseTimeMs = performance.now() - started;
|
|
48
|
-
if (!eligible(response.status) || response.redirected || !this.api.
|
|
167
|
+
if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
|
|
168
|
+
this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
|
|
169
|
+
return response;
|
|
170
|
+
}
|
|
49
171
|
return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
|
|
50
172
|
};
|
|
51
173
|
async handleResponse(request, response, body, responseTimeMs, extras = {}) {
|
|
@@ -175,6 +297,7 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
175
297
|
return ((...received) => {
|
|
176
298
|
const args = [...received];
|
|
177
299
|
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
300
|
+
const startedAt = Date.now();
|
|
178
301
|
const started = performance.now();
|
|
179
302
|
const request = original(...args);
|
|
180
303
|
const capture = captureBody(request);
|
|
@@ -183,7 +306,14 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
183
306
|
request.emit = ((event, ...values) => {
|
|
184
307
|
if (event !== "response") return emit(event, ...values);
|
|
185
308
|
const response = values[0];
|
|
186
|
-
if (!eligible(response.statusCode ?? 0) || !runtime.api.
|
|
309
|
+
if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
|
|
310
|
+
runtime.track(
|
|
311
|
+
request.method,
|
|
312
|
+
() => requestUrl(request, protocol).toString(),
|
|
313
|
+
response.statusCode ?? 0,
|
|
314
|
+
startedAt,
|
|
315
|
+
performance.now() - started
|
|
316
|
+
);
|
|
187
317
|
return emit(event, ...values);
|
|
188
318
|
}
|
|
189
319
|
void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
|
|
@@ -202,6 +332,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
|
|
|
202
332
|
const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
|
|
203
333
|
return incomingResponse(healed, clientRequest);
|
|
204
334
|
}
|
|
335
|
+
function requestUrl(request, protocol) {
|
|
336
|
+
const authority = String(request.getHeader("host") ?? request.host);
|
|
337
|
+
return new URL(request.path, `${protocol}//${authority}`);
|
|
338
|
+
}
|
|
205
339
|
function webRequest(request, protocol, captured, signal) {
|
|
206
340
|
const headers = new Headers();
|
|
207
341
|
for (const name of request.getHeaderNames()) {
|
|
@@ -210,8 +344,7 @@ function webRequest(request, protocol, captured, signal) {
|
|
|
210
344
|
if (item !== void 0) headers.append(name, String(item));
|
|
211
345
|
}
|
|
212
346
|
}
|
|
213
|
-
const
|
|
214
|
-
const url = new URL(request.path, `${protocol}//${authority}`);
|
|
347
|
+
const url = requestUrl(request, protocol);
|
|
215
348
|
const method = request.method;
|
|
216
349
|
return new Request(url, {
|
|
217
350
|
method,
|
|
@@ -320,6 +453,7 @@ function cancellation(request, external) {
|
|
|
320
453
|
|
|
321
454
|
// src/index.ts
|
|
322
455
|
var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
|
|
456
|
+
var EXIT_FLUSH_MS = 2e3;
|
|
323
457
|
var globals = globalThis;
|
|
324
458
|
function manifest(options = {}) {
|
|
325
459
|
const key = options.key || process.env.MNFST_KEY;
|
|
@@ -351,6 +485,9 @@ function manifest(options = {}) {
|
|
|
351
485
|
installHttp(runtime);
|
|
352
486
|
globals[STATE] = runtime;
|
|
353
487
|
runtime.api.hello(`node-${process.versions.node}`);
|
|
488
|
+
process.once("beforeExit", () => {
|
|
489
|
+
if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
|
|
490
|
+
});
|
|
354
491
|
}
|
|
355
492
|
|
|
356
493
|
export {
|
|
@@ -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,
|
|
@@ -281,7 +294,8 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
|
|
|
281
294
|
}
|
|
282
295
|
|
|
283
296
|
// src/api.ts
|
|
284
|
-
var VERSION = "7.
|
|
297
|
+
var VERSION = "7.2.0";
|
|
298
|
+
var MAX_HEALS_IN_FLIGHT = 8;
|
|
285
299
|
var warn = (message) => process.emitWarning(message, { code: "MNFST" });
|
|
286
300
|
var HealApi = class {
|
|
287
301
|
constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
|
|
@@ -303,6 +317,14 @@ var HealApi = class {
|
|
|
303
317
|
enabled() {
|
|
304
318
|
return performance.now() >= this.disabledUntil;
|
|
305
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Whether a heal call would be sent right now: the project is not disabled
|
|
322
|
+
* and a heal slot is free. A healable failure that cannot be sent is tracked
|
|
323
|
+
* instead, so it is never lost from both ledgers.
|
|
324
|
+
*/
|
|
325
|
+
canHeal() {
|
|
326
|
+
return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
|
|
327
|
+
}
|
|
306
328
|
headers() {
|
|
307
329
|
return {
|
|
308
330
|
authorization: `Bearer ${this.key}`,
|
|
@@ -311,7 +333,7 @@ var HealApi = class {
|
|
|
311
333
|
};
|
|
312
334
|
}
|
|
313
335
|
async heal(capture, signal) {
|
|
314
|
-
if (!this.
|
|
336
|
+
if (!this.canHeal()) return null;
|
|
315
337
|
this.inFlight++;
|
|
316
338
|
const controller = new AbortController();
|
|
317
339
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -386,6 +408,41 @@ var HealApi = class {
|
|
|
386
408
|
}).catch(() => {
|
|
387
409
|
}).finally(() => clearTimeout(timer));
|
|
388
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
|
|
413
|
+
* retry could help (network error, timeout, 429, 5xx), so the buffer retries
|
|
414
|
+
* once; any other answer, including 404 from a backend that predates the
|
|
415
|
+
* route, drops the batch quietly. Uses `rawFetch`, so the send is never
|
|
416
|
+
* tracked or healed by our own patch.
|
|
417
|
+
*/
|
|
418
|
+
async sendRequests(calls, signal) {
|
|
419
|
+
if (!this.enabled() || calls.length === 0) return;
|
|
420
|
+
const controller = new AbortController();
|
|
421
|
+
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
422
|
+
try {
|
|
423
|
+
const response = await this.rawFetch(new URL("v1/requests", this.url), {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: this.headers(),
|
|
426
|
+
body: JSON.stringify({ requests: calls }),
|
|
427
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
428
|
+
redirect: "error"
|
|
429
|
+
});
|
|
430
|
+
if (response.status === 403) {
|
|
431
|
+
const body = await response.json().catch(() => null);
|
|
432
|
+
if (isObject(body) && body.error === "project_disabled") {
|
|
433
|
+
this.disabledUntil = performance.now() + 3e5;
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
void response.body?.cancel().catch(() => {
|
|
438
|
+
});
|
|
439
|
+
if (response.status === 429 || response.status >= 500) {
|
|
440
|
+
throw new Error(`tracked calls refused (${response.status})`);
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
389
446
|
report(id, outcome) {
|
|
390
447
|
if (!id) return;
|
|
391
448
|
if (this.pending.size >= 64) {
|
|
@@ -425,6 +482,7 @@ var HealApi = class {
|
|
|
425
482
|
export {
|
|
426
483
|
isObject,
|
|
427
484
|
safeUrl,
|
|
485
|
+
trackedUrl,
|
|
428
486
|
safeHeaders,
|
|
429
487
|
travelingBody,
|
|
430
488
|
mergeBody,
|
package/dist/index.cjs
CHANGED
|
@@ -81,6 +81,19 @@ function safeUrl(raw) {
|
|
|
81
81
|
url.search = new URLSearchParams(pairs).toString();
|
|
82
82
|
return url.toString();
|
|
83
83
|
}
|
|
84
|
+
function trackedUrl(raw) {
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(raw);
|
|
87
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
88
|
+
url.username = "";
|
|
89
|
+
url.password = "";
|
|
90
|
+
url.search = "";
|
|
91
|
+
url.hash = "";
|
|
92
|
+
return url.toString();
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
84
97
|
function safeHeaders(headers) {
|
|
85
98
|
return Object.fromEntries([...headers].map(([key, value]) => [
|
|
86
99
|
key,
|
|
@@ -328,7 +341,8 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
|
|
|
328
341
|
var import_node_crypto = require("crypto");
|
|
329
342
|
|
|
330
343
|
// src/api.ts
|
|
331
|
-
var VERSION = "7.
|
|
344
|
+
var VERSION = "7.2.0";
|
|
345
|
+
var MAX_HEALS_IN_FLIGHT = 8;
|
|
332
346
|
var warn = (message) => process.emitWarning(message, { code: "MNFST" });
|
|
333
347
|
var HealApi = class {
|
|
334
348
|
constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
|
|
@@ -350,6 +364,14 @@ var HealApi = class {
|
|
|
350
364
|
enabled() {
|
|
351
365
|
return performance.now() >= this.disabledUntil;
|
|
352
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Whether a heal call would be sent right now: the project is not disabled
|
|
369
|
+
* and a heal slot is free. A healable failure that cannot be sent is tracked
|
|
370
|
+
* instead, so it is never lost from both ledgers.
|
|
371
|
+
*/
|
|
372
|
+
canHeal() {
|
|
373
|
+
return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
|
|
374
|
+
}
|
|
353
375
|
headers() {
|
|
354
376
|
return {
|
|
355
377
|
authorization: `Bearer ${this.key}`,
|
|
@@ -358,7 +380,7 @@ var HealApi = class {
|
|
|
358
380
|
};
|
|
359
381
|
}
|
|
360
382
|
async heal(capture, signal) {
|
|
361
|
-
if (!this.
|
|
383
|
+
if (!this.canHeal()) return null;
|
|
362
384
|
this.inFlight++;
|
|
363
385
|
const controller = new AbortController();
|
|
364
386
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -433,6 +455,41 @@ var HealApi = class {
|
|
|
433
455
|
}).catch(() => {
|
|
434
456
|
}).finally(() => clearTimeout(timer));
|
|
435
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
|
|
460
|
+
* retry could help (network error, timeout, 429, 5xx), so the buffer retries
|
|
461
|
+
* once; any other answer, including 404 from a backend that predates the
|
|
462
|
+
* route, drops the batch quietly. Uses `rawFetch`, so the send is never
|
|
463
|
+
* tracked or healed by our own patch.
|
|
464
|
+
*/
|
|
465
|
+
async sendRequests(calls, signal) {
|
|
466
|
+
if (!this.enabled() || calls.length === 0) return;
|
|
467
|
+
const controller = new AbortController();
|
|
468
|
+
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
469
|
+
try {
|
|
470
|
+
const response = await this.rawFetch(new URL("v1/requests", this.url), {
|
|
471
|
+
method: "POST",
|
|
472
|
+
headers: this.headers(),
|
|
473
|
+
body: JSON.stringify({ requests: calls }),
|
|
474
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
475
|
+
redirect: "error"
|
|
476
|
+
});
|
|
477
|
+
if (response.status === 403) {
|
|
478
|
+
const body = await response.json().catch(() => null);
|
|
479
|
+
if (isObject(body) && body.error === "project_disabled") {
|
|
480
|
+
this.disabledUntil = performance.now() + 3e5;
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
void response.body?.cancel().catch(() => {
|
|
485
|
+
});
|
|
486
|
+
if (response.status === 429 || response.status >= 500) {
|
|
487
|
+
throw new Error(`tracked calls refused (${response.status})`);
|
|
488
|
+
}
|
|
489
|
+
} finally {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
436
493
|
report(id, outcome) {
|
|
437
494
|
if (!id) return;
|
|
438
495
|
if (this.pending.size >= 64) {
|
|
@@ -469,6 +526,98 @@ var HealApi = class {
|
|
|
469
526
|
}
|
|
470
527
|
};
|
|
471
528
|
|
|
529
|
+
// src/tracking.ts
|
|
530
|
+
var MAX_BUFFER = 5e3;
|
|
531
|
+
var MAX_BATCH = 500;
|
|
532
|
+
var CallBuffer = class {
|
|
533
|
+
constructor(send, options = {}) {
|
|
534
|
+
this.send = send;
|
|
535
|
+
this.flushAt = options.flushAt ?? 500;
|
|
536
|
+
this.minGapMs = options.minGapMs ?? 1e3;
|
|
537
|
+
this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
|
|
538
|
+
this.timer.unref();
|
|
539
|
+
}
|
|
540
|
+
send;
|
|
541
|
+
queue = [];
|
|
542
|
+
inFlight = null;
|
|
543
|
+
lastSentAt = -Infinity;
|
|
544
|
+
/** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
|
|
545
|
+
deadline = Infinity;
|
|
546
|
+
controller = new AbortController();
|
|
547
|
+
timer;
|
|
548
|
+
flushAt;
|
|
549
|
+
minGapMs;
|
|
550
|
+
record(call) {
|
|
551
|
+
if (this.queue.length >= MAX_BUFFER) return;
|
|
552
|
+
this.queue.push(call);
|
|
553
|
+
if (this.queue.length >= this.flushAt) void this.kick();
|
|
554
|
+
}
|
|
555
|
+
size() {
|
|
556
|
+
return this.queue.length;
|
|
557
|
+
}
|
|
558
|
+
/** Resolves when the send in flight, if any, has settled. */
|
|
559
|
+
async idle() {
|
|
560
|
+
await this.inFlight;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Send everything buffered now, one batch at a time through the same
|
|
564
|
+
* in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
|
|
565
|
+
* up at the deadline: the send in flight is aborted, nothing is retried, and
|
|
566
|
+
* what is left is dropped, so a script never hangs on an unreachable server.
|
|
567
|
+
*/
|
|
568
|
+
async flush(deadlineMs = Infinity) {
|
|
569
|
+
this.deadline = performance.now() + deadlineMs;
|
|
570
|
+
const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
|
|
571
|
+
try {
|
|
572
|
+
while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
|
|
573
|
+
await (this.inFlight ?? this.start());
|
|
574
|
+
}
|
|
575
|
+
} finally {
|
|
576
|
+
if (abortAt) clearTimeout(abortAt);
|
|
577
|
+
if (this.controller.signal.aborted) this.controller = new AbortController();
|
|
578
|
+
this.deadline = Infinity;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
stop() {
|
|
582
|
+
clearInterval(this.timer);
|
|
583
|
+
}
|
|
584
|
+
kick() {
|
|
585
|
+
if (this.inFlight || this.queue.length === 0) return;
|
|
586
|
+
if (performance.now() - this.lastSentAt < this.minGapMs) return;
|
|
587
|
+
void this.start();
|
|
588
|
+
}
|
|
589
|
+
start() {
|
|
590
|
+
this.inFlight = this.sendOne().finally(() => {
|
|
591
|
+
this.inFlight = null;
|
|
592
|
+
});
|
|
593
|
+
return this.inFlight;
|
|
594
|
+
}
|
|
595
|
+
async sendOne() {
|
|
596
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
597
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
598
|
+
if (attempt > 0 && performance.now() >= this.deadline) return;
|
|
599
|
+
await this.waitForGap();
|
|
600
|
+
this.lastSentAt = performance.now();
|
|
601
|
+
try {
|
|
602
|
+
await this.send(batch, this.controller.signal);
|
|
603
|
+
return;
|
|
604
|
+
} catch {
|
|
605
|
+
}
|
|
606
|
+
if (this.controller.signal.aborted) {
|
|
607
|
+
this.controller = new AbortController();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Referenced on purpose: this wait only happens while a send or an exit
|
|
613
|
+
// flush is under way, and an unref'd wait would let the process exit with
|
|
614
|
+
// the batch unsent. It never outlasts the flush deadline.
|
|
615
|
+
async waitForGap() {
|
|
616
|
+
const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
|
|
617
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
472
621
|
// src/runtime.ts
|
|
473
622
|
var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
|
|
474
623
|
var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
|
|
@@ -479,20 +628,47 @@ var Runtime = class {
|
|
|
479
628
|
this.options = options;
|
|
480
629
|
this.original = original;
|
|
481
630
|
this.api = api ?? new HealApi(original, options.key, options.url);
|
|
631
|
+
this.tracker = new CallBuffer((batch, signal) => this.api.sendRequests(batch, signal));
|
|
482
632
|
}
|
|
483
633
|
options;
|
|
484
634
|
original;
|
|
485
635
|
api;
|
|
636
|
+
/** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
|
|
637
|
+
tracker;
|
|
638
|
+
/**
|
|
639
|
+
* Record a call that is not being healed. An in-memory append: never awaited,
|
|
640
|
+
* never throws, reads nothing of the response.
|
|
641
|
+
*/
|
|
642
|
+
track(method, url, statusCode, startedAt, responseTimeMs) {
|
|
643
|
+
try {
|
|
644
|
+
const reported = trackedUrl(url());
|
|
645
|
+
const verb = method.toUpperCase();
|
|
646
|
+
if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
|
|
647
|
+
this.tracker.record({
|
|
648
|
+
traceId: (0, import_node_crypto.randomUUID)(),
|
|
649
|
+
method: verb,
|
|
650
|
+
url: reported,
|
|
651
|
+
statusCode,
|
|
652
|
+
responseTimeMs: Math.round(responseTimeMs),
|
|
653
|
+
occurredAt: new Date(startedAt).toISOString()
|
|
654
|
+
});
|
|
655
|
+
} catch {
|
|
656
|
+
}
|
|
657
|
+
}
|
|
486
658
|
fetch = async (input, init) => {
|
|
487
659
|
const request = new Request(input, init);
|
|
488
660
|
const extras = { ...init };
|
|
489
661
|
delete extras.body;
|
|
490
662
|
delete extras.headers;
|
|
491
663
|
const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
|
|
664
|
+
const startedAt = Date.now();
|
|
492
665
|
const started = performance.now();
|
|
493
666
|
const response = await this.original(request, extras);
|
|
494
667
|
const responseTimeMs = performance.now() - started;
|
|
495
|
-
if (!eligible(response.status) || response.redirected || !this.api.
|
|
668
|
+
if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
|
|
669
|
+
this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
|
|
670
|
+
return response;
|
|
671
|
+
}
|
|
496
672
|
return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
|
|
497
673
|
};
|
|
498
674
|
async handleResponse(request, response, body, responseTimeMs, extras = {}) {
|
|
@@ -622,6 +798,7 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
622
798
|
return ((...received) => {
|
|
623
799
|
const args = [...received];
|
|
624
800
|
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
801
|
+
const startedAt = Date.now();
|
|
625
802
|
const started = performance.now();
|
|
626
803
|
const request = original(...args);
|
|
627
804
|
const capture = captureBody(request);
|
|
@@ -630,7 +807,14 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
630
807
|
request.emit = ((event, ...values) => {
|
|
631
808
|
if (event !== "response") return emit(event, ...values);
|
|
632
809
|
const response = values[0];
|
|
633
|
-
if (!eligible(response.statusCode ?? 0) || !runtime.api.
|
|
810
|
+
if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
|
|
811
|
+
runtime.track(
|
|
812
|
+
request.method,
|
|
813
|
+
() => requestUrl(request, protocol).toString(),
|
|
814
|
+
response.statusCode ?? 0,
|
|
815
|
+
startedAt,
|
|
816
|
+
performance.now() - started
|
|
817
|
+
);
|
|
634
818
|
return emit(event, ...values);
|
|
635
819
|
}
|
|
636
820
|
void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
|
|
@@ -649,6 +833,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
|
|
|
649
833
|
const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
|
|
650
834
|
return incomingResponse(healed, clientRequest);
|
|
651
835
|
}
|
|
836
|
+
function requestUrl(request, protocol) {
|
|
837
|
+
const authority = String(request.getHeader("host") ?? request.host);
|
|
838
|
+
return new URL(request.path, `${protocol}//${authority}`);
|
|
839
|
+
}
|
|
652
840
|
function webRequest(request, protocol, captured, signal) {
|
|
653
841
|
const headers = new Headers();
|
|
654
842
|
for (const name of request.getHeaderNames()) {
|
|
@@ -657,8 +845,7 @@ function webRequest(request, protocol, captured, signal) {
|
|
|
657
845
|
if (item !== void 0) headers.append(name, String(item));
|
|
658
846
|
}
|
|
659
847
|
}
|
|
660
|
-
const
|
|
661
|
-
const url = new URL(request.path, `${protocol}//${authority}`);
|
|
848
|
+
const url = requestUrl(request, protocol);
|
|
662
849
|
const method = request.method;
|
|
663
850
|
return new Request(url, {
|
|
664
851
|
method,
|
|
@@ -767,6 +954,7 @@ function cancellation(request, external) {
|
|
|
767
954
|
|
|
768
955
|
// src/index.ts
|
|
769
956
|
var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
|
|
957
|
+
var EXIT_FLUSH_MS = 2e3;
|
|
770
958
|
var globals = globalThis;
|
|
771
959
|
function manifest(options = {}) {
|
|
772
960
|
const key = options.key || process.env.MNFST_KEY;
|
|
@@ -798,6 +986,9 @@ function manifest(options = {}) {
|
|
|
798
986
|
installHttp(runtime);
|
|
799
987
|
globals[STATE] = runtime;
|
|
800
988
|
runtime.api.hello(`node-${process.versions.node}`);
|
|
989
|
+
process.once("beforeExit", () => {
|
|
990
|
+
if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
|
|
991
|
+
});
|
|
801
992
|
}
|
|
802
993
|
// Annotate the CommonJS export names for ESM import in node:
|
|
803
994
|
0 && (module.exports = {
|
package/dist/index.d.cts
CHANGED
|
@@ -12,7 +12,7 @@ interface HealEvent {
|
|
|
12
12
|
operations?: unknown[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
declare const VERSION = "7.
|
|
15
|
+
declare const VERSION = "7.2.0";
|
|
16
16
|
|
|
17
17
|
/** Install once, before libraries capture their own reference to global fetch. */
|
|
18
18
|
declare function manifest(options?: ManifestOptions): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ interface HealEvent {
|
|
|
12
12
|
operations?: unknown[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
declare const VERSION = "7.
|
|
15
|
+
declare const VERSION = "7.2.0";
|
|
16
16
|
|
|
17
17
|
/** Install once, before libraries capture their own reference to global fetch. */
|
|
18
18
|
declare function manifest(options?: ManifestOptions): void;
|
package/dist/index.js
CHANGED
package/dist/register.cjs
CHANGED
|
@@ -68,6 +68,19 @@ function safeUrl(raw) {
|
|
|
68
68
|
url.search = new URLSearchParams(pairs).toString();
|
|
69
69
|
return url.toString();
|
|
70
70
|
}
|
|
71
|
+
function trackedUrl(raw) {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(raw);
|
|
74
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
75
|
+
url.username = "";
|
|
76
|
+
url.password = "";
|
|
77
|
+
url.search = "";
|
|
78
|
+
url.hash = "";
|
|
79
|
+
return url.toString();
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
71
84
|
function safeHeaders(headers) {
|
|
72
85
|
return Object.fromEntries([...headers].map(([key, value]) => [
|
|
73
86
|
key,
|
|
@@ -315,7 +328,8 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
|
|
|
315
328
|
var import_node_crypto = require("crypto");
|
|
316
329
|
|
|
317
330
|
// src/api.ts
|
|
318
|
-
var VERSION = "7.
|
|
331
|
+
var VERSION = "7.2.0";
|
|
332
|
+
var MAX_HEALS_IN_FLIGHT = 8;
|
|
319
333
|
var warn = (message) => process.emitWarning(message, { code: "MNFST" });
|
|
320
334
|
var HealApi = class {
|
|
321
335
|
constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
|
|
@@ -337,6 +351,14 @@ var HealApi = class {
|
|
|
337
351
|
enabled() {
|
|
338
352
|
return performance.now() >= this.disabledUntil;
|
|
339
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Whether a heal call would be sent right now: the project is not disabled
|
|
356
|
+
* and a heal slot is free. A healable failure that cannot be sent is tracked
|
|
357
|
+
* instead, so it is never lost from both ledgers.
|
|
358
|
+
*/
|
|
359
|
+
canHeal() {
|
|
360
|
+
return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
|
|
361
|
+
}
|
|
340
362
|
headers() {
|
|
341
363
|
return {
|
|
342
364
|
authorization: `Bearer ${this.key}`,
|
|
@@ -345,7 +367,7 @@ var HealApi = class {
|
|
|
345
367
|
};
|
|
346
368
|
}
|
|
347
369
|
async heal(capture, signal) {
|
|
348
|
-
if (!this.
|
|
370
|
+
if (!this.canHeal()) return null;
|
|
349
371
|
this.inFlight++;
|
|
350
372
|
const controller = new AbortController();
|
|
351
373
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -420,6 +442,41 @@ var HealApi = class {
|
|
|
420
442
|
}).catch(() => {
|
|
421
443
|
}).finally(() => clearTimeout(timer));
|
|
422
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
|
|
447
|
+
* retry could help (network error, timeout, 429, 5xx), so the buffer retries
|
|
448
|
+
* once; any other answer, including 404 from a backend that predates the
|
|
449
|
+
* route, drops the batch quietly. Uses `rawFetch`, so the send is never
|
|
450
|
+
* tracked or healed by our own patch.
|
|
451
|
+
*/
|
|
452
|
+
async sendRequests(calls, signal) {
|
|
453
|
+
if (!this.enabled() || calls.length === 0) return;
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
456
|
+
try {
|
|
457
|
+
const response = await this.rawFetch(new URL("v1/requests", this.url), {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: this.headers(),
|
|
460
|
+
body: JSON.stringify({ requests: calls }),
|
|
461
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
462
|
+
redirect: "error"
|
|
463
|
+
});
|
|
464
|
+
if (response.status === 403) {
|
|
465
|
+
const body = await response.json().catch(() => null);
|
|
466
|
+
if (isObject(body) && body.error === "project_disabled") {
|
|
467
|
+
this.disabledUntil = performance.now() + 3e5;
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
void response.body?.cancel().catch(() => {
|
|
472
|
+
});
|
|
473
|
+
if (response.status === 429 || response.status >= 500) {
|
|
474
|
+
throw new Error(`tracked calls refused (${response.status})`);
|
|
475
|
+
}
|
|
476
|
+
} finally {
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
423
480
|
report(id, outcome) {
|
|
424
481
|
if (!id) return;
|
|
425
482
|
if (this.pending.size >= 64) {
|
|
@@ -456,6 +513,98 @@ var HealApi = class {
|
|
|
456
513
|
}
|
|
457
514
|
};
|
|
458
515
|
|
|
516
|
+
// src/tracking.ts
|
|
517
|
+
var MAX_BUFFER = 5e3;
|
|
518
|
+
var MAX_BATCH = 500;
|
|
519
|
+
var CallBuffer = class {
|
|
520
|
+
constructor(send, options = {}) {
|
|
521
|
+
this.send = send;
|
|
522
|
+
this.flushAt = options.flushAt ?? 500;
|
|
523
|
+
this.minGapMs = options.minGapMs ?? 1e3;
|
|
524
|
+
this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
|
|
525
|
+
this.timer.unref();
|
|
526
|
+
}
|
|
527
|
+
send;
|
|
528
|
+
queue = [];
|
|
529
|
+
inFlight = null;
|
|
530
|
+
lastSentAt = -Infinity;
|
|
531
|
+
/** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
|
|
532
|
+
deadline = Infinity;
|
|
533
|
+
controller = new AbortController();
|
|
534
|
+
timer;
|
|
535
|
+
flushAt;
|
|
536
|
+
minGapMs;
|
|
537
|
+
record(call) {
|
|
538
|
+
if (this.queue.length >= MAX_BUFFER) return;
|
|
539
|
+
this.queue.push(call);
|
|
540
|
+
if (this.queue.length >= this.flushAt) void this.kick();
|
|
541
|
+
}
|
|
542
|
+
size() {
|
|
543
|
+
return this.queue.length;
|
|
544
|
+
}
|
|
545
|
+
/** Resolves when the send in flight, if any, has settled. */
|
|
546
|
+
async idle() {
|
|
547
|
+
await this.inFlight;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Send everything buffered now, one batch at a time through the same
|
|
551
|
+
* in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
|
|
552
|
+
* up at the deadline: the send in flight is aborted, nothing is retried, and
|
|
553
|
+
* what is left is dropped, so a script never hangs on an unreachable server.
|
|
554
|
+
*/
|
|
555
|
+
async flush(deadlineMs = Infinity) {
|
|
556
|
+
this.deadline = performance.now() + deadlineMs;
|
|
557
|
+
const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
|
|
558
|
+
try {
|
|
559
|
+
while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
|
|
560
|
+
await (this.inFlight ?? this.start());
|
|
561
|
+
}
|
|
562
|
+
} finally {
|
|
563
|
+
if (abortAt) clearTimeout(abortAt);
|
|
564
|
+
if (this.controller.signal.aborted) this.controller = new AbortController();
|
|
565
|
+
this.deadline = Infinity;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
stop() {
|
|
569
|
+
clearInterval(this.timer);
|
|
570
|
+
}
|
|
571
|
+
kick() {
|
|
572
|
+
if (this.inFlight || this.queue.length === 0) return;
|
|
573
|
+
if (performance.now() - this.lastSentAt < this.minGapMs) return;
|
|
574
|
+
void this.start();
|
|
575
|
+
}
|
|
576
|
+
start() {
|
|
577
|
+
this.inFlight = this.sendOne().finally(() => {
|
|
578
|
+
this.inFlight = null;
|
|
579
|
+
});
|
|
580
|
+
return this.inFlight;
|
|
581
|
+
}
|
|
582
|
+
async sendOne() {
|
|
583
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
584
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
585
|
+
if (attempt > 0 && performance.now() >= this.deadline) return;
|
|
586
|
+
await this.waitForGap();
|
|
587
|
+
this.lastSentAt = performance.now();
|
|
588
|
+
try {
|
|
589
|
+
await this.send(batch, this.controller.signal);
|
|
590
|
+
return;
|
|
591
|
+
} catch {
|
|
592
|
+
}
|
|
593
|
+
if (this.controller.signal.aborted) {
|
|
594
|
+
this.controller = new AbortController();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Referenced on purpose: this wait only happens while a send or an exit
|
|
600
|
+
// flush is under way, and an unref'd wait would let the process exit with
|
|
601
|
+
// the batch unsent. It never outlasts the flush deadline.
|
|
602
|
+
async waitForGap() {
|
|
603
|
+
const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
|
|
604
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
459
608
|
// src/runtime.ts
|
|
460
609
|
var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
|
|
461
610
|
var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
|
|
@@ -466,20 +615,47 @@ var Runtime = class {
|
|
|
466
615
|
this.options = options;
|
|
467
616
|
this.original = original;
|
|
468
617
|
this.api = api ?? new HealApi(original, options.key, options.url);
|
|
618
|
+
this.tracker = new CallBuffer((batch, signal) => this.api.sendRequests(batch, signal));
|
|
469
619
|
}
|
|
470
620
|
options;
|
|
471
621
|
original;
|
|
472
622
|
api;
|
|
623
|
+
/** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
|
|
624
|
+
tracker;
|
|
625
|
+
/**
|
|
626
|
+
* Record a call that is not being healed. An in-memory append: never awaited,
|
|
627
|
+
* never throws, reads nothing of the response.
|
|
628
|
+
*/
|
|
629
|
+
track(method, url, statusCode, startedAt, responseTimeMs) {
|
|
630
|
+
try {
|
|
631
|
+
const reported = trackedUrl(url());
|
|
632
|
+
const verb = method.toUpperCase();
|
|
633
|
+
if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
|
|
634
|
+
this.tracker.record({
|
|
635
|
+
traceId: (0, import_node_crypto.randomUUID)(),
|
|
636
|
+
method: verb,
|
|
637
|
+
url: reported,
|
|
638
|
+
statusCode,
|
|
639
|
+
responseTimeMs: Math.round(responseTimeMs),
|
|
640
|
+
occurredAt: new Date(startedAt).toISOString()
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
}
|
|
644
|
+
}
|
|
473
645
|
fetch = async (input, init) => {
|
|
474
646
|
const request = new Request(input, init);
|
|
475
647
|
const extras = { ...init };
|
|
476
648
|
delete extras.body;
|
|
477
649
|
delete extras.headers;
|
|
478
650
|
const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
|
|
651
|
+
const startedAt = Date.now();
|
|
479
652
|
const started = performance.now();
|
|
480
653
|
const response = await this.original(request, extras);
|
|
481
654
|
const responseTimeMs = performance.now() - started;
|
|
482
|
-
if (!eligible(response.status) || response.redirected || !this.api.
|
|
655
|
+
if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
|
|
656
|
+
this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
|
|
657
|
+
return response;
|
|
658
|
+
}
|
|
483
659
|
return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
|
|
484
660
|
};
|
|
485
661
|
async handleResponse(request, response, body, responseTimeMs, extras = {}) {
|
|
@@ -609,6 +785,7 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
609
785
|
return ((...received) => {
|
|
610
786
|
const args = [...received];
|
|
611
787
|
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
788
|
+
const startedAt = Date.now();
|
|
612
789
|
const started = performance.now();
|
|
613
790
|
const request = original(...args);
|
|
614
791
|
const capture = captureBody(request);
|
|
@@ -617,7 +794,14 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
617
794
|
request.emit = ((event, ...values) => {
|
|
618
795
|
if (event !== "response") return emit(event, ...values);
|
|
619
796
|
const response = values[0];
|
|
620
|
-
if (!eligible(response.statusCode ?? 0) || !runtime.api.
|
|
797
|
+
if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
|
|
798
|
+
runtime.track(
|
|
799
|
+
request.method,
|
|
800
|
+
() => requestUrl(request, protocol).toString(),
|
|
801
|
+
response.statusCode ?? 0,
|
|
802
|
+
startedAt,
|
|
803
|
+
performance.now() - started
|
|
804
|
+
);
|
|
621
805
|
return emit(event, ...values);
|
|
622
806
|
}
|
|
623
807
|
void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
|
|
@@ -636,6 +820,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
|
|
|
636
820
|
const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
|
|
637
821
|
return incomingResponse(healed, clientRequest);
|
|
638
822
|
}
|
|
823
|
+
function requestUrl(request, protocol) {
|
|
824
|
+
const authority = String(request.getHeader("host") ?? request.host);
|
|
825
|
+
return new URL(request.path, `${protocol}//${authority}`);
|
|
826
|
+
}
|
|
639
827
|
function webRequest(request, protocol, captured, signal) {
|
|
640
828
|
const headers = new Headers();
|
|
641
829
|
for (const name of request.getHeaderNames()) {
|
|
@@ -644,8 +832,7 @@ function webRequest(request, protocol, captured, signal) {
|
|
|
644
832
|
if (item !== void 0) headers.append(name, String(item));
|
|
645
833
|
}
|
|
646
834
|
}
|
|
647
|
-
const
|
|
648
|
-
const url = new URL(request.path, `${protocol}//${authority}`);
|
|
835
|
+
const url = requestUrl(request, protocol);
|
|
649
836
|
const method = request.method;
|
|
650
837
|
return new Request(url, {
|
|
651
838
|
method,
|
|
@@ -754,6 +941,7 @@ function cancellation(request, external) {
|
|
|
754
941
|
|
|
755
942
|
// src/index.ts
|
|
756
943
|
var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
|
|
944
|
+
var EXIT_FLUSH_MS = 2e3;
|
|
757
945
|
var globals = globalThis;
|
|
758
946
|
function manifest(options = {}) {
|
|
759
947
|
const key = options.key || process.env.MNFST_KEY;
|
|
@@ -785,6 +973,9 @@ function manifest(options = {}) {
|
|
|
785
973
|
installHttp(runtime);
|
|
786
974
|
globals[STATE] = runtime;
|
|
787
975
|
runtime.api.hello(`node-${process.versions.node}`);
|
|
976
|
+
process.once("beforeExit", () => {
|
|
977
|
+
if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
|
|
978
|
+
});
|
|
788
979
|
}
|
|
789
980
|
|
|
790
981
|
// src/register.ts
|
package/dist/register.js
CHANGED
package/docs/guide.md
CHANGED
|
@@ -105,6 +105,7 @@ You can also verify by hand: send a JSON request that your test API rejects with
|
|
|
105
105
|
- JSON and `application/x-www-form-urlencoded` APIs, including nested form fields. No provider-specific request format is required.
|
|
106
106
|
- One retry per capture. Same-origin URL and header repairs are supported by the SDK; the current app returns structured body repairs.
|
|
107
107
|
- Successful calls and successful retries remain streamed. Failed responses retain their bytes, status, headers, URL and redirect metadata.
|
|
108
|
+
- 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").
|
|
108
109
|
|
|
109
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.
|
|
110
111
|
|
|
@@ -123,7 +124,9 @@ Outcome reports are best effort, limited to 64 concurrent requests with five-sec
|
|
|
123
124
|
|
|
124
125
|
## Data sent to Manifest
|
|
125
126
|
|
|
126
|
-
|
|
127
|
+
**Every call (metadata only).** For each call that is not healed, whatever its status, the SDK sends its method, URL without the query string, userinfo or fragment, status code, response time and time of the call. No headers and no bodies. Calls are batched and sent in the background, at most once per second; recording one never slows the call. Calls still buffered when a serverless runtime freezes the process can be lost.
|
|
128
|
+
|
|
129
|
+
**Healable failures (full capture).** Failed URLs, request headers, JSON or form-urlencoded bodies, and raw error responses go to the configured server. Known credential names in query parameters and headers are masked; credential-named top-level request body fields are withheld and restored on retry. Exception prose is not sent for transport failures.
|
|
127
130
|
|
|
128
131
|
This is not general secret detection: nested fields, arbitrary secret names, business data and response bodies may contain sensitive information. Enable it only for traffic you permit Manifest to process and store. The SDK makes the actual retry locally.
|
|
129
132
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "manifest",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "Repair eligible failed API calls made by Node HTTP clients.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"dist",
|
|
36
36
|
"README.md",
|
|
37
37
|
"CONTRACT.md",
|
|
38
|
-
"docs"
|
|
38
|
+
"docs",
|
|
39
|
+
"!docs/github-sdk.png",
|
|
40
|
+
"!docs/sdk-flow-diagram.svg"
|
|
39
41
|
],
|
|
40
42
|
"engines": {
|
|
41
43
|
"node": ">=22"
|
package/docs/github-sdk.png
DELETED
|
Binary file
|