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.
@@ -0,0 +1,495 @@
1
+ import {
2
+ HealApi,
3
+ REQUEST_LIMIT,
4
+ TRANSPORT_ERROR,
5
+ captureRequest,
6
+ captureResponse,
7
+ isObject,
8
+ mergeBody,
9
+ parseRequestBody,
10
+ safeHeaders,
11
+ safeUrl,
12
+ serializeRequestBody,
13
+ trackedUrl,
14
+ travelingBody,
15
+ warn
16
+ } from "./chunk-NHEF7GUP.js";
17
+
18
+ // src/http.ts
19
+ import http from "http";
20
+ import https from "https";
21
+ import { syncBuiltinESMExports } from "module";
22
+ import { Readable } from "stream";
23
+ import { createBrotliDecompress, createUnzip } from "zlib";
24
+
25
+ // src/runtime.ts
26
+ import { randomUUID } from "crypto";
27
+
28
+ // src/tracking.ts
29
+ var MAX_BUFFER = 5e3;
30
+ var MAX_BATCH = 500;
31
+ var CallBuffer = class {
32
+ constructor(send, options = {}) {
33
+ this.send = send;
34
+ this.flushAt = options.flushAt ?? 500;
35
+ this.minGapMs = options.minGapMs ?? 1e3;
36
+ this.timer = setInterval(() => void this.kick(), options.intervalMs ?? 5e3);
37
+ this.timer.unref();
38
+ }
39
+ send;
40
+ queue = [];
41
+ inFlight = null;
42
+ lastSentAt = -Infinity;
43
+ /** Past this instant, nothing is retried or waited for, and a send in flight is aborted. */
44
+ deadline = Infinity;
45
+ controller = new AbortController();
46
+ timer;
47
+ flushAt;
48
+ minGapMs;
49
+ record(call) {
50
+ if (this.queue.length >= MAX_BUFFER) return;
51
+ this.queue.push(call);
52
+ if (this.queue.length >= this.flushAt) void this.kick();
53
+ }
54
+ size() {
55
+ return this.queue.length;
56
+ }
57
+ /** Resolves when the send in flight, if any, has settled. */
58
+ async idle() {
59
+ await this.inFlight;
60
+ }
61
+ /**
62
+ * Send everything buffered now, one batch at a time through the same
63
+ * in-flight guard as the timer. With `deadlineMs` (the exit path), it gives
64
+ * up at the deadline: the send in flight is aborted, nothing is retried, and
65
+ * what is left is dropped, so a script never hangs on an unreachable server.
66
+ */
67
+ async flush(deadlineMs = Infinity) {
68
+ this.deadline = performance.now() + deadlineMs;
69
+ const abortAt = Number.isFinite(deadlineMs) ? setTimeout(() => this.controller.abort(), deadlineMs) : null;
70
+ try {
71
+ while ((this.queue.length > 0 || this.inFlight) && performance.now() < this.deadline) {
72
+ await (this.inFlight ?? this.start());
73
+ }
74
+ } finally {
75
+ if (abortAt) clearTimeout(abortAt);
76
+ if (this.controller.signal.aborted) this.controller = new AbortController();
77
+ this.deadline = Infinity;
78
+ }
79
+ }
80
+ stop() {
81
+ clearInterval(this.timer);
82
+ }
83
+ kick() {
84
+ if (this.inFlight || this.queue.length === 0) return;
85
+ if (performance.now() - this.lastSentAt < this.minGapMs) return;
86
+ void this.start();
87
+ }
88
+ start() {
89
+ this.inFlight = this.sendOne().finally(() => {
90
+ this.inFlight = null;
91
+ });
92
+ return this.inFlight;
93
+ }
94
+ async sendOne() {
95
+ const batch = this.queue.splice(0, MAX_BATCH);
96
+ for (let attempt = 0; attempt < 2; attempt++) {
97
+ if (attempt > 0 && performance.now() >= this.deadline) return;
98
+ await this.waitForGap();
99
+ this.lastSentAt = performance.now();
100
+ try {
101
+ await this.send(batch, this.controller.signal);
102
+ return;
103
+ } catch {
104
+ }
105
+ if (this.controller.signal.aborted) {
106
+ this.controller = new AbortController();
107
+ return;
108
+ }
109
+ }
110
+ }
111
+ // Referenced on purpose: this wait only happens while a send or an exit
112
+ // flush is under way, and an unref'd wait would let the process exit with
113
+ // the batch unsent. It never outlasts the flush deadline.
114
+ async waitForGap() {
115
+ const wait = Math.min(this.lastSentAt + this.minGapMs, this.deadline) - performance.now();
116
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
117
+ }
118
+ };
119
+
120
+ // src/runtime.ts
121
+ var forbidden = /* @__PURE__ */ new Set([401, 402, 403, 429]);
122
+ var bodyless = ["GET", "HEAD", "DELETE", "OPTIONS"];
123
+ var neverBodied = (method) => method === "GET" || method === "HEAD";
124
+ var eligible = (status) => status >= 400 && status < 500 && !forbidden.has(status);
125
+ var Runtime = class {
126
+ constructor(options, original, api) {
127
+ this.options = options;
128
+ this.original = original;
129
+ this.api = api ?? new HealApi(original, options.key, options.url);
130
+ this.tracker = new CallBuffer((batch, signal) => this.api.sendRequests(batch, signal));
131
+ }
132
+ options;
133
+ original;
134
+ api;
135
+ /** Calls that did not go to `/v1/heal`, any status, on their way to `/v1/requests`. */
136
+ tracker;
137
+ /**
138
+ * Record a call that is not being healed. An in-memory append: never awaited,
139
+ * never throws, reads nothing of the response.
140
+ */
141
+ track(method, url, statusCode, startedAt, responseTimeMs) {
142
+ try {
143
+ const reported = trackedUrl(url());
144
+ const verb = method.toUpperCase();
145
+ if (!reported || reported.length > 4096 || !verb || verb.length > 16 || statusCode < 100 || statusCode > 599) return;
146
+ this.tracker.record({
147
+ traceId: randomUUID(),
148
+ method: verb,
149
+ url: reported,
150
+ statusCode,
151
+ responseTimeMs: Math.round(responseTimeMs),
152
+ occurredAt: new Date(startedAt).toISOString()
153
+ });
154
+ } catch {
155
+ }
156
+ }
157
+ fetch = async (input, init) => {
158
+ const request = new Request(input, init);
159
+ const extras = { ...init };
160
+ delete extras.body;
161
+ delete extras.headers;
162
+ const bodyPromise = captureRequest(request).catch(() => ({ body: null, complete: false }));
163
+ const startedAt = Date.now();
164
+ const started = performance.now();
165
+ const response = await this.original(request, extras);
166
+ const responseTimeMs = performance.now() - started;
167
+ if (!eligible(response.status) || response.redirected || !this.api.canHeal()) {
168
+ this.track(request.method, () => request.url, response.status, startedAt, responseTimeMs);
169
+ return response;
170
+ }
171
+ return this.handleResponse(request, response, await bodyPromise, responseTimeMs, extras);
172
+ };
173
+ async handleResponse(request, response, body, responseTimeMs, extras = {}) {
174
+ if (!eligible(response.status) || response.redirected || !this.api.enabled()) return response;
175
+ return this.repair(request, extras, response, body, responseTimeMs);
176
+ }
177
+ async repair(request, extras, original, body, responseTimeMs) {
178
+ let response = original;
179
+ let result = null;
180
+ const started = performance.now();
181
+ let replayStatusCode = null;
182
+ let replayAttempted = false;
183
+ try {
184
+ const captured = await captureResponse(response);
185
+ response = captured.response;
186
+ const payload = {
187
+ traceId: randomUUID(),
188
+ request: { method: request.method, url: safeUrl(request.url), headers: safeHeaders(request.headers), body: travelingBody(body.body) },
189
+ response: { statusCode: response.status, body: captured.body, truncated: captured.truncated },
190
+ responseTimeMs: Math.round(responseTimeMs)
191
+ };
192
+ request.signal.throwIfAborted();
193
+ result = await this.api.heal(payload, request.signal);
194
+ request.signal.throwIfAborted();
195
+ const retry = body.complete && captured.complete ? buildRetry(request, body.body, result) : null;
196
+ if (!retry) {
197
+ this.api.report(result?.healAttemptId, { failure: { kind: "not_attempted", message: "replay_not_attempted" } });
198
+ return response;
199
+ }
200
+ replayAttempted = true;
201
+ let retried;
202
+ try {
203
+ retried = await this.original(retry, extras);
204
+ } catch {
205
+ this.api.report(result?.healAttemptId, { failure: { kind: "transport_error", message: TRANSPORT_ERROR } });
206
+ request.signal.throwIfAborted();
207
+ return response;
208
+ }
209
+ replayStatusCode = retried.status;
210
+ if (retried.status >= 400) {
211
+ const capturedRetry = await captureResponse(retried);
212
+ retried = capturedRetry.response;
213
+ this.api.report(result?.healAttemptId, { response: { statusCode: retried.status, body: capturedRetry.body, truncated: capturedRetry.truncated } });
214
+ } else {
215
+ this.api.report(result?.healAttemptId, { response: { statusCode: retried.status } });
216
+ }
217
+ void response.body?.cancel().catch(() => {
218
+ });
219
+ return retried;
220
+ } catch {
221
+ request.signal.throwIfAborted();
222
+ this.api.report(result?.healAttemptId, { failure: { kind: "not_attempted", message: "replay_not_attempted" } });
223
+ return response;
224
+ } finally {
225
+ if (this.options.onHeal) {
226
+ try {
227
+ void Promise.resolve(this.options.onHeal({
228
+ url: safeUrl(request.url),
229
+ statusCode: original.status,
230
+ healStatus: replayAttempted && replayStatusCode === null ? "replay_failed" : result?.status ?? "heal_unreachable",
231
+ replayStatusCode,
232
+ healMs: Math.round(performance.now() - started),
233
+ operations: result?.operations
234
+ })).catch(() => warn("onHeal callback failed"));
235
+ } catch {
236
+ warn("onHeal callback failed");
237
+ }
238
+ }
239
+ }
240
+ }
241
+ };
242
+ function buildRetry(request, originalBody, result) {
243
+ if (!result || !["patched", "unverified"].includes(result.status) || !isObject(result.healedRequest)) return null;
244
+ const healed = result.healedRequest;
245
+ if (!["url", "headers", "body"].some((key) => Object.hasOwn(healed, key))) return null;
246
+ try {
247
+ const url = new URL(healed.url ?? request.url);
248
+ if (url.origin !== new URL(request.url).origin || url.username || url.password) return null;
249
+ const headers = new Headers(request.headers);
250
+ const contentType = headers.get("content-type");
251
+ headers.delete("content-length");
252
+ if (healed.headers !== void 0 && !isObject(healed.headers)) return null;
253
+ for (const [name, value] of Object.entries(healed.headers ?? {})) {
254
+ if (value === null) headers.delete(name);
255
+ else if (typeof value === "string") headers.set(name, value);
256
+ else return null;
257
+ }
258
+ const body = Object.hasOwn(healed, "body") ? mergeBody(originalBody, healed.body) : originalBody;
259
+ if (body === null && !bodyless.includes(request.method)) return null;
260
+ return new Request(url, {
261
+ method: request.method,
262
+ headers,
263
+ body: body === null || neverBodied(request.method) ? void 0 : serializeRequestBody(body, contentType),
264
+ signal: request.signal,
265
+ redirect: request.redirect,
266
+ credentials: request.credentials,
267
+ cache: request.cache,
268
+ integrity: request.integrity,
269
+ keepalive: request.keepalive,
270
+ mode: request.mode,
271
+ referrer: request.referrer,
272
+ referrerPolicy: request.referrerPolicy
273
+ });
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ // src/http.ts
280
+ function installHttp(runtime) {
281
+ const httpRequest = wrapRequest(http.request, "http:", runtime);
282
+ const httpsRequest = wrapRequest(https.request, "https:", runtime);
283
+ http.request = httpRequest;
284
+ https.request = httpsRequest;
285
+ http.get = wrapGet(httpRequest);
286
+ https.get = wrapGet(httpsRequest);
287
+ syncBuiltinESMExports();
288
+ }
289
+ function wrapGet(request) {
290
+ return ((...args) => {
291
+ const result = request(...args);
292
+ result.end();
293
+ return result;
294
+ });
295
+ }
296
+ function wrapRequest(original, protocol, runtime) {
297
+ return ((...received) => {
298
+ const args = [...received];
299
+ const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
300
+ const startedAt = Date.now();
301
+ const started = performance.now();
302
+ const request = original(...args);
303
+ const capture = captureBody(request);
304
+ const signal = cancellation(request, requestSignal(args));
305
+ const emit = request.emit.bind(request);
306
+ request.emit = ((event, ...values) => {
307
+ if (event !== "response") return emit(event, ...values);
308
+ const response = values[0];
309
+ if (!eligible(response.statusCode ?? 0) || !runtime.api.canHeal()) {
310
+ runtime.track(
311
+ request.method,
312
+ () => requestUrl(request, protocol).toString(),
313
+ response.statusCode ?? 0,
314
+ startedAt,
315
+ performance.now() - started
316
+ );
317
+ return emit(event, ...values);
318
+ }
319
+ void handleResponse(runtime, request, response, protocol, capture.body(), signal, started).then((healed) => emit("response", healed)).catch((error) => {
320
+ response.destroy();
321
+ if (!request.destroyed) request.destroy(error instanceof Error ? error : void 0);
322
+ });
323
+ return true;
324
+ });
325
+ if (callback) request.once("response", callback);
326
+ return request;
327
+ });
328
+ }
329
+ async function handleResponse(runtime, clientRequest, incoming, protocol, body, signal, started) {
330
+ const request = webRequest(clientRequest, protocol, body, signal);
331
+ const response = webResponse(incoming, request.url);
332
+ const healed = await runtime.handleResponse(request, response, body, performance.now() - started);
333
+ return incomingResponse(healed, clientRequest);
334
+ }
335
+ function requestUrl(request, protocol) {
336
+ const authority = String(request.getHeader("host") ?? request.host);
337
+ return new URL(request.path, `${protocol}//${authority}`);
338
+ }
339
+ function webRequest(request, protocol, captured, signal) {
340
+ const headers = new Headers();
341
+ for (const name of request.getHeaderNames()) {
342
+ const value = request.getHeader(name);
343
+ for (const item of Array.isArray(value) ? value : [value]) {
344
+ if (item !== void 0) headers.append(name, String(item));
345
+ }
346
+ }
347
+ const url = requestUrl(request, protocol);
348
+ const method = request.method;
349
+ return new Request(url, {
350
+ method,
351
+ headers,
352
+ signal,
353
+ body: ["GET", "HEAD"].includes(method) || !captured.complete || captured.body === null ? void 0 : serializeRequestBody(captured.body, headers.get("content-type"))
354
+ });
355
+ }
356
+ function webResponse(response, url) {
357
+ const headers = new Headers();
358
+ for (const [name, value] of Object.entries(response.headers)) {
359
+ for (const item of Array.isArray(value) ? value : [value]) {
360
+ if (item !== void 0) headers.append(name, String(item));
361
+ }
362
+ }
363
+ const encoding = headers.get("content-encoding")?.toLowerCase();
364
+ let body = response;
365
+ if (encoding === "gzip" || encoding === "x-gzip" || encoding === "deflate") {
366
+ body = response.pipe(createUnzip());
367
+ headers.delete("content-encoding");
368
+ headers.delete("content-length");
369
+ } else if (encoding === "br") {
370
+ body = response.pipe(createBrotliDecompress());
371
+ headers.delete("content-encoding");
372
+ headers.delete("content-length");
373
+ }
374
+ const converted = new Response(
375
+ Readable.toWeb(body),
376
+ { status: response.statusCode, statusText: response.statusMessage, headers }
377
+ );
378
+ Object.defineProperty(converted, "url", { value: url });
379
+ return converted;
380
+ }
381
+ function incomingResponse(response, request) {
382
+ const stream = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
383
+ const headers = {};
384
+ for (const [name, value] of response.headers) headers[name] = value;
385
+ if (["gzip", "x-gzip", "deflate", "br"].includes(String(headers["content-encoding"]).toLowerCase())) {
386
+ delete headers["content-encoding"];
387
+ delete headers["content-length"];
388
+ }
389
+ const cookies = response.headers.getSetCookie();
390
+ if (cookies.length) headers["set-cookie"] = cookies;
391
+ const rawHeaders = Object.entries(headers).flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).flatMap((item) => item === void 0 ? [] : [name, String(item)]));
392
+ return Object.assign(stream, {
393
+ statusCode: response.status,
394
+ statusMessage: response.statusText,
395
+ headers,
396
+ rawHeaders,
397
+ trailers: {},
398
+ rawTrailers: [],
399
+ httpVersion: "1.1",
400
+ httpVersionMajor: 1,
401
+ httpVersionMinor: 1,
402
+ complete: true,
403
+ req: request
404
+ });
405
+ }
406
+ function captureBody(request) {
407
+ let chunks = [];
408
+ let size = 0;
409
+ let complete = true;
410
+ const record = (chunk, encoding) => {
411
+ if (chunk === void 0 || chunk === null || typeof chunk === "function" || !complete) return;
412
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, encoding) : Buffer.from(chunk);
413
+ size += bytes.byteLength;
414
+ if (size > REQUEST_LIMIT) {
415
+ complete = false;
416
+ chunks = [];
417
+ return;
418
+ }
419
+ chunks.push(bytes);
420
+ };
421
+ const write = request.write;
422
+ request.write = ((...args) => {
423
+ record(args[0], typeof args[1] === "string" ? args[1] : void 0);
424
+ return Reflect.apply(write, request, args);
425
+ });
426
+ const end = request.end;
427
+ request.end = ((...args) => {
428
+ record(args[0], typeof args[1] === "string" ? args[1] : void 0);
429
+ return Reflect.apply(end, request, args);
430
+ });
431
+ return { body: () => {
432
+ if (!complete) return { body: null, complete: false };
433
+ const header = request.getHeader("content-type");
434
+ const contentType = Array.isArray(header) ? header[0] : header === void 0 ? void 0 : String(header);
435
+ const parsed = parseRequestBody(Buffer.concat(chunks, size), contentType);
436
+ return { body: parsed.body, complete: parsed.valid };
437
+ } };
438
+ }
439
+ function requestSignal(args) {
440
+ for (const value of args.slice(0, 2)) {
441
+ if (value && typeof value === "object" && "signal" in value && value.signal instanceof AbortSignal) return value.signal;
442
+ }
443
+ }
444
+ function cancellation(request, external) {
445
+ const controller = new AbortController();
446
+ const destroy = request.destroy.bind(request);
447
+ request.destroy = ((error) => {
448
+ if (!controller.signal.aborted) controller.abort(error);
449
+ return destroy(error);
450
+ });
451
+ return external ? AbortSignal.any([external, controller.signal]) : controller.signal;
452
+ }
453
+
454
+ // src/index.ts
455
+ var STATE = /* @__PURE__ */ Symbol.for("mnfst.node.runtime.v1");
456
+ var EXIT_FLUSH_MS = 2e3;
457
+ var globals = globalThis;
458
+ function manifest(options = {}) {
459
+ const key = options.key || process.env.MNFST_KEY;
460
+ if (!key) {
461
+ warn("MNFST_KEY is not set; Manifest is disabled");
462
+ return;
463
+ }
464
+ const url = new URL(
465
+ options.url || process.env.MNFST_URL || "https://api.manifest.build"
466
+ );
467
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
468
+ throw new TypeError(
469
+ "Manifest URL must be an HTTP(S) base URL without credentials, query or fragment"
470
+ );
471
+ }
472
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
473
+ const resolved = { ...options, key, url: url.toString() };
474
+ const existing = globals[STATE];
475
+ if (existing) {
476
+ if (existing.options.key !== key || existing.options.url !== resolved.url || existing.options.onHeal !== options.onHeal) {
477
+ warn(
478
+ "Manifest is already configured; changing configuration requires a process restart"
479
+ );
480
+ }
481
+ return;
482
+ }
483
+ const runtime = new Runtime(resolved, globalThis.fetch.bind(globalThis));
484
+ globalThis.fetch = runtime.fetch;
485
+ installHttp(runtime);
486
+ globals[STATE] = runtime;
487
+ runtime.api.hello(`node-${process.versions.node}`);
488
+ process.once("beforeExit", () => {
489
+ if (runtime.tracker.size() > 0) void runtime.tracker.flush(EXIT_FLUSH_MS);
490
+ });
491
+ }
492
+
493
+ export {
494
+ manifest
495
+ };