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