@jaredwray/mockhttp 3.0.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.d.mts CHANGED
@@ -2,6 +2,43 @@ import { RateLimitPluginOptions } from "@fastify/rate-limit";
2
2
  import { FastifyInstance, FastifyRequest } from "fastify";
3
3
  import { Hookified, HookifiedOptions } from "hookified";
4
4
  import Keyv from "keyv";
5
+ //#region src/bin-store-diagnostics.d.ts
6
+ type BinStoreErrorInfo = {
7
+ name: string;
8
+ message: string;
9
+ code: string | number | null;
10
+ };
11
+ type BinHealth = {
12
+ status: "ok";
13
+ bins: {
14
+ store: "mongo" | "memory";
15
+ connected: true;
16
+ latencyMs: number;
17
+ uriPresent: boolean;
18
+ uri?: UriDiagnostics;
19
+ };
20
+ } | {
21
+ status: "error";
22
+ bins: {
23
+ store: "mongo" | "memory";
24
+ connected: false;
25
+ uriPresent: boolean;
26
+ uri?: UriDiagnostics;
27
+ error: BinStoreErrorInfo;
28
+ };
29
+ };
30
+ /** Redacted URI facts for `GET /healthz`. No password and no full URI. */
31
+ type UriDiagnostics = {
32
+ length: number;
33
+ leadingWhitespace: boolean;
34
+ trailingWhitespace: boolean;
35
+ newline: boolean;
36
+ wrappedQuotes: boolean;
37
+ atCount: number;
38
+ passwordReserved: string[];
39
+ passwordHasPercent: boolean;
40
+ };
41
+ //#endregion
5
42
  //#region src/certificate.d.ts
6
43
  type CertificateOptions = {
7
44
  /**
@@ -455,6 +492,7 @@ declare class MockHttp extends Hookified {
455
492
  private _server;
456
493
  private _taps;
457
494
  private _bins;
495
+ private _binHealthCheck?;
458
496
  constructor(options?: MockHttpOptions);
459
497
  /**
460
498
  * The port to listen on. If not provided, defaults to 3000 and detects the next available port if in use.
@@ -624,6 +662,13 @@ declare class MockHttp extends Hookified {
624
662
  * The MockHttpBins instance for request bins.
625
663
  */
626
664
  set bins(bins: MockHttpBins);
665
+ /**
666
+ * Override the bin storage probe used by `GET /healthz`.
667
+ * The default probe reads the in-memory Keyv.
668
+ */
669
+ set binHealthCheck(check: () => Promise<BinHealth>);
670
+ /** Whether bin storage can serve a read or a MongoDB ping. */
671
+ checkBinStore(): Promise<BinHealth>;
627
672
  /**
628
673
  * Register plugins and routes without listening. Use `inject()` against
629
674
  * `server`, or call `start()` to bind a port.
package/dist/index.mjs CHANGED
@@ -293,6 +293,134 @@ function getFastifyConfig(logging = true) {
293
293
  };
294
294
  }
295
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
296
424
  //#region src/mock-http-bins.ts
297
425
  const DEFAULT_TTL_MS = 864e5;
298
426
  const DEFAULT_MAX_REQUESTS = 100;
@@ -439,6 +567,7 @@ var MockHttpBins = class {
439
567
  try {
440
568
  saved = await this._keyv.set(this.binKey(doc.bin.id), doc, this.ttlFor(doc.bin));
441
569
  } catch (cause) {
570
+ if (isBinStoreFailure(cause)) throw new BinStoreFailure(`${message}. ${cause.message}`, cause);
442
571
  throw new Error(message, { cause });
443
572
  }
444
573
  if (!saved) throw new Error(message);
@@ -1162,6 +1291,18 @@ const clearRequestsSchema = {
1162
1291
  }
1163
1292
  };
1164
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
+ /**
1165
1306
  * Create a Fastify plugin that registers bin management endpoints, with the
1166
1307
  * provided {@link MockHttpBins} injected.
1167
1308
  */
@@ -1173,7 +1314,7 @@ const binsManagementRoute = (bins) => {
1173
1314
  const host = request.headers.host ?? request.hostname;
1174
1315
  return {
1175
1316
  ...bin,
1176
- 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)
1177
1318
  };
1178
1319
  });
1179
1320
  fastify.get("/bins", { schema: listBinsSchema }, async (_request, _reply) => ({ bins: await bins.listBins() }));
@@ -1844,6 +1985,164 @@ const uuidRoute = (fastify) => {
1844
1985
  fastify.get("/uuid", { schema: uuidSchema }, async () => ({ uuid: randomUUID() }));
1845
1986
  };
1846
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
1847
2146
  //#region src/routes/http-methods/delete.ts
1848
2147
  const deleteSchema = {
1849
2148
  description: "Handles a DELETE request and returns request information",
@@ -3355,7 +3654,7 @@ A simple HTTP server that can be used to mock HTTP responses for testing purpose
3355
3654
 
3356
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.
3357
3656
  `,
3358
- version: "3.0.0"
3657
+ version: "3.0.1"
3359
3658
  } } };
3360
3659
  const withLocalServer = (spec) => {
3361
3660
  spec.servers = [{
@@ -3516,6 +3815,7 @@ var MockHttp = class extends Hookified {
3516
3815
  _server;
3517
3816
  _taps = new TapManager();
3518
3817
  _bins = new MockHttpBins();
3818
+ _binHealthCheck;
3519
3819
  constructor(options) {
3520
3820
  super(options?.hookOptions);
3521
3821
  if (options?.port) this._port = options.port;
@@ -3782,6 +4082,18 @@ var MockHttp = class extends Hookified {
3782
4082
  this._bins = bins;
3783
4083
  }
3784
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
+ /**
3785
4097
  * Register plugins and routes without listening. Use `inject()` against
3786
4098
  * `server`, or call `start()` to bind a port.
3787
4099
  */
@@ -3834,6 +4146,7 @@ var MockHttp = class extends Hookified {
3834
4146
  } } });
3835
4147
  if (this._rateLimit) await this._server.register(fastifyRateLimit, this._rateLimit);
3836
4148
  if (this._apiDocs) await this.registerSwagger();
4149
+ await this.server.register(healthRoute(() => this.checkBinStore()));
3837
4150
  const { httpMethods, redirects, requestInspection, responseInspection, responseFormats, statusCodes, cookies, anything, auth, images, dynamicData, bins } = this._httpBin;
3838
4151
  if (httpMethods) await this.registerHttpMethods();
3839
4152
  if (statusCodes) await this.registerStatusCodeRoutes();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaredwray/mockhttp",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Mock Http - Easy way to mock http with httpbin replacement",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -49,7 +49,8 @@
49
49
  "tsx": "^4.23.15",
50
50
  "typescript": "7.0.2",
51
51
  "vite": "^8.3.4",
52
- "vitest": "^5.0.3"
52
+ "vitest": "^5.0.3",
53
+ "wrangler": "4.127.0"
53
54
  },
54
55
  "dependencies": {
55
56
  "@fastify/cookie": "^11.1.3",
@@ -63,6 +64,7 @@
63
64
  "hookified": "^3.0.4",
64
65
  "html-escaper": "^3.0.3",
65
66
  "keyv": "^6.1.0",
67
+ "mongodb": "7.7.0",
66
68
  "pino": "^10.4.0",
67
69
  "pino-pretty": "^13.2.0"
68
70
  },
@@ -70,7 +72,7 @@
70
72
  "dist",
71
73
  "public",
72
74
  "site/dist",
73
- "LICENCE"
75
+ "LICENSE"
74
76
  ],
75
77
  "scripts": {
76
78
  "lint": "biome check --write --error-on-warnings",