diff --git a/README.md b/README.md index 9063793..85227f4 100644 --- a/README.md +++ b/README.md @@ -1230,7 +1230,7 @@ A getter that returns whether there are any active injections. # About mockhttp.org -[mockhttp.org](https://mockhttp.org) is a free hosted instance of this codebase for testing. It runs on a [Cloudflare Worker](https://developers.cloudflare.com/workers/) (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. +[mockhttp.org](https://mockhttp.org) is a free hosted instance of this codebase for testing. It runs on a [Cloudflare Worker](https://developers.cloudflare.com/workers/) (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. Request bins on this service are stored in MongoDB Atlas, in the `bins` collection, so they are shared across isolates. All [mockhttp.org](https://mockhttp.org) traffic stays on Cloudflare. A separate test app runs the Node server on Wasmer Edge at [mockhttp.wasmer.app](https://mockhttp.wasmer.app). diff --git a/package.json b/package.json index bfae24a..38a1e5b 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@fastify/rate-limit": "^11.2.1", "@fastify/static": "^10.1.6", "@fastify/swagger": "^9.9.2", + "@keyv/mongo": "^6.1.0", "detect-port": "^2.1.0", "fastify": "^5.12.5", "hookified": "^3.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea94968..5096101 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: '@fastify/swagger': specifier: ^9.9.2 version: 9.9.2(supports-color@7.2.0) + '@keyv/mongo': + specifier: ^6.1.0 + version: 6.1.0(keyv@6.1.0) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -590,6 +593,12 @@ packages: peerDependencies: keyv: ^5.6.0 + '@keyv/mongo@6.1.0': + resolution: {integrity: sha512-aUvY7HSNSpxdZYqTUIRkEp+7AfGC64aShJ55w4tiq90ZO87Jb/AWa9O9n0W2G7B1ILtxSjiqJQSS6tk7f9G+nw==} + engines: {node: '>= 22.19.0'} + peerDependencies: + keyv: ^6.1.0 + '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} @@ -597,6 +606,9 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@mongodb-js/saslprep@1.6.0': + resolution: {integrity: sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -866,6 +878,12 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@13.0.0': + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1217,6 +1235,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bson@7.3.3: + resolution: {integrity: sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==} + engines: {node: '>=20.19.0'} + bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -2023,6 +2045,9 @@ packages: mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2162,6 +2187,37 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mongodb-connection-string-url@7.0.2: + resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==} + engines: {node: '>=20.19.0'} + + mongodb@7.7.0: + resolution: {integrity: sha512-XG1xXEqyouAV8GCb3PuRaujofeXKpAJAPvXYWZgHl6Dz4QHSJ1nRLl0+GpqaLTwKvC0RFE9truOur34RNFY1nw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2322,6 +2378,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pupa@3.3.0: resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} engines: {node: '>=12.20'} @@ -2511,6 +2571,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2604,6 +2667,10 @@ packages: token-stream@1.0.0: resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2826,6 +2893,14 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} @@ -3189,10 +3264,28 @@ snapshots: hookified: 1.15.1 keyv: 5.6.0 + '@keyv/mongo@6.1.0(keyv@6.1.0)': + dependencies: + hookified: 3.0.4 + keyv: 6.1.0 + mongodb: 7.7.0 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + '@keyv/serialize@1.1.1': {} '@lukeed/ms@2.0.2': {} + '@mongodb-js/saslprep@1.6.0': + dependencies: + sparse-bitfield: 3.0.3 + '@opentelemetry/api@1.9.0': optional: true @@ -3375,6 +3468,12 @@ snapshots: '@types/unist@3.0.3': {} + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@13.0.0': + dependencies: + '@types/webidl-conversions': 7.0.3 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3619,6 +3718,8 @@ snapshots: dependencies: fill-range: 7.1.1 + bson@7.3.3: {} + bytes@3.0.0: {} cac@7.0.1: {} @@ -4579,6 +4680,8 @@ snapshots: mdurl@2.1.0: {} + memory-pager@1.5.0: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -4878,6 +4981,17 @@ snapshots: minipass@7.1.3: {} + mongodb-connection-string-url@7.0.2: + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + + mongodb@7.7.0: + dependencies: + '@mongodb-js/saslprep': 1.6.0 + bson: 7.3.3 + mongodb-connection-string-url: 7.0.2 + ms@2.1.3: {} nanoid@3.3.20: {} @@ -5079,6 +5193,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + pupa@3.3.0: dependencies: escape-goat: 4.0.0 @@ -5320,6 +5436,10 @@ snapshots: space-separated-tokens@2.0.2: {} + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + split2@4.2.0: {} statuses@2.0.2: {} @@ -5401,6 +5521,10 @@ snapshots: token-stream@1.0.0: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -5605,6 +5729,13 @@ snapshots: web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + when-exit@2.1.5: {} why-is-node-running@3.2.1: {} diff --git a/site/docs/hosted.md b/site/docs/hosted.md index cdf9401..cfbc727 100644 --- a/site/docs/hosted.md +++ b/site/docs/hosted.md @@ -5,7 +5,7 @@ order: 8 # About mockhttp.org -[mockhttp.org](https://mockhttp.org) is a free hosted instance of this codebase for testing. It runs on a [Cloudflare Worker](/docs/how-to-deploy/cloudflare/) (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. +[mockhttp.org](https://mockhttp.org) is a free hosted instance of this codebase for testing. It runs on a [Cloudflare Worker](/docs/how-to-deploy/cloudflare/) (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. Request bins on this service are stored in MongoDB Atlas, in the `bins` collection, so they are shared across isolates. All [mockhttp.org](https://mockhttp.org) traffic stays on Cloudflare. A separate test app runs the Node server on [Wasmer Edge](/docs/how-to-deploy/wasmer/) at [mockhttp.wasmer.app](https://mockhttp.wasmer.app). diff --git a/test/worker.test.ts b/test/worker.test.ts index 7517c7a..ced3308 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,14 +1,65 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { readPublicFile, setPublicFileReader } from "../src/public-files.js"; -const { handleAsNodeRequestMock } = vi.hoisted(() => ({ - handleAsNodeRequestMock: vi.fn(), -})); +const { handleAsNodeRequestMock, KeyvMongoMock } = vi.hoisted(() => { + class KeyvMongoMock { + static instances: KeyvMongoMock[] = []; + readonly url: string; + readonly options: Record | undefined; + readonly data = new Map(); + + constructor( + url?: string | Record, + options?: Record, + ) { + if (typeof url === "string") { + this.url = url; + this.options = options; + } else { + this.url = ""; + this.options = url; + } + KeyvMongoMock.instances.push(this); + } + + async get(key: string): Promise { + return this.data.get(key); + } + + async set(key: string, value: unknown): Promise { + this.data.set(key, value); + return true; + } + + async delete(key: string): Promise { + return this.data.delete(key); + } + + async clear(): Promise { + this.data.clear(); + } + + async *iterator(): AsyncGenerator<[string, unknown]> { + for (const entry of this.data) { + yield entry; + } + } + } + + return { + handleAsNodeRequestMock: vi.fn(), + KeyvMongoMock, + }; +}); vi.mock("cloudflare:node", () => ({ handleAsNodeRequest: handleAsNodeRequestMock, })); +vi.mock("@keyv/mongo", () => ({ + default: KeyvMongoMock, +})); + const { WORKER_PORT, applyRateLimit, @@ -18,6 +69,7 @@ const { handleWorkerFetch, listenWorkerApp, workerMockHttpOptions, + workerMongoUri, workerRuntime, } = await import("../worker/app.js"); @@ -223,4 +275,77 @@ describe("worker Fastify app", () => { spy.mockRestore(); await mockHttp.close(); }); + + it("serves bin routes from memory when no Atlas URI is configured", async () => { + delete process.env.MONGODB_URI; + KeyvMongoMock.instances.length = 0; + expect(workerMongoUri()).toBeUndefined(); + expect(workerMongoUri({ MONGODB_URI: "" })).toBeUndefined(); + + const mockHttp = await createWorkerApp({ MONGODB_URI: "" }); + const created = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(created.statusCode).toBe(200); + const id = created.json().id as string; + const fetched = await mockHttp.server.inject({ + method: "GET", + url: `/bins/${id}`, + }); + expect(fetched.statusCode).toBe(200); + expect(fetched.json()).toMatchObject({ id }); + expect(KeyvMongoMock.instances).toHaveLength(0); + await mockHttp.close(); + }); + + it("stores bins in MongoDB Atlas when MONGODB_URI is set", async () => { + const uri = "mongodb+srv://user:pass@cluster.mongodb.net/mockhttp"; + KeyvMongoMock.instances.length = 0; + process.env.MONGODB_URI = "mongodb://from-process-env"; + + const mockHttp = await createWorkerApp({ MONGODB_URI: uri }); + const store = KeyvMongoMock.instances[0]; + expect(store?.url).toBe(uri); + expect(store?.options).toEqual({ + collection: "bins", + maxPoolSize: 5, + minPoolSize: 0, + serverSelectionTimeoutMS: 5_000, + }); + + const created = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(created.statusCode).toBe(200); + const id = created.json().id as string; + expect(store?.data.size).toBeGreaterThan(0); + + const fetched = await mockHttp.server.inject({ + method: "GET", + url: `/bins/${id}`, + }); + expect(fetched.statusCode).toBe(200); + expect(fetched.json()).toMatchObject({ id }); + await mockHttp.close(); + delete process.env.MONGODB_URI; + }); + + it("reads the Atlas URI from process.env when the binding is unset", async () => { + const uri = "mongodb+srv://user:pass@cluster.mongodb.net/mockhttp"; + process.env.MONGODB_URI = uri; + KeyvMongoMock.instances.length = 0; + + expect(workerMongoUri({})).toBe(uri); + const mockHttp = await createWorkerApp(); + expect(KeyvMongoMock.instances[0]?.url).toBe(uri); + const created = await mockHttp.server.inject({ + method: "POST", + url: "/bins", + }); + expect(created.statusCode).toBe(200); + await mockHttp.close(); + delete process.env.MONGODB_URI; + }); }); diff --git a/worker/app.ts b/worker/app.ts index 17ef292..cf39b98 100644 --- a/worker/app.ts +++ b/worker/app.ts @@ -1,5 +1,8 @@ import { handleAsNodeRequest } from "cloudflare:node"; +import KeyvMongo from "@keyv/mongo"; +import Keyv from "keyv"; import { MockHttp, type MockHttpOptions } from "../src/mock-http.js"; +import { MockHttpBins } from "../src/mock-http-bins.js"; import { setPublicFileReader } from "../src/public-files.js"; export const WORKER_PORT = 3000; @@ -26,8 +29,43 @@ export type WorkerAssets = { export type WorkerEnv = { ASSETS?: WorkerAssets; RATE_LIMITER?: RateLimiter; + /** MongoDB Atlas connection string for shared request bins. */ + MONGODB_URI?: string; }; +/** + * Pool stays small because each Worker isolate opens its own client. + * `serverSelectionTimeoutMS` keeps a down Atlas from holding the request + * for the driver's 30 second default. + */ +const workerBinMongoOptions = { + collection: "bins", + maxPoolSize: 5, + minPoolSize: 0, + serverSelectionTimeoutMS: 5_000, +}; + +/** + * Atlas URI for this request. The Worker binding wins. An empty string is + * treated as unset so a blank secret does not open a client. + */ +export function workerMongoUri(env: WorkerEnv = {}): string | undefined { + const uri = env.MONGODB_URI || process.env.MONGODB_URI; + if (!uri) { + return undefined; + } + return uri; +} + +/** Request bins stored in MongoDB through Keyv. */ +export function createWorkerBins(uri: string): MockHttpBins { + return new MockHttpBins({ + keyv: new Keyv({ + store: new KeyvMongo(uri, workerBinMongoOptions), + }), + }); +} + export type WorkerDispatch = ( port: number, request: Request, @@ -89,8 +127,12 @@ export function bindPublicAssets(assets: WorkerAssets | undefined): void { }); } -export async function createWorkerApp(): Promise { +export async function createWorkerApp(env: WorkerEnv = {}): Promise { const mockHttp = new MockHttp(workerMockHttpOptions); + const uri = workerMongoUri(env); + if (uri) { + mockHttp.bins = createWorkerBins(uri); + } await mockHttp.initialize(); return mockHttp; } diff --git a/worker/index.ts b/worker/index.ts index 18ab552..ee00fd1 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -5,10 +5,30 @@ import { type WorkerEnv, } from "./app.js"; -await listenWorkerApp(await createWorkerApp()); +let listening: Promise | undefined; + +// The Atlas URI is on the request env, so Fastify starts on the first fetch. +async function ensureWorkerListening(env: WorkerEnv): Promise { + if (!listening) { + const pending = (async () => { + try { + await listenWorkerApp(await createWorkerApp(env)); + } catch (error) { + if (listening === pending) { + listening = undefined; + } + throw error; + } + })(); + listening = pending; + } + + await listening; +} export default { async fetch(request: Request, env: WorkerEnv): Promise { + await ensureWorkerListening(env); return handleWorkerFetch(request, env); }, };