manifest 7.0.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 +25 -1
- package/LICENSE +21 -0
- package/README.md +64 -19
- package/dist/bin.cjs +410 -0
- package/dist/bin.d.cts +1 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +383 -0
- package/dist/chunk-GC5H22R2.js +495 -0
- package/dist/{chunk-DP2C4E42.js → chunk-NHEF7GUP.js} +126 -348
- package/dist/index.cjs +264 -24
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -2
- package/dist/register.cjs +264 -24
- package/dist/register.js +2 -1
- package/docs/guide.md +70 -10
- package/docs/sdk-flow-diagram.png +0 -0
- package/package.json +8 -3
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,16 +351,23 @@ 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}`,
|
|
343
365
|
"content-type": "application/json",
|
|
344
|
-
"user-agent": `mnfst-node/${VERSION}
|
|
345
|
-
"x-mnfst-source": "node-sdk"
|
|
366
|
+
"user-agent": `mnfst-node/${VERSION}`
|
|
346
367
|
};
|
|
347
368
|
}
|
|
348
369
|
async heal(capture, signal) {
|
|
349
|
-
if (!this.
|
|
370
|
+
if (!this.canHeal()) return null;
|
|
350
371
|
this.inFlight++;
|
|
351
372
|
const controller = new AbortController();
|
|
352
373
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -363,14 +384,20 @@ var HealApi = class {
|
|
|
363
384
|
});
|
|
364
385
|
return null;
|
|
365
386
|
}
|
|
366
|
-
const captured = await captureResponse(
|
|
387
|
+
const captured = await captureResponse(
|
|
388
|
+
response,
|
|
389
|
+
1048576,
|
|
390
|
+
this.timeoutMs
|
|
391
|
+
);
|
|
367
392
|
void captured.response.body?.cancel().catch(() => {
|
|
368
393
|
});
|
|
369
|
-
if (!captured.complete || !isObject(captured.body) || !boundedJson(captured.body))
|
|
394
|
+
if (!captured.complete || !isObject(captured.body) || !boundedJson(captured.body))
|
|
395
|
+
return null;
|
|
370
396
|
if (response.status === 403 && captured.body.error === "project_disabled") {
|
|
371
397
|
this.disabledUntil = performance.now() + 3e5;
|
|
372
398
|
}
|
|
373
|
-
if (response.status !== 200 || typeof captured.body.status !== "string")
|
|
399
|
+
if (response.status !== 200 || typeof captured.body.status !== "string")
|
|
400
|
+
return null;
|
|
374
401
|
return {
|
|
375
402
|
status: captured.body.status,
|
|
376
403
|
...typeof captured.body.healAttemptId === "string" ? { healAttemptId: captured.body.healAttemptId } : {},
|
|
@@ -384,6 +411,72 @@ var HealApi = class {
|
|
|
384
411
|
this.inFlight--;
|
|
385
412
|
}
|
|
386
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Announce this install: "I am installed."
|
|
416
|
+
*
|
|
417
|
+
* Without it, a broken install and a healthy app look identical from the
|
|
418
|
+
* dashboard — both are silence. Four causes hide behind that silence (key
|
|
419
|
+
* unset, key invalid, app not restarted, SDK never loaded), and an app with
|
|
420
|
+
* no failing calls produces exactly the same nothing.
|
|
421
|
+
*
|
|
422
|
+
* Deliberately fire-and-forget and deliberately quiet. It carries no
|
|
423
|
+
* application data: the name and version ride in the user-agent this class
|
|
424
|
+
* already sends, so the body is the runtime and nothing else. A failure is
|
|
425
|
+
* not warned about — the dashboard showing "not connected" IS the signal,
|
|
426
|
+
* and an app that cannot reach Manifest must not print on every boot.
|
|
427
|
+
*
|
|
428
|
+
* Uses `rawFetch`, so the handshake cannot be intercepted by our own patch.
|
|
429
|
+
*/
|
|
430
|
+
hello(runtime) {
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
433
|
+
void this.rawFetch(new URL("v1/hello", this.url), {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: this.headers(),
|
|
436
|
+
body: JSON.stringify({ runtime }),
|
|
437
|
+
signal: controller.signal,
|
|
438
|
+
redirect: "error"
|
|
439
|
+
}).then((response) => {
|
|
440
|
+
void response.body?.cancel().catch(() => {
|
|
441
|
+
});
|
|
442
|
+
}).catch(() => {
|
|
443
|
+
}).finally(() => clearTimeout(timer));
|
|
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
|
+
}
|
|
387
480
|
report(id, outcome) {
|
|
388
481
|
if (!id) return;
|
|
389
482
|
if (this.pending.size >= 64) {
|
|
@@ -398,16 +491,20 @@ var HealApi = class {
|
|
|
398
491
|
const controller = new AbortController();
|
|
399
492
|
const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
|
|
400
493
|
try {
|
|
401
|
-
const response = await this.rawFetch(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
494
|
+
const response = await this.rawFetch(
|
|
495
|
+
new URL(`v1/heal-attempts/${encodeURIComponent(id)}`, this.url),
|
|
496
|
+
{
|
|
497
|
+
method: "PATCH",
|
|
498
|
+
headers: this.headers(),
|
|
499
|
+
body: JSON.stringify(outcome),
|
|
500
|
+
signal: controller.signal,
|
|
501
|
+
redirect: "error"
|
|
502
|
+
}
|
|
503
|
+
);
|
|
408
504
|
void response.body?.cancel().catch(() => {
|
|
409
505
|
});
|
|
410
|
-
if (!response.ok)
|
|
506
|
+
if (!response.ok)
|
|
507
|
+
warn("Outcome report rejected; attempt remains unconfirmed");
|
|
411
508
|
} catch {
|
|
412
509
|
warn("Outcome report failed; attempt remains unconfirmed");
|
|
413
510
|
} finally {
|
|
@@ -416,28 +513,149 @@ var HealApi = class {
|
|
|
416
513
|
}
|
|
417
514
|
};
|
|
418
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
|
+
|
|
419
608
|
// src/runtime.ts
|
|
420
609
|
var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
|
|
610
|
+
var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
|
|
611
|
+
var neverBodied = (method) => method === "GET" || method === "HEAD";
|
|
421
612
|
var eligible = (status) => status >= 400 && status < 500 && !forbidden.has(status);
|
|
422
613
|
var Runtime = class {
|
|
423
614
|
constructor(options, original, api) {
|
|
424
615
|
this.options = options;
|
|
425
616
|
this.original = original;
|
|
426
617
|
this.api = api ?? new HealApi(original, options.key, options.url);
|
|
618
|
+
this.tracker = new CallBuffer((batch, signal) => this.api.sendRequests(batch, signal));
|
|
427
619
|
}
|
|
428
620
|
options;
|
|
429
621
|
original;
|
|
430
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
|
+
}
|
|
431
645
|
fetch = async (input, init) => {
|
|
432
646
|
const request = new Request(input, init);
|
|
433
647
|
const extras = { ...init };
|
|
434
648
|
delete extras.body;
|
|
435
649
|
delete extras.headers;
|
|
436
650
|
const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
|
|
651
|
+
const startedAt = Date.now();
|
|
437
652
|
const started = performance.now();
|
|
438
653
|
const response = await this.original(request, extras);
|
|
439
654
|
const responseTimeMs = performance.now() - started;
|
|
440
|
-
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
|
+
}
|
|
441
659
|
return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
|
|
442
660
|
};
|
|
443
661
|
async handleResponse(request, response, body, responseTimeMs, extras = {}) {
|
|
@@ -526,11 +744,11 @@ function buildRetry(request, originalBody, result) {
|
|
|
526
744
|
else return null;
|
|
527
745
|
}
|
|
528
746
|
const body = Object.hasOwn(healed, "body") ? mergeBody(originalBody, healed.body) : originalBody;
|
|
529
|
-
if (body === null && !
|
|
747
|
+
if (body === null && !bodyless.includes(request.method)) return null;
|
|
530
748
|
return new Request(url, {
|
|
531
749
|
method: request.method,
|
|
532
750
|
headers,
|
|
533
|
-
body:
|
|
751
|
+
body: body === null || neverBodied(request.method) ? void 0 : serializeRequestBody(body, contentType),
|
|
534
752
|
signal: request.signal,
|
|
535
753
|
redirect: request.redirect,
|
|
536
754
|
credentials: request.credentials,
|
|
@@ -567,6 +785,7 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
567
785
|
return ((...received) => {
|
|
568
786
|
const args = [...received];
|
|
569
787
|
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
788
|
+
const startedAt = Date.now();
|
|
570
789
|
const started = performance.now();
|
|
571
790
|
const request = original(...args);
|
|
572
791
|
const capture = captureBody(request);
|
|
@@ -575,7 +794,14 @@ function wrapRequest(original, protocol, runtime) {
|
|
|
575
794
|
request.emit = ((event, ...values) => {
|
|
576
795
|
if (event !== "response") return emit(event, ...values);
|
|
577
796
|
const response = values[0];
|
|
578
|
-
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
|
+
);
|
|
579
805
|
return emit(event, ...values);
|
|
580
806
|
}
|
|
581
807
|
void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
|
|
@@ -594,6 +820,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
|
|
|
594
820
|
const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
|
|
595
821
|
return incomingResponse(healed, clientRequest);
|
|
596
822
|
}
|
|
823
|
+
function requestUrl(request, protocol) {
|
|
824
|
+
const authority = String(request.getHeader("host") ?? request.host);
|
|
825
|
+
return new URL(request.path, `${protocol}//${authority}`);
|
|
826
|
+
}
|
|
597
827
|
function webRequest(request, protocol, captured, signal) {
|
|
598
828
|
const headers = new Headers();
|
|
599
829
|
for (const name of request.getHeaderNames()) {
|
|
@@ -602,8 +832,7 @@ function webRequest(request, protocol, captured, signal) {
|
|
|
602
832
|
if (item !== void 0) headers.append(name, String(item));
|
|
603
833
|
}
|
|
604
834
|
}
|
|
605
|
-
const
|
|
606
|
-
const url = new URL(request.path, `${protocol}//${authority}`);
|
|
835
|
+
const url = requestUrl(request, protocol);
|
|
607
836
|
const method = request.method;
|
|
608
837
|
return new Request(url, {
|
|
609
838
|
method,
|
|
@@ -712,6 +941,7 @@ function cancellation(request, external) {
|
|
|
712
941
|
|
|
713
942
|
// src/index.ts
|
|
714
943
|
var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
|
|
944
|
+
var EXIT_FLUSH_MS = 2e3;
|
|
715
945
|
var globals = globalThis;
|
|
716
946
|
function manifest(options = {}) {
|
|
717
947
|
const key = options.key || process.env.MNFST_KEY;
|
|
@@ -719,16 +949,22 @@ function manifest(options = {}) {
|
|
|
719
949
|
warn("MNFST_KEY is not set; Manifest is disabled");
|
|
720
950
|
return;
|
|
721
951
|
}
|
|
722
|
-
const url = new URL(
|
|
952
|
+
const url = new URL(
|
|
953
|
+
options.url || process.env.MNFST_URL || "https://api.manifest.build"
|
|
954
|
+
);
|
|
723
955
|
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
724
|
-
throw new TypeError(
|
|
956
|
+
throw new TypeError(
|
|
957
|
+
"Manifest URL must be an HTTP(S) base URL without credentials, query or fragment"
|
|
958
|
+
);
|
|
725
959
|
}
|
|
726
960
|
if (!url.pathname.endsWith("/")) url.pathname += "/";
|
|
727
961
|
const resolved = { ...options, key, url: url.toString() };
|
|
728
962
|
const existing = globals[STATE];
|
|
729
963
|
if (existing) {
|
|
730
964
|
if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
|
|
731
|
-
warn(
|
|
965
|
+
warn(
|
|
966
|
+
"Manifest is already configured; changing configuration requires a process restart"
|
|
967
|
+
);
|
|
732
968
|
}
|
|
733
969
|
return;
|
|
734
970
|
}
|
|
@@ -736,6 +972,10 @@ function manifest(options = {}) {
|
|
|
736
972
|
globalThis.fetch = runtime.fetch;
|
|
737
973
|
installHttp(runtime);
|
|
738
974
|
globals[STATE] = runtime;
|
|
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
|
+
});
|
|
739
979
|
}
|
|
740
980
|
|
|
741
981
|
// src/register.ts
|
package/dist/register.js
CHANGED
package/docs/guide.md
CHANGED
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Configuration
|
|
6
6
|
|
|
7
|
-
Call `manifest()` once at startup, before other libraries save a reference to `fetch`, `node:http` or `node:https`.
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
node --import manifest/register app.js
|
|
11
|
-
# or, for a process you do not launch yourself:
|
|
12
|
-
NODE_OPTIONS="--import manifest/register" some-agent
|
|
13
|
-
```
|
|
7
|
+
Call `manifest()` once at startup, before other libraries save a reference to `fetch`, `node:http` or `node:https`. Where a client is built at import time and would capture the original `fetch` first, [preload the register entry](#preloading) instead.
|
|
14
8
|
|
|
15
9
|
It reads `MNFST_KEY` and `MNFST_URL` and takes no options.
|
|
16
10
|
|
|
@@ -35,9 +29,72 @@ manifest({
|
|
|
35
29
|
|
|
36
30
|
`onHeal` receives `url`, `statusCode`, `healStatus`, `replayStatusCode`, `healMs` and optional `operations`. URLs have known credential query fields masked. Callback errors do not fail application requests.
|
|
37
31
|
|
|
32
|
+
## Preloading
|
|
33
|
+
|
|
34
|
+
Calling `manifest()` from the first import of your entry file covers clients that read `fetch` after it runs, which is most of them. Some clients, the OpenAI and Anthropic SDKs among them, read global `fetch` once when constructed, and a client built at import time runs before any `manifest()` call in your code. Preload the register entry to install first:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
node -r manifest/register app.js
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`-r` is CommonJS `require`; `--import manifest/register` is the ESM loader form. Both resolve the same entry and install before the app module evaluates, in CJS and ESM apps alike. Prefer `-r` where you launch the process: it takes no quoting and works on Windows.
|
|
41
|
+
|
|
42
|
+
For a process you do not launch yourself — a framework CLI such as `nest start`, or a host dashboard that owns the command — pass it through `NODE_OPTIONS`:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
NODE_OPTIONS="--require manifest/register" nest start
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The register entry reads `MNFST_KEY` **once, at load**. Setting or changing the key without restarting the process does nothing. Without a key it warns once, leaves `fetch`, `node:http` and `node:https` untouched, and the app runs to exit 0.
|
|
49
|
+
|
|
50
|
+
### Next.js
|
|
51
|
+
|
|
52
|
+
Serverless deployments such as Vercel have no start command, so neither preload flag applies. Install from `instrumentation.ts`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// src/instrumentation.ts
|
|
56
|
+
export async function register() {
|
|
57
|
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
58
|
+
const { manifest } = await import('manifest');
|
|
59
|
+
manifest();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Guard inside an `if` block, as above, rather than returning early, and import `manifest` dynamically. A top-level import is resolved for both runtimes, and the Edge build fails with `Can't resolve 'http'`. On Next.js 14, set `experimental.instrumentationHook: true` in `next.config.js`.
|
|
65
|
+
|
|
66
|
+
### Edge runtime
|
|
67
|
+
|
|
68
|
+
Not supported. The SDK needs `node:crypto`, `node:http` and `node:https`, none of which exist on Edge. Next.js `middleware.ts` always runs on Edge and is therefore never covered.
|
|
69
|
+
|
|
38
70
|
## Verifying the installation
|
|
39
71
|
|
|
40
|
-
|
|
72
|
+
Run the doctor from the project directory. It checks the install without guessing from source:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx manifest doctor
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
✅ SDK installed manifest 7.0.0
|
|
80
|
+
✅ MNFST_KEY set mnfst_proj_…DZDw
|
|
81
|
+
✅ Key valid project "Find Concierge"
|
|
82
|
+
⚠️ Loads before your app cannot tell from here whether manifest() runs; check
|
|
83
|
+
Requests received
|
|
84
|
+
⚠️ Requests received no requests received yet
|
|
85
|
+
|
|
86
|
+
Runtime coverage
|
|
87
|
+
Node.js runtime fetch, http.request, https.request, http.get are patched
|
|
88
|
+
Edge runtime not supported — middleware.ts and Edge route handlers are never covered
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Loads before your app** recognizes a preload flag in a script and `instrumentation.ts` on a Next.js project. It does not read your source, so the ordinary `manifest()` call in an entry file is invisible to it: that is a warning, not a failure, and **Requests received** is what settles it. The one failure it reports is a Next.js project with no instrumentation file, where nothing can install Manifest at all.
|
|
92
|
+
|
|
93
|
+
`doctor` resolves the installed version, masks the key (it is never printed in full), and makes one round trip to the handshake endpoint — the only check that tells a good key from a typo'd or revoked one, which both look like silence otherwise. A non-zero exit code means a check failed.
|
|
94
|
+
|
|
95
|
+
The key check is a probe: it proves the key works without registering an install, so a diagnostic run never makes the dashboard claim your app is connected. Only a real boot does that.
|
|
96
|
+
|
|
97
|
+
You can also verify by hand: send a JSON request that your test API rejects with 400, 404, 422 or any other request-side 4xx. The failure appears in your project's dashboard, and the `onHeal` callback reports the repair result. A successful request alone does not contact Manifest. Outcome reports are asynchronous, so a short-lived script may exit before the report is delivered.
|
|
41
98
|
|
|
42
99
|
## Supported traffic
|
|
43
100
|
|
|
@@ -48,6 +105,7 @@ Send a JSON request that your test API rejects with 400, 404, 422 or any other r
|
|
|
48
105
|
- JSON and `application/x-www-form-urlencoded` APIs, including nested form fields. No provider-specific request format is required.
|
|
49
106
|
- One retry per capture. Same-origin URL and header repairs are supported by the SDK; the current app returns structured body repairs.
|
|
50
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").
|
|
51
109
|
|
|
52
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.
|
|
53
111
|
|
|
@@ -56,7 +114,7 @@ Send a JSON request that your test API rejects with 400, 404, 422 or any other r
|
|
|
56
114
|
- Request capture is bounded to 256 KiB and structure depth 64. Fetch uploads are teed for at most one second; Node HTTP writes are copied as they are sent. Oversized, malformed or slow fetch uploads travel as `null` and are not retried.
|
|
57
115
|
- Form-urlencoded retries are re-encoded from the parsed structure, so repeated keys such as `expand=a&expand=b` return as `expand[0]=a&expand[1]=b`. Servers that reject indexed keys see the retry fail like any other unsuccessful repair.
|
|
58
116
|
- Error capture reads at most 64 KiB plus one transport chunk, within one second. The prefix and remaining stream are preserved for the caller. Incomplete errors are reported without retry. One unusually large transport chunk can exceed that memory estimate.
|
|
59
|
-
- The Manifest heal call has a 60-second deadline and at most eight concurrent requests. Capacity exhaustion and service errors return the original API error response.
|
|
117
|
+
- The Manifest heal call has a 60-second deadline and at most eight concurrent requests. Capacity exhaustion and service errors return the original API error response. If the configured server is unreachable, the heal attempt fails in about 10 ms and the app's original error response surfaces unchanged.
|
|
60
118
|
- Caller abort signals apply during healing and retry; cancellation remains observable to the caller.
|
|
61
119
|
- A transport failure on retry returns the original error response and reports inconclusive evidence. If the retry returns another HTTP error, its raw body is reported so the app can distinguish recurrence from a new issue.
|
|
62
120
|
- Automatic retries can repeat side effects. Use APIs with safe retry semantics and caller-managed idempotency keys; these headers are preserved unless explicitly changed by a repair.
|
|
@@ -66,7 +124,9 @@ Outcome reports are best effort, limited to 64 concurrent requests with five-sec
|
|
|
66
124
|
|
|
67
125
|
## Data sent to Manifest
|
|
68
126
|
|
|
69
|
-
|
|
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.
|
|
70
130
|
|
|
71
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.
|
|
72
132
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
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",
|
|
7
7
|
"module": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"manifest": "./dist/bin.js"
|
|
11
|
+
},
|
|
9
12
|
"exports": {
|
|
10
13
|
".": {
|
|
11
14
|
"import": {
|
|
@@ -32,13 +35,15 @@
|
|
|
32
35
|
"dist",
|
|
33
36
|
"README.md",
|
|
34
37
|
"CONTRACT.md",
|
|
35
|
-
"docs"
|
|
38
|
+
"docs",
|
|
39
|
+
"!docs/github-sdk.png",
|
|
40
|
+
"!docs/sdk-flow-diagram.svg"
|
|
36
41
|
],
|
|
37
42
|
"engines": {
|
|
38
43
|
"node": ">=22"
|
|
39
44
|
},
|
|
40
45
|
"scripts": {
|
|
41
|
-
"build": "tsup src/index.ts src/register.ts --format esm,cjs --dts --clean",
|
|
46
|
+
"build": "tsup src/index.ts src/register.ts src/bin.ts --format esm,cjs --dts --clean",
|
|
42
47
|
"changeset": "changeset",
|
|
43
48
|
"typecheck": "tsc --noEmit",
|
|
44
49
|
"test": "tsx --test tests/*.test.ts",
|