@sebspark/emulator
Advanced tools
+117
-110
@@ -1,20 +0,16 @@ | ||
| //#region src/index.d.ts | ||
| //#region src/types.d.ts | ||
| /** | ||
| * A filter function that decides whether a responder should be used | ||
| * for a given request. If the function returns `true`, the responder | ||
| * is eligible to handle the request; otherwise it is skipped. | ||
| * A filter function that decides whether a responder should handle a given | ||
| * request. When registered, a responder with a filter is only eligible if | ||
| * the filter returns `true` for the incoming args. | ||
| * | ||
| * @template A Arguments type for the request | ||
| * @param args The request arguments | ||
| * @returns `true` if this responder should handle the request | ||
| */ | ||
| type Filter<A> = (args: A) => boolean; | ||
| /** | ||
| * Callback invoked by a responder whenever it emits a response. | ||
| * This may be called once for simple responders, or many times | ||
| * for streaming responders. | ||
| * Callback invoked by a responder to deliver one response to the transport | ||
| * layer. Simple responders call it once; streaming responders may call it | ||
| * many times. | ||
| * | ||
| * @template R Response type | ||
| * @param resp A response object of type `R` | ||
| * @returns An arbitrary value; ignored by the emulator. | ||
| */ | ||
@@ -24,38 +20,38 @@ type ResponseCb<R> = (resp: R) => unknown; | ||
| * A handle returned by `.stream()` that lets a test drive a stateful, | ||
| * externally-controlled streaming responder. | ||
| * externally-controlled streaming responder one response at a time. | ||
| * | ||
| * - `waitForCall()` — resolves when the next request has arrived and the | ||
| * initializer has been called. Each invocation dequeues the next pending | ||
| * request in arrival order. | ||
| * - `send(modifier)` — derives a new response from `latestResponse` and | ||
| * delivers it via the transport callback. Throws if called before | ||
| * `waitForCall()` resolves. | ||
| * - `latestResponse` — the most recent response emitted for the active stream, | ||
| * or `undefined` before `waitForCall()` resolves. | ||
| * Typical usage in a test: | ||
| * ```ts | ||
| * const stream = emu.chat().stream(() => ({ token: 'Hello', done: false })) | ||
| * | ||
| * client.chat({ message: 'hi' }, onToken) | ||
| * | ||
| * await stream.waitForCall() // blocks until the request arrives | ||
| * await stream.send(() => ({ token: 'World', done: true })) | ||
| * ``` | ||
| * | ||
| * - `waitForCall(timeoutMs?)` — resolves when the next request has arrived | ||
| * and the initializer has been called. Rejects after `timeoutMs` ms | ||
| * (default `5000`) if no request arrives. | ||
| * - `send(modifier)` — derives the next response from `latestResponse` and | ||
| * pushes it to the transport. Throws if called before `waitForCall()`. | ||
| * - `latestResponse` — the most recent response sent, or `undefined` before | ||
| * the first `waitForCall()` resolves. | ||
| * - `hasBeenCalled` — `true` once the first `waitForCall()` has resolved. | ||
| * | ||
| * @template R Response type | ||
| */ | ||
| interface StreamHandle<R> { | ||
| waitForCall(): Promise<void>; | ||
| waitForCall(timeoutMs?: number): Promise<void>; | ||
| send(modifier: (prev: R) => R): Promise<void>; | ||
| readonly latestResponse: R | undefined; | ||
| /** `true` once the first `waitForCall()` has resolved. */ | ||
| readonly hasBeenCalled: boolean; | ||
| } | ||
| /** | ||
| * Builder API returned by `.times(n)` or `.persist()`. | ||
| * The builder returned by `.times(n)` or `.persist()` that exposes the | ||
| * same responder registration methods as {@link MethodCall} but without | ||
| * the lifetime-configuration methods (those have already been applied). | ||
| * | ||
| * These builders let you specify how many times a responder should be used: | ||
| * - `.times(n)` → expires after `n` uses | ||
| * - `.persist()` → never expires | ||
| * | ||
| * They expose the same responder registration methods as normal: | ||
| * - `.reply(response | fn)` → single-response style | ||
| * - `.callback(fn)` → streaming style | ||
| * - `.stream(initializer)` → externally-driven streaming style | ||
| * | ||
| * Each returns an `execute(...)` helper for direct invocation. | ||
| * | ||
| * Note: `.pending()` and `.reset()` are on {@link MethodCall} (before lifetime | ||
| * configuration), not on this builder — they operate on the method as a whole. | ||
| * @template A Arguments type | ||
| * @template R Response type | ||
| */ | ||
@@ -75,15 +71,10 @@ interface MethodCallBuilder<A, R> { | ||
| /** | ||
| * The full API available for each method in the emulator. | ||
| * The full registration API exposed for a single emulator method. | ||
| * | ||
| * By default, `.reply`, `.callback`, and `.stream` register **single-use** responders. | ||
| * For repeated use, chain `.times(n)` or `.persist()`. | ||
| * Default behaviour (without a lifetime modifier) registers a **one-shot** | ||
| * responder. Chain `.times(n)`, `.persist()`, `.once()`, `.twice()`, or | ||
| * `.thrice()` to change that. | ||
| * | ||
| * - `.reply(...)` — one-time single-response responder | ||
| * - `.callback(...)` — one-time streaming responder | ||
| * - `.stream(initializer)` — externally-driven streaming responder | ||
| * - `.times(n)` — limit responder to `n` uses | ||
| * - `.persist()` — make responder permanent (infinite uses) | ||
| * - `.once()` / `.twice()` / `.thrice()` — convenience aliases for `.times(1/2/3)` | ||
| * | ||
| * Each registration returns an `execute(...)` helper for direct invocation. | ||
| * @template A Arguments type | ||
| * @template R Response type | ||
| */ | ||
@@ -106,16 +97,18 @@ interface MethodCall<A, R> { | ||
| thrice(): MethodCallBuilder<A, R>; | ||
| /** The number of responders registered for this method that have not yet been fully consumed. */ | ||
| /** Number of responders registered for this method that are not yet consumed. */ | ||
| readonly pending: number; | ||
| /** Removes all registered responders for this method. */ | ||
| /** Remove all registered responders for this method. */ | ||
| reset(): void; | ||
| } | ||
| /** | ||
| * Describes the shape of all methods supported by an emulator. | ||
| * Describes every method that an emulator instance understands. | ||
| * | ||
| * Each entry in the map defines: | ||
| * - `args`: the type of the request arguments the method accepts | ||
| * - `resp`: the type of the responses the method will emit | ||
| * Each key maps a method name to its request (`args`) and response (`resp`) | ||
| * types. Only used at the type level — no runtime value is required. | ||
| * | ||
| * The map is only used at the type level. At runtime, methods are | ||
| * generated dynamically using a Proxy, so no actual object is required. | ||
| * @example | ||
| * type PaymentMethodMap = { | ||
| * authorise: { args: { amount: number }; resp: { authCode: string } } | ||
| * refund: { args: { authCode: string }; resp: { success: boolean } } | ||
| * } | ||
| */ | ||
@@ -127,74 +120,88 @@ type MethodMap = Record<string, { | ||
| /** | ||
| * Create a generic emulator for a given `MethodMap`. | ||
| * Adds explicit and symbol-based disposal to any object. | ||
| * | ||
| * - Uses a Proxy so you only need a *type* for your method map — no | ||
| * runtime object is required. | ||
| * Supports manual `.dispose()`, synchronous `using` (Node 20+), and | ||
| * asynchronous `await using` (Node 20+). | ||
| * | ||
| * - For each method key in the `MethodMap`, the emulator provides: | ||
| * - `.reply(response | fn)` — one-time static or single-response functions | ||
| * - `.callback(fn)` — one-time streaming responders | ||
| * - `.times(n)` — responders valid for `n` calls | ||
| * - `.persist()` — responders valid forever | ||
| * - `.once()` / `.twice()` / `.thrice()` — shorthands for `.times(1/2/3)` | ||
| * @template T The base object type to extend. | ||
| */ | ||
| type Disposable<T> = T & { | ||
| dispose(): void | Promise<void>; | ||
| [Symbol.dispose](): void; | ||
| [Symbol.asyncDispose]?(): Promise<void>; | ||
| }; | ||
| //#endregion | ||
| //#region src/disposable.d.ts | ||
| /** | ||
| * Wraps any object with explicit and symbol-based disposal semantics. | ||
| * | ||
| * - Responders are matched in **strict LIFO order**: | ||
| * - The most recently registered matching responder is invoked first. | ||
| * - Only one responder handles a given request, even if others also match. | ||
| * - Filters still apply: a responder is considered only if its filter | ||
| * returns `true` (or it has no filter). | ||
| * The returned object passes all method calls through to `emulator` and | ||
| * additionally exposes: | ||
| * | ||
| * - Provides `.handle(method, args, cb)` to manually dispatch a request | ||
| * to the appropriate responder and stream results via the callback. | ||
| * - `.dispose()` — explicit cleanup (calls `dispose` callback) | ||
| * - `[Symbol.dispose]()` — enables synchronous `using` (Node 20+) | ||
| * - `[Symbol.asyncDispose]()` — enables `await using` (Node 20+) | ||
| * | ||
| * @template T The `MethodMap` describing all methods, their args and responses | ||
| * @returns An emulator object with per-method registration APIs and a | ||
| * `.handle` dispatcher | ||
| * | ||
| * @example | ||
| * type MyMethodMap = { | ||
| * echo: { args: { msg: string }, resp: { echoed: string } } | ||
| * double: { args: { n: number }, resp: { result: number } } | ||
| * } | ||
| * const gateway = disposable(createEmulator<PaymentMethodMap>(), () => server.close()) | ||
| * // In a test: | ||
| * await using gateway = startPaymentEmulator(server) | ||
| * | ||
| * const emu = createEmulator<MyMethodMap>() | ||
| * @template T The base emulator type. | ||
| */ | ||
| declare const disposable: <T>(emulator: T, dispose: () => void | Promise<void>) => Disposable<T>; | ||
| //#endregion | ||
| //#region src/emulator.d.ts | ||
| /** | ||
| * Creates a generic, type-safe emulator for a given {@link MethodMap}. | ||
| * | ||
| * emu.echo().reply({ echoed: 'hello' }) // one-time | ||
| * emu.echo().persist().reply({ echoed: 'always' }) // infinite | ||
| * The emulator uses a `Proxy` so you only need a *type* — no runtime object | ||
| * describing the method map is required. | ||
| * | ||
| * const r1 = await emu.echo().times(2).reply(args => ({ echoed: args.msg })) | ||
| * .execute({ msg: 'hi' }) | ||
| * ### Per-method registration API | ||
| * | ||
| * emu.double().callback((args, cb) => { | ||
| * cb({ result: args.n }) | ||
| * cb({ result: args.n * 2 }) | ||
| * }) | ||
| */ | ||
| declare const createEmulator: <T extends MethodMap>() => { [M in keyof T]: (filter?: Filter<T[M]["args"]>) => MethodCall<T[M]["args"], T[M]["resp"]> } & { | ||
| handle<M extends keyof T>(method: M, args: T[M]["args"], cb: ResponseCb<T[M]["resp"]>): Promise<void>; /** Removes all registered responders across every method. */ | ||
| reset(): void; | ||
| }; | ||
| /** | ||
| * Adds disposable semantics to an object `T`. | ||
| * For every method key `M` in `T`, the emulator exposes `emu.M(filter?)` which | ||
| * returns a {@link MethodCall} builder. From there you can: | ||
| * | ||
| * This augments any object with cleanup capabilities that can be invoked | ||
| * explicitly via `.dispose()` or automatically using the `using` / | ||
| * `await using` constructs available in Node.js 20+ and modern runtimes. | ||
| * | Chain | Effect | | ||
| * |---|---| | ||
| * | `.reply(value \| fn)` | Register a single-response responder | | ||
| * | `.callback(fn)` | Register a streaming responder | | ||
| * | `.stream(initializer)` | Register an externally-driven streaming responder | | ||
| * | `.times(n)` | Limit the next responder to `n` uses | | ||
| * | `.persist()` | Make the next responder permanent | | ||
| * | `.once()` / `.twice()` / `.thrice()` | Shorthands for `.times(1/2/3)` | | ||
| * | `.pending` | Number of unconsumed responders for this method | | ||
| * | `.reset()` | Remove all responders for this method | | ||
| * | ||
| * - `.dispose()` → Manual cleanup, may be sync or async. | ||
| * - `[Symbol.dispose]()` → Enables synchronous `using`. | ||
| * - `[Symbol.asyncDispose]()` → Enables asynchronous `await using`. | ||
| * ### Dispatch | ||
| * | ||
| * Useful for managing emulator lifetimes, open connections, or other | ||
| * resources that must be cleaned up deterministically. | ||
| * `emu.handle(method, args, cb)` dispatches a request to the highest-priority | ||
| * matching responder and streams responses through `cb`. This is the entry | ||
| * point called by your transport adapter (HTTP handler, Pub/Sub listener, etc.). | ||
| * | ||
| * @template T Base object type to be extended with disposal methods | ||
| * ### Responder resolution | ||
| * | ||
| * Responders are matched in **strict LIFO order**: the most recently registered | ||
| * responder that passes its filter (if any) is invoked. Only one responder | ||
| * handles a given request. | ||
| * | ||
| * @template T The `MethodMap` describing every method, its args, and its response. | ||
| * | ||
| * @example | ||
| * type Api = { | ||
| * greet: { args: { name: string }; resp: { message: string } } | ||
| * } | ||
| * | ||
| * const emu = createEmulator<Api>() | ||
| * | ||
| * emu.greet().reply({ message: 'hello' }) | ||
| * await emu.handle('greet', { name: 'Alice' }, (r) => console.log(r)) | ||
| */ | ||
| type Disposable<T> = T & { | ||
| /** Explicit synchronous or async disposal */dispose(): void | Promise<void>; /** Symbol-based sync disposal for Node 20+ */ | ||
| [Symbol.dispose](): void; /** Symbol-based async disposal for Node 20+ */ | ||
| [Symbol.asyncDispose]?(): Promise<void>; | ||
| declare const createEmulator: <T extends MethodMap>() => { [M in keyof T]: (filter?: Filter<T[M]["args"]>) => MethodCall<T[M]["args"], T[M]["resp"]> } & { | ||
| handle<M extends keyof T>(method: M, args: T[M]["args"], cb: ResponseCb<T[M]["resp"]>): Promise<void>; /** Removes all registered responders across every method. */ | ||
| reset(): void; | ||
| }; | ||
| declare const disposable: <T>(emulator: T, dispose: () => void | Promise<void>) => Disposable<T>; | ||
| //#endregion | ||
| export { Disposable, MethodMap, StreamHandle, createEmulator, disposable }; | ||
| export { type Disposable, type MethodMap, type StreamHandle, createEmulator, disposable }; | ||
| //# sourceMappingURL=index.d.mts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":"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"} | ||
| {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/disposable.ts","../src/emulator.ts"],"mappings":"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"} |
+242
-186
@@ -1,64 +0,213 @@ | ||
| //#region src/index.ts | ||
| import { setTimeout } from "node:timers/promises"; | ||
| //#region src/disposable.ts | ||
| /** | ||
| * Converts any user-supplied responder into the internal | ||
| * streaming responder form `(args, cb) => void | Promise<void>`. | ||
| * Wraps any object with explicit and symbol-based disposal semantics. | ||
| * | ||
| * This allows the emulator to treat all responders uniformly, | ||
| * regardless of whether they were registered as: | ||
| * - A static response object of type `R` | ||
| * - A simple function `(args) => R | Promise<R>` returning a single response | ||
| * - A streaming function `(args, cb) => { cb(r1); cb(r2); ... }` | ||
| * The returned object passes all method calls through to `emulator` and | ||
| * additionally exposes: | ||
| * | ||
| * @template A Arguments type for the request | ||
| * - `.dispose()` — explicit cleanup (calls `dispose` callback) | ||
| * - `[Symbol.dispose]()` — enables synchronous `using` (Node 20+) | ||
| * - `[Symbol.asyncDispose]()` — enables `await using` (Node 20+) | ||
| * | ||
| * @example | ||
| * const gateway = disposable(createEmulator<PaymentMethodMap>(), () => server.close()) | ||
| * // In a test: | ||
| * await using gateway = startPaymentEmulator(server) | ||
| * | ||
| * @template T The base emulator type. | ||
| */ | ||
| const disposable = (emulator, dispose) => { | ||
| const d = emulator; | ||
| d.dispose = dispose; | ||
| d[Symbol.dispose] = dispose; | ||
| d[Symbol.asyncDispose] = dispose; | ||
| return d; | ||
| }; | ||
| //#endregion | ||
| //#region src/call-responder.ts | ||
| /** | ||
| * Dispatches a single request to the highest-priority matching responder. | ||
| * | ||
| * **LIFO ordering** — responders are considered in reverse insertion order, | ||
| * so the most recently registered one wins. This lets tests temporarily | ||
| * override a default with a more specific responder, then fall back once | ||
| * it is consumed. | ||
| * | ||
| * **Filters** — a responder is only eligible if it has no filter, or its | ||
| * filter returns `true` for the incoming `args`. | ||
| * | ||
| * **Lifetime** — before invoking the chosen responder, its `remaining` | ||
| * count is decremented. If it reaches zero the responder is removed from | ||
| * the set. Decrement happens *before* invocation so that concurrent or | ||
| * subsequent requests see the updated count even for long-lived streaming | ||
| * responders that only complete when the test calls `waitForCall()`. | ||
| * | ||
| * @throws {Error} When no matching responder is found. | ||
| * | ||
| * @template A Arguments type | ||
| * @template R Response type | ||
| */ | ||
| const callResponder = async (methodName, responders, args, cb) => { | ||
| const [chosen] = [...responders].reverse().filter((r) => !r.filter || r.filter(args)); | ||
| if (!chosen) throw new Error(`No responder found for .${methodName}(${args && JSON.stringify(args)})`); | ||
| if (chosen.remaining !== Number.POSITIVE_INFINITY) { | ||
| chosen.remaining -= 1; | ||
| if (chosen.remaining <= 0) responders.delete(chosen); | ||
| } | ||
| await chosen.cb(args, cb); | ||
| }; | ||
| //#endregion | ||
| //#region src/normalize-responder.ts | ||
| /** | ||
| * Converts any user-supplied responder form into the canonical | ||
| * `StreamResponder` — a function `(args, cb) => void | Promise<void>`. | ||
| * | ||
| * @param input The user-supplied responder or response | ||
| * @returns A {@link StreamResponder} that invokes the callback appropriately | ||
| * This lets the rest of the emulator treat all responders uniformly, | ||
| * regardless of how they were registered: | ||
| * | ||
| * | Input form | Detected by | Behaviour | | ||
| * |---|---|---| | ||
| * | Static value `R` | `typeof input !== 'function'` | Wraps value in `cb(input)` | | ||
| * | Single-response fn `(args) => R` | `fn.length < 2` | Calls fn, passes result to `cb` | | ||
| * | Streaming fn `(args, cb) => void` | `fn.length >= 2` | Used as-is | | ||
| * | ||
| * @template A Arguments type | ||
| * @template R Response type | ||
| */ | ||
| const normalizeResponder = (input) => { | ||
| if (typeof input === "function") { | ||
| if (input.length >= 2) return input; | ||
| return async (args, cb) => { | ||
| await cb(await input(args)); | ||
| }; | ||
| } | ||
| return async (_args, cb) => { | ||
| if (typeof input !== "function") return async (_args, cb) => { | ||
| await cb(input); | ||
| }; | ||
| if (input.length >= 2) return input; | ||
| return async (args, cb) => { | ||
| await cb(await input(args)); | ||
| }; | ||
| }; | ||
| //#endregion | ||
| //#region src/await-with-timeout.ts | ||
| /** | ||
| * Creates the responder registration API for a single method. | ||
| * Races `signal` against a deadline of `ms` milliseconds. | ||
| * | ||
| * For the given set of responders, this function returns a builder | ||
| * `(filter?) => MethodCall<A, R>` which allows users to register responders | ||
| * with optional filter logic. | ||
| * - Resolves immediately when `signal` resolves first. | ||
| * - Rejects with a descriptive error when the deadline fires first. | ||
| * | ||
| * The returned {@link MethodCall} supports: | ||
| * - `.reply(...)` / `.callback(...)` → one-time responders (default) | ||
| * - `.times(n)` → responders valid for `n` calls | ||
| * - `.persist()` → responders valid forever | ||
| * - `.once()` / `.twice()` / `.thrice()` → shorthand for `.times(1/2/3)` | ||
| * Used by {@link createStreamHandle} to give `waitForCall()` a timeout so | ||
| * tests fail fast instead of hanging until Vitest's global test timeout. | ||
| * | ||
| * Each of these also returns an `.execute(...)` convenience helper | ||
| * to directly trigger the responder logic without going through `.handle`. | ||
| * @param ms Deadline in milliseconds. | ||
| * @param signal A promise that resolves when the awaited event arrives. | ||
| */ | ||
| const awaitWithTimeout = (ms, signal) => Promise.race([signal, setTimeout(ms).then(() => { | ||
| throw new Error(`waitForCall() timed out after ${ms}ms — no request arrived`); | ||
| })]); | ||
| //#endregion | ||
| //#region src/stream-handle.ts | ||
| /** | ||
| * Creates a {@link StreamHandle} for a single externally-driven streaming | ||
| * responder and registers it with the emulator via `register`. | ||
| * | ||
| * Responder resolution is **strict LIFO**: | ||
| * - The most recently added matching responder is chosen. | ||
| * - Filters are applied, but do not affect ordering. | ||
| * ### How it works | ||
| * | ||
| * When a request arrives the registered `responder`: | ||
| * 1. Calls `initializer(args)` to produce the first response and delivers it | ||
| * to the transport via `cb`. | ||
| * 2. Pushes an entry onto `queue` and parks — keeping the streaming connection | ||
| * open — until `waitForCall()` dequeues it. | ||
| * | ||
| * `waitForCall()` picks up entries in arrival order (FIFO within a single | ||
| * `StreamHandle`). After dequeuing, `send()` targets that connection until | ||
| * the next `waitForCall()`. | ||
| * | ||
| * ### Queue / wakeup protocol | ||
| * | ||
| * `queueResolve` is the resolve function of the Promise that `waitForCall()` | ||
| * is currently awaiting. When the responder pushes onto the queue it calls | ||
| * `queueResolve` to wake the waiting `waitForCall()`. If `waitForCall()` is | ||
| * not yet parked, `queueResolve` is null and the responder simply pushes — | ||
| * `waitForCall()` will find the entry already in the queue when it runs. | ||
| * | ||
| * @template A Arguments type for the request | ||
| * @template R Response type | ||
| * | ||
| * @param responders The set of responders registered for this method | ||
| * @returns A function `(filter?) => MethodCall<A, R>` that exposes | ||
| * the responder registration API for this method | ||
| * @param initializer Produces the first response from the incoming args. | ||
| * @param register Callback that stores the responder in the emulator's set. | ||
| */ | ||
| const createStreamHandle = (initializer, register) => { | ||
| const queue = []; | ||
| let queueResolve = null; | ||
| let activeTransportCb = null; | ||
| let latestResponse; | ||
| let hasBeenCalled = false; | ||
| const responder = async (args, cb) => { | ||
| latestResponse = initializer(args); | ||
| activeTransportCb = cb; | ||
| await cb(latestResponse); | ||
| await new Promise((resolve) => { | ||
| queue.push({ | ||
| resolve, | ||
| transportCb: cb | ||
| }); | ||
| if (queueResolve) { | ||
| const wake = queueResolve; | ||
| queueResolve = null; | ||
| wake(); | ||
| } | ||
| }); | ||
| }; | ||
| register(responder); | ||
| return { | ||
| get latestResponse() { | ||
| return latestResponse; | ||
| }, | ||
| get hasBeenCalled() { | ||
| return hasBeenCalled; | ||
| }, | ||
| async waitForCall(timeoutMs = 5e3) { | ||
| if (queue.length === 0) await awaitWithTimeout(timeoutMs, new Promise((resolve) => { | ||
| queueResolve = resolve; | ||
| })); | ||
| const entry = queue.shift(); | ||
| /* istanbul ignore next */ | ||
| if (!entry) throw new Error("Stream queue is empty"); | ||
| activeTransportCb = entry.transportCb; | ||
| hasBeenCalled = true; | ||
| entry.resolve(); | ||
| }, | ||
| async send(modifier) { | ||
| if (activeTransportCb === null) throw new Error("No active stream — call waitForCall() first"); | ||
| const next = modifier(latestResponse); | ||
| latestResponse = next; | ||
| await activeTransportCb(next); | ||
| } | ||
| }; | ||
| }; | ||
| //#endregion | ||
| //#region src/make-request.ts | ||
| /** | ||
| * Builds the full {@link MethodCall} registration API for one emulator method. | ||
| * | ||
| * The returned function accepts an optional `filter` and exposes: | ||
| * - `.reply(...)` / `.callback(...)` — register a one-shot responder | ||
| * - `.times(n)` / `.persist()` / `.once()` / `.twice()` / `.thrice()` — set | ||
| * the lifetime before registering | ||
| * - `.stream(initializer)` — register an externally-driven streaming responder | ||
| * - `.pending` — number of unconsumed responders | ||
| * - `.reset()` — clear all responders for this method | ||
| * | ||
| * Each `reply` / `callback` / `stream` call also returns an `execute(...)` | ||
| * helper for invoking the responder directly without going through `.handle`. | ||
| * | ||
| * @template A Arguments type | ||
| * @template R Response type | ||
| * | ||
| * @param responders The live set of responders for this method. Mutations are | ||
| * observed immediately by `callResponder` and `.pending`. | ||
| */ | ||
| const makeRequest = (responders) => { | ||
| function addResponder(filter, input, remaining) { | ||
| responders.add({ | ||
| filter, | ||
| cb: normalizeResponder(input), | ||
| remaining | ||
| }); | ||
| } | ||
| const addResponder = (filter, input, remaining) => responders.add({ | ||
| filter, | ||
| cb: normalizeResponder(input), | ||
| remaining | ||
| }); | ||
| return (filter) => { | ||
@@ -83,49 +232,3 @@ const builder = (remaining) => { | ||
| function stream(initializer) { | ||
| const queue = []; | ||
| let queueResolve = null; | ||
| let activeTransportCb = null; | ||
| let latestResponse; | ||
| let hasBeenCalled = false; | ||
| const responder = async (args, cb) => { | ||
| const initial = initializer(args); | ||
| latestResponse = initial; | ||
| activeTransportCb = cb; | ||
| await cb(initial); | ||
| await new Promise((resolve) => { | ||
| queue.push({ | ||
| resolve, | ||
| transportCb: cb | ||
| }); | ||
| if (queueResolve) { | ||
| const wake = queueResolve; | ||
| queueResolve = null; | ||
| wake(); | ||
| } | ||
| }); | ||
| }; | ||
| addResponder(filter, responder, remaining); | ||
| return { | ||
| get latestResponse() { | ||
| return latestResponse; | ||
| }, | ||
| get hasBeenCalled() { | ||
| return hasBeenCalled; | ||
| }, | ||
| async waitForCall() { | ||
| if (queue.length === 0) await new Promise((resolve) => { | ||
| queueResolve = resolve; | ||
| }); | ||
| const entry = queue.shift(); | ||
| if (!entry) throw new Error("Stream queue is empty"); | ||
| activeTransportCb = entry.transportCb; | ||
| hasBeenCalled = true; | ||
| entry.resolve(); | ||
| }, | ||
| async send(modifier) { | ||
| if (activeTransportCb === null) throw new Error("No active stream — call waitForCall() first"); | ||
| const next = modifier(latestResponse); | ||
| latestResponse = next; | ||
| await activeTransportCb(next); | ||
| } | ||
| }; | ||
| return createStreamHandle(initializer, (responder) => addResponder(filter, responder, remaining)); | ||
| } | ||
@@ -140,17 +243,7 @@ return { | ||
| ...builder(1), | ||
| times(n) { | ||
| return builder(n); | ||
| }, | ||
| persist() { | ||
| return builder(Number.POSITIVE_INFINITY); | ||
| }, | ||
| once() { | ||
| return builder(1); | ||
| }, | ||
| twice() { | ||
| return builder(2); | ||
| }, | ||
| thrice() { | ||
| return builder(3); | ||
| }, | ||
| times: (n) => builder(n), | ||
| persist: () => builder(Number.POSITIVE_INFINITY), | ||
| once: () => builder(1), | ||
| twice: () => builder(2), | ||
| thrice: () => builder(3), | ||
| get pending() { | ||
@@ -165,80 +258,60 @@ return responders.size; | ||
| }; | ||
| //#endregion | ||
| //#region src/emulator.ts | ||
| /** | ||
| * Dispatch a request to the highest-priority matching responder. | ||
| * Creates a generic, type-safe emulator for a given {@link MethodMap}. | ||
| * | ||
| * - Responders are resolved in **strict LIFO order**: | ||
| * - The most recently registered responder that matches the request | ||
| * (by filter, if provided) is invoked. | ||
| * - Only one responder is invoked per request, even if others also match. | ||
| * The emulator uses a `Proxy` so you only need a *type* — no runtime object | ||
| * describing the method map is required. | ||
| * | ||
| * - Streaming responders may call the provided callback multiple times. | ||
| * - Responders created with `.reply()` / `.callback()` are single-use by default. | ||
| * Use `.times(n)` or `.persist()` to extend their lifetime. | ||
| * ### Per-method registration API | ||
| * | ||
| * @template T The full MethodMap type | ||
| * @template M The specific method name | ||
| * For every method key `M` in `T`, the emulator exposes `emu.M(filter?)` which | ||
| * returns a {@link MethodCall} builder. From there you can: | ||
| * | ||
| * @param methodName The name of the method (for error messages) | ||
| * @param responders The set of responders registered for the method | ||
| * @param args The request arguments to match against filters | ||
| * @param cb Callback used to deliver one or more responses | ||
| * | Chain | Effect | | ||
| * |---|---| | ||
| * | `.reply(value \| fn)` | Register a single-response responder | | ||
| * | `.callback(fn)` | Register a streaming responder | | ||
| * | `.stream(initializer)` | Register an externally-driven streaming responder | | ||
| * | `.times(n)` | Limit the next responder to `n` uses | | ||
| * | `.persist()` | Make the next responder permanent | | ||
| * | `.once()` / `.twice()` / `.thrice()` | Shorthands for `.times(1/2/3)` | | ||
| * | `.pending` | Number of unconsumed responders for this method | | ||
| * | `.reset()` | Remove all responders for this method | | ||
| * | ||
| * @throws {Error} If no matching responder is found | ||
| */ | ||
| const callResponder = async (methodName, responders, args, cb) => { | ||
| const [chosen] = [...responders].reverse().filter((r) => !r.filter || r.filter(args)); | ||
| if (!chosen) throw new Error(`No responder found for .${methodName}(${args && JSON.stringify(args)})`); | ||
| if (chosen.remaining !== Number.POSITIVE_INFINITY) { | ||
| chosen.remaining -= 1; | ||
| if (chosen.remaining <= 0) responders.delete(chosen); | ||
| } | ||
| await chosen.cb(args, cb); | ||
| }; | ||
| /** | ||
| * Create a generic emulator for a given `MethodMap`. | ||
| * ### Dispatch | ||
| * | ||
| * - Uses a Proxy so you only need a *type* for your method map — no | ||
| * runtime object is required. | ||
| * `emu.handle(method, args, cb)` dispatches a request to the highest-priority | ||
| * matching responder and streams responses through `cb`. This is the entry | ||
| * point called by your transport adapter (HTTP handler, Pub/Sub listener, etc.). | ||
| * | ||
| * - For each method key in the `MethodMap`, the emulator provides: | ||
| * - `.reply(response | fn)` — one-time static or single-response functions | ||
| * - `.callback(fn)` — one-time streaming responders | ||
| * - `.times(n)` — responders valid for `n` calls | ||
| * - `.persist()` — responders valid forever | ||
| * - `.once()` / `.twice()` / `.thrice()` — shorthands for `.times(1/2/3)` | ||
| * ### Responder resolution | ||
| * | ||
| * - Responders are matched in **strict LIFO order**: | ||
| * - The most recently registered matching responder is invoked first. | ||
| * - Only one responder handles a given request, even if others also match. | ||
| * - Filters still apply: a responder is considered only if its filter | ||
| * returns `true` (or it has no filter). | ||
| * Responders are matched in **strict LIFO order**: the most recently registered | ||
| * responder that passes its filter (if any) is invoked. Only one responder | ||
| * handles a given request. | ||
| * | ||
| * - Provides `.handle(method, args, cb)` to manually dispatch a request | ||
| * to the appropriate responder and stream results via the callback. | ||
| * @template T The `MethodMap` describing every method, its args, and its response. | ||
| * | ||
| * @template T The `MethodMap` describing all methods, their args and responses | ||
| * @returns An emulator object with per-method registration APIs and a | ||
| * `.handle` dispatcher | ||
| * | ||
| * @example | ||
| * type MyMethodMap = { | ||
| * echo: { args: { msg: string }, resp: { echoed: string } } | ||
| * double: { args: { n: number }, resp: { result: number } } | ||
| * type Api = { | ||
| * greet: { args: { name: string }; resp: { message: string } } | ||
| * } | ||
| * | ||
| * const emu = createEmulator<MyMethodMap>() | ||
| * const emu = createEmulator<Api>() | ||
| * | ||
| * emu.echo().reply({ echoed: 'hello' }) // one-time | ||
| * emu.echo().persist().reply({ echoed: 'always' }) // infinite | ||
| * | ||
| * const r1 = await emu.echo().times(2).reply(args => ({ echoed: args.msg })) | ||
| * .execute({ msg: 'hi' }) | ||
| * | ||
| * emu.double().callback((args, cb) => { | ||
| * cb({ result: args.n }) | ||
| * cb({ result: args.n * 2 }) | ||
| * }) | ||
| * emu.greet().reply({ message: 'hello' }) | ||
| * await emu.handle('greet', { name: 'Alice' }, (r) => console.log(r)) | ||
| */ | ||
| const createEmulator = () => { | ||
| const responders = /* @__PURE__ */ new Map(); | ||
| const getOrCreateSet = (method) => { | ||
| let set = responders.get(method); | ||
| if (!set) { | ||
| set = /* @__PURE__ */ new Set(); | ||
| responders.set(method, set); | ||
| } | ||
| return set; | ||
| }; | ||
| return new Proxy({}, { get(_target, prop) { | ||
@@ -248,26 +321,9 @@ if (prop === "reset") return () => { | ||
| }; | ||
| if (prop === "handle") return async (method, args, cb) => { | ||
| const set = responders.get(method) ?? /* @__PURE__ */ new Set(); | ||
| responders.set(method, set); | ||
| return callResponder(method, set, args, cb); | ||
| }; | ||
| if (prop === "dispose" || typeof prop === "symbol") return Reflect.get(_target, prop); | ||
| if (typeof prop === "string") { | ||
| const method = prop; | ||
| let set = responders.get(method); | ||
| if (!set) { | ||
| set = /* @__PURE__ */ new Set(); | ||
| responders.set(method, set); | ||
| } | ||
| return (filter) => makeRequest(set)(filter); | ||
| } | ||
| if (prop === "handle") return async (method, args, cb) => callResponder(method, getOrCreateSet(method), args, cb); | ||
| if (prop === "dispose" || typeof prop === "symbol") | ||
| /* istanbul ignore next */ return Reflect.get(_target, prop); | ||
| /* istanbul ignore else */ | ||
| if (typeof prop === "string") return (filter) => makeRequest(getOrCreateSet(prop))(filter); | ||
| } }); | ||
| }; | ||
| const disposable = (emulator, dispose) => { | ||
| const _disposable = emulator; | ||
| _disposable.dispose = dispose; | ||
| _disposable[Symbol.dispose] = dispose; | ||
| _disposable[Symbol.asyncDispose] = dispose; | ||
| return _disposable; | ||
| }; | ||
| //#endregion | ||
@@ -274,0 +330,0 @@ export { createEmulator, disposable }; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * A filter function that decides whether a responder should be used\n * for a given request. If the function returns `true`, the responder\n * is eligible to handle the request; otherwise it is skipped.\n *\n * @template A Arguments type for the request\n * @param args The request arguments\n * @returns `true` if this responder should handle the request\n */\ntype Filter<A> = (args: A) => boolean\n\n/**\n * Callback invoked by a responder whenever it emits a response.\n * This may be called once for simple responders, or many times\n * for streaming responders.\n *\n * @template R Response type\n * @param resp A response object of type `R`\n * @returns An arbitrary value; ignored by the emulator.\n */\ntype ResponseCb<R> = (resp: R) => unknown\n\n/**\n * The normalized internal responder form. Regardless of whether\n * the user provided a static response, a function, or a streaming\n * callback, it is converted into this form internally.\n *\n * @template A Arguments type for the request\n * @template R Response type\n * @param args The request arguments\n * @param cb A callback to deliver one or more responses\n * @returns Nothing meaningful; may be synchronous or asynchronous\n */\ntype StreamResponder<A, R> = (\n args: A,\n cb: ResponseCb<R>\n) => void | Promise<void>\n\n/**\n * The user-facing responder input form. When registering responders,\n * users can choose one of three styles:\n *\n * 1. **Static response** — provide a plain object of type `R`.\n * Example: `.reply({ ok: true })`\n *\n * 2. **Function responder** — provide a function `(args) => R | Promise<R>`\n * that computes a single response from the request arguments.\n * Example: `.reply(args => ({ ok: args.id > 0 }))`\n *\n * 3. **Streaming responder** — provide a function `(args, cb) => { ... }`\n * that can invoke `cb(r)` one or more times to emit multiple responses.\n * Example:\n * ```ts\n * .callback((args, cb) => {\n * cb({ step: 1 })\n * cb({ step: 2 })\n * })\n * ```\n *\n * All three forms are internally normalized into a {@link StreamResponder}.\n *\n * @template A Arguments type\n * @template R Response type\n */\ntype ReplyInput<A, R> =\n | R\n | ((args: A) => R | Promise<R>)\n | StreamResponder<A, R>\n\n/**\n * Internal structure representing a registered responder.\n *\n * @template A Arguments type\n * @template R Response type\n *\n * @property filter Optional {@link Filter} restricting which requests\n * this responder will handle\n * @property cb The normalized {@link StreamResponder} to invoke\n * @property remaining How many times this responder may be invoked.\n * - `Infinity` = unlimited (persist)\n * - `n` = valid for `n` calls\n * - `1` = one-shot\n */\ninterface StoredResponder<A, R> {\n filter?: Filter<A>\n cb: StreamResponder<A, R>\n remaining: number\n}\n\n/**\n * A handle returned by `.stream()` that lets a test drive a stateful,\n * externally-controlled streaming responder.\n *\n * - `waitForCall()` — resolves when the next request has arrived and the\n * initializer has been called. Each invocation dequeues the next pending\n * request in arrival order.\n * - `send(modifier)` — derives a new response from `latestResponse` and\n * delivers it via the transport callback. Throws if called before\n * `waitForCall()` resolves.\n * - `latestResponse` — the most recent response emitted for the active stream,\n * or `undefined` before `waitForCall()` resolves.\n *\n * @template R Response type\n */\nexport interface StreamHandle<R> {\n waitForCall(): Promise<void>\n send(modifier: (prev: R) => R): Promise<void>\n readonly latestResponse: R | undefined\n /** `true` once the first `waitForCall()` has resolved. */\n readonly hasBeenCalled: boolean\n}\n\n/**\n * Builder API returned by `.times(n)` or `.persist()`.\n *\n * These builders let you specify how many times a responder should be used:\n * - `.times(n)` → expires after `n` uses\n * - `.persist()` → never expires\n *\n * They expose the same responder registration methods as normal:\n * - `.reply(response | fn)` → single-response style\n * - `.callback(fn)` → streaming style\n * - `.stream(initializer)` → externally-driven streaming style\n *\n * Each returns an `execute(...)` helper for direct invocation.\n *\n * Note: `.pending()` and `.reset()` are on {@link MethodCall} (before lifetime\n * configuration), not on this builder — they operate on the method as a whole.\n */\ninterface MethodCallBuilder<A, R> {\n reply(response: R): { execute(arg: A): Promise<R> }\n reply(fn: (args: A) => R | Promise<R>): { execute(arg: A): Promise<R> }\n\n callback(fn: (args: A, cb: (resp: R) => unknown) => unknown): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n }\n\n stream(initializer: (args: A) => R): StreamHandle<R>\n}\n\n/**\n * The full API available for each method in the emulator.\n *\n * By default, `.reply`, `.callback`, and `.stream` register **single-use** responders.\n * For repeated use, chain `.times(n)` or `.persist()`.\n *\n * - `.reply(...)` — one-time single-response responder\n * - `.callback(...)` — one-time streaming responder\n * - `.stream(initializer)` — externally-driven streaming responder\n * - `.times(n)` — limit responder to `n` uses\n * - `.persist()` — make responder permanent (infinite uses)\n * - `.once()` / `.twice()` / `.thrice()` — convenience aliases for `.times(1/2/3)`\n *\n * Each registration returns an `execute(...)` helper for direct invocation.\n */\ninterface MethodCall<A, R> {\n // One-time single-response\n reply(response: R): { execute(arg: A): Promise<R> }\n reply(fn: (args: A) => R | Promise<R>): { execute(arg: A): Promise<R> }\n\n // One-time streaming\n callback(fn: (args: A, cb: (resp: R) => unknown) => unknown): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n }\n\n // Externally-driven streaming\n stream(initializer: (args: A) => R): StreamHandle<R>\n\n // Lifetime configuration\n times(n: number): MethodCallBuilder<A, R>\n persist(): MethodCallBuilder<A, R>\n\n // Aliases\n once(): MethodCallBuilder<A, R>\n twice(): MethodCallBuilder<A, R>\n thrice(): MethodCallBuilder<A, R>\n\n /** The number of responders registered for this method that have not yet been fully consumed. */\n readonly pending: number\n\n /** Removes all registered responders for this method. */\n reset(): void\n}\n\n/**\n * Describes the shape of all methods supported by an emulator.\n *\n * Each entry in the map defines:\n * - `args`: the type of the request arguments the method accepts\n * - `resp`: the type of the responses the method will emit\n *\n * The map is only used at the type level. At runtime, methods are\n * generated dynamically using a Proxy, so no actual object is required.\n */\n// biome-ignore lint/suspicious/noExplicitAny: emulator code\nexport type MethodMap = Record<string, { args: any; resp: any }>\n\n/**\n * Converts any user-supplied responder into the internal\n * streaming responder form `(args, cb) => void | Promise<void>`.\n *\n * This allows the emulator to treat all responders uniformly,\n * regardless of whether they were registered as:\n * - A static response object of type `R`\n * - A simple function `(args) => R | Promise<R>` returning a single response\n * - A streaming function `(args, cb) => { cb(r1); cb(r2); ... }`\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param input The user-supplied responder or response\n * @returns A {@link StreamResponder} that invokes the callback appropriately\n */\nconst normalizeResponder = <A, R>(\n input: ReplyInput<A, R>\n): StreamResponder<A, R> => {\n if (typeof input === 'function') {\n // Either (args) => R or (args, cb) => void\n if (input.length >= 2) {\n // Already a streaming responder\n return input as StreamResponder<A, R>\n }\n // Single-response function\n return async (args, cb) => {\n const result = await (input as (args: A) => R | Promise<R>)(args)\n await cb(result)\n }\n }\n // Static response object\n return async (_args, cb) => {\n await cb(input)\n }\n}\n\n/**\n * Creates the responder registration API for a single method.\n *\n * For the given set of responders, this function returns a builder\n * `(filter?) => MethodCall<A, R>` which allows users to register responders\n * with optional filter logic.\n *\n * The returned {@link MethodCall} supports:\n * - `.reply(...)` / `.callback(...)` → one-time responders (default)\n * - `.times(n)` → responders valid for `n` calls\n * - `.persist()` → responders valid forever\n * - `.once()` / `.twice()` / `.thrice()` → shorthand for `.times(1/2/3)`\n *\n * Each of these also returns an `.execute(...)` convenience helper\n * to directly trigger the responder logic without going through `.handle`.\n *\n * Responder resolution is **strict LIFO**:\n * - The most recently added matching responder is chosen.\n * - Filters are applied, but do not affect ordering.\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param responders The set of responders registered for this method\n * @returns A function `(filter?) => MethodCall<A, R>` that exposes\n * the responder registration API for this method\n */\nconst makeRequest = <A, R>(responders: Set<StoredResponder<A, R>>) => {\n function addResponder(\n filter: Filter<A> | undefined,\n input: ReplyInput<A, R>,\n remaining: number\n ) {\n responders.add({ filter, cb: normalizeResponder(input), remaining })\n }\n\n return (filter?: Filter<A>): MethodCall<A, R> => {\n const builder = (remaining: number) => {\n function reply(response: R): { execute(arg: A): Promise<R> }\n function reply(fn: (args: A) => R | Promise<R>): {\n execute(arg: A): Promise<R>\n }\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n function reply(input: any) {\n addResponder(filter, input, remaining)\n return {\n async execute(arg: A): Promise<R> {\n let result!: R\n await callResponder('reply', responders, arg, (r) => {\n result = r\n })\n return result\n },\n }\n }\n\n function callback(fn: StreamResponder<A, R>): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n } {\n addResponder(filter, fn, remaining)\n return {\n async execute(arg: A, cb: (resp: R) => unknown): Promise<void> {\n await callResponder('callback', responders, arg, cb)\n },\n }\n }\n\n function stream(initializer: (args: A) => R): StreamHandle<R> {\n // Queue of { resolve, transportCb } entries — one per accepted request.\n const queue: Array<{\n resolve: () => void\n transportCb: ResponseCb<R>\n }> = []\n let queueResolve: (() => void) | null = null\n\n let activeTransportCb: ResponseCb<R> | null = null\n let latestResponse: R | undefined\n let hasBeenCalled = false\n\n // Register the underlying streaming responder.\n const responder: StreamResponder<A, R> = async (args, cb) => {\n const initial = initializer(args)\n latestResponse = initial\n activeTransportCb = cb\n await cb(initial)\n\n // Park until waitForCall() dequeues this entry.\n await new Promise<void>((resolve) => {\n queue.push({ resolve, transportCb: cb })\n if (queueResolve) {\n const wake = queueResolve\n queueResolve = null\n wake()\n }\n })\n }\n addResponder(filter, responder, remaining)\n\n const handle: StreamHandle<R> = {\n get latestResponse() {\n return latestResponse\n },\n get hasBeenCalled() {\n return hasBeenCalled\n },\n\n async waitForCall(): Promise<void> {\n // Wait if no request has arrived yet.\n if (queue.length === 0) {\n await new Promise<void>((resolve) => {\n queueResolve = resolve\n })\n }\n const entry = queue.shift()\n if (!entry) throw new Error('Stream queue is empty')\n activeTransportCb = entry.transportCb\n hasBeenCalled = true\n entry.resolve()\n },\n\n async send(modifier: (prev: R) => R): Promise<void> {\n if (activeTransportCb === null) {\n throw new Error('No active stream — call waitForCall() first')\n }\n const next = modifier(latestResponse as R)\n latestResponse = next\n await activeTransportCb(next)\n },\n }\n\n return handle\n }\n\n return { reply, callback, stream }\n }\n\n return {\n ...builder(1), // default: one-shot\n times(n: number) {\n return builder(n)\n },\n persist() {\n return builder(Number.POSITIVE_INFINITY)\n },\n once() {\n return builder(1)\n },\n twice() {\n return builder(2)\n },\n thrice() {\n return builder(3)\n },\n get pending() {\n return responders.size\n },\n reset() {\n responders.clear()\n },\n } as MethodCall<A, R>\n }\n}\n\n/**\n * Dispatch a request to the highest-priority matching responder.\n *\n * - Responders are resolved in **strict LIFO order**:\n * - The most recently registered responder that matches the request\n * (by filter, if provided) is invoked.\n * - Only one responder is invoked per request, even if others also match.\n *\n * - Streaming responders may call the provided callback multiple times.\n * - Responders created with `.reply()` / `.callback()` are single-use by default.\n * Use `.times(n)` or `.persist()` to extend their lifetime.\n *\n * @template T The full MethodMap type\n * @template M The specific method name\n *\n * @param methodName The name of the method (for error messages)\n * @param responders The set of responders registered for the method\n * @param args The request arguments to match against filters\n * @param cb Callback used to deliver one or more responses\n *\n * @throws {Error} If no matching responder is found\n */\nconst callResponder = async <T extends MethodMap, M extends keyof T>(\n methodName: string,\n responders: Set<StoredResponder<T[M]['args'], T[M]['resp']>>,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n): Promise<void> => {\n // Collect responders in insertion order, then reverse for LIFO\n const [chosen] = [...responders]\n .reverse()\n .filter((r) => !r.filter || r.filter(args))\n\n if (!chosen) {\n throw new Error(\n `No responder found for .${methodName}(${args && JSON.stringify(args)})`\n )\n }\n\n // Decrement before invoking so that concurrent/subsequent requests see the\n // updated count immediately — even for long-lived streaming responders whose\n // body only completes when the test calls waitForCall().\n if (chosen.remaining !== Number.POSITIVE_INFINITY) {\n chosen.remaining -= 1\n if (chosen.remaining <= 0) {\n responders.delete(chosen)\n }\n }\n\n await chosen.cb(args, cb)\n}\n\n/**\n * Create a generic emulator for a given `MethodMap`.\n *\n * - Uses a Proxy so you only need a *type* for your method map — no\n * runtime object is required.\n *\n * - For each method key in the `MethodMap`, the emulator provides:\n * - `.reply(response | fn)` — one-time static or single-response functions\n * - `.callback(fn)` — one-time streaming responders\n * - `.times(n)` — responders valid for `n` calls\n * - `.persist()` — responders valid forever\n * - `.once()` / `.twice()` / `.thrice()` — shorthands for `.times(1/2/3)`\n *\n * - Responders are matched in **strict LIFO order**:\n * - The most recently registered matching responder is invoked first.\n * - Only one responder handles a given request, even if others also match.\n * - Filters still apply: a responder is considered only if its filter\n * returns `true` (or it has no filter).\n *\n * - Provides `.handle(method, args, cb)` to manually dispatch a request\n * to the appropriate responder and stream results via the callback.\n *\n * @template T The `MethodMap` describing all methods, their args and responses\n * @returns An emulator object with per-method registration APIs and a\n * `.handle` dispatcher\n *\n * @example\n * type MyMethodMap = {\n * echo: { args: { msg: string }, resp: { echoed: string } }\n * double: { args: { n: number }, resp: { result: number } }\n * }\n *\n * const emu = createEmulator<MyMethodMap>()\n *\n * emu.echo().reply({ echoed: 'hello' }) // one-time\n * emu.echo().persist().reply({ echoed: 'always' }) // infinite\n *\n * const r1 = await emu.echo().times(2).reply(args => ({ echoed: args.msg }))\n * .execute({ msg: 'hi' })\n *\n * emu.double().callback((args, cb) => {\n * cb({ result: args.n })\n * cb({ result: args.n * 2 })\n * })\n */\nexport const createEmulator = <T extends MethodMap>() => {\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n const responders = new Map<keyof T, Set<StoredResponder<any, any>>>()\n\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n const handler: ProxyHandler<any> = {\n get(_target, prop) {\n if (prop === 'reset') {\n return () => {\n for (const set of responders.values()) {\n set.clear()\n }\n }\n }\n\n if (prop === 'handle') {\n return async <M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void> => {\n const set = responders.get(method) ?? new Set()\n responders.set(method, set)\n return callResponder(method as string, set, args, cb)\n }\n }\n\n // Pass dispose and symbols through to the target\n if (prop === 'dispose' || typeof prop === 'symbol') {\n return Reflect.get(_target, prop)\n }\n\n // If it’s a method name\n if (typeof prop === 'string') {\n const method = prop as keyof T\n let set = responders.get(method)\n if (!set) {\n set = new Set()\n responders.set(method, set)\n }\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n return (filter?: Filter<any>) => makeRequest(set)(filter)\n }\n\n return undefined\n },\n }\n\n return new Proxy({}, handler) as {\n [M in keyof T]: (\n filter?: Filter<T[M]['args']>\n ) => MethodCall<T[M]['args'], T[M]['resp']>\n } & {\n handle<M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void>\n /** Removes all registered responders across every method. */\n reset(): void\n }\n}\n\n/**\n * Adds disposable semantics to an object `T`.\n *\n * This augments any object with cleanup capabilities that can be invoked\n * explicitly via `.dispose()` or automatically using the `using` /\n * `await using` constructs available in Node.js 20+ and modern runtimes.\n *\n * - `.dispose()` → Manual cleanup, may be sync or async.\n * - `[Symbol.dispose]()` → Enables synchronous `using`.\n * - `[Symbol.asyncDispose]()` → Enables asynchronous `await using`.\n *\n * Useful for managing emulator lifetimes, open connections, or other\n * resources that must be cleaned up deterministically.\n *\n * @template T Base object type to be extended with disposal methods\n */\nexport type Disposable<T> = T & {\n /** Explicit synchronous or async disposal */\n dispose(): void | Promise<void>\n /** Symbol-based sync disposal for Node 20+ */\n [Symbol.dispose](): void\n /** Symbol-based async disposal for Node 20+ */\n [Symbol.asyncDispose]?(): Promise<void>\n}\n\nexport const disposable = <T>(\n emulator: T,\n dispose: () => void | Promise<void>\n): Disposable<T> => {\n const _disposable = emulator as Disposable<T>\n _disposable.dispose = dispose\n _disposable[Symbol.dispose] = dispose\n _disposable[Symbol.asyncDispose] = dispose as () => Promise<void>\n\n return _disposable\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAqNA,MAAM,sBACJ,UAC0B;AAC1B,KAAI,OAAO,UAAU,YAAY;AAE/B,MAAI,MAAM,UAAU,EAElB,QAAO;AAGT,SAAO,OAAO,MAAM,OAAO;AAEzB,SAAM,GAAG,MADa,MAAsC,KAAK,CACjD;;;AAIpB,QAAO,OAAO,OAAO,OAAO;AAC1B,QAAM,GAAG,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BnB,MAAM,eAAqB,eAA2C;CACpE,SAAS,aACP,QACA,OACA,WACA;AACA,aAAW,IAAI;GAAE;GAAQ,IAAI,mBAAmB,MAAM;GAAE;GAAW,CAAC;;AAGtE,SAAQ,WAAyC;EAC/C,MAAM,WAAW,cAAsB;GAMrC,SAAS,MAAM,OAAY;AACzB,iBAAa,QAAQ,OAAO,UAAU;AACtC,WAAO,EACL,MAAM,QAAQ,KAAoB;KAChC,IAAI;AACJ,WAAM,cAAc,SAAS,YAAY,MAAM,MAAM;AACnD,eAAS;OACT;AACF,YAAO;OAEV;;GAGH,SAAS,SAAS,IAEhB;AACA,iBAAa,QAAQ,IAAI,UAAU;AACnC,WAAO,EACL,MAAM,QAAQ,KAAQ,IAAyC;AAC7D,WAAM,cAAc,YAAY,YAAY,KAAK,GAAG;OAEvD;;GAGH,SAAS,OAAO,aAA8C;IAE5D,MAAM,QAGD,EAAE;IACP,IAAI,eAAoC;IAExC,IAAI,oBAA0C;IAC9C,IAAI;IACJ,IAAI,gBAAgB;IAGpB,MAAM,YAAmC,OAAO,MAAM,OAAO;KAC3D,MAAM,UAAU,YAAY,KAAK;AACjC,sBAAiB;AACjB,yBAAoB;AACpB,WAAM,GAAG,QAAQ;AAGjB,WAAM,IAAI,SAAe,YAAY;AACnC,YAAM,KAAK;OAAE;OAAS,aAAa;OAAI,CAAC;AACxC,UAAI,cAAc;OAChB,MAAM,OAAO;AACb,sBAAe;AACf,aAAM;;OAER;;AAEJ,iBAAa,QAAQ,WAAW,UAAU;AAkC1C,WAAO;KA/BL,IAAI,iBAAiB;AACnB,aAAO;;KAET,IAAI,gBAAgB;AAClB,aAAO;;KAGT,MAAM,cAA6B;AAEjC,UAAI,MAAM,WAAW,EACnB,OAAM,IAAI,SAAe,YAAY;AACnC,sBAAe;QACf;MAEJ,MAAM,QAAQ,MAAM,OAAO;AAC3B,UAAI,CAAC,MAAO,OAAM,IAAI,MAAM,wBAAwB;AACpD,0BAAoB,MAAM;AAC1B,sBAAgB;AAChB,YAAM,SAAS;;KAGjB,MAAM,KAAK,UAAyC;AAClD,UAAI,sBAAsB,KACxB,OAAM,IAAI,MAAM,8CAA8C;MAEhE,MAAM,OAAO,SAAS,eAAoB;AAC1C,uBAAiB;AACjB,YAAM,kBAAkB,KAAK;;KAIpB;;AAGf,UAAO;IAAE;IAAO;IAAU;IAAQ;;AAGpC,SAAO;GACL,GAAG,QAAQ,EAAE;GACb,MAAM,GAAW;AACf,WAAO,QAAQ,EAAE;;GAEnB,UAAU;AACR,WAAO,QAAQ,OAAO,kBAAkB;;GAE1C,OAAO;AACL,WAAO,QAAQ,EAAE;;GAEnB,QAAQ;AACN,WAAO,QAAQ,EAAE;;GAEnB,SAAS;AACP,WAAO,QAAQ,EAAE;;GAEnB,IAAI,UAAU;AACZ,WAAO,WAAW;;GAEpB,QAAQ;AACN,eAAW,OAAO;;GAErB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BL,MAAM,gBAAgB,OACpB,YACA,YACA,MACA,OACkB;CAElB,MAAM,CAAC,UAAU,CAAC,GAAG,WAAW,CAC7B,SAAS,CACT,QAAQ,MAAM,CAAC,EAAE,UAAU,EAAE,OAAO,KAAK,CAAC;AAE7C,KAAI,CAAC,OACH,OAAM,IAAI,MACR,2BAA2B,WAAW,GAAG,QAAQ,KAAK,UAAU,KAAK,CAAC,GACvE;AAMH,KAAI,OAAO,cAAc,OAAO,mBAAmB;AACjD,SAAO,aAAa;AACpB,MAAI,OAAO,aAAa,EACtB,YAAW,OAAO,OAAO;;AAI7B,OAAM,OAAO,GAAG,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgD3B,MAAa,uBAA4C;CAEvD,MAAM,6BAAa,IAAI,KAA8C;AA8CrE,QAAO,IAAI,MAAM,EAAE,EAAE,EA1CnB,IAAI,SAAS,MAAM;AACjB,MAAI,SAAS,QACX,cAAa;AACX,QAAK,MAAM,OAAO,WAAW,QAAQ,CACnC,KAAI,OAAO;;AAKjB,MAAI,SAAS,SACX,QAAO,OACL,QACA,MACA,OACkB;GAClB,MAAM,MAAM,WAAW,IAAI,OAAO,oBAAI,IAAI,KAAK;AAC/C,cAAW,IAAI,QAAQ,IAAI;AAC3B,UAAO,cAAc,QAAkB,KAAK,MAAM,GAAG;;AAKzD,MAAI,SAAS,aAAa,OAAO,SAAS,SACxC,QAAO,QAAQ,IAAI,SAAS,KAAK;AAInC,MAAI,OAAO,SAAS,UAAU;GAC5B,MAAM,SAAS;GACf,IAAI,MAAM,WAAW,IAAI,OAAO;AAChC,OAAI,CAAC,KAAK;AACR,0BAAM,IAAI,KAAK;AACf,eAAW,IAAI,QAAQ,IAAI;;AAG7B,WAAQ,WAAyB,YAAY,IAAI,CAAC,OAAO;;IAOnC,CAAC;;AAwC/B,MAAa,cACX,UACA,YACkB;CAClB,MAAM,cAAc;AACpB,aAAY,UAAU;AACtB,aAAY,OAAO,WAAW;AAC9B,aAAY,OAAO,gBAAgB;AAEnC,QAAO"} | ||
| {"version":3,"file":"index.mjs","names":["setTimeoutPromise"],"sources":["../src/disposable.ts","../src/call-responder.ts","../src/normalize-responder.ts","../src/await-with-timeout.ts","../src/stream-handle.ts","../src/make-request.ts","../src/emulator.ts"],"sourcesContent":["import type { Disposable } from './types'\n\nexport type { Disposable }\n\n/**\n * Wraps any object with explicit and symbol-based disposal semantics.\n *\n * The returned object passes all method calls through to `emulator` and\n * additionally exposes:\n *\n * - `.dispose()` — explicit cleanup (calls `dispose` callback)\n * - `[Symbol.dispose]()` — enables synchronous `using` (Node 20+)\n * - `[Symbol.asyncDispose]()` — enables `await using` (Node 20+)\n *\n * @example\n * const gateway = disposable(createEmulator<PaymentMethodMap>(), () => server.close())\n * // In a test:\n * await using gateway = startPaymentEmulator(server)\n *\n * @template T The base emulator type.\n */\nexport const disposable = <T>(\n emulator: T,\n dispose: () => void | Promise<void>\n): Disposable<T> => {\n const d = emulator as Disposable<T>\n d.dispose = dispose\n d[Symbol.dispose] = dispose\n d[Symbol.asyncDispose] = dispose as () => Promise<void>\n return d\n}\n","import type { ResponseCb, StoredResponder } from './types'\n\n/**\n * Dispatches a single request to the highest-priority matching responder.\n *\n * **LIFO ordering** — responders are considered in reverse insertion order,\n * so the most recently registered one wins. This lets tests temporarily\n * override a default with a more specific responder, then fall back once\n * it is consumed.\n *\n * **Filters** — a responder is only eligible if it has no filter, or its\n * filter returns `true` for the incoming `args`.\n *\n * **Lifetime** — before invoking the chosen responder, its `remaining`\n * count is decremented. If it reaches zero the responder is removed from\n * the set. Decrement happens *before* invocation so that concurrent or\n * subsequent requests see the updated count even for long-lived streaming\n * responders that only complete when the test calls `waitForCall()`.\n *\n * @throws {Error} When no matching responder is found.\n *\n * @template A Arguments type\n * @template R Response type\n */\nexport const callResponder = async <A, R>(\n methodName: string,\n responders: Set<StoredResponder<A, R>>,\n args: A,\n cb: ResponseCb<R>\n): Promise<void> => {\n const [chosen] = [...responders]\n .reverse()\n .filter((r) => !r.filter || r.filter(args))\n\n if (!chosen) {\n throw new Error(\n `No responder found for .${methodName}(${args && JSON.stringify(args)})`\n )\n }\n\n if (chosen.remaining !== Number.POSITIVE_INFINITY) {\n chosen.remaining -= 1\n if (chosen.remaining <= 0) {\n responders.delete(chosen)\n }\n }\n\n await chosen.cb(args, cb)\n}\n","import type { ReplyInput, StreamResponder } from './types'\n\n/**\n * Converts any user-supplied responder form into the canonical\n * `StreamResponder` — a function `(args, cb) => void | Promise<void>`.\n *\n * This lets the rest of the emulator treat all responders uniformly,\n * regardless of how they were registered:\n *\n * | Input form | Detected by | Behaviour |\n * |---|---|---|\n * | Static value `R` | `typeof input !== 'function'` | Wraps value in `cb(input)` |\n * | Single-response fn `(args) => R` | `fn.length < 2` | Calls fn, passes result to `cb` |\n * | Streaming fn `(args, cb) => void` | `fn.length >= 2` | Used as-is |\n *\n * @template A Arguments type\n * @template R Response type\n */\nexport const normalizeResponder = <A, R>(\n input: ReplyInput<A, R>\n): StreamResponder<A, R> => {\n if (typeof input !== 'function') {\n return async (_args, cb) => {\n await cb(input)\n }\n }\n\n if (input.length >= 2) {\n // Already a streaming responder: (args, cb) => void\n return input as StreamResponder<A, R>\n }\n\n // Single-response function: (args) => R | Promise<R>\n return async (args, cb) => {\n const result = await (input as (args: A) => R | Promise<R>)(args)\n await cb(result)\n }\n}\n","import { setTimeout as setTimeoutPromise } from 'node:timers/promises'\n\n/**\n * Races `signal` against a deadline of `ms` milliseconds.\n *\n * - Resolves immediately when `signal` resolves first.\n * - Rejects with a descriptive error when the deadline fires first.\n *\n * Used by {@link createStreamHandle} to give `waitForCall()` a timeout so\n * tests fail fast instead of hanging until Vitest's global test timeout.\n *\n * @param ms Deadline in milliseconds.\n * @param signal A promise that resolves when the awaited event arrives.\n */\nexport const awaitWithTimeout = (\n ms: number,\n signal: Promise<void>\n): Promise<void> =>\n Promise.race([\n signal,\n setTimeoutPromise(ms).then(() => {\n throw new Error(\n `waitForCall() timed out after ${ms}ms — no request arrived`\n )\n }),\n ])\n","import { awaitWithTimeout } from './await-with-timeout'\nimport type { ResponseCb, StreamHandle, StreamResponder } from './types'\n\n/**\n * Creates a {@link StreamHandle} for a single externally-driven streaming\n * responder and registers it with the emulator via `register`.\n *\n * ### How it works\n *\n * When a request arrives the registered `responder`:\n * 1. Calls `initializer(args)` to produce the first response and delivers it\n * to the transport via `cb`.\n * 2. Pushes an entry onto `queue` and parks — keeping the streaming connection\n * open — until `waitForCall()` dequeues it.\n *\n * `waitForCall()` picks up entries in arrival order (FIFO within a single\n * `StreamHandle`). After dequeuing, `send()` targets that connection until\n * the next `waitForCall()`.\n *\n * ### Queue / wakeup protocol\n *\n * `queueResolve` is the resolve function of the Promise that `waitForCall()`\n * is currently awaiting. When the responder pushes onto the queue it calls\n * `queueResolve` to wake the waiting `waitForCall()`. If `waitForCall()` is\n * not yet parked, `queueResolve` is null and the responder simply pushes —\n * `waitForCall()` will find the entry already in the queue when it runs.\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param initializer Produces the first response from the incoming args.\n * @param register Callback that stores the responder in the emulator's set.\n */\nexport const createStreamHandle = <A, R>(\n initializer: (args: A) => R,\n register: (responder: StreamResponder<A, R>) => void\n): StreamHandle<R> => {\n const queue: Array<{ resolve: () => void; transportCb: ResponseCb<R> }> = []\n let queueResolve: (() => void) | null = null\n let activeTransportCb: ResponseCb<R> | null = null\n let latestResponse: R | undefined\n let hasBeenCalled = false\n\n const responder: StreamResponder<A, R> = async (args, cb) => {\n latestResponse = initializer(args)\n activeTransportCb = cb\n await cb(latestResponse)\n\n // Park here, keeping the streaming connection open, until waitForCall()\n // dequeues this entry and calls entry.resolve().\n await new Promise<void>((resolve) => {\n queue.push({ resolve, transportCb: cb })\n if (queueResolve) {\n const wake = queueResolve\n queueResolve = null\n wake()\n }\n })\n }\n register(responder)\n\n return {\n get latestResponse() {\n return latestResponse\n },\n get hasBeenCalled() {\n return hasBeenCalled\n },\n\n async waitForCall(timeoutMs = 5000): Promise<void> {\n if (queue.length === 0) {\n const arrived = new Promise<void>((resolve) => {\n queueResolve = resolve\n })\n await awaitWithTimeout(timeoutMs, arrived)\n }\n const entry = queue.shift()\n /* istanbul ignore next */\n if (!entry) throw new Error('Stream queue is empty')\n activeTransportCb = entry.transportCb\n hasBeenCalled = true\n entry.resolve()\n },\n\n async send(modifier: (prev: R) => R): Promise<void> {\n if (activeTransportCb === null) {\n throw new Error('No active stream — call waitForCall() first')\n }\n const next = modifier(latestResponse as R)\n latestResponse = next\n await activeTransportCb(next)\n },\n }\n}\n","import { callResponder } from './call-responder'\nimport { normalizeResponder } from './normalize-responder'\nimport { createStreamHandle } from './stream-handle'\nimport type {\n Filter,\n MethodCall,\n ReplyInput,\n ResponseCb,\n StoredResponder,\n StreamHandle,\n StreamResponder,\n} from './types'\n\n/**\n * Builds the full {@link MethodCall} registration API for one emulator method.\n *\n * The returned function accepts an optional `filter` and exposes:\n * - `.reply(...)` / `.callback(...)` — register a one-shot responder\n * - `.times(n)` / `.persist()` / `.once()` / `.twice()` / `.thrice()` — set\n * the lifetime before registering\n * - `.stream(initializer)` — register an externally-driven streaming responder\n * - `.pending` — number of unconsumed responders\n * - `.reset()` — clear all responders for this method\n *\n * Each `reply` / `callback` / `stream` call also returns an `execute(...)`\n * helper for invoking the responder directly without going through `.handle`.\n *\n * @template A Arguments type\n * @template R Response type\n *\n * @param responders The live set of responders for this method. Mutations are\n * observed immediately by `callResponder` and `.pending`.\n */\nexport const makeRequest = <A, R>(responders: Set<StoredResponder<A, R>>) => {\n const addResponder = (\n filter: Filter<A> | undefined,\n input: ReplyInput<A, R>,\n remaining: number\n ) => responders.add({ filter, cb: normalizeResponder(input), remaining })\n\n return (filter?: Filter<A>): MethodCall<A, R> => {\n const builder = (remaining: number) => {\n function reply(response: R): { execute(arg: A): Promise<R> }\n function reply(fn: (args: A) => R | Promise<R>): {\n execute(arg: A): Promise<R>\n }\n // biome-ignore lint/suspicious/noExplicitAny: overload implementation\n function reply(input: any) {\n addResponder(filter, input, remaining)\n return {\n async execute(arg: A): Promise<R> {\n let result!: R\n await callResponder('reply', responders, arg, (r) => {\n result = r\n })\n return result\n },\n }\n }\n\n function callback(fn: StreamResponder<A, R>): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n } {\n addResponder(filter, fn, remaining)\n return {\n async execute(arg: A, cb: ResponseCb<R>): Promise<void> {\n await callResponder('callback', responders, arg, cb)\n },\n }\n }\n\n function stream(initializer: (args: A) => R): StreamHandle<R> {\n return createStreamHandle(initializer, (responder) =>\n addResponder(filter, responder, remaining)\n )\n }\n\n return { reply, callback, stream }\n }\n\n return {\n ...builder(1), // default: one-shot\n times: (n: number) => builder(n),\n persist: () => builder(Number.POSITIVE_INFINITY),\n once: () => builder(1),\n twice: () => builder(2),\n thrice: () => builder(3),\n get pending() {\n return responders.size\n },\n reset() {\n responders.clear()\n },\n } as MethodCall<A, R>\n }\n}\n","import { callResponder } from './call-responder'\nimport { makeRequest } from './make-request'\nimport type {\n Filter,\n MethodCall,\n MethodMap,\n ResponseCb,\n StoredResponder,\n} from './types'\n\n/**\n * Creates a generic, type-safe emulator for a given {@link MethodMap}.\n *\n * The emulator uses a `Proxy` so you only need a *type* — no runtime object\n * describing the method map is required.\n *\n * ### Per-method registration API\n *\n * For every method key `M` in `T`, the emulator exposes `emu.M(filter?)` which\n * returns a {@link MethodCall} builder. From there you can:\n *\n * | Chain | Effect |\n * |---|---|\n * | `.reply(value \\| fn)` | Register a single-response responder |\n * | `.callback(fn)` | Register a streaming responder |\n * | `.stream(initializer)` | Register an externally-driven streaming responder |\n * | `.times(n)` | Limit the next responder to `n` uses |\n * | `.persist()` | Make the next responder permanent |\n * | `.once()` / `.twice()` / `.thrice()` | Shorthands for `.times(1/2/3)` |\n * | `.pending` | Number of unconsumed responders for this method |\n * | `.reset()` | Remove all responders for this method |\n *\n * ### Dispatch\n *\n * `emu.handle(method, args, cb)` dispatches a request to the highest-priority\n * matching responder and streams responses through `cb`. This is the entry\n * point called by your transport adapter (HTTP handler, Pub/Sub listener, etc.).\n *\n * ### Responder resolution\n *\n * Responders are matched in **strict LIFO order**: the most recently registered\n * responder that passes its filter (if any) is invoked. Only one responder\n * handles a given request.\n *\n * @template T The `MethodMap` describing every method, its args, and its response.\n *\n * @example\n * type Api = {\n * greet: { args: { name: string }; resp: { message: string } }\n * }\n *\n * const emu = createEmulator<Api>()\n *\n * emu.greet().reply({ message: 'hello' })\n * await emu.handle('greet', { name: 'Alice' }, (r) => console.log(r))\n */\nexport const createEmulator = <T extends MethodMap>() => {\n // biome-ignore lint/suspicious/noExplicitAny: MethodMap keys are resolved at call sites\n const responders = new Map<keyof T, Set<StoredResponder<any, any>>>()\n\n const getOrCreateSet = (method: keyof T) => {\n let set = responders.get(method)\n if (!set) {\n set = new Set()\n responders.set(method, set)\n }\n return set\n }\n\n // biome-ignore lint/suspicious/noExplicitAny: Proxy handler operates on unknown keys\n const handler: ProxyHandler<any> = {\n get(_target, prop) {\n if (prop === 'reset') {\n return () => {\n for (const set of responders.values()) set.clear()\n }\n }\n\n if (prop === 'handle') {\n return async <M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void> =>\n callResponder(method as string, getOrCreateSet(method), args, cb)\n }\n\n if (prop === 'dispose' || typeof prop === 'symbol') {\n /* istanbul ignore next */ return Reflect.get(_target, prop)\n }\n\n /* istanbul ignore else */\n if (typeof prop === 'string') {\n // biome-ignore lint/suspicious/noExplicitAny: filter type is resolved at call site\n return (filter?: Filter<any>) =>\n makeRequest(getOrCreateSet(prop as keyof T))(filter)\n }\n\n /* istanbul ignore next */\n return undefined\n },\n }\n\n return new Proxy({}, handler) as {\n [M in keyof T]: (\n filter?: Filter<T[M]['args']>\n ) => MethodCall<T[M]['args'], T[M]['resp']>\n } & {\n handle<M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void>\n /** Removes all registered responders across every method. */\n reset(): void\n }\n}\n"],"mappings":"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"} |
+1
-1
| { | ||
| "name": "@sebspark/emulator", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "license": "Apache-2.0", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+1
-1
@@ -135,3 +135,3 @@ # `@sebspark/emulator` | ||
| |---|---|---| | ||
| | `waitForCall()` | `() => Promise<void>` | Resolves when the next request has arrived and the initializer has fired | | ||
| | `waitForCall(timeoutMs?)` | `(timeoutMs?: number) => Promise<void>` | Resolves when the next request has arrived and the initializer has fired. Rejects with `Error: waitForCall() timed out after {n}ms — no request arrived` if no request arrives within `timeoutMs` ms (default: `5000`) | | ||
| | `send(modifier)` | `(fn: (prev) => Resp) => Promise<void>` | Derives and sends the next response from the last one | | ||
@@ -138,0 +138,0 @@ | `latestResponse` | `Resp \| undefined` | The most recent response sent, or `undefined` before the first `waitForCall()` | |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
326
20.74%61417
-4.27%1
Infinity%