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/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,
|
|
@@ -314,8 +327,29 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
|
|
|
314
327
|
// src/runtime.ts
|
|
315
328
|
var import_node_crypto = require("crypto");
|
|
316
329
|
|
|
330
|
+
// src/serverless.ts
|
|
331
|
+
var CONTEXTS = [/* @__PURE__ */ Symbol.for("@vercel/request-context"), /* @__PURE__ */ Symbol.for("@next/request-context")];
|
|
332
|
+
function isServerless(env = process.env) {
|
|
333
|
+
return Boolean(env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.K_SERVICE);
|
|
334
|
+
}
|
|
335
|
+
function keepAlive(promise) {
|
|
336
|
+
try {
|
|
337
|
+
const globals2 = globalThis;
|
|
338
|
+
for (const symbol of CONTEXTS) {
|
|
339
|
+
const waitUntil = globals2[symbol]?.get?.()?.waitUntil;
|
|
340
|
+
if (typeof waitUntil === "function") {
|
|
341
|
+
waitUntil(promise.catch(() => {
|
|
342
|
+
}));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
317
350
|
// src/api.ts
|
|
318
|
-
var VERSION = "7.1
|
|
351
|
+
var VERSION = "7.2.1";
|
|
352
|
+
var MAX_HEALS_IN_FLIGHT = 8;
|
|
319
353
|
var warn = (message) => process.emitWarning(message, { code: "MNFST" });
|
|
320
354
|
var HealApi = class {
|
|
321
355
|
constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
|
|
@@ -337,6 +371,14 @@ var HealApi = class {
|
|
|
337
371
|
enabled() {
|
|
338
372
|
return performance.now() >= this.disabledUntil;
|
|
339
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Whether a heal call would be sent right now: the project is not disabled
|
|
376
|
+
* and a heal slot is free. A healable failure that cannot be sent is tracked
|
|
377
|
+
* instead, so it is never lost from both ledgers.
|
|
378
|
+
*/
|
|
379
|
+
canHeal() {
|
|
380
|
+
return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
|
|
381
|
+
}
|
|
340
382
|
headers() {
|
|
341
383
|
return {
|
|
342
384
|
authorization: `Bearer ${this.key}`,
|
|
@@ -345,7 +387,7 @@ var HealApi = class {
|
|
|
345
387
|
};
|
|
346
388
|
}
|
|
347
389
|
async heal(capture, signal) {
|
|
348
|
-
if (!this.
|
|
390
|
+
if (!this.canHeal()) return null;
|
|
349
391
|
this.inFlight++;
|
|
350
392
|
const controller = new AbortController();
|
|
351
393
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -420,6 +462,41 @@ var HealApi = class {
|
|
|
420
462
|
}).catch(() => {
|
|
421
463
|
}).finally(() => clearTimeout(timer));
|
|
422
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
|
|
467
|
+
* retry could help (network error, timeout, 429, 5xx), so the buffer retries
|
|
468
|
+
* once; any other answer, including 404 from a backend that predates the
|
|
469
|
+
* route, drops the batch quietly. Uses `rawFetch`, so the send is never
|
|
470
|
+
* tracked or healed by our own patch.
|
|
471
|
+
*/
|
|
472
|
+
async sendRequests(calls, signal) {
|
|
473
|
+
if (!this.enabled() || calls.length === 0) return;
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
476
|
+
try {
|
|
477
|
+
const response = await this.rawFetch(new URL("v1/requests", this.url), {
|
|
478
|
+
method: "POST",
|
|
479
|
+
headers: this.headers(),
|
|
480
|
+
body: JSON.stringify({ requests: calls }),
|
|
481
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
482
|
+
redirect: "error"
|
|
483
|
+
});
|
|
484
|
+
if (response.status === 403) {
|
|
485
|
+
const body = await response.json().catch(() => null);
|
|
486
|
+
if (isObject(body) && body.error === "project_disabled") {
|
|
487
|
+
this.disabledUntil = performance.now() + 3e5;
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
void response.body?.cancel().catch(() => {
|
|
492
|
+
});
|
|
493
|
+
if (response.status === 429 || response.status >= 500) {
|
|
494
|
+
throw new Error(`tracked calls refused (${response.status})`);
|
|
495
|
+
}
|
|
496
|
+
} finally {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
423
500
|
report(id, outcome) {
|
|
424
501
|
if (!id) return;
|
|
425
502
|
if (this.pending.size >= 64) {
|
|
@@ -429,6 +506,7 @@ var HealApi = class {
|
|
|
429
506
|
const promise = this.send(id, outcome);
|
|
430
507
|
this.pending.add(promise);
|
|
431
508
|
void promise.then(() => this.pending.delete(promise));
|
|
509
|
+
keepAlive(promise);
|
|
432
510
|
}
|
|
433
511
|
async send(id, outcome) {
|
|
434
512
|
const controller = new AbortController();
|
|
@@ -456,6 +534,119 @@ var HealApi = class {
|
|
|
456
534
|
}
|
|
457
535
|
};
|
|
458
536
|
|
|
537
|
+
// src/tracking.ts
|
|
538
|
+
var MAX_BUFFER = 5e3;
|
|
539
|
+
var MAX_BATCH = 500;
|
|
540
|
+
var CallBuffer = class {
|
|
541
|
+
constructor(send, options = {}) {
|
|
542
|
+
this.send = send;
|
|
543
|
+
this.flushAt = options.flushAt ?? 500;
|
|
544
|
+
this.minGapMs = options.minGapMs ?? 1e3;
|
|
545
|
+
this.immediate = options.immediate ?? false;
|
|
546
|
+
this.keepAlive = options.keepAlive ?? keepAlive;
|
|
547
|
+
this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
|
|
548
|
+
this.timer.unref();
|
|
549
|
+
}
|
|
550
|
+
send;
|
|
551
|
+
queue = [];
|
|
552
|
+
inFlight = null;
|
|
553
|
+
lastSentAt = -Infinity;
|
|
554
|
+
/** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
|
|
555
|
+
deadline = Infinity;
|
|
556
|
+
controller = new AbortController();
|
|
557
|
+
timer;
|
|
558
|
+
flushAt;
|
|
559
|
+
minGapMs;
|
|
560
|
+
immediate;
|
|
561
|
+
keepAlive;
|
|
562
|
+
draining = null;
|
|
563
|
+
record(call) {
|
|
564
|
+
if (this.queue.length >= MAX_BUFFER) return;
|
|
565
|
+
this.queue.push(call);
|
|
566
|
+
if (this.immediate) this.drain();
|
|
567
|
+
else if (this.queue.length >= this.flushAt) void this.kick();
|
|
568
|
+
}
|
|
569
|
+
size() {
|
|
570
|
+
return this.queue.length;
|
|
571
|
+
}
|
|
572
|
+
/** Resolves when the send in flight, if any, has settled. */
|
|
573
|
+
async idle() {
|
|
574
|
+
await this.inFlight;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Send everything buffered now, one batch at a time through the same
|
|
578
|
+
* in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
|
|
579
|
+
* up at the deadline: the send in flight is aborted, nothing is retried, and
|
|
580
|
+
* what is left is dropped, so a script never hangs on an unreachable server.
|
|
581
|
+
*/
|
|
582
|
+
async flush(deadlineMs = Infinity) {
|
|
583
|
+
this.deadline = performance.now() + deadlineMs;
|
|
584
|
+
const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
|
|
585
|
+
try {
|
|
586
|
+
while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
|
|
587
|
+
await (this.inFlight ?? this.start());
|
|
588
|
+
}
|
|
589
|
+
} finally {
|
|
590
|
+
if (abortAt) clearTimeout(abortAt);
|
|
591
|
+
if (this.controller.signal.aborted) this.controller = new AbortController();
|
|
592
|
+
this.deadline = Infinity;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
stop() {
|
|
596
|
+
clearInterval(this.timer);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Serverless: the function may freeze as soon as its response is sent, before
|
|
600
|
+
* any timer fires. So send now, through `flush()` (same gap, same single send
|
|
601
|
+
* in flight), and let the platform wait for it. Calls recorded meanwhile ride
|
|
602
|
+
* the same drain; one that lands as it settles starts the next.
|
|
603
|
+
*/
|
|
604
|
+
drain() {
|
|
605
|
+
if (this.draining) return;
|
|
606
|
+
const run = this.flush().finally(() => {
|
|
607
|
+
this.draining = null;
|
|
608
|
+
if (this.queue.length > 0) this.drain();
|
|
609
|
+
});
|
|
610
|
+
this.draining = run;
|
|
611
|
+
this.keepAlive(run);
|
|
612
|
+
}
|
|
613
|
+
kick() {
|
|
614
|
+
if (this.inFlight || this.queue.length === 0) return;
|
|
615
|
+
if (performance.now() - this.lastSentAt < this.minGapMs) return;
|
|
616
|
+
void this.start();
|
|
617
|
+
}
|
|
618
|
+
start() {
|
|
619
|
+
this.inFlight = this.sendOne().finally(() => {
|
|
620
|
+
this.inFlight = null;
|
|
621
|
+
});
|
|
622
|
+
return this.inFlight;
|
|
623
|
+
}
|
|
624
|
+
async sendOne() {
|
|
625
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
626
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
627
|
+
if (attempt > 0 && performance.now() >= this.deadline) return;
|
|
628
|
+
await this.waitForGap();
|
|
629
|
+
this.lastSentAt = performance.now();
|
|
630
|
+
try {
|
|
631
|
+
await this.send(batch, this.controller.signal);
|
|
632
|
+
return;
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
if (this.controller.signal.aborted) {
|
|
636
|
+
this.controller = new AbortController();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Referenced on purpose: this wait only happens while a send or an exit
|
|
642
|
+
// flush is under way, and an unref'd wait would let the process exit with
|
|
643
|
+
// the batch unsent. It never outlasts the flush deadline.
|
|
644
|
+
async waitForGap() {
|
|
645
|
+
const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
|
|
646
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
459
650
|
// src/runtime.ts
|
|
460
651
|
var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
|
|
461
652
|
var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
|
|
@@ -466,20 +657,50 @@ var Runtime = class {
|
|
|
466
657
|
this.options = options;
|
|
467
658
|
this.original = original;
|
|
468
659
|
this.api = api ?? new HealApi(original, options.key, options.url);
|
|
660
|
+
this.tracker = new CallBuffer(
|
|
661
|
+
(batch, signal) => this.api.sendRequests(batch, signal),
|
|
662
|
+
{ immediate: isServerless() }
|
|
663
|
+
);
|
|
469
664
|
}
|
|
470
665
|
options;
|
|
471
666
|
original;
|
|
472
667
|
api;
|
|
668
|
+
/** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
|
|
669
|
+
tracker;
|
|
670
|
+
/**
|
|
671
|
+
* Record a call that is not being healed. An in-memory append: never awaited,
|
|
672
|
+
* never throws, reads nothing of the response.
|
|
673
|
+
*/
|
|
674
|
+
track(method, url, statusCode, startedAt, responseTimeMs) {
|
|
675
|
+
try {
|
|
676
|
+
const reported = trackedUrl(url());
|
|
677
|
+
const verb = method.toUpperCase();
|
|
678
|
+
if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
|
|
679
|
+
this.tracker.record({
|
|
680
|
+
traceId: (0, import_node_crypto.randomUUID)(),
|
|
681
|
+
method: verb,
|
|
682
|
+
url: reported,
|
|
683
|
+
statusCode,
|
|
684
|
+
responseTimeMs: Math.round(responseTimeMs),
|
|
685
|
+
occurredAt: new Date(startedAt).toISOString()
|
|
686
|
+
});
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
}
|
|
473
690
|
fetch = async (input, init) => {
|
|
474
691
|
const request = new Request(input, init);
|
|
475
692
|
const extras = { ...init };
|
|
476
693
|
delete extras.body;
|
|
477
694
|
delete extras.headers;
|
|
478
695
|
const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
|
|
696
|
+
const startedAt = Date.now();
|
|
479
697
|
const started = performance.now();
|
|
480
698
|
const response = await this.original(request, extras);
|
|
481
699
|
const responseTimeMs = performance.now() - started;
|
|
482
|
-
if (!eligible(response.status) || response.redirected || !this.api.
|
|
700
|
+
if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
|
|
701
|
+
this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
|
|
702
|
+
return response;
|
|
703
|
+
}
|
|
483
704
|
return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
|
|
484
705
|
};
|
|
485
706
|
async handleResponse(request, response, body, responseTimeMs, extras = {}) {
|
|
@@ -609,6 +830,7 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
609
830
|
return ((...received) => {
|
|
610
831
|
const args = [...received];
|
|
611
832
|
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
833
|
+
const startedAt = Date.now();
|
|
612
834
|
const started = performance.now();
|
|
613
835
|
const request = original(...args);
|
|
614
836
|
const capture = captureBody(request);
|
|
@@ -617,7 +839,14 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
617
839
|
request.emit = ((event, ...values) => {
|
|
618
840
|
if (event !== "response") return emit(event, ...values);
|
|
619
841
|
const response = values[0];
|
|
620
|
-
if (!eligible(response.statusCode ?? 0) || !runtime.api.
|
|
842
|
+
if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
|
|
843
|
+
runtime.track(
|
|
844
|
+
request.method,
|
|
845
|
+
() => requestUrl(request, protocol).toString(),
|
|
846
|
+
response.statusCode ?? 0,
|
|
847
|
+
startedAt,
|
|
848
|
+
performance.now() - started
|
|
849
|
+
);
|
|
621
850
|
return emit(event, ...values);
|
|
622
851
|
}
|
|
623
852
|
void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
|
|
@@ -636,6 +865,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
|
|
|
636
865
|
const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
|
|
637
866
|
return incomingResponse(healed, clientRequest);
|
|
638
867
|
}
|
|
868
|
+
function requestUrl(request, protocol) {
|
|
869
|
+
const authority = String(request.getHeader("host") ?? request.host);
|
|
870
|
+
return new URL(request.path, `${protocol}//${authority}`);
|
|
871
|
+
}
|
|
639
872
|
function webRequest(request, protocol, captured, signal) {
|
|
640
873
|
const headers = new Headers();
|
|
641
874
|
for (const name of request.getHeaderNames()) {
|
|
@@ -644,8 +877,7 @@ function webRequest(request, protocol, captured, signal) {
|
|
|
644
877
|
if (item !== void 0) headers.append(name, String(item));
|
|
645
878
|
}
|
|
646
879
|
}
|
|
647
|
-
const
|
|
648
|
-
const url = new URL(request.path, `${protocol}//${authority}`);
|
|
880
|
+
const url = requestUrl(request, protocol);
|
|
649
881
|
const method = request.method;
|
|
650
882
|
return new Request(url, {
|
|
651
883
|
method,
|
|
@@ -754,6 +986,7 @@ function cancellation(request, external) {
|
|
|
754
986
|
|
|
755
987
|
// src/index.ts
|
|
756
988
|
var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
|
|
989
|
+
var EXIT_FLUSH_MS = 2e3;
|
|
757
990
|
var globals = globalThis;
|
|
758
991
|
function manifest(options = {}) {
|
|
759
992
|
const key = options.key || process.env.MNFST_KEY;
|
|
@@ -785,6 +1018,9 @@ function manifest(options = {}) {
|
|
|
785
1018
|
installHttp(runtime);
|
|
786
1019
|
globals[STATE] = runtime;
|
|
787
1020
|
runtime.api.hello(`node-${process.versions.node}`);
|
|
1021
|
+
process.once("beforeExit", () => {
|
|
1022
|
+
if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
|
|
1023
|
+
});
|
|
788
1024
|
}
|
|
789
1025
|
|
|
790
1026
|
// 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. On Vercel, AWS Lambda and Cloud Run, each call is sent as soon as it is recorded, and the function is kept alive until it is delivered.
|
|
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.1
|
|
3
|
+
"version": "7.2.1",
|
|
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
|