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/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.0";
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.enabled() || this.inFlight >= 8) return null;
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.enabled()) return response;
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.enabled()) {
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 authority = String(request.getHeader("host") ?? request.host);
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
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  manifest
3
- } from "./chunk-4OVLT3PO.js";
4
- import "./chunk-AR23I2QK.js";
3
+ } from "./chunk-RO6CIYR2.js";
4
+ import "./chunk-OXOZQLZ3.js";
5
5
 
6
6
  // src/register.ts
7
7
  manifest();
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
- 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
+ **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.0",
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"
Binary file