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/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,
@@ -328,7 +341,8 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
328
341
  var import_node_crypto = require("crypto");
329
342
 
330
343
  // src/api.ts
331
- var VERSION = "7.0.0";
344
+ var VERSION = "7.2.0";
345
+ var MAX_HEALS_IN_FLIGHT = 8;
332
346
  var warn = (message) => process.emitWarning(message, { code: "MNFST" });
333
347
  var HealApi = class {
334
348
  constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
@@ -350,16 +364,23 @@ var HealApi = class {
350
364
  enabled() {
351
365
  return performance.now() >= this.disabledUntil;
352
366
  }
367
+ /**
368
+ * Whether a heal call would be sent right now: the project is not disabled
369
+ * and a heal slot is free. A healable failure that cannot be sent is tracked
370
+ * instead, so it is never lost from both ledgers.
371
+ */
372
+ canHeal() {
373
+ return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
374
+ }
353
375
  headers() {
354
376
  return {
355
377
  authorization: `Bearer ${this.key}`,
356
378
  "content-type": "application/json",
357
- "user-agent": `mnfst-node/${VERSION}`,
358
- "x-mnfst-source": "node-sdk"
379
+ "user-agent": `mnfst-node/${VERSION}`
359
380
  };
360
381
  }
361
382
  async heal(capture, signal) {
362
- if (!this.enabled() || this.inFlight >= 8) return null;
383
+ if (!this.canHeal()) return null;
363
384
  this.inFlight++;
364
385
  const controller = new AbortController();
365
386
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -376,14 +397,20 @@ var HealApi = class {
376
397
  });
377
398
  return null;
378
399
  }
379
- const captured = await captureResponse(response, 1048576, this.timeoutMs);
400
+ const captured = await captureResponse(
401
+ response,
402
+ 1048576,
403
+ this.timeoutMs
404
+ );
380
405
  void captured.response.body?.cancel().catch(() => {
381
406
  });
382
- if (!captured.complete || !isObject(captured.body) || !boundedJson(captured.body)) return null;
407
+ if (!captured.complete || !isObject(captured.body) || !boundedJson(captured.body))
408
+ return null;
383
409
  if (response.status === 403 && captured.body.error === "project_disabled") {
384
410
  this.disabledUntil = performance.now() + 3e5;
385
411
  }
386
- if (response.status !== 200 || typeof captured.body.status !== "string") return null;
412
+ if (response.status !== 200 || typeof captured.body.status !== "string")
413
+ return null;
387
414
  return {
388
415
  status: captured.body.status,
389
416
  ...typeof captured.body.healAttemptId === "string" ? { healAttemptId: captured.body.healAttemptId } : {},
@@ -397,6 +424,72 @@ var HealApi = class {
397
424
  this.inFlight--;
398
425
  }
399
426
  }
427
+ /**
428
+ * Announce this install: "I am installed."
429
+ *
430
+ * Without it, a broken install and a healthy app look identical from the
431
+ * dashboard — both are silence. Four causes hide behind that silence (key
432
+ * unset, key invalid, app not restarted, SDK never loaded), and an app with
433
+ * no failing calls produces exactly the same nothing.
434
+ *
435
+ * Deliberately fire-and-forget and deliberately quiet. It carries no
436
+ * application data: the name and version ride in the user-agent this class
437
+ * already sends, so the body is the runtime and nothing else. A failure is
438
+ * not warned about — the dashboard showing "not connected" IS the signal,
439
+ * and an app that cannot reach Manifest must not print on every boot.
440
+ *
441
+ * Uses `rawFetch`, so the handshake cannot be intercepted by our own patch.
442
+ */
443
+ hello(runtime) {
444
+ const controller = new AbortController();
445
+ const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
446
+ void this.rawFetch(new URL("v1/hello", this.url), {
447
+ method: "POST",
448
+ headers: this.headers(),
449
+ body: JSON.stringify({ runtime }),
450
+ signal: controller.signal,
451
+ redirect: "error"
452
+ }).then((response) => {
453
+ void response.body?.cancel().catch(() => {
454
+ });
455
+ }).catch(() => {
456
+ }).finally(() => clearTimeout(timer));
457
+ }
458
+ /**
459
+ * Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
460
+ * retry could help (network error, timeout, 429, 5xx), so the buffer retries
461
+ * once; any other answer, including 404 from a backend that predates the
462
+ * route, drops the batch quietly. Uses `rawFetch`, so the send is never
463
+ * tracked or healed by our own patch.
464
+ */
465
+ async sendRequests(calls, signal) {
466
+ if (!this.enabled() || calls.length === 0) return;
467
+ const controller = new AbortController();
468
+ const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
469
+ try {
470
+ const response = await this.rawFetch(new URL("v1/requests", this.url), {
471
+ method: "POST",
472
+ headers: this.headers(),
473
+ body: JSON.stringify({ requests: calls }),
474
+ signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
475
+ redirect: "error"
476
+ });
477
+ if (response.status === 403) {
478
+ const body = await response.json().catch(() => null);
479
+ if (isObject(body) && body.error === "project_disabled") {
480
+ this.disabledUntil = performance.now() + 3e5;
481
+ }
482
+ return;
483
+ }
484
+ void response.body?.cancel().catch(() => {
485
+ });
486
+ if (response.status === 429 || response.status >= 500) {
487
+ throw new Error(`tracked calls refused (${response.status})`);
488
+ }
489
+ } finally {
490
+ clearTimeout(timer);
491
+ }
492
+ }
400
493
  report(id, outcome) {
401
494
  if (!id) return;
402
495
  if (this.pending.size >= 64) {
@@ -411,16 +504,20 @@ var HealApi = class {
411
504
  const controller = new AbortController();
412
505
  const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
413
506
  try {
414
- const response = await this.rawFetch(new URL(`v1/heal-attempts/${encodeURIComponent(id)}`, this.url), {
415
- method: "PATCH",
416
- headers: this.headers(),
417
- body: JSON.stringify(outcome),
418
- signal: controller.signal,
419
- redirect: "error"
420
- });
507
+ const response = await this.rawFetch(
508
+ new URL(`v1/heal-attempts/${encodeURIComponent(id)}`, this.url),
509
+ {
510
+ method: "PATCH",
511
+ headers: this.headers(),
512
+ body: JSON.stringify(outcome),
513
+ signal: controller.signal,
514
+ redirect: "error"
515
+ }
516
+ );
421
517
  void response.body?.cancel().catch(() => {
422
518
  });
423
- if (!response.ok) warn("Outcome report rejected; attempt remains unconfirmed");
519
+ if (!response.ok)
520
+ warn("Outcome report rejected; attempt remains unconfirmed");
424
521
  } catch {
425
522
  warn("Outcome report failed; attempt remains unconfirmed");
426
523
  } finally {
@@ -429,28 +526,149 @@ var HealApi = class {
429
526
  }
430
527
  };
431
528
 
529
+ // src/tracking.ts
530
+ var MAX_BUFFER = 5e3;
531
+ var MAX_BATCH = 500;
532
+ var CallBuffer = class {
533
+ constructor(send, options = {}) {
534
+ this.send = send;
535
+ this.flushAt = options.flushAt ?? 500;
536
+ this.minGapMs = options.minGapMs ?? 1e3;
537
+ this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
538
+ this.timer.unref();
539
+ }
540
+ send;
541
+ queue = [];
542
+ inFlight = null;
543
+ lastSentAt = -Infinity;
544
+ /** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
545
+ deadline = Infinity;
546
+ controller = new AbortController();
547
+ timer;
548
+ flushAt;
549
+ minGapMs;
550
+ record(call) {
551
+ if (this.queue.length >= MAX_BUFFER) return;
552
+ this.queue.push(call);
553
+ if (this.queue.length >= this.flushAt) void this.kick();
554
+ }
555
+ size() {
556
+ return this.queue.length;
557
+ }
558
+ /** Resolves when the send in flight, if any, has settled. */
559
+ async idle() {
560
+ await this.inFlight;
561
+ }
562
+ /**
563
+ * Send everything buffered now, one batch at a time through the same
564
+ * in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
565
+ * up at the deadline: the send in flight is aborted, nothing is retried, and
566
+ * what is left is dropped, so a script never hangs on an unreachable server.
567
+ */
568
+ async flush(deadlineMs = Infinity) {
569
+ this.deadline = performance.now() + deadlineMs;
570
+ const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
571
+ try {
572
+ while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
573
+ await (this.inFlight ?? this.start());
574
+ }
575
+ } finally {
576
+ if (abortAt) clearTimeout(abortAt);
577
+ if (this.controller.signal.aborted) this.controller = new AbortController();
578
+ this.deadline = Infinity;
579
+ }
580
+ }
581
+ stop() {
582
+ clearInterval(this.timer);
583
+ }
584
+ kick() {
585
+ if (this.inFlight || this.queue.length === 0) return;
586
+ if (performance.now() - this.lastSentAt < this.minGapMs) return;
587
+ void this.start();
588
+ }
589
+ start() {
590
+ this.inFlight = this.sendOne().finally(() => {
591
+ this.inFlight = null;
592
+ });
593
+ return this.inFlight;
594
+ }
595
+ async sendOne() {
596
+ const batch = this.queue.splice(0, MAX_BATCH);
597
+ for (let attempt = 0; attempt < 2; attempt++) {
598
+ if (attempt > 0 && performance.now() >= this.deadline) return;
599
+ await this.waitForGap();
600
+ this.lastSentAt = performance.now();
601
+ try {
602
+ await this.send(batch, this.controller.signal);
603
+ return;
604
+ } catch {
605
+ }
606
+ if (this.controller.signal.aborted) {
607
+ this.controller = new AbortController();
608
+ return;
609
+ }
610
+ }
611
+ }
612
+ // Referenced on purpose: this wait only happens while a send or an exit
613
+ // flush is under way, and an unref'd wait would let the process exit with
614
+ // the batch unsent. It never outlasts the flush deadline.
615
+ async waitForGap() {
616
+ const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
617
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
618
+ }
619
+ };
620
+
432
621
  // src/runtime.ts
433
622
  var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
623
+ var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
624
+ var neverBodied = (method) => method === "GET" || method === "HEAD";
434
625
  var eligible = (status) => status >= 400 && status < 500 && !forbidden.has(status);
435
626
  var Runtime = class {
436
627
  constructor(options, original, api) {
437
628
  this.options = options;
438
629
  this.original = original;
439
630
  this.api = api ?? new HealApi(original, options.key, options.url);
631
+ this.tracker = new CallBuffer((batch, signal) => this.api.sendRequests(batch, signal));
440
632
  }
441
633
  options;
442
634
  original;
443
635
  api;
636
+ /** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
637
+ tracker;
638
+ /**
639
+ * Record a call that is not being healed. An in-memory append: never awaited,
640
+ * never throws, reads nothing of the response.
641
+ */
642
+ track(method, url, statusCode, startedAt, responseTimeMs) {
643
+ try {
644
+ const reported = trackedUrl(url());
645
+ const verb = method.toUpperCase();
646
+ if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
647
+ this.tracker.record({
648
+ traceId: (0, import_node_crypto.randomUUID)(),
649
+ method: verb,
650
+ url: reported,
651
+ statusCode,
652
+ responseTimeMs: Math.round(responseTimeMs),
653
+ occurredAt: new Date(startedAt).toISOString()
654
+ });
655
+ } catch {
656
+ }
657
+ }
444
658
  fetch = async (input, init) => {
445
659
  const request = new Request(input, init);
446
660
  const extras = { ...init };
447
661
  delete extras.body;
448
662
  delete extras.headers;
449
663
  const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
664
+ const startedAt = Date.now();
450
665
  const started = performance.now();
451
666
  const response = await this.original(request, extras);
452
667
  const responseTimeMs = performance.now() - started;
453
- if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
668
+ if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
669
+ this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
670
+ return response;
671
+ }
454
672
  return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
455
673
  };
456
674
  async handleResponse(request, response, body, responseTimeMs, extras = {}) {
@@ -539,11 +757,11 @@ function buildRetry(request, originalBody, result) {
539
757
  else return null;
540
758
  }
541
759
  const body = Object.hasOwn(healed, "body") ? mergeBody(originalBody, healed.body) : originalBody;
542
- if (body === null && !["GET", "HEAD"].includes(request.method)) return null;
760
+ if (body === null && !bodyless.includes(request.method)) return null;
543
761
  return new Request(url, {
544
762
  method: request.method,
545
763
  headers,
546
- body: ["GET", "HEAD"].includes(request.method) ? void 0 : serializeRequestBody(body, contentType),
764
+ body: body === null || neverBodied(request.method) ? void 0 : serializeRequestBody(body, contentType),
547
765
  signal: request.signal,
548
766
  redirect: request.redirect,
549
767
  credentials: request.credentials,
@@ -580,6 +798,7 @@ function wrapRequest(original, protocol, runtime) {
580
798
  return ((...received) => {
581
799
  const args = [...received];
582
800
  const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
801
+ const startedAt = Date.now();
583
802
  const started = performance.now();
584
803
  const request = original(...args);
585
804
  const capture = captureBody(request);
@@ -588,7 +807,14 @@ function wrapRequest(original, protocol, runtime) {
588
807
  request.emit = ((event, ...values) => {
589
808
  if (event !== "response") return emit(event, ...values);
590
809
  const response = values[0];
591
- if (!eligible(response.statusCode ?? 0) || !runtime.api.enabled()) {
810
+ if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
811
+ runtime.track(
812
+ request.method,
813
+ () => requestUrl(request, protocol).toString(),
814
+ response.statusCode ?? 0,
815
+ startedAt,
816
+ performance.now() - started
817
+ );
592
818
  return emit(event, ...values);
593
819
  }
594
820
  void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
@@ -607,6 +833,10 @@ async function handleResponse(runtime, clientRequest, incoming, protocol, body,
607
833
  const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
608
834
  return incomingResponse(healed, clientRequest);
609
835
  }
836
+ function requestUrl(request, protocol) {
837
+ const authority = String(request.getHeader("host") ?? request.host);
838
+ return new URL(request.path, `${protocol}//${authority}`);
839
+ }
610
840
  function webRequest(request, protocol, captured, signal) {
611
841
  const headers = new Headers();
612
842
  for (const name of request.getHeaderNames()) {
@@ -615,8 +845,7 @@ function webRequest(request, protocol, captured, signal) {
615
845
  if (item !== void 0) headers.append(name, String(item));
616
846
  }
617
847
  }
618
- const authority = String(request.getHeader("host") ?? request.host);
619
- const url = new URL(request.path, `${protocol}//${authority}`);
848
+ const url = requestUrl(request, protocol);
620
849
  const method = request.method;
621
850
  return new Request(url, {
622
851
  method,
@@ -725,6 +954,7 @@ function cancellation(request, external) {
725
954
 
726
955
  // src/index.ts
727
956
  var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
957
+ var EXIT_FLUSH_MS = 2e3;
728
958
  var globals = globalThis;
729
959
  function manifest(options = {}) {
730
960
  const key = options.key || process.env.MNFST_KEY;
@@ -732,16 +962,22 @@ function manifest(options = {}) {
732
962
  warn("MNFST_KEY is not set; Manifest is disabled");
733
963
  return;
734
964
  }
735
- const url = new URL(options.url || process.env.MNFST_URL || "https://api.manifest.build");
965
+ const url = new URL(
966
+ options.url || process.env.MNFST_URL || "https://api.manifest.build"
967
+ );
736
968
  if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
737
- throw new TypeError("Manifest URL must be an HTTP(S) base URL without credentials, query or fragment");
969
+ throw new TypeError(
970
+ "Manifest URL must be an HTTP(S) base URL without credentials, query or fragment"
971
+ );
738
972
  }
739
973
  if (!url.pathname.endsWith("/")) url.pathname += "/";
740
974
  const resolved = { ...options, key, url: url.toString() };
741
975
  const existing = globals[STATE];
742
976
  if (existing) {
743
977
  if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
744
- warn("Manifest is already configured; changing configuration requires a process restart");
978
+ warn(
979
+ "Manifest is already configured; changing configuration requires a process restart"
980
+ );
745
981
  }
746
982
  return;
747
983
  }
@@ -749,6 +985,10 @@ function manifest(options = {}) {
749
985
  globalThis.fetch = runtime.fetch;
750
986
  installHttp(runtime);
751
987
  globals[STATE] = runtime;
988
+ runtime.api.hello(`node-${process.versions.node}`);
989
+ process.once("beforeExit", () => {
990
+ if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
991
+ });
752
992
  }
753
993
  // Annotate the CommonJS export names for ESM import in node:
754
994
  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.0.0";
15
+ declare const VERSION = "7.2.0";
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.0.0";
15
+ declare const VERSION = "7.2.0";
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,7 +1,9 @@
1
1
  import {
2
- VERSION,
3
2
  manifest
4
- } from "./chunk-DP2C4E42.js";
3
+ } from "./chunk-GC5H22R2.js";
4
+ import {
5
+ VERSION
6
+ } from "./chunk-NHEF7GUP.js";
5
7
  export {
6
8
  VERSION,
7
9
  manifest