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/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.0.0";
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.enabled() || this.inFlight >= 8) return null;
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(response, 1048576, this.timeoutMs);
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)) return null;
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") return null;
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(new URL(`v1/heal-attempts/${encodeURIComponent(id)}`, this.url), {
402
- method: "PATCH",
403
- headers: this.headers(),
404
- body: JSON.stringify(outcome),
405
- signal: controller.signal,
406
- redirect: "error"
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) warn("Outcome report rejected; attempt remains unconfirmed");
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.enabled()) return response;
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 && !["GET", "HEAD"].includes(request.method)) return 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: ["GET", "HEAD"].includes(request.method) ? void 0 : serializeRequestBody(body, contentType),
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.enabled()) {
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 authority = String(request.getHeader("host") ?? request.host);
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(options.url || process.env.MNFST_URL || "https://api.manifest.build");
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("Manifest URL must be an HTTP(S) base URL without credentials, query or fragment");
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("Manifest is already configured; changing configuration requires a process restart");
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
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  manifest
3
- } from "./chunk-DP2C4E42.js";
3
+ } from "./chunk-GC5H22R2.js";
4
+ import "./chunk-NHEF7GUP.js";
4
5
 
5
6
  // src/register.ts
6
7
  manifest();
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`. 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:
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
- 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.
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
- 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. 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.0.0",
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",