counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
154 lines (153 loc) • 5.38 kB
JavaScript
/**
* A context object that lives at a specific route path and is shared across
* all requests to that path (and its descendants).
*
* Route handlers receive this object as `$.context` and may freely add or
* modify properties to maintain state between requests.
*/
export class Context {
constructor() { }
}
/**
* Returns the parent path of a route path by stripping the last segment.
*
* @param path - A route path such as `"/pets/1"`.
* @returns The parent path (e.g. `"/pets"`), or `"/"` for top-level paths.
*/
export function parentPath(path) {
return String(path.split("/").slice(0, -1).join("/")) || "/";
}
/**
* Deep-clones an object for caching purposes.
* Plain objects and class instances have their own enumerable properties
* recursively cloned (preserving the prototype chain). Functions are copied
* by reference since they are not structurally comparable data.
*/
function cloneForCache(value) {
if (value === null || typeof value === "function") {
return value;
}
if (typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map(cloneForCache);
}
const proto = Object.getPrototypeOf(value);
const clone = proto !== null && proto !== Object.prototype
? Object.create(proto)
: {};
for (const key of Object.keys(value)) {
clone[key] = cloneForCache(value[key]);
}
return clone;
}
/**
* Registry of per-path {@link Context} objects that persist state across
* requests.
*
* The registry is case-insensitive for path lookups and uses a write-through
* cache to detect which context properties have changed between hot-reloads.
* It extends {@link EventTarget} so that listeners can react to structural
* changes (e.g. to regenerate type files):
*
* ```ts
* contextRegistry.addEventListener("context-changed", () => { ... });
* ```
*/
export class ContextRegistry extends EventTarget {
entries = new Map();
cache = new Map();
seen = new Set();
constructor() {
super();
this.add("/", {});
}
getContextIgnoreCase(map, key) {
const lowerCaseKey = key.toLowerCase();
for (const currentKey of map.keys()) {
if (currentKey.toLowerCase() === lowerCaseKey) {
return map.get(currentKey);
}
}
return undefined;
}
/**
* Registers a new context for `path`, replacing any existing one, and
* dispatches a `"context-changed"` event so listeners can react.
*
* @param path - The route path (e.g. `"/pets"`).
* @param context - The context object to store.
*/
add(path, context) {
this.entries.set(path, context);
this.cache.set(path, cloneForCache(context));
this.dispatchEvent(new Event("context-changed"));
}
/**
* Removes the context entry for the given path and dispatches a
* "context-changed" event so that listeners (e.g. the _.context type
* generator) can regenerate type files in response to the removal.
*
* @param path - The route path whose context entry should be deleted
* (e.g. "/pets").
*/
remove(path) {
this.entries.delete(path);
this.cache.delete(path);
this.seen.delete(path);
this.dispatchEvent(new Event("context-changed"));
}
/**
* Finds the context for `path`, walking up the path hierarchy until a
* context is found. Falls back to `"/"` which always has a context.
*
* @param path - The route path to look up.
* @returns The nearest ancestor context (or the root context).
*/
find(path) {
return (this.getContextIgnoreCase(this.entries, path) ??
this.find(parentPath(path)));
}
/**
* Merges `updatedContext` into the existing context for `path`.
*
* On the first call for a path the context is added directly. On subsequent
* calls only properties whose values differ from the cached snapshot are
* applied, preserving live mutations made by route handlers between reloads.
*
* @param path - The route path (e.g. `"/pets"`).
* @param updatedContext - The new context instance (typically freshly
* constructed from the reloaded `_.context.ts` file).
*/
update(path, updatedContext) {
if (updatedContext === undefined) {
return;
}
if (!this.seen.has(path)) {
this.seen.add(path);
this.add(path, updatedContext);
return;
}
const context = this.find(path);
for (const property in updatedContext) {
if (updatedContext[property] !== this.cache.get(path)?.[property]) {
context[property] = updatedContext[property];
}
}
Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
this.cache.set(path, cloneForCache(updatedContext));
}
/** Returns all registered route paths as an array. */
getAllPaths() {
return Array.from(this.entries.keys());
}
/** Returns a plain object mapping every registered path to its context. */
getAllContexts() {
const result = {};
for (const [path, context] of this.entries.entries()) {
result[path] = context;
}
return result;
}
}