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.
@@ -1,10 +1,3 @@
1
- // src/http.ts
2
- import http from "http";
3
- import https from "https";
4
- import { syncBuiltinESMExports } from "module";
5
- import { Readable } from "stream";
6
- import { createBrotliDecompress, createUnzip } from "zlib";
7
-
8
1
  // src/wire.ts
9
2
  var secretNames = /* @__PURE__ */ new Set([
10
3
  "api_key",
@@ -44,6 +37,19 @@ function safeUrl(raw) {
44
37
  url.search = new URLSearchParams(pairs).toString();
45
38
  return url.toString();
46
39
  }
40
+ function trackedUrl(raw) {
41
+ try {
42
+ const url = new URL(raw);
43
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
44
+ url.username = "";
45
+ url.password = "";
46
+ url.search = "";
47
+ url.hash = "";
48
+ return url.toString();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
47
53
  function safeHeaders(headers) {
48
54
  return Object.fromEntries([...headers].map(([key, value]) => [
49
55
  key,
@@ -287,11 +293,9 @@ async function captureResponse(original, limit = RESPONSE_LIMIT, timeoutMs = CAP
287
293
  return { response, body: errorBody(captured.bytes), truncated: !captured.complete || Buffer.byteLength(Buffer.from(captured.bytes).toString("utf8")) > limit, complete: captured.complete };
288
294
  }
289
295
 
290
- // src/runtime.ts
291
- import { randomUUID } from "crypto";
292
-
293
296
  // src/api.ts
294
- var VERSION = "7.0.0";
297
+ var VERSION = "7.2.0";
298
+ var MAX_HEALS_IN_FLIGHT = 8;
295
299
  var warn = (message) => process.emitWarning(message, { code: "MNFST" });
296
300
  var HealApi = class {
297
301
  constructor(rawFetch, key, url, timeoutMs = 6e4, reportTimeoutMs = 5e3) {
@@ -313,16 +317,23 @@ var HealApi = class {
313
317
  enabled() {
314
318
  return performance.now() >= this.disabledUntil;
315
319
  }
320
+ /**
321
+ * Whether a heal call would be sent right now: the project is not disabled
322
+ * and a heal slot is free. A healable failure that cannot be sent is tracked
323
+ * instead, so it is never lost from both ledgers.
324
+ */
325
+ canHeal() {
326
+ return this.enabled() && this.inFlight < MAX_HEALS_IN_FLIGHT;
327
+ }
316
328
  headers() {
317
329
  return {
318
330
  authorization: `Bearer ${this.key}`,
319
331
  "content-type": "application/json",
320
- "user-agent": `mnfst-node/${VERSION}`,
321
- "x-mnfst-source": "node-sdk"
332
+ "user-agent": `mnfst-node/${VERSION}`
322
333
  };
323
334
  }
324
335
  async heal(capture, signal) {
325
- if (!this.enabled() || this.inFlight >= 8) return null;
336
+ if (!this.canHeal()) return null;
326
337
  this.inFlight++;
327
338
  const controller = new AbortController();
328
339
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -339,14 +350,20 @@ var HealApi = class {
339
350
  });
340
351
  return null;
341
352
  }
342
- const captured = await captureResponse(response, 1048576, this.timeoutMs);
353
+ const captured = await captureResponse(
354
+ response,
355
+ 1048576,
356
+ this.timeoutMs
357
+ );
343
358
  void captured.response.body?.cancel().catch(() => {
344
359
  });
345
- if (!captured.complete || !isObject(captured.body) || !boundedJson(captured.body)) return null;
360
+ if (!captured.complete || !isObject(captured.body) || !boundedJson(captured.body))
361
+ return null;
346
362
  if (response.status === 403 && captured.body.error === "project_disabled") {
347
363
  this.disabledUntil = performance.now() + 3e5;
348
364
  }
349
- if (response.status !== 200 || typeof captured.body.status !== "string") return null;
365
+ if (response.status !== 200 || typeof captured.body.status !== "string")
366
+ return null;
350
367
  return {
351
368
  status: captured.body.status,
352
369
  ...typeof captured.body.healAttemptId === "string" ? { healAttemptId: captured.body.healAttemptId } : {},
@@ -360,6 +377,72 @@ var HealApi = class {
360
377
  this.inFlight--;
361
378
  }
362
379
  }
380
+ /**
381
+ * Announce this install: "I am installed."
382
+ *
383
+ * Without it, a broken install and a healthy app look identical from the
384
+ * dashboard — both are silence. Four causes hide behind that silence (key
385
+ * unset, key invalid, app not restarted, SDK never loaded), and an app with
386
+ * no failing calls produces exactly the same nothing.
387
+ *
388
+ * Deliberately fire-and-forget and deliberately quiet. It carries no
389
+ * application data: the name and version ride in the user-agent this class
390
+ * already sends, so the body is the runtime and nothing else. A failure is
391
+ * not warned about — the dashboard showing "not connected" IS the signal,
392
+ * and an app that cannot reach Manifest must not print on every boot.
393
+ *
394
+ * Uses `rawFetch`, so the handshake cannot be intercepted by our own patch.
395
+ */
396
+ hello(runtime) {
397
+ const controller = new AbortController();
398
+ const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
399
+ void this.rawFetch(new URL("v1/hello", this.url), {
400
+ method: "POST",
401
+ headers: this.headers(),
402
+ body: JSON.stringify({ runtime }),
403
+ signal: controller.signal,
404
+ redirect: "error"
405
+ }).then((response) => {
406
+ void response.body?.cancel().catch(() => {
407
+ });
408
+ }).catch(() => {
409
+ }).finally(() => clearTimeout(timer));
410
+ }
411
+ /**
412
+ * Ship one batch of tracked calls to `POST /v1/requests`. Throws only when a
413
+ * retry could help (network error, timeout, 429, 5xx), so the buffer retries
414
+ * once; any other answer, including 404 from a backend that predates the
415
+ * route, drops the batch quietly. Uses `rawFetch`, so the send is never
416
+ * tracked or healed by our own patch.
417
+ */
418
+ async sendRequests(calls, signal) {
419
+ if (!this.enabled() || calls.length === 0) return;
420
+ const controller = new AbortController();
421
+ const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
422
+ try {
423
+ const response = await this.rawFetch(new URL("v1/requests", this.url), {
424
+ method: "POST",
425
+ headers: this.headers(),
426
+ body: JSON.stringify({ requests: calls }),
427
+ signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
428
+ redirect: "error"
429
+ });
430
+ if (response.status === 403) {
431
+ const body = await response.json().catch(() => null);
432
+ if (isObject(body) && body.error === "project_disabled") {
433
+ this.disabledUntil = performance.now() + 3e5;
434
+ }
435
+ return;
436
+ }
437
+ void response.body?.cancel().catch(() => {
438
+ });
439
+ if (response.status === 429 || response.status >= 500) {
440
+ throw new Error(`tracked calls refused (${response.status})`);
441
+ }
442
+ } finally {
443
+ clearTimeout(timer);
444
+ }
445
+ }
363
446
  report(id, outcome) {
364
447
  if (!id) return;
365
448
  if (this.pending.size >= 64) {
@@ -374,16 +457,20 @@ var HealApi = class {
374
457
  const controller = new AbortController();
375
458
  const timer = setTimeout(() => controller.abort(), this.reportTimeoutMs);
376
459
  try {
377
- const response = await this.rawFetch(new URL(`v1/heal-attempts/${encodeURIComponent(id)}`, this.url), {
378
- method: "PATCH",
379
- headers: this.headers(),
380
- body: JSON.stringify(outcome),
381
- signal: controller.signal,
382
- redirect: "error"
383
- });
460
+ const response = await this.rawFetch(
461
+ new URL(`v1/heal-attempts/${encodeURIComponent(id)}`, this.url),
462
+ {
463
+ method: "PATCH",
464
+ headers: this.headers(),
465
+ body: JSON.stringify(outcome),
466
+ signal: controller.signal,
467
+ redirect: "error"
468
+ }
469
+ );
384
470
  void response.body?.cancel().catch(() => {
385
471
  });
386
- if (!response.ok) warn("Outcome report rejected; attempt remains unconfirmed");
472
+ if (!response.ok)
473
+ warn("Outcome report rejected; attempt remains unconfirmed");
387
474
  } catch {
388
475
  warn("Outcome report failed; attempt remains unconfirmed");
389
476
  } finally {
@@ -392,329 +479,20 @@ var HealApi = class {
392
479
  }
393
480
  };
394
481
 
395
- // src/runtime.ts
396
- var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
397
- var eligible = (status) => status >= 400 && status < 500 && !forbidden.has(status);
398
- var Runtime = class {
399
- constructor(options, original, api) {
400
- this.options = options;
401
- this.original = original;
402
- this.api = api ?? new HealApi(original, options.key, options.url);
403
- }
404
- options;
405
- original;
406
- api;
407
- fetch = async (input, init) => {
408
- const request = new Request(input, init);
409
- const extras = { ...init };
410
- delete extras.body;
411
- delete extras.headers;
412
- const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
413
- const started = performance.now();
414
- const response = await this.original(request, extras);
415
- const responseTimeMs = performance.now() - started;
416
- if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
417
- return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
418
- };
419
- async handleResponse(request, response, body, responseTimeMs, extras = {}) {
420
- if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
421
- return this.repair(request, extras, response, body, responseTimeMs);
422
- }
423
- async repair(request, extras, original, body, responseTimeMs) {
424
- let response = original;
425
- let result = null;
426
- const started = performance.now();
427
- let replayStatusCode = null;
428
- let replayAttempted = false;
429
- try {
430
- const captured = await captureResponse(response);
431
- response = captured.response;
432
- const payload = {
433
- traceId: randomUUID(),
434
- request: { method: request.method, url: safeUrl(request.url), headers: safeHeaders(request.headers), body: travelingBody(body.body) },
435
- response: { statusCode: response.status, body: captured.body, truncated: captured.truncated },
436
- responseTimeMs: Math.round(responseTimeMs)
437
- };
438
- request.signal.throwIfAborted();
439
- result = await this.api.heal(payload, request.signal);
440
- request.signal.throwIfAborted();
441
- const retry = body.complete && captured.complete ? buildRetry(request, body.body, result) : null;
442
- if (!retry) {
443
- this.api.report(result?.healAttemptId, { failure: { kind: "not_attempted", message: "replay_not_attempted" } });
444
- return response;
445
- }
446
- replayAttempted = true;
447
- let retried;
448
- try {
449
- retried = await this.original(retry, extras);
450
- } catch {
451
- this.api.report(result?.healAttemptId, { failure: { kind: "transport_error", message: TRANSPORT_ERROR } });
452
- request.signal.throwIfAborted();
453
- return response;
454
- }
455
- replayStatusCode = retried.status;
456
- if (retried.status >= 400) {
457
- const capturedRetry = await captureResponse(retried);
458
- retried = capturedRetry.response;
459
- this.api.report(result?.healAttemptId, { response: { statusCode: retried.status, body: capturedRetry.body, truncated: capturedRetry.truncated } });
460
- } else {
461
- this.api.report(result?.healAttemptId, { response: { statusCode: retried.status } });
462
- }
463
- void response.body?.cancel().catch(() => {
464
- });
465
- return retried;
466
- } catch {
467
- request.signal.throwIfAborted();
468
- this.api.report(result?.healAttemptId, { failure: { kind: "not_attempted", message: "replay_not_attempted" } });
469
- return response;
470
- } finally {
471
- if (this.options.onHeal) {
472
- try {
473
- void Promise.resolve(this.options.onHeal({
474
- url: safeUrl(request.url),
475
- statusCode: original.status,
476
- healStatus: replayAttempted && replayStatusCode === null ? "replay_failed" : result?.status ?? "heal_unreachable",
477
- replayStatusCode,
478
- healMs: Math.round(performance.now() - started),
479
- operations: result?.operations
480
- })).catch(() => warn("onHeal callback failed"));
481
- } catch {
482
- warn("onHeal callback failed");
483
- }
484
- }
485
- }
486
- }
487
- };
488
- function buildRetry(request, originalBody, result) {
489
- if (!result || !["patched", "unverified"].includes(result.status) || !isObject(result.healedRequest)) return null;
490
- const healed = result.healedRequest;
491
- if (!["url", "headers", "body"].some((key) => Object.hasOwn(healed, key))) return null;
492
- try {
493
- const url = new URL(healed.url ?? request.url);
494
- if (url.origin !== new URL(request.url).origin || url.username || url.password) return null;
495
- const headers = new Headers(request.headers);
496
- const contentType = headers.get("content-type");
497
- headers.delete("content-length");
498
- if (healed.headers !== void 0 && !isObject(healed.headers)) return null;
499
- for (const [name, value] of Object.entries(healed.headers ?? {})) {
500
- if (value === null) headers.delete(name);
501
- else if (typeof value === "string") headers.set(name, value);
502
- else return null;
503
- }
504
- const body = Object.hasOwn(healed, "body") ? mergeBody(originalBody, healed.body) : originalBody;
505
- if (body === null && !["GET", "HEAD"].includes(request.method)) return null;
506
- return new Request(url, {
507
- method: request.method,
508
- headers,
509
- body: ["GET", "HEAD"].includes(request.method) ? void 0 : serializeRequestBody(body, contentType),
510
- signal: request.signal,
511
- redirect: request.redirect,
512
- credentials: request.credentials,
513
- cache: request.cache,
514
- integrity: request.integrity,
515
- keepalive: request.keepalive,
516
- mode: request.mode,
517
- referrer: request.referrer,
518
- referrerPolicy: request.referrerPolicy
519
- });
520
- } catch {
521
- return null;
522
- }
523
- }
524
-
525
- // src/http.ts
526
- function installHttp(runtime) {
527
- const httpRequest = wrapRequest(http.request, "http:", runtime);
528
- const httpsRequest = wrapRequest(https.request, "https:", runtime);
529
- http.request = httpRequest;
530
- https.request = httpsRequest;
531
- http.get = wrapGet(httpRequest);
532
- https.get = wrapGet(httpsRequest);
533
- syncBuiltinESMExports();
534
- }
535
- function wrapGet(request) {
536
- return ((...args) => {
537
- const result = request(...args);
538
- result.end();
539
- return result;
540
- });
541
- }
542
- function wrapRequest(original, protocol, runtime) {
543
- return ((...received) => {
544
- const args = [...received];
545
- const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
546
- const started = performance.now();
547
- const request = original(...args);
548
- const capture = captureBody(request);
549
- const signal = cancellation(request, requestSignal(args));
550
- const emit = request.emit.bind(request);
551
- request.emit = ((event, ...values) => {
552
- if (event !== "response") return emit(event, ...values);
553
- const response = values[0];
554
- if (!eligible(response.statusCode ?? 0) || !runtime.api.enabled()) {
555
- return emit(event, ...values);
556
- }
557
- void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
558
- response.destroy();
559
- if (!request.destroyed) request.destroy(error instanceof Error ? error : void 0);
560
- });
561
- return true;
562
- });
563
- if (callback) request.once("response", callback);
564
- return request;
565
- });
566
- }
567
- async function handleResponse(runtime, clientRequest, incoming, protocol, body, signal, started) {
568
- const request = webRequest(clientRequest, protocol, body, signal);
569
- const response = webResponse(incoming, request.url);
570
- const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
571
- return incomingResponse(healed, clientRequest);
572
- }
573
- function webRequest(request, protocol, captured, signal) {
574
- const headers = new Headers();
575
- for (const name of request.getHeaderNames()) {
576
- const value = request.getHeader(name);
577
- for (const item of Array.isArray(value) ? value : [value]) {
578
- if (item !== void 0) headers.append(name, String(item));
579
- }
580
- }
581
- const authority = String(request.getHeader("host") ?? request.host);
582
- const url = new URL(request.path, `${protocol}//${authority}`);
583
- const method = request.method;
584
- return new Request(url, {
585
- method,
586
- headers,
587
- signal,
588
- body: ["GET", "HEAD"].includes(method) || !captured.complete || captured.body === null ? void 0 : serializeRequestBody(captured.body, headers.get("content-type"))
589
- });
590
- }
591
- function webResponse(response, url) {
592
- const headers = new Headers();
593
- for (const [name, value] of Object.entries(response.headers)) {
594
- for (const item of Array.isArray(value) ? value : [value]) {
595
- if (item !== void 0) headers.append(name, String(item));
596
- }
597
- }
598
- const encoding = headers.get("content-encoding")?.toLowerCase();
599
- let body = response;
600
- if (encoding === "gzip" || encoding === "x-gzip" || encoding === "deflate") {
601
- body = response.pipe(createUnzip());
602
- headers.delete("content-encoding");
603
- headers.delete("content-length");
604
- } else if (encoding === "br") {
605
- body = response.pipe(createBrotliDecompress());
606
- headers.delete("content-encoding");
607
- headers.delete("content-length");
608
- }
609
- const converted = new Response(
610
- Readable.toWeb(body),
611
- { status: response.statusCode, statusText: response.statusMessage, headers }
612
- );
613
- Object.defineProperty(converted, "url", { value: url });
614
- return converted;
615
- }
616
- function incomingResponse(response, request) {
617
- const stream = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
618
- const headers = {};
619
- for (const [name, value] of response.headers) headers[name] = value;
620
- if (["gzip", "x-gzip", "deflate", "br"].includes(String(headers["content-encoding"]).toLowerCase())) {
621
- delete headers["content-encoding"];
622
- delete headers["content-length"];
623
- }
624
- const cookies = response.headers.getSetCookie();
625
- if (cookies.length) headers["set-cookie"] = cookies;
626
- const rawHeaders = Object.entries(headers).flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).flatMap((item) => item === void 0 ? [] : [name, String(item)]));
627
- return Object.assign(stream, {
628
- statusCode: response.status,
629
- statusMessage: response.statusText,
630
- headers,
631
- rawHeaders,
632
- trailers: {},
633
- rawTrailers: [],
634
- httpVersion: "1.1",
635
- httpVersionMajor: 1,
636
- httpVersionMinor: 1,
637
- complete: true,
638
- req: request
639
- });
640
- }
641
- function captureBody(request) {
642
- let chunks = [];
643
- let size = 0;
644
- let complete = true;
645
- const record = (chunk, encoding) => {
646
- if (chunk === void 0 || chunk === null || typeof chunk === "function" || !complete) return;
647
- const bytes = typeof chunk === "string" ? Buffer.from(chunk, encoding) : Buffer.from(chunk);
648
- size += bytes.byteLength;
649
- if (size > REQUEST_LIMIT) {
650
- complete = false;
651
- chunks = [];
652
- return;
653
- }
654
- chunks.push(bytes);
655
- };
656
- const write = request.write;
657
- request.write = ((...args) => {
658
- record(args[0], typeof args[1] === "string" ? args[1] : void 0);
659
- return Reflect.apply(write, request, args);
660
- });
661
- const end = request.end;
662
- request.end = ((...args) => {
663
- record(args[0], typeof args[1] === "string" ? args[1] : void 0);
664
- return Reflect.apply(end, request, args);
665
- });
666
- return { body: () => {
667
- if (!complete) return { body: null, complete: false };
668
- const header = request.getHeader("content-type");
669
- const contentType = Array.isArray(header) ? header[0] : header === void 0 ? void 0 : String(header);
670
- const parsed = parseRequestBody(Buffer.concat(chunks, size), contentType);
671
- return { body: parsed.body, complete: parsed.valid };
672
- } };
673
- }
674
- function requestSignal(args) {
675
- for (const value of args.slice(0, 2)) {
676
- if (value && typeof value === "object" && "signal" in value && value.signal instanceof AbortSignal) return value.signal;
677
- }
678
- }
679
- function cancellation(request, external) {
680
- const controller = new AbortController();
681
- const destroy = request.destroy.bind(request);
682
- request.destroy = ((error) => {
683
- if (!controller.signal.aborted) controller.abort(error);
684
- return destroy(error);
685
- });
686
- return external ? AbortSignal.any([external, controller.signal]) : controller.signal;
687
- }
688
-
689
- // src/index.ts
690
- var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
691
- var globals = globalThis;
692
- function manifest(options = {}) {
693
- const key = options.key || process.env.MNFST_KEY;
694
- if (!key) {
695
- warn("MNFST_KEY is not set; Manifest is disabled");
696
- return;
697
- }
698
- const url = new URL(options.url || process.env.MNFST_URL || "https://api.manifest.build");
699
- if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
700
- throw new TypeError("Manifest URL must be an HTTP(S) base URL without credentials, query or fragment");
701
- }
702
- if (!url.pathname.endsWith("/")) url.pathname += "/";
703
- const resolved = { ...options, key, url: url.toString() };
704
- const existing = globals[STATE];
705
- if (existing) {
706
- if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
707
- warn("Manifest is already configured; changing configuration requires a process restart");
708
- }
709
- return;
710
- }
711
- const runtime = new Runtime(resolved, globalThis.fetch.bind(globalThis));
712
- globalThis.fetch = runtime.fetch;
713
- installHttp(runtime);
714
- globals[STATE] = runtime;
715
- }
716
-
717
482
  export {
483
+ isObject,
484
+ safeUrl,
485
+ trackedUrl,
486
+ safeHeaders,
487
+ travelingBody,
488
+ mergeBody,
489
+ parseRequestBody,
490
+ serializeRequestBody,
491
+ TRANSPORT_ERROR,
492
+ REQUEST_LIMIT,
493
+ captureRequest,
494
+ captureResponse,
718
495
  VERSION,
719
- manifest
496
+ warn,
497
+ HealApi
720
498
  };