🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@sebspark/emulator

Package Overview
Dependencies
Maintainers
3
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sebspark/emulator - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+117
-110
dist/index.d.mts

@@ -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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"}

@@ -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":"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"}
{"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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"}
{
"name": "@sebspark/emulator",
"version": "0.2.0",
"version": "0.3.0",
"license": "Apache-2.0",

@@ -5,0 +5,0 @@ "type": "module",

@@ -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()` |