@jaredwray/mockhttp 2.1.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -13,195 +13,10 @@ import Fastify from "fastify";
13
13
  import { Hookified } from "hookified";
14
14
  import * as crypto$1 from "node:crypto";
15
15
  import crypto, { randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
16
+ import Keyv from "keyv";
16
17
  import { Buffer as Buffer$1 } from "node:buffer";
17
18
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
18
19
  import { escape } from "html-escaper";
19
- //#region src/bin-store.ts
20
- /**
21
- * In-memory implementation of {@link BinStore} backed by two Maps. State is
22
- * lost when the process exits.
23
- */
24
- var InMemoryBinStore = class {
25
- _bins = /* @__PURE__ */ new Map();
26
- _requests = /* @__PURE__ */ new Map();
27
- createBin(bin) {
28
- this._bins.set(bin.id, bin);
29
- this._requests.set(bin.id, []);
30
- }
31
- getBin(id) {
32
- return this._bins.get(id);
33
- }
34
- listBins() {
35
- return [...this._bins.values()];
36
- }
37
- deleteBin(id) {
38
- this._requests.delete(id);
39
- return this._bins.delete(id);
40
- }
41
- addRequest(binId, req, max) {
42
- const bin = this._bins.get(binId);
43
- const list = this._requests.get(binId);
44
- if (!bin || !list) return;
45
- list.unshift(req);
46
- while (list.length > max && list.length > 0) list.pop();
47
- bin.requestCount = list.length;
48
- }
49
- getRequests(binId) {
50
- return [...this._requests.get(binId) ?? []];
51
- }
52
- getRequest(binId, reqId) {
53
- return this._requests.get(binId)?.find((r) => r.id === reqId);
54
- }
55
- clearRequests(binId) {
56
- const bin = this._bins.get(binId);
57
- if (!bin) return;
58
- this._requests.set(binId, []);
59
- bin.requestCount = 0;
60
- }
61
- cleanupExpired(now) {
62
- const removed = [];
63
- for (const [id, bin] of this._bins) if (Date.parse(bin.expiresAt) <= now) {
64
- this._bins.delete(id);
65
- this._requests.delete(id);
66
- removed.push(id);
67
- }
68
- return removed;
69
- }
70
- };
71
- //#endregion
72
- //#region src/bin-manager.ts
73
- const DEFAULT_TTL_MS = 864e5;
74
- const DEFAULT_MAX_REQUESTS = 100;
75
- const DEFAULT_MAX_BODY_SIZE = 1048576;
76
- const DEFAULT_ID_LENGTH = 12;
77
- const DEFAULT_CLEANUP_INTERVAL_MS = 6e4;
78
- /**
79
- * Manages request bins: ephemeral URL endpoints that capture incoming HTTP
80
- * requests so they can be inspected later.
81
- */
82
- var BinManager = class {
83
- _store;
84
- _defaultTtlMs;
85
- _maxRequestsPerBin;
86
- _maxBodySize;
87
- _idLength;
88
- _cleanupIntervalMs;
89
- _cleanupTimer;
90
- constructor(options) {
91
- this._store = options?.store ?? new InMemoryBinStore();
92
- this._defaultTtlMs = options?.defaultTtlMs ?? DEFAULT_TTL_MS;
93
- this._maxRequestsPerBin = Math.max(0, options?.maxRequestsPerBin ?? DEFAULT_MAX_REQUESTS);
94
- this._maxBodySize = options?.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
95
- this._idLength = options?.idLength ?? DEFAULT_ID_LENGTH;
96
- this._cleanupIntervalMs = options?.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
97
- }
98
- /** Underlying storage backend. */
99
- get store() {
100
- return this._store;
101
- }
102
- /** Maximum captured body size in bytes. */
103
- get maxBodySize() {
104
- return this._maxBodySize;
105
- }
106
- /** Maximum number of requests retained per bin. */
107
- get maxRequestsPerBin() {
108
- return this._maxRequestsPerBin;
109
- }
110
- /** Default bin lifetime in milliseconds. */
111
- get defaultTtlMs() {
112
- return this._defaultTtlMs;
113
- }
114
- /** Create a new bin with the configured TTL. */
115
- createBin() {
116
- const id = this.generateId();
117
- const now = Date.now();
118
- const bin = {
119
- id,
120
- createdAt: new Date(now).toISOString(),
121
- expiresAt: new Date(now + this._defaultTtlMs).toISOString(),
122
- requestCount: 0
123
- };
124
- this._store.createBin(bin);
125
- return bin;
126
- }
127
- /**
128
- * Look up a bin by id. Returns `undefined` if the bin does not exist or
129
- * has expired (in which case it is also lazily removed).
130
- */
131
- getBin(id) {
132
- const bin = this._store.getBin(id);
133
- if (!bin) return;
134
- if (Date.parse(bin.expiresAt) <= Date.now()) {
135
- this._store.deleteBin(id);
136
- return;
137
- }
138
- return bin;
139
- }
140
- /** List all non-expired bins. */
141
- listBins() {
142
- const now = Date.now();
143
- const result = [];
144
- for (const bin of this._store.listBins()) {
145
- if (Date.parse(bin.expiresAt) <= now) {
146
- this._store.deleteBin(bin.id);
147
- continue;
148
- }
149
- result.push(bin);
150
- }
151
- return result;
152
- }
153
- /** Delete a bin. Returns true if the bin existed. */
154
- deleteBin(id) {
155
- return this._store.deleteBin(id);
156
- }
157
- /**
158
- * Record an incoming request against a bin. Returns the stored
159
- * {@link CapturedRequest}, or `undefined` if the bin does not exist or is
160
- * expired.
161
- */
162
- recordRequest(binId, raw) {
163
- if (!this.getBin(binId)) return;
164
- const captured = {
165
- ...raw,
166
- id: this.generateId(),
167
- binId,
168
- capturedAt: (/* @__PURE__ */ new Date()).toISOString()
169
- };
170
- this._store.addRequest(binId, captured, this._maxRequestsPerBin);
171
- return captured;
172
- }
173
- /** List captured requests for a bin (newest first). */
174
- getRequests(binId) {
175
- return this._store.getRequests(binId);
176
- }
177
- /** Look up a single captured request. */
178
- getRequest(binId, reqId) {
179
- return this._store.getRequest(binId, reqId);
180
- }
181
- /** Clear all captured requests in a bin. */
182
- clearRequests(binId) {
183
- this._store.clearRequests(binId);
184
- }
185
- /** Start the periodic cleanup of expired bins. */
186
- start() {
187
- if (this._cleanupTimer) return;
188
- this._cleanupTimer = setInterval(() => {
189
- this._store.cleanupExpired(Date.now());
190
- }, this._cleanupIntervalMs);
191
- this._cleanupTimer.unref?.();
192
- }
193
- /** Stop the periodic cleanup. Idempotent. */
194
- stop() {
195
- if (this._cleanupTimer) {
196
- clearInterval(this._cleanupTimer);
197
- this._cleanupTimer = void 0;
198
- }
199
- }
200
- generateId() {
201
- return randomUUID().replaceAll("-", "").slice(0, this._idLength);
202
- }
203
- };
204
- //#endregion
205
20
  //#region src/certificate.ts
206
21
  function encodeLength(length) {
207
22
  if (length < 128) return Buffer.from([length]);
@@ -478,6 +293,310 @@ function getFastifyConfig(logging = true) {
478
293
  };
479
294
  }
480
295
  //#endregion
296
+ //#region src/bin-store-diagnostics.ts
297
+ const ENCODING_HINT = "Percent-encode reserved characters in the username and password (@ : / ? # [ ] $).";
298
+ const loggedPhases = /* @__PURE__ */ new WeakMap();
299
+ /** Error whose message is already safe to return to clients. */
300
+ var BinStoreFailure = class extends Error {
301
+ binStoreFailure = true;
302
+ constructor(message, cause) {
303
+ super(message, cause === void 0 ? void 0 : { cause });
304
+ this.name = "BinStoreFailure";
305
+ }
306
+ };
307
+ function isBinStoreFailure(error) {
308
+ return error instanceof BinStoreFailure || typeof error === "object" && error !== null && "binStoreFailure" in error && error.binStoreFailure === true;
309
+ }
310
+ function mongoUriFacts(uri) {
311
+ if (!uri) return {
312
+ present: false,
313
+ scheme: null
314
+ };
315
+ if (uri.startsWith("mongodb+srv://")) return {
316
+ present: true,
317
+ scheme: "mongodb+srv"
318
+ };
319
+ if (uri.startsWith("mongodb://")) return {
320
+ present: true,
321
+ scheme: "mongodb"
322
+ };
323
+ return {
324
+ present: true,
325
+ scheme: null
326
+ };
327
+ }
328
+ /**
329
+ * Shape flags only. A normal URI has one `@` or zero `@`.
330
+ * A second `@` makes the driver throw `Invalid connection string` before the network call.
331
+ */
332
+ function uriShape(uri) {
333
+ return {
334
+ whitespace: /\s/.test(uri),
335
+ wrappedQuotes: uri.startsWith("\"") || uri.startsWith("'") || uri.endsWith("\"") || uri.endsWith("'"),
336
+ extraAt: uri.split("@").length - 1 > 1
337
+ };
338
+ }
339
+ function replaceSecret(message, secret) {
340
+ if (secret.length < 3) return message;
341
+ if (/^[a-z]+$/i.test(secret) && secret.length < 8) return message;
342
+ return message.split(secret).join("[redacted]");
343
+ }
344
+ function sensitiveParts(uri) {
345
+ const parts = [];
346
+ const body = uri.replace(/^mongodb(?:\+srv)?:\/\//i, "");
347
+ const at = body.lastIndexOf("@");
348
+ if (at >= 0) {
349
+ const userinfo = body.slice(0, at);
350
+ const colon = userinfo.indexOf(":");
351
+ if (colon >= 0) parts.push(userinfo.slice(0, colon), userinfo.slice(colon + 1));
352
+ else if (userinfo) parts.push(userinfo);
353
+ const hostname = (body.slice(at + 1).split(/[/?]/)[0] ?? "").split(":")[0] ?? "";
354
+ if (hostname) parts.push(hostname);
355
+ } else {
356
+ const hostname = (body.split(/[/?]/)[0] ?? "").split(":")[0] ?? "";
357
+ if (hostname) parts.push(hostname);
358
+ }
359
+ return parts;
360
+ }
361
+ function redactSecrets(message, uri) {
362
+ let out = message;
363
+ if (uri) {
364
+ out = out.split(uri).join("[redacted-uri]");
365
+ for (const part of sensitiveParts(uri)) out = replaceSecret(out, part);
366
+ }
367
+ out = out.replace(/mongodb(?:\+srv)?:\/\/\S+/gi, "[redacted-uri]");
368
+ out = out.replace(/\/\/([^/\s:@]+):([^/\s@]+)@/g, "//[redacted]:[redacted]@");
369
+ return out;
370
+ }
371
+ /**
372
+ * Add the encode hint only when the driver message is already a parse error.
373
+ * A DNS error or a scheme error keeps its own text.
374
+ */
375
+ function withEncodingHint(message) {
376
+ if (!/invalid connection string|unescaped characters/i.test(message) || message.includes("Percent-encode")) return message;
377
+ return `${message}. ${ENCODING_HINT}`;
378
+ }
379
+ function redactStoreError(error, uri) {
380
+ const name = error instanceof Error && error.name ? error.name : "Error";
381
+ const raw = error instanceof Error ? error.message : "Bin store request failed";
382
+ const codeValue = typeof error === "object" && error !== null && "code" in error && error.code != null ? error.code : null;
383
+ const code = typeof codeValue === "string" || typeof codeValue === "number" ? codeValue : null;
384
+ return {
385
+ name,
386
+ message: withEncodingHint(redactSecrets(raw, uri)),
387
+ code
388
+ };
389
+ }
390
+ function alreadyLogged(error, phase) {
391
+ if (typeof error !== "object" || error === null) return false;
392
+ let phases = loggedPhases.get(error);
393
+ if (!phases) {
394
+ phases = /* @__PURE__ */ new Set();
395
+ loggedPhases.set(error, phases);
396
+ }
397
+ if (phases.has(phase)) return true;
398
+ phases.add(phase);
399
+ return false;
400
+ }
401
+ /** One JSON line. Safe to print in Workers logs. */
402
+ function logBinStore(input) {
403
+ if (input.error !== void 0 && alreadyLogged(input.error, input.phase)) return;
404
+ const facts = mongoUriFacts(input.uri);
405
+ const payload = {
406
+ source: "mockhttp.bin-store",
407
+ phase: input.phase,
408
+ store: input.store,
409
+ uriPresent: facts.present,
410
+ scheme: facts.scheme
411
+ };
412
+ if (input.uri) payload.uriShape = uriShape(input.uri);
413
+ if (input.connected !== void 0) payload.connected = input.connected;
414
+ if (input.latencyMs !== void 0) payload.latencyMs = input.latencyMs;
415
+ if (input.error !== void 0) payload.error = redactStoreError(input.error, input.uri);
416
+ const line = JSON.stringify(payload);
417
+ if (input.level === "info") {
418
+ console.info(line);
419
+ return;
420
+ }
421
+ console.error(line);
422
+ }
423
+ //#endregion
424
+ //#region src/mock-http-bins.ts
425
+ const DEFAULT_TTL_MS = 864e5;
426
+ const DEFAULT_MAX_REQUESTS = 100;
427
+ const DEFAULT_MAX_BODY_SIZE = 131072;
428
+ const DEFAULT_ID_LENGTH = 12;
429
+ const BIN_KEY_PREFIX = "bin:";
430
+ /**
431
+ * Stores request bins in Keyv.
432
+ * The default store is `new Keyv()`, which keeps data in memory.
433
+ */
434
+ var MockHttpBins = class {
435
+ _keyv;
436
+ _defaultTtlMs;
437
+ _maxRequestsPerBin;
438
+ _maxBodySize;
439
+ _idLength;
440
+ _locks = /* @__PURE__ */ new Map();
441
+ constructor(options) {
442
+ this._keyv = options?.keyv ?? new Keyv();
443
+ this._defaultTtlMs = options?.defaultTtlMs ?? DEFAULT_TTL_MS;
444
+ this._maxRequestsPerBin = Math.max(0, options?.maxRequestsPerBin ?? DEFAULT_MAX_REQUESTS);
445
+ this._maxBodySize = options?.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
446
+ this._idLength = options?.idLength ?? DEFAULT_ID_LENGTH;
447
+ }
448
+ /** Keyv instance that stores the bins. */
449
+ get keyv() {
450
+ return this._keyv;
451
+ }
452
+ /** Maximum captured body size in bytes. */
453
+ get maxBodySize() {
454
+ return this._maxBodySize;
455
+ }
456
+ /** Maximum number of requests in one bin. */
457
+ get maxRequestsPerBin() {
458
+ return this._maxRequestsPerBin;
459
+ }
460
+ /** Default bin lifetime in milliseconds. */
461
+ get defaultTtlMs() {
462
+ return this._defaultTtlMs;
463
+ }
464
+ /**
465
+ * Create a new bin with the configured lifetime.
466
+ * Throws when Keyv does not store the bin.
467
+ */
468
+ async createBin() {
469
+ const id = this.generateId();
470
+ const now = Date.now();
471
+ const bin = {
472
+ id,
473
+ createdAt: new Date(now).toISOString(),
474
+ expiresAt: new Date(now + this._defaultTtlMs).toISOString(),
475
+ requestCount: 0
476
+ };
477
+ await this.save({
478
+ bin,
479
+ requests: []
480
+ });
481
+ return bin;
482
+ }
483
+ /**
484
+ * Look up a bin by id.
485
+ * Returns `undefined` when the bin does not exist or Keyv has expired it.
486
+ * A failed read also returns `undefined` when the Keyv instance has an `error` listener.
487
+ */
488
+ async getBin(id) {
489
+ return (await this._keyv.get(this.binKey(id)))?.bin;
490
+ }
491
+ /**
492
+ * List the bins that Keyv still holds.
493
+ * Throws when the Keyv store has no iterator.
494
+ * When the Keyv instance has an `error` listener, a failed walk returns the bins read before the error.
495
+ */
496
+ async listBins() {
497
+ if (!this.canIterate()) throw new Error("MockHttpBins: the configured Keyv store does not support iteration; listBins() is unavailable");
498
+ const bins = [];
499
+ for await (const [key, value] of this._keyv.iterator()) if (key.startsWith(BIN_KEY_PREFIX)) bins.push(value.bin);
500
+ return bins;
501
+ }
502
+ /**
503
+ * Delete a bin. Returns true when the bin existed.
504
+ * The delete waits for captures already queued for that bin, so a capture cannot write the bin back.
505
+ */
506
+ async deleteBin(id) {
507
+ return this.enqueue(id, () => this._keyv.delete(this.binKey(id)));
508
+ }
509
+ /**
510
+ * Record a request for a bin.
511
+ * Returns `undefined` when the bin does not exist.
512
+ * Throws when Keyv does not store the capture.
513
+ */
514
+ async recordRequest(binId, raw) {
515
+ const captured = {
516
+ ...raw,
517
+ id: this.generateId(),
518
+ binId,
519
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
520
+ };
521
+ return await this.enqueue(binId, () => this.appendRequest(binId, captured)) ? captured : void 0;
522
+ }
523
+ /** List captured requests for a bin, newest first. */
524
+ async getRequests(binId) {
525
+ return [...(await this._keyv.get(this.binKey(binId)))?.requests ?? []];
526
+ }
527
+ /** Look up one captured request. */
528
+ async getRequest(binId, reqId) {
529
+ return (await this._keyv.get(this.binKey(binId)))?.requests.find((request) => request.id === reqId);
530
+ }
531
+ /**
532
+ * Remove captured requests and keep the bin.
533
+ * Returns false when the bin does not exist.
534
+ * Throws when Keyv does not store the change.
535
+ */
536
+ async clearRequests(binId) {
537
+ return this.enqueue(binId, async () => {
538
+ const doc = await this._keyv.get(this.binKey(binId));
539
+ if (!doc) return false;
540
+ doc.requests = [];
541
+ doc.bin.requestCount = 0;
542
+ await this.save(doc);
543
+ return true;
544
+ });
545
+ }
546
+ /** Keyv 6 yields nothing when the store has no iterator. Listing must fail instead. */
547
+ canIterate() {
548
+ return typeof this._keyv.store.iterator === "function";
549
+ }
550
+ /** Returns false when the bin does not exist. */
551
+ async appendRequest(binId, captured) {
552
+ const doc = await this._keyv.get(this.binKey(binId));
553
+ if (!doc) return false;
554
+ doc.requests.unshift(captured);
555
+ while (doc.requests.length > this._maxRequestsPerBin) doc.requests.pop();
556
+ doc.bin.requestCount = doc.requests.length;
557
+ await this.save(doc);
558
+ return true;
559
+ }
560
+ /**
561
+ * Write a bin document with a TTL from its lifetime.
562
+ * Keyv returns false or throws when the write fails. Both cases throw here.
563
+ */
564
+ async save(doc) {
565
+ const message = `MockHttpBins: could not save bin ${doc.bin.id}`;
566
+ let saved;
567
+ try {
568
+ saved = await this._keyv.set(this.binKey(doc.bin.id), doc, this.ttlFor(doc.bin));
569
+ } catch (cause) {
570
+ if (isBinStoreFailure(cause)) throw new BinStoreFailure(`${message}. ${cause.message}`, cause);
571
+ throw new Error(message, { cause });
572
+ }
573
+ if (!saved) throw new Error(message);
574
+ }
575
+ /**
576
+ * Run writes for one bin in order.
577
+ * A failed write does not block the next write for that bin.
578
+ */
579
+ enqueue(id, fn) {
580
+ const run = (this._locks.get(id) ?? Promise.resolve()).then(fn);
581
+ const settled = run.then(() => void 0, () => void 0);
582
+ this._locks.set(id, settled);
583
+ settled.finally(() => {
584
+ if (this._locks.get(id) === settled) this._locks.delete(id);
585
+ });
586
+ return run;
587
+ }
588
+ /** Keyv keeps a key without a TTL forever, so the TTL never drops below 1 ms. */
589
+ ttlFor(bin) {
590
+ return Math.max(1, Date.parse(bin.expiresAt) - Date.now());
591
+ }
592
+ binKey(id) {
593
+ return `${BIN_KEY_PREFIX}${id}`;
594
+ }
595
+ generateId() {
596
+ return randomUUID().replaceAll("-", "").slice(0, this._idLength);
597
+ }
598
+ };
599
+ //#endregion
481
600
  //#region src/routes/anything/index.ts
482
601
  const anythingSchema = {
483
602
  description: "Will return anything that you send it",
@@ -907,9 +1026,9 @@ const captureSchema = {
907
1026
  const TEXT_CONTENT_TYPE_REGEX = /^(text\/|application\/(json|xml|x-www-form-urlencoded|javascript))/i;
908
1027
  /**
909
1028
  * Create a Fastify plugin that registers the catch-all bin capture endpoint
910
- * at `/b/:id` and `/b/:id/*`, with the provided {@link BinManager} injected.
1029
+ * at `/b/:id` and `/b/:id/*`, with the provided {@link MockHttpBins} injected.
911
1030
  */
912
- const binsCaptureRoute = (binManager) => {
1031
+ const binsCaptureRoute = (bins) => {
913
1032
  return (fastify) => {
914
1033
  fastify.removeAllContentTypeParsers();
915
1034
  fastify.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => {
@@ -918,10 +1037,9 @@ const binsCaptureRoute = (binManager) => {
918
1037
  const handler = async (request, reply) => {
919
1038
  const { id } = request.params;
920
1039
  const rest = request.params["*"] ?? "";
921
- if (!binManager.getBin(id)) return reply.code(404).send({ error: "bin_not_found" });
922
1040
  const raw = request.body;
923
1041
  const bodySize = raw?.length ?? 0;
924
- const max = binManager.maxBodySize;
1042
+ const max = bins.maxBodySize;
925
1043
  const truncated = bodySize > max;
926
1044
  const slice = raw && raw.length > 0 ? raw.subarray(0, max) : void 0;
927
1045
  const contentTypeHeader = request.headers["content-type"];
@@ -942,7 +1060,7 @@ const binsCaptureRoute = (binManager) => {
942
1060
  const prefix = `/b/${id}`;
943
1061
  const url = request.url.slice(prefix.length) || "/";
944
1062
  const path = rest ? `/${rest}` : "/";
945
- const captured = binManager.recordRequest(id, {
1063
+ const captured = await bins.recordRequest(id, {
946
1064
  method: request.method,
947
1065
  url,
948
1066
  path,
@@ -955,11 +1073,11 @@ const binsCaptureRoute = (binManager) => {
955
1073
  bodyEncoding,
956
1074
  truncated
957
1075
  });
1076
+ if (!captured) return reply.code(404).send({ error: "bin_not_found" });
958
1077
  return reply.code(200).send({
959
1078
  ok: true,
960
1079
  binId: id,
961
- /* v8 ignore next -- @preserve */
962
- requestId: captured?.id ?? ""
1080
+ requestId: captured.id
963
1081
  });
964
1082
  };
965
1083
  const opts = {
@@ -1173,43 +1291,54 @@ const clearRequestsSchema = {
1173
1291
  }
1174
1292
  };
1175
1293
  /**
1294
+ * Public capture URL for a new bin.
1295
+ * The last forwarded protocol wins, matching a proxy chain. An unrecognized
1296
+ * value falls back to the socket protocol, then `http`.
1297
+ */
1298
+ function binCaptureUrl(forwardedProto, socketProtocol, host, id) {
1299
+ const forwarded = (Array.isArray(forwardedProto) ? forwardedProto.join(",") : forwardedProto)?.split(",").at(-1)?.trim().toLowerCase();
1300
+ let protocol = "http";
1301
+ if (forwarded === "https" || forwarded === "http") protocol = forwarded;
1302
+ else if (socketProtocol === "https") protocol = "https";
1303
+ return `${protocol}://${host}/b/${id}`;
1304
+ }
1305
+ /**
1176
1306
  * Create a Fastify plugin that registers bin management endpoints, with the
1177
- * provided {@link BinManager} injected.
1307
+ * provided {@link MockHttpBins} injected.
1178
1308
  */
1179
- const binsManagementRoute = (binManager) => {
1309
+ const binsManagementRoute = (bins) => {
1180
1310
  return (fastify) => {
1181
1311
  fastify.post("/bins", { schema: createBinSchema }, async (request, _reply) => {
1182
- const bin = binManager.createBin();
1312
+ const bin = await bins.createBin();
1183
1313
  /* v8 ignore next -- @preserve */
1184
1314
  const host = request.headers.host ?? request.hostname;
1185
1315
  return {
1186
1316
  ...bin,
1187
- url: `${request.protocol}://${host}/b/${bin.id}`
1317
+ url: binCaptureUrl(request.headers["x-mockhttp-forwarded-proto"] ?? request.headers["x-forwarded-proto"], request.protocol, host, bin.id)
1188
1318
  };
1189
1319
  });
1190
- fastify.get("/bins", { schema: listBinsSchema }, async (_request, _reply) => ({ bins: binManager.listBins() }));
1320
+ fastify.get("/bins", { schema: listBinsSchema }, async (_request, _reply) => ({ bins: await bins.listBins() }));
1191
1321
  fastify.get("/bins/:id", { schema: getBinSchema }, async (request, reply) => {
1192
- const bin = binManager.getBin(request.params.id);
1322
+ const bin = await bins.getBin(request.params.id);
1193
1323
  if (!bin) return reply.code(404).send({ error: "bin_not_found" });
1194
1324
  return bin;
1195
1325
  });
1196
1326
  fastify.get("/bins/:id/requests", { schema: listRequestsSchema }, async (request, reply) => {
1197
- if (!binManager.getBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1198
- return { requests: binManager.getRequests(request.params.id) };
1327
+ if (!await bins.getBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1328
+ return { requests: await bins.getRequests(request.params.id) };
1199
1329
  });
1200
1330
  fastify.get("/bins/:id/requests/:reqId", { schema: getRequestSchema }, async (request, reply) => {
1201
- if (!binManager.getBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1202
- const captured = binManager.getRequest(request.params.id, request.params.reqId);
1331
+ if (!await bins.getBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1332
+ const captured = await bins.getRequest(request.params.id, request.params.reqId);
1203
1333
  if (!captured) return reply.code(404).send({ error: "request_not_found" });
1204
1334
  return captured;
1205
1335
  });
1206
1336
  fastify.delete("/bins/:id/requests", { schema: clearRequestsSchema }, async (request, reply) => {
1207
- if (!binManager.getBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1208
- binManager.clearRequests(request.params.id);
1337
+ if (!await bins.clearRequests(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1209
1338
  return reply.code(204).send();
1210
1339
  });
1211
1340
  fastify.delete("/bins/:id", { schema: deleteBinSchema }, async (request, reply) => {
1212
- if (!binManager.deleteBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1341
+ if (!await bins.deleteBin(request.params.id)) return reply.code(404).send({ error: "bin_not_found" });
1213
1342
  return reply.code(204).send();
1214
1343
  });
1215
1344
  };
@@ -1856,6 +1985,164 @@ const uuidRoute = (fastify) => {
1856
1985
  fastify.get("/uuid", { schema: uuidSchema }, async () => ({ uuid: randomUUID() }));
1857
1986
  };
1858
1987
  //#endregion
1988
+ //#region src/routes/health.ts
1989
+ /** Cheap read against the in-memory Keyv used when MongoDB is not configured. */
1990
+ async function checkMemoryBinStore(keyv) {
1991
+ const started = Date.now();
1992
+ try {
1993
+ await keyv.get("__mockhttp_healthz__");
1994
+ return {
1995
+ status: "ok",
1996
+ bins: {
1997
+ store: "memory",
1998
+ connected: true,
1999
+ latencyMs: Math.max(0, Date.now() - started),
2000
+ uriPresent: false
2001
+ }
2002
+ };
2003
+ } catch (error) {
2004
+ logBinStore({
2005
+ phase: "healthz",
2006
+ store: "memory",
2007
+ error
2008
+ });
2009
+ return {
2010
+ status: "error",
2011
+ bins: {
2012
+ store: "memory",
2013
+ connected: false,
2014
+ uriPresent: false,
2015
+ error: redactStoreError(error)
2016
+ }
2017
+ };
2018
+ }
2019
+ }
2020
+ const binHealthFields = {
2021
+ store: {
2022
+ type: "string",
2023
+ enum: ["mongo", "memory"]
2024
+ },
2025
+ connected: { type: "boolean" },
2026
+ latencyMs: { type: "integer" },
2027
+ uriPresent: { type: "boolean" },
2028
+ uri: {
2029
+ type: "object",
2030
+ properties: {
2031
+ length: { type: "integer" },
2032
+ leadingWhitespace: { type: "boolean" },
2033
+ trailingWhitespace: { type: "boolean" },
2034
+ newline: { type: "boolean" },
2035
+ wrappedQuotes: { type: "boolean" },
2036
+ atCount: { type: "integer" },
2037
+ passwordReserved: {
2038
+ type: "array",
2039
+ items: { type: "string" }
2040
+ },
2041
+ passwordHasPercent: { type: "boolean" }
2042
+ },
2043
+ required: [
2044
+ "length",
2045
+ "leadingWhitespace",
2046
+ "trailingWhitespace",
2047
+ "newline",
2048
+ "wrappedQuotes",
2049
+ "atCount",
2050
+ "passwordReserved",
2051
+ "passwordHasPercent"
2052
+ ]
2053
+ }
2054
+ };
2055
+ const healthSchema = {
2056
+ description: "Report whether bin storage is reachable. MongoDB failures include the driver error. The scheme, the host, the username, the password, the password length, and the full URI are never returned.",
2057
+ tags: ["Health"],
2058
+ response: {
2059
+ 200: {
2060
+ type: "object",
2061
+ properties: {
2062
+ status: {
2063
+ type: "string",
2064
+ enum: ["ok"]
2065
+ },
2066
+ bins: {
2067
+ type: "object",
2068
+ properties: binHealthFields,
2069
+ required: [
2070
+ "store",
2071
+ "connected",
2072
+ "latencyMs",
2073
+ "uriPresent"
2074
+ ]
2075
+ }
2076
+ },
2077
+ required: ["status", "bins"]
2078
+ },
2079
+ 503: {
2080
+ type: "object",
2081
+ properties: {
2082
+ status: {
2083
+ type: "string",
2084
+ enum: ["error"]
2085
+ },
2086
+ bins: {
2087
+ type: "object",
2088
+ properties: {
2089
+ ...binHealthFields,
2090
+ error: {
2091
+ type: "object",
2092
+ properties: {
2093
+ name: { type: "string" },
2094
+ message: { type: "string" },
2095
+ code: { type: [
2096
+ "string",
2097
+ "number",
2098
+ "null"
2099
+ ] }
2100
+ },
2101
+ required: [
2102
+ "name",
2103
+ "message",
2104
+ "code"
2105
+ ]
2106
+ }
2107
+ },
2108
+ required: [
2109
+ "store",
2110
+ "connected",
2111
+ "uriPresent",
2112
+ "error"
2113
+ ]
2114
+ }
2115
+ },
2116
+ required: ["status", "bins"]
2117
+ }
2118
+ }
2119
+ };
2120
+ const healthRoute = (check) => (fastify) => {
2121
+ fastify.get("/healthz", { schema: healthSchema }, async (_request, reply) => {
2122
+ try {
2123
+ const report = await check();
2124
+ if (report.status === "error") return reply.status(503).send(report);
2125
+ return report;
2126
+ } catch (error) {
2127
+ logBinStore({
2128
+ phase: "healthz",
2129
+ store: "unknown",
2130
+ error
2131
+ });
2132
+ const info = redactStoreError(error);
2133
+ return reply.status(503).send({
2134
+ status: "error",
2135
+ bins: {
2136
+ store: "mongo",
2137
+ connected: false,
2138
+ uriPresent: false,
2139
+ error: info
2140
+ }
2141
+ });
2142
+ }
2143
+ });
2144
+ };
2145
+ //#endregion
1859
2146
  //#region src/routes/http-methods/delete.ts
1860
2147
  const deleteSchema = {
1861
2148
  description: "Handles a DELETE request and returns request information",
@@ -3367,7 +3654,7 @@ A simple HTTP server that can be used to mock HTTP responses for testing purpose
3367
3654
 
3368
3655
  [mockhttp.org](https://mockhttp.org) is a free hosted instance of this codebase for testing. It runs on a Cloudflare Worker (no containers): documentation is served from Workers Assets, and mock APIs such as \`/get\` and \`/post\` run in the Worker. The service is globally available and rate-limited (1000 requests per minute per IP) to prevent abuse.
3369
3656
  `,
3370
- version: "2.1.0"
3657
+ version: "3.0.1"
3371
3658
  } } };
3372
3659
  const withLocalServer = (spec) => {
3373
3660
  spec.servers = [{
@@ -3502,7 +3789,6 @@ var MockHttp = class extends Hookified {
3502
3789
  _siteDistPath = defaultSiteDistPath;
3503
3790
  _logging = true;
3504
3791
  _pluginTimeout;
3505
- _startBins = true;
3506
3792
  _httpBin = {
3507
3793
  httpMethods: true,
3508
3794
  redirects: true,
@@ -3528,7 +3814,8 @@ var MockHttp = class extends Hookified {
3528
3814
  _httpsCredentials;
3529
3815
  _server;
3530
3816
  _taps = new TapManager();
3531
- _bins = new BinManager();
3817
+ _bins = new MockHttpBins();
3818
+ _binHealthCheck;
3532
3819
  constructor(options) {
3533
3820
  super(options?.hookOptions);
3534
3821
  if (options?.port) this._port = options.port;
@@ -3545,7 +3832,6 @@ var MockHttp = class extends Hookified {
3545
3832
  }
3546
3833
  if (options?.logging !== void 0) this._logging = options.logging;
3547
3834
  if (options?.pluginTimeout !== void 0) this._pluginTimeout = options.pluginTimeout;
3548
- if (options?.startBins !== void 0) this._startBins = options.startBins;
3549
3835
  if (options?.http2 !== void 0) this._http2 = options.http2;
3550
3836
  if (options?.http1 !== void 0) this._http1 = options.http1;
3551
3837
  if (options?.https !== void 0) {
@@ -3679,20 +3965,6 @@ var MockHttp = class extends Hookified {
3679
3965
  this._pluginTimeout = pluginTimeout;
3680
3966
  }
3681
3967
  /**
3682
- * Whether to start the request-bin cleanup timer during `initialize()`.
3683
- * @default true
3684
- */
3685
- get startBins() {
3686
- return this._startBins;
3687
- }
3688
- /**
3689
- * Whether to start the request-bin cleanup timer during `initialize()`.
3690
- * @default true
3691
- */
3692
- set startBins(startBins) {
3693
- this._startBins = startBins;
3694
- }
3695
- /**
3696
3968
  * HTTPS configuration. Set to true to auto-generate a self-signed certificate,
3697
3969
  * or provide an HttpsOptions object with cert/key or auto-generation options.
3698
3970
  */
@@ -3798,18 +4070,30 @@ var MockHttp = class extends Hookified {
3798
4070
  this._taps = taps;
3799
4071
  }
3800
4072
  /**
3801
- * The BinManager instance for managing request bins.
4073
+ * The MockHttpBins instance for request bins.
3802
4074
  */
3803
4075
  get bins() {
3804
4076
  return this._bins;
3805
4077
  }
3806
4078
  /**
3807
- * The BinManager instance for managing request bins.
4079
+ * The MockHttpBins instance for request bins.
3808
4080
  */
3809
4081
  set bins(bins) {
3810
4082
  this._bins = bins;
3811
4083
  }
3812
4084
  /**
4085
+ * Override the bin storage probe used by `GET /healthz`.
4086
+ * The default probe reads the in-memory Keyv.
4087
+ */
4088
+ set binHealthCheck(check) {
4089
+ this._binHealthCheck = check;
4090
+ }
4091
+ /** Whether bin storage can serve a read or a MongoDB ping. */
4092
+ checkBinStore() {
4093
+ if (this._binHealthCheck) return this._binHealthCheck();
4094
+ return checkMemoryBinStore(this._bins.keyv);
4095
+ }
4096
+ /**
3813
4097
  * Register plugins and routes without listening. Use `inject()` against
3814
4098
  * `server`, or call `start()` to bind a port.
3815
4099
  */
@@ -3862,6 +4146,7 @@ var MockHttp = class extends Hookified {
3862
4146
  } } });
3863
4147
  if (this._rateLimit) await this._server.register(fastifyRateLimit, this._rateLimit);
3864
4148
  if (this._apiDocs) await this.registerSwagger();
4149
+ await this.server.register(healthRoute(() => this.checkBinStore()));
3865
4150
  const { httpMethods, redirects, requestInspection, responseInspection, responseFormats, statusCodes, cookies, anything, auth, images, dynamicData, bins } = this._httpBin;
3866
4151
  if (httpMethods) await this.registerHttpMethods();
3867
4152
  if (statusCodes) await this.registerStatusCodeRoutes();
@@ -3874,10 +4159,7 @@ var MockHttp = class extends Hookified {
3874
4159
  if (auth) await this.registerAuthRoutes();
3875
4160
  if (images) await this.registerImageRoutes();
3876
4161
  if (dynamicData) await this.registerDynamicDataRoutes();
3877
- if (bins) {
3878
- await this.registerBinRoutes();
3879
- if (this._startBins) this._bins.start();
3880
- }
4162
+ if (bins) await this.registerBinRoutes();
3881
4163
  if (this._apiDocs) await this.registerSite();
3882
4164
  } catch (error) {
3883
4165
  /* v8 ignore next -- @preserve */
@@ -3908,7 +4190,6 @@ var MockHttp = class extends Hookified {
3908
4190
  * Close the Fastify server.
3909
4191
  */
3910
4192
  async close() {
3911
- this._bins.stop();
3912
4193
  if (this._server) await this._server.close();
3913
4194
  }
3914
4195
  /**
@@ -4111,4 +4392,4 @@ const start = async () => {
4111
4392
  /* v8 ignore next -- @preserve */
4112
4393
  if (import.meta.url === `file://${process$1.argv[1]}`) await start();
4113
4394
  //#endregion
4114
- export { BinManager, InMemoryBinStore, MockHttp as default, MockHttp as mockhttp, generateCertificate, generateCertificateFiles, start };
4395
+ export { MockHttpBins, MockHttp as default, MockHttp as mockhttp, generateCertificate, generateCertificateFiles, start };