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/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,
|
|
@@ -327,8 +340,29 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
|
|
|
327
340
|
// src/runtime.ts
|
|
328
341
|
var import_node_crypto = require("crypto");
|
|
329
342
|
|
|
343
|
+
// src/serverless.ts
|
|
344
|
+
var CONTEXTS = [/* @__PURE__ */ Symbol.for("@vercel/request-context"), /* @__PURE__ */ Symbol.for("@next/request-context")];
|
|
345
|
+
function isServerless(env = process.env) {
|
|
346
|
+
return Boolean(env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.K_SERVICE);
|
|
347
|
+
}
|
|
348
|
+
function keepAlive(promise) {
|
|
349
|
+
try {
|
|
350
|
+
const globals2 = globalThis;
|
|
351
|
+
for (const symbol of CONTEXTS) {
|
|
352
|
+
const waitUntil = globals2[symbol]?.get?.()?.waitUntil;
|
|
353
|
+
if (typeof waitUntil === "function") {
|
|
354
|
+
waitUntil(promise.catch(() => {
|
|
355
|
+
}));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
330
363
|
// src/api.ts
|
|
331
|
-
var VERSION = "7.1
|
|
364
|
+
var VERSION = "7.2.1";
|
|
365
|
+
var MAX_HEALS_IN_FLIGHT = 8;
|
|
332
366
|
var warn = (message) => process.emitWarning(message, { code: "MNFST" });
|
|
333
367
|
var HealApi = class {
|
|
334
368
|
constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
|
|
@@ -350,6 +384,14 @@ var HealApi = class {
|
|
|
350
384
|
enabled() {
|
|
351
385
|
return performance.now() >= this.disabledUntil;
|
|
352
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Whether a heal call would be sent right now: the project is not disabled
|
|
389
|
+
* and a heal slot is free. A healable failure that cannot be sent is tracked
|
|
390
|
+
* instead, so it is never lost from both ledgers.
|
|
391
|
+
*/
|
|
392
|
+
canHeal() {
|
|
393
|
+
return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
|
|
394
|
+
}
|
|
353
395
|
headers() {
|
|
354
396
|
return {
|
|
355
397
|
authorization: `Bearer ${this.key}`,
|
|
@@ -358,7 +400,7 @@ var HealApi = class {
|
|
|
358
400
|
};
|
|
359
401
|
}
|
|
360
402
|
async heal(capture, signal) {
|
|
361
|
-
if (!this.
|
|
403
|
+
if (!this.canHeal()) return null;
|
|
362
404
|
this.inFlight++;
|
|
363
405
|
const controller = new AbortController();
|
|
364
406
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -433,6 +475,41 @@ var HealApi = class {
|
|
|
433
475
|
}).catch(() => {
|
|
434
476
|
}).finally(() => clearTimeout(timer));
|
|
435
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
|
|
480
|
+
* retry could help (network error, timeout, 429, 5xx), so the buffer retries
|
|
481
|
+
* once; any other answer, including 404 from a backend that predates the
|
|
482
|
+
* route, drops the batch quietly. Uses `rawFetch`, so the send is never
|
|
483
|
+
* tracked or healed by our own patch.
|
|
484
|
+
*/
|
|
485
|
+
async sendRequests(calls, signal) {
|
|
486
|
+
if (!this.enabled() || calls.length === 0) return;
|
|
487
|
+
const controller = new AbortController();
|
|
488
|
+
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
489
|
+
try {
|
|
490
|
+
const response = await this.rawFetch(new URL("v1/requests", this.url), {
|
|
491
|
+
method: "POST",
|
|
492
|
+
headers: this.headers(),
|
|
493
|
+
body: JSON.stringify({ requests: calls }),
|
|
494
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
495
|
+
redirect: "error"
|
|
496
|
+
});
|
|
497
|
+
if (response.status === 403) {
|
|
498
|
+
const body = await response.json().catch(() => null);
|
|
499
|
+
if (isObject(body) && body.error === "project_disabled") {
|
|
500
|
+
this.disabledUntil = performance.now() + 3e5;
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
void response.body?.cancel().catch(() => {
|
|
505
|
+
});
|
|
506
|
+
if (response.status === 429 || response.status >= 500) {
|
|
507
|
+
throw new Error(`tracked calls refused (${response.status})`);
|
|
508
|
+
}
|
|
509
|
+
} finally {
|
|
510
|
+
clearTimeout(timer);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
436
513
|
report(id, outcome) {
|
|
437
514
|
if (!id) return;
|
|
438
515
|
if (this.pending.size >= 64) {
|
|
@@ -442,6 +519,7 @@ var HealApi = class {
|
|
|
442
519
|
const promise = this.send(id, outcome);
|
|
443
520
|
this.pending.add(promise);
|
|
444
521
|
void promise.then(() => this.pending.delete(promise));
|
|
522
|
+
keepAlive(promise);
|
|
445
523
|
}
|
|
446
524
|
async send(id, outcome) {
|
|
447
525
|
const controller = new AbortController();
|
|
@@ -469,6 +547,119 @@ var HealApi = class {
|
|
|
469
547
|
}
|
|
470
548
|
};
|
|
471
549
|
|
|
550
|
+
// src/tracking.ts
|
|
551
|
+
var MAX_BUFFER = 5e3;
|
|
552
|
+
var MAX_BATCH = 500;
|
|
553
|
+
var CallBuffer = class {
|
|
554
|
+
constructor(send, options = {}) {
|
|
555
|
+
this.send = send;
|
|
556
|
+
this.flushAt = options.flushAt ?? 500;
|
|
557
|
+
this.minGapMs = options.minGapMs ?? 1e3;
|
|
558
|
+
this.immediate = options.immediate ?? false;
|
|
559
|
+
this.keepAlive = options.keepAlive ?? keepAlive;
|
|
560
|
+
this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
|
|
561
|
+
this.timer.unref();
|
|
562
|
+
}
|
|
563
|
+
send;
|
|
564
|
+
queue = [];
|
|
565
|
+
inFlight = null;
|
|
566
|
+
lastSentAt = -Infinity;
|
|
567
|
+
/** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
|
|
568
|
+
deadline = Infinity;
|
|
569
|
+
controller = new AbortController();
|
|
570
|
+
timer;
|
|
571
|
+
flushAt;
|
|
572
|
+
minGapMs;
|
|
573
|
+
immediate;
|
|
574
|
+
keepAlive;
|
|
575
|
+
draining = null;
|
|
576
|
+
record(call) {
|
|
577
|
+
if (this.queue.length >= MAX_BUFFER) return;
|
|
578
|
+
this.queue.push(call);
|
|
579
|
+
if (this.immediate) this.drain();
|
|
580
|
+
else if (this.queue.length >= this.flushAt) void this.kick();
|
|
581
|
+
}
|
|
582
|
+
size() {
|
|
583
|
+
return this.queue.length;
|
|
584
|
+
}
|
|
585
|
+
/** Resolves when the send in flight, if any, has settled. */
|
|
586
|
+
async idle() {
|
|
587
|
+
await this.inFlight;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Send everything buffered now, one batch at a time through the same
|
|
591
|
+
* in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
|
|
592
|
+
* up at the deadline: the send in flight is aborted, nothing is retried, and
|
|
593
|
+
* what is left is dropped, so a script never hangs on an unreachable server.
|
|
594
|
+
*/
|
|
595
|
+
async flush(deadlineMs = Infinity) {
|
|
596
|
+
this.deadline = performance.now() + deadlineMs;
|
|
597
|
+
const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
|
|
598
|
+
try {
|
|
599
|
+
while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
|
|
600
|
+
await (this.inFlight ?? this.start());
|
|
601
|
+
}
|
|
602
|
+
} finally {
|
|
603
|
+
if (abortAt) clearTimeout(abortAt);
|
|
604
|
+
if (this.controller.signal.aborted) this.controller = new AbortController();
|
|
605
|
+
this.deadline = Infinity;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
stop() {
|
|
609
|
+
clearInterval(this.timer);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Serverless: the function may freeze as soon as its response is sent, before
|
|
613
|
+
* any timer fires. So send now, through `flush()` (same gap, same single send
|
|
614
|
+
* in flight), and let the platform wait for it. Calls recorded meanwhile ride
|
|
615
|
+
* the same drain; one that lands as it settles starts the next.
|
|
616
|
+
*/
|
|
617
|
+
drain() {
|
|
618
|
+
if (this.draining) return;
|
|
619
|
+
const run = this.flush().finally(() => {
|
|
620
|
+
this.draining = null;
|
|
621
|
+
if (this.queue.length > 0) this.drain();
|
|
622
|
+
});
|
|
623
|
+
this.draining = run;
|
|
624
|
+
this.keepAlive(run);
|
|
625
|
+
}
|
|
626
|
+
kick() {
|
|
627
|
+
if (this.inFlight || this.queue.length === 0) return;
|
|
628
|
+
if (performance.now() - this.lastSentAt < this.minGapMs) return;
|
|
629
|
+
void this.start();
|
|
630
|
+
}
|
|
631
|
+
start() {
|
|
632
|
+
this.inFlight = this.sendOne().finally(() => {
|
|
633
|
+
this.inFlight = null;
|
|
634
|
+
});
|
|
635
|
+
return this.inFlight;
|
|
636
|
+
}
|
|
637
|
+
async sendOne() {
|
|
638
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
639
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
640
|
+
if (attempt > 0 && performance.now() >= this.deadline) return;
|
|
641
|
+
await this.waitForGap();
|
|
642
|
+
this.lastSentAt = performance.now();
|
|
643
|
+
try {
|
|
644
|
+
await this.send(batch, this.controller.signal);
|
|
645
|
+
return;
|
|
646
|
+
} catch {
|
|
647
|
+
}
|
|
648
|
+
if (this.controller.signal.aborted) {
|
|
649
|
+
this.controller = new AbortController();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Referenced on purpose: this wait only happens while a send or an exit
|
|
655
|
+
// flush is under way, and an unref'd wait would let the process exit with
|
|
656
|
+
// the batch unsent. It never outlasts the flush deadline.
|
|
657
|
+
async waitForGap() {
|
|
658
|
+
const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
|
|
659
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
472
663
|
// src/runtime.ts
|
|
473
664
|
var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
|
|
474
665
|
var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
|
|
@@ -479,20 +670,50 @@ var Runtime = class {
|
|
|
479
670
|
this.options = options;
|
|
480
671
|
this.original = original;
|
|
481
672
|
this.api = api ?? new HealApi(original, options.key, options.url);
|
|
673
|
+
this.tracker = new CallBuffer(
|
|
674
|
+
(batch, signal) => this.api.sendRequests(batch, signal),
|
|
675
|
+
{ immediate: isServerless() }
|
|
676
|
+
);
|
|
482
677
|
}
|
|
483
678
|
options;
|
|
484
679
|
original;
|
|
485
680
|
api;
|
|
681
|
+
/** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
|
|
682
|
+
tracker;
|
|
683
|
+
/**
|
|
684
|
+
* Record a call that is not being healed. An in-memory append: never awaited,
|
|
685
|
+
* never throws, reads nothing of the response.
|
|
686
|
+
*/
|
|
687
|
+
track(method, url, statusCode, startedAt, responseTimeMs) {
|
|
688
|
+
try {
|
|
689
|
+
const reported = trackedUrl(url());
|
|
690
|
+
const verb = method.toUpperCase();
|
|
691
|
+
if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
|
|
692
|
+
this.tracker.record({
|
|
693
|
+
traceId: (0, import_node_crypto.randomUUID)(),
|
|
694
|
+
method: verb,
|
|
695
|
+
url: reported,
|
|
696
|
+
statusCode,
|
|
697
|
+
responseTimeMs: Math.round(responseTimeMs),
|
|
698
|
+
occurredAt: new Date(startedAt).toISOString()
|
|
699
|
+
});
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
}
|
|
486
703
|
fetch = async (input, init) => {
|
|
487
704
|
const request = new Request(input, init);
|
|
488
705
|
const extras = { ...init };
|
|
489
706
|
delete extras.body;
|
|
490
707
|
delete extras.headers;
|
|
491
708
|
const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
|
|
709
|
+
const startedAt = Date.now();
|
|
492
710
|
const started = performance.now();
|
|
493
711
|
const response = await this.original(request, extras);
|
|
494
712
|
const responseTimeMs = performance.now() - started;
|
|
495
|
-
if (!eligible(response.status) || response.redirected || !this.api.
|
|
713
|
+
if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
|
|
714
|
+
this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
|
|
715
|
+
return response;
|
|
716
|
+
}
|
|
496
717
|
return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
|
|
497
718
|
};
|
|
498
719
|
async handleResponse(request, response, body, responseTimeMs, extras = {}) {
|
|
@@ -622,6 +843,7 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
622
843
|
return ((...received) => {
|
|
623
844
|
const args = [...received];
|
|
624
845
|
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
846
|
+
const startedAt = Date.now();
|
|
625
847
|
const started = performance.now();
|
|
626
848
|
const request = original(...args);
|
|
627
849
|
const capture = captureBody(request);
|
|
@@ -630,7 +852,14 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
630
852
|
request.emit = ((event, ...values) => {
|
|
631
853
|
if (event !== "response") return emit(event, ...values);
|
|
632
854
|
const response = values[0];
|
|
633
|
-
if (!eligible(response.statusCode ?? 0) || !runtime.api.
|
|
855
|
+
if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
|
|
856
|
+
runtime.track(
|
|
857
|
+
request.method,
|
|
858
|
+
() => requestUrl(request, protocol).toString(),
|
|
859
|
+
response.statusCode ?? 0,
|
|
860
|
+
startedAt,
|
|
861
|
+
performance.now() - started
|
|
862
|
+
);
|
|
634
863
|
return emit(event, ...values);
|
|
635
864
|
}
|
|
636
865
|
void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
|
|
@@ -649,6 +878,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
|
|
|
649
878
|
const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
|
|
650
879
|
return incomingResponse(healed, clientRequest);
|
|
651
880
|
}
|
|
881
|
+
function requestUrl(request, protocol) {
|
|
882
|
+
const authority = String(request.getHeader("host") ?? request.host);
|
|
883
|
+
return new URL(request.path, `${protocol}//${authority}`);
|
|
884
|
+
}
|
|
652
885
|
function webRequest(request, protocol, captured, signal) {
|
|
653
886
|
const headers = new Headers();
|
|
654
887
|
for (const name of request.getHeaderNames()) {
|
|
@@ -657,8 +890,7 @@ function webRequest(request, protocol, captured, signal) {
|
|
|
657
890
|
if (item !== void 0) headers.append(name, String(item));
|
|
658
891
|
}
|
|
659
892
|
}
|
|
660
|
-
const
|
|
661
|
-
const url = new URL(request.path, `${protocol}//${authority}`);
|
|
893
|
+
const url = requestUrl(request, protocol);
|
|
662
894
|
const method = request.method;
|
|
663
895
|
return new Request(url, {
|
|
664
896
|
method,
|
|
@@ -767,6 +999,7 @@ function cancellation(request, external) {
|
|
|
767
999
|
|
|
768
1000
|
// src/index.ts
|
|
769
1001
|
var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
|
|
1002
|
+
var EXIT_FLUSH_MS = 2e3;
|
|
770
1003
|
var globals = globalThis;
|
|
771
1004
|
function manifest(options = {}) {
|
|
772
1005
|
const key = options.key || process.env.MNFST_KEY;
|
|
@@ -798,6 +1031,9 @@ function manifest(options = {}) {
|
|
|
798
1031
|
installHttp(runtime);
|
|
799
1032
|
globals[STATE] = runtime;
|
|
800
1033
|
runtime.api.hello(`node-${process.versions.node}`);
|
|
1034
|
+
process.once("beforeExit", () => {
|
|
1035
|
+
if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
|
|
1036
|
+
});
|
|
801
1037
|
}
|
|
802
1038
|
// Annotate the CommonJS export names for ESM import in node:
|
|
803
1039
|
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.1
|
|
15
|
+
declare const VERSION = "7.2.1";
|
|
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.1
|
|
15
|
+
declare const VERSION = "7.2.1";
|
|
16
16
|
|
|
17
17
|
/** Install once, before libraries capture their own reference to global fetch. */
|
|
18
18
|
declare function manifest(options?: ManifestOptions): void;
|