Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# @suaveplan/api-router-in-memory — in-memory router adapter for the @suaveplan/api plugin system. Implements both the abstract Router contract from @suaveplan/api-router AND the PipelineRouter contract from @suaveplan/api-router on a single InMemoryRouter class. Production-grade in-memory adapter — keeps state in process, dispatches without binding a socket. Passes runRouterConformance + runPipelineRouterConformance.
Stage-5 in-memory router adapter for `@suaveplan/api`. Implements both the abstract `Router` contract from `@suaveplan/api-router` AND the `PipelineRouter` contract from `@suaveplan/api-router` on a single `InMemoryRouter` class. Production-grade in-memory adapter — not a test double.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — `InMemoryRouter` is the Stage-5 in-memory router adapter for `@suaveplan/api`. It implements both…
- [adapter](./src/adapter/adapter.md) — `InMemoryRouter` is the Stage-5 in-memory router adapter for `@suaveplan/api`. It implements both…
- [blue-green-slot](./src/blue-green-slot/blue-green-slot.md) — `InMemoryBlueGreenSlot` is the `BlueGreenSlot` implementation paired with `InMemoryRouter`. It is a thin extension of `…
- [blue-green-slot](./src/blue-green-slot/blue-green-slot.md) — `InMemoryBlueGreenSlot` is the `BlueGreenSlot` implementation paired with `InMemoryRouter`. It is a thin extension of `…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `ROUTER_IN_MEMORY.*` error-code namespace used by every production path in `@suaveplan/api-router-in…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `ROUTER_IN_MEMORY.*` error-code namespace used by every production path in `@suaveplan/api-router-in…
- [pipeline](./src/pipeline/pipeline.md) — `PipelineRouteTable` is the in-memory metadata store that backs `InMemoryRouter`'s pipeline facet. It stores per-route…
- [pipeline](./src/pipeline/pipeline.md) — `PipelineRouteTable` is the in-memory metadata store that backs `InMemoryRouter`'s pipeline facet. It stores per-route…
- [testing](./src/testing/testing.md) — `createInMemoryRouterHarness()` is the canonical "spin up an in-memory router for a test" entry point. It bundles a fre…
- [testing](./src/testing/testing.md) — `createInMemoryRouterHarness()` is the canonical "spin up an in-memory router for a test" entry point. It bundles a fre…
<!-- end:auto-docs -->
## What it ships
- `InMemoryRouter` / `createInMemoryRouter()` — concrete `Router` adapter that subclasses the abstract base AND exposes the pipeline facet (`registerPipelineRoute`, `matchPipeline`, `listPipelineRoutes`, `unregisterPipelineRoute`, `unregisterPipelinePlugin`).
- `InMemoryBlueGreenSlot` / `createInMemoryBlueGreenSlot` — hot-reload primitive paired with the router. Thin extension of `@suaveplan/api-router`'s reference `InMemoryBlueGreenSlot`.
- `PipelineRouteTable` — the in-memory metadata store backing the pipeline facet, exported for advanced use cases that need to share a table across routers.
- `createInMemoryRouterHarness()` — downstream test helper that bundles a router + slot factory pair, replacing the hand-rolled router shims that have grown across the foundation + plugin tests.
- `ROUTER_IN_MEMORY_CODES` / `RouterInMemoryError` — stable error-code registry (`ROUTER_IN_MEMORY.ROUTE_COLLISION`, `ROUTER_IN_MEMORY.INVALID_PREFIX`).
## Conformance
The package passes BOTH foundation conformance suites:
- `runRouterConformance` from `@suaveplan/api-router/testing` — see `tests/conformance/api-router.test.ts`.
- `runPipelineRouterConformance` from `@suaveplan/api-router/testing/pipeline-router-conformance` — see `tests/conformance/pipeline-router.test.ts`.
Adding new behaviours requires re-running both harnesses.
## Modules
- [`src/adapter/adapter.md`](./src/adapter/adapter.md) — `InMemoryRouter` + factory.
- [`src/blue-green-slot/blue-green-slot.md`](./src/blue-green-slot/blue-green-slot.md) — hot-reload primitive.
- [`src/pipeline/pipeline.md`](./src/pipeline/pipeline.md) — `PipelineRouteTable` + types.
- [`src/testing/testing.md`](./src/testing/testing.md) — `createInMemoryRouterHarness` for downstream tests.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `ROUTER_IN_MEMORY.*` registry.
## Usage
```ts
import {
createInMemoryRouter,
InMemoryBlueGreenSlot,
} from "@suaveplan/api-router-in-memory";
const router = createInMemoryRouter({ plugin: "billing" });
router.get("/invoices/:id", () => null);
router.registerPipelineRoute({
plugin: "billing",
method: "POST",
path: "/invoices",
handler: async () => ({ ok: true }),
});
const slot = new InMemoryBlueGreenSlot("billing", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
await slot.dispose(draining.slotId, { timeoutMs: 100 });
```
## When to use
- Foundation tests that need a real `Router`/`PipelineRouter` without a Hono peer.
- Plugin tests that previously hand-rolled their own router shim (`api-router-hono` carries the Hono peer-dep cost).
- The default `router: "in-memory"` slot in the Stage-5 `@suaveplan/api-host/testing` adapter table.

View File

@@ -0,0 +1,92 @@
{
"name": "@suaveplan/api-router-in-memory",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-5 in-memory router adapter for the @suaveplan/api plugin foundation. Implements both the abstract Router contract from @suaveplan/api-router AND the PipelineRouter contract from @suaveplan/api-router on a single InMemoryRouter class. Production-grade in-memory adapter — keeps state in process, dispatches without binding a socket. Passes runRouterConformance + runPipelineRouterConformance.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter": {
"import": {
"types": "./dist/adapter/index.d.ts",
"default": "./dist/adapter/index.js"
}
},
"./pipeline": {
"import": {
"types": "./dist/pipeline/index.d.ts",
"default": "./dist/pipeline/index.js"
}
},
"./blue-green-slot": {
"import": {
"types": "./dist/blue-green-slot/index.d.ts",
"default": "./dist/blue-green-slot/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-router": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,69 @@
# `adapter` — `InMemoryRouter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InMemoryRouter` is The in-memory router adapter for `@suaveplan/api`. It implements both the abstract `Router` contract from `@suaveplan/api-router` (verb registration, collision detection, mount/prefix rewriting, param parsing) AND the `PipelineRouter` contract from `@suaveplan/api-router` (route metadata sidecar with plugin ownership, status-map propagation, and hot-reload teardown).
Designed as a real, production-grade adapter that happens to keep its state in process memory — not a test double. The class passes both `runRouterConformance` and `runPipelineRouterConformance` end-to-end.
## Features
- Single concrete class satisfying two contracts (`Router` + `PipelineRouter`).
- Zero network I/O — `match()`/`matchPipeline()` resolve from in-memory maps.
- Plugin-isolation invariant: cross-plugin collisions throw `ROUTER_IN_MEMORY.ROUTE_COLLISION`; same-plugin re-registration overwrites (the hot-reload path).
- `unregisterPipelinePlugin(plugin)` clears every entry owned by a plugin in one call — wired by the host's reload driver.
- Compatible with `@suaveplan/api-router-in-memory/blue-green-slot`'s `InMemoryBlueGreenSlot` for the same hot-reload primitive Stage-4 ships.
## Usage: Basic
```ts
import { createInMemoryRouter } from "@suaveplan/api-router-in-memory/adapter";
const router = createInMemoryRouter({ plugin: "billing" });
router.get("/invoices/:id", () => null);
const match = router.match("GET", "/invoices/i_42");
// match.params.id === "i_42"
```
## Usage: Advanced
Wire the pipeline facet behind the host's `PipelineRouter` contract, then exercise it through the host's request pipeline:
```ts
import { createInMemoryRouter } from "@suaveplan/api-router-in-memory/adapter";
const router = createInMemoryRouter();
router.registerPipelineRoute({
plugin: "billing",
method: "POST",
path: "/invoices",
handler: async (ctx) => ({ ok: true }),
options: { auth: "required", runtimeValidation: true },
statusMap: { "BILLING.INSUFFICIENT_FUNDS": 402 },
});
// The host's request pipeline consumes this as a PipelineRouter:
const route = router.matchPipeline("POST", "http://h.local/invoices");
// route.plugin === "billing"; route.statusMap["BILLING.INSUFFICIENT_FUNDS"] === 402
```
## API Reference
- `class InMemoryRouter extends @suaveplan/api-router.Router`
- `constructor(options?: { plugin?: string })`
- Inherits `get/post/put/patch/delete/head/options/all`, `match`, `mount`, `collidesWith`, `use`, `chain`.
- `registerPipelineRoute(reg: InMemoryPipelineRegistration): void`
- `unregisterPipelineRoute(method: string, path: string): void`
- `unregisterPipelinePlugin(plugin: string): void`
- `listPipelineRoutes(): readonly InMemoryPipelineRoute[]`
- `matchPipeline(method: string, url: string): InMemoryPipelineRoute | null`
- `createInMemoryRouter(options?): InMemoryRouter` — factory returning the concrete type so callers can reach the pipeline-facet methods without a cast.
## Implementation Notes
Verb-method registration is inherited unchanged from `@suaveplan/api-router.Router` — there is no Hono mirroring to perform, so the parent class's behaviour is the production behaviour. The pipeline facet is a separate `Map`-backed table because the host stores extra per-route metadata (`statusMap`, plugin ownership) the abstract router does not need; keeping them parallel mirrors the `HonoRouter` layering and makes the conformance suites apply identically. `createInMemoryRouter` returns the concrete `InMemoryRouter` type (not the abstract `Router`) so the pipeline-facet methods are reachable directly — this is the small, deliberate divergence from `createHonoRouter`, which hides the concrete class behind the abstract interface.

View File

@@ -0,0 +1,144 @@
import { RouterError } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterInMemoryError } from "../error-codes/error-codes.js";
import { createInMemoryRouter, InMemoryRouter } from "./adapter.js";
describe("InMemoryRouter adapter", () => {
it("createInMemoryRouter returns an InMemoryRouter instance", {
timeout: 5000,
}, () => {
const r = createInMemoryRouter();
expect(r).toBeInstanceOf(InMemoryRouter);
});
it("registers every verb on the abstract router", {
timeout: 5000,
}, () => {
const r = new InMemoryRouter();
r.get("/g", () => null);
r.post("/p", () => null);
r.put("/u", () => null);
r.patch("/pa", () => null);
r.delete("/d", () => null);
r.head("/h", () => null);
r.options("/o", () => null);
expect(r.match("GET", "/g")).not.toBeNull();
expect(r.match("POST", "/p")).not.toBeNull();
expect(r.match("PUT", "/u")).not.toBeNull();
expect(r.match("PATCH", "/pa")).not.toBeNull();
expect(r.match("DELETE", "/d")).not.toBeNull();
expect(r.match("HEAD", "/h")).not.toBeNull();
expect(r.match("OPTIONS", "/o")).not.toBeNull();
});
it("collisions throw RouterError", { timeout: 5000 }, () => {
const r = new InMemoryRouter();
r.get("/same", () => null);
expect(() => r.get("/same", () => null)).toThrow(RouterError);
});
it("mount rewrites paths", { timeout: 5000 }, () => {
const parent = new InMemoryRouter();
const child = new InMemoryRouter();
child.get("/leaf", () => null);
parent.mount("/branch", child);
expect(parent.match("GET", "/branch/leaf")).not.toBeNull();
});
it("parses path params", { timeout: 5000 }, () => {
const r = new InMemoryRouter();
r.get("/u/:id", () => null);
expect(r.match("GET", "/u/abc")?.params).toEqual({ id: "abc" });
});
it("pipeline facet — register/match/list/unregister/unregisterPlugin", {
timeout: 5000,
}, () => {
const r = new InMemoryRouter();
r.registerPipelineRoute({
plugin: "a",
method: "GET",
path: "/a1",
handler: () => null,
});
r.registerPipelineRoute({
plugin: "a",
method: "POST",
path: "/a2",
handler: () => null,
});
r.registerPipelineRoute({
plugin: "b",
method: "GET",
path: "/b1",
handler: () => null,
});
expect(r.matchPipeline("GET", "/a1")).not.toBeNull();
expect(r.listPipelineRoutes()).toHaveLength(3);
r.unregisterPipelineRoute("GET", "/a1");
expect(r.matchPipeline("GET", "/a1")).toBeNull();
expect(r.listPipelineRoutes()).toHaveLength(2);
r.unregisterPipelinePlugin("a");
expect(r.matchPipeline("POST", "/a2")).toBeNull();
expect(r.matchPipeline("GET", "/b1")).not.toBeNull();
expect(r.listPipelineRoutes()).toHaveLength(1);
});
it("pipeline cross-plugin collision throws RouterInMemoryError", {
timeout: 5000,
}, () => {
const r = new InMemoryRouter();
r.registerPipelineRoute({
plugin: "a",
method: "GET",
path: "/x",
handler: () => null,
});
expect(() =>
r.registerPipelineRoute({
plugin: "b",
method: "GET",
path: "/x",
handler: () => null,
})
).toThrow(RouterInMemoryError);
});
it("invalid plugin option throws INVALID_PREFIX", {
timeout: 5000,
}, () => {
try {
new InMemoryRouter({ plugin: "bad/plugin" });
expect(false).toBe(true);
} catch (err) {
expect(err).toBeInstanceOf(RouterInMemoryError);
expect((err as RouterInMemoryError).code).toBe(
"ROUTER_IN_MEMORY.INVALID_PREFIX"
);
}
});
it("empty plugin string throws INVALID_PREFIX", {
timeout: 5000,
}, () => {
expect(() => createInMemoryRouter({ plugin: "" })).toThrow(
RouterInMemoryError
);
});
it("dispatch sanity — no network sockets opened by match", {
timeout: 5000,
}, () => {
const r = createInMemoryRouter();
r.get("/x", () => null);
// The in-memory adapter exposes no `listen`/`fetch` HTTP entry
// point — verify by absence on the prototype chain.
expect(
(r as unknown as Record<string, unknown>).listen
).toBeUndefined();
expect(r.match("GET", "/x")).not.toBeNull();
});
});

View File

@@ -0,0 +1,110 @@
import { Router } from "@suaveplan/api-router";
import z from "zod";
import {
ROUTER_IN_MEMORY_CODES,
RouterInMemoryError,
} from "../error-codes/error-codes.js";
import { PipelineRouteTable } from "../pipeline/pipeline.js";
import type {
InMemoryPipelineRegistration,
InMemoryPipelineRoute,
} from "../pipeline/pipeline.types.js";
/**
* Constructor-options schema. The abstract `Router` accepts a `plugin`
* label; we validate the shape here so callers passing accidental
* `undefined`/non-string values get a `ROUTER_IN_MEMORY.INVALID_PREFIX`
* with the offending value attached.
*/
const inMemoryRouterOptionsSchema = z.object({
plugin: z
.string()
.min(1, "plugin must be a non-empty string")
.regex(/^[a-z0-9_-]+$/i, "plugin must be [a-z0-9_-] only")
.optional(),
});
export type InMemoryRouterOptions = z.infer<typeof inMemoryRouterOptionsSchema>;
/**
* Concrete in-memory `Router` adapter for Stage-5 testing.
*
* Subclasses the abstract `Router` so every `match` / `collidesWith` /
* `mount` semantic is inherited verbatim — no HTTP transport, no socket,
* no Hono peer. Adds a parallel pipeline facet (`PipelineRouteTable`)
* structurally compatible with `@suaveplan/api-types/pipeline-router`'s `PipelineRouter`
* contract, so the in-memory adapter can sit behind the host's request
* pipeline in unit + integration tests.
*
* The verb registration methods (`get`/`post`/…) are not overridden — the
* parent class already implements them and there is no driver-specific
* mirroring to perform.
*/
export class InMemoryRouter extends Router {
private readonly pipelineTable: PipelineRouteTable;
public constructor(options: InMemoryRouterOptions = {}) {
const parsed = inMemoryRouterOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new RouterInMemoryError(
ROUTER_IN_MEMORY_CODES.INVALID_PREFIX,
`invalid InMemoryRouter options: ${parsed.error.message}`,
{ context: { options, issues: parsed.error.issues } }
);
}
const superOpts: { plugin?: string } = {};
if (parsed.data.plugin !== undefined) {
superOpts.plugin = parsed.data.plugin;
}
super(superOpts);
this.pipelineTable = new PipelineRouteTable();
}
/**
* Register a route on the pipeline facet — the metadata sidecar
* consumed by `@suaveplan/api-types/pipeline-router`'s `PipelineRouter`. Throws
* `ROUTER_IN_MEMORY.ROUTE_COLLISION` on cross-plugin collision.
*/
public registerPipelineRoute(reg: InMemoryPipelineRegistration): void {
this.pipelineTable.register(reg);
}
/** Remove a single pipeline-facet entry — no-op when absent. */
public unregisterPipelineRoute(method: string, path: string): void {
this.pipelineTable.unregister(method, path);
}
/** Remove every pipeline-facet entry owned by `plugin`. */
public unregisterPipelinePlugin(plugin: string): void {
this.pipelineTable.unregisterPlugin(plugin);
}
/** Snapshot of every pipeline-facet entry. */
public listPipelineRoutes(): readonly InMemoryPipelineRoute[] {
return this.pipelineTable.list();
}
/**
* Resolve `(method, url)` on the pipeline facet. Returns a route
* structurally compatible with `@suaveplan/api-types/pipeline-router`'s `PipelineRoute`
* or `null` on miss.
*/
public matchPipeline(
method: string,
url: string
): InMemoryPipelineRoute | null {
return this.pipelineTable.match(method, url);
}
}
/**
* Factory — returns a fresh `InMemoryRouter` typed as the abstract `Router`
* so conformance harnesses accept it unchanged. Callers needing the
* pipeline facet keep the concrete return type.
*/
export function createInMemoryRouter(
options: InMemoryRouterOptions = {}
): InMemoryRouter {
return new InMemoryRouter(options);
}

View File

@@ -0,0 +1,2 @@
export type { InMemoryRouterOptions } from "./adapter.js";
export { createInMemoryRouter, InMemoryRouter } from "./adapter.js";

View File

@@ -0,0 +1,64 @@
# `blue-green-slot` — hot-reload primitive for the in-memory router
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InMemoryBlueGreenSlot` is the `BlueGreenSlot` implementation paired with `InMemoryRouter`. It is a thin extension of `@suaveplan/api-router`'s reference `InMemoryBlueGreenSlot`, kept locally so consumers of `@suaveplan/api-router-in-memory` import their router adapter AND their slot from a single package — symmetric with `@suaveplan/api-router-hono`'s `HonoBlueGreenSlot`.
The reference implementation in `api-router` already satisfies every invariant the conformance suite asserts (set/flip/drain/dispose/abort), so this class adds no behaviour; it exists solely to keep the public surface uniform.
## Features
- Inherits the reference impl's three-position state machine (`live` / `shadow` / `draining`).
- `flip()` returns a `DrainingHandle` whose `settled` promise resolves once all in-flight requests against the previous live slot complete.
- `dispose(slotId, { timeoutMs })` races a timer against drain settle; on timeout, aborts the slot's `AbortController` and throws `ROUTER.SLOT_DISPOSE_TIMEOUT`.
- `acquire(slotId)` / `release(slotId)` are wired by the router so the pending counter reflects in-flight dispatch.
## Usage: Basic
```ts
import { InMemoryBlueGreenSlot } from "@suaveplan/api-router-in-memory/blue-green-slot";
const slot = new InMemoryBlueGreenSlot("billing", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
// slot.live now serves v2; draining points at the previous v1 slot.
await draining.settled;
```
## Usage: Advanced
Drive a hot-reload from a manifest-driven loader, with abort-on-timeout safety:
```ts
import {
createInMemoryBlueGreenSlot,
} from "@suaveplan/api-router-in-memory/blue-green-slot";
const slot = createInMemoryBlueGreenSlot("billing", () => "v1");
slot.setShadow(() => "v2");
const drain = slot.flip();
try {
await slot.dispose(drain.slotId, { timeoutMs: 50 });
} catch (err) {
// dispose timed out — the AbortController on the previous slot has
// already fired; in-flight handlers should observe their AbortSignal
// and unwind. Bubble the error up to the reload driver.
throw err;
}
```
## API Reference
- `class InMemoryBlueGreenSlot extends @suaveplan/api-router.InMemoryBlueGreenSlot`
- `constructor(plugin: string, initialHandler: SlotHandler)`
- Inherits `live`, `shadow`, `setShadow`, `flip`, `dispose`, `acquire`, `release`.
- `createInMemoryBlueGreenSlot(plugin, initialHandler): BlueGreenSlot` — convenience factory typed as the abstract interface.
## Implementation Notes
The subclass exists to keep the package's exported surface aligned with `api-router-hono` (HonoBlueGreenSlot is its own concrete class because the live pointer is one mutable field that swaps atomically — see that package's notes). For the in-memory adapter there is no driver-specific work, so this is intentionally a no-op extension. The conformance suite in `api-router/testing` accepts either the parent class or this subclass.

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createInMemoryBlueGreenSlot,
InMemoryBlueGreenSlot,
} from "./blue-green-slot.js";
describe("InMemoryBlueGreenSlot", () => {
it("constructor produces a live slot with the initial handler", {
timeout: 5000,
}, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
expect(slot.live.handler()).toBe("v1");
expect(slot.shadow).toBeNull();
});
it("createInMemoryBlueGreenSlot returns an InMemoryBlueGreenSlot", {
timeout: 5000,
}, () => {
const slot = createInMemoryBlueGreenSlot("p", () => null);
expect(slot).toBeInstanceOf(InMemoryBlueGreenSlot);
});
it("set shadow → flip transitions the live pointer", {
timeout: 5000,
}, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
const liveId = slot.live.slotId;
slot.setShadow(() => "v2");
const draining = slot.flip();
expect(slot.live.handler()).toBe("v2");
expect(draining.slotId).toBe(liveId);
await expect(
slot.dispose(draining.slotId, { timeoutMs: 100 })
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,26 @@
import {
type BlueGreenSlot,
InMemoryBlueGreenSlot as RefSlot,
type SlotHandler,
} from "@suaveplan/api-router";
/**
* In-memory `BlueGreenSlot` — thin re-export of the reference
* `InMemoryBlueGreenSlot` from `@suaveplan/api-router`.
*
* The reference implementation already covers every invariant the
* conformance suite exercises (set / flip / drain / dispose / abort).
* Wrapping it here keeps the public surface of `api-router-in-memory`
* symmetric with `api-router-hono`'s `HonoBlueGreenSlot` — consumers can
* import a slot impl from the same package as their router adapter
* without learning the cross-package re-export rule.
*/
export class InMemoryBlueGreenSlot extends RefSlot {}
/** Convenience factory mirroring `createHonoRouter`'s shape. */
export function createInMemoryBlueGreenSlot(
plugin: string,
initialHandler: SlotHandler
): BlueGreenSlot {
return new InMemoryBlueGreenSlot(plugin, initialHandler);
}

View File

@@ -0,0 +1,4 @@
export {
createInMemoryBlueGreenSlot,
InMemoryBlueGreenSlot,
} from "./blue-green-slot.js";

View File

@@ -0,0 +1,66 @@
# `error-codes` — `@suaveplan/api-router-in-memory` error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `ROUTER_IN_MEMORY.*` error-code namespace used by every production path in `@suaveplan/api-router-in-memory`. The registry is consumed by the host's error-envelope serializer, by the observability stack for span attribution, and by downstream tests asserting on stable codes rather than string-matched messages.
Every code listed here MUST have at least one production-path throw site. The unit test for this module enumerates the registry against the package source and refuses to compile when a code is added without a corresponding throw.
## Features
- Stable, dot-namespaced codes (`ROUTER_IN_MEMORY.ROUTE_COLLISION`, `ROUTER_IN_MEMORY.INVALID_PREFIX`).
- `RouterInMemoryError` subclass of `@suaveplan/error`'s `SuaveplanError` carrying typed `code`, optional `cause`, and a free-form `context` record.
- Compatible with the `SuaveplanError` wire-envelope serializer — codes survive serialization across worker boundaries and HTTP responses.
## Usage: Basic
```ts
import {
ROUTER_IN_MEMORY_CODES,
RouterInMemoryError,
} from "@suaveplan/api-router-in-memory/error-codes";
throw new RouterInMemoryError(
ROUTER_IN_MEMORY_CODES.ROUTE_COLLISION,
"duplicate GET /users",
{ context: { method: "GET", path: "/users" } }
);
```
## Usage: Advanced
Pattern-match against the typed code union in the host's error boundary:
```ts
import type { RouterInMemoryCode } from "@suaveplan/api-router-in-memory/error-codes";
import {
ROUTER_IN_MEMORY_CODES,
RouterInMemoryError,
} from "@suaveplan/api-router-in-memory/error-codes";
function classify(err: unknown): RouterInMemoryCode | "unknown" {
if (err instanceof RouterInMemoryError) {
if (err.code === ROUTER_IN_MEMORY_CODES.ROUTE_COLLISION) {
return ROUTER_IN_MEMORY_CODES.ROUTE_COLLISION;
}
if (err.code === ROUTER_IN_MEMORY_CODES.INVALID_PREFIX) {
return ROUTER_IN_MEMORY_CODES.INVALID_PREFIX;
}
}
return "unknown";
}
```
## API Reference
- `ROUTER_IN_MEMORY_CODES` — frozen record mapping `<DOMAIN>` to the dot-namespaced literal.
- `RouterInMemoryCode` — union of every value in `ROUTER_IN_MEMORY_CODES`.
- `RouterInMemoryError` — `SuaveplanError` subclass; constructor `(code, message, { cause?, context? })`.
## Implementation Notes
`defineCodes` returns a `const` record whose values are `${NAMESPACE}.${KEY}` literals — the literal type is preserved into `RouterInMemoryCode` so any switch over the union is exhaustively checked by TypeScript. The error class sets `this.name = "RouterInMemoryError"` for accurate stack traces. The set of codes is intentionally minimal — the in-memory adapter inherits most semantics from the abstract `Router`'s `RouterError`; `ROUTER_IN_MEMORY.*` covers only adapter-specific failures.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ROUTER_IN_MEMORY_CODES, RouterInMemoryError } from "./error-codes.js";
describe("ROUTER_IN_MEMORY error registry", () => {
it("exposes the documented namespace literals", {
timeout: 5000,
}, () => {
expect(ROUTER_IN_MEMORY_CODES.ROUTE_COLLISION).toBe(
"ROUTER_IN_MEMORY.ROUTE_COLLISION"
);
expect(ROUTER_IN_MEMORY_CODES.INVALID_PREFIX).toBe(
"ROUTER_IN_MEMORY.INVALID_PREFIX"
);
});
it("RouterInMemoryError carries code + context + cause", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new RouterInMemoryError(
ROUTER_IN_MEMORY_CODES.ROUTE_COLLISION,
"boom",
{ cause, context: { hello: "world" } }
);
expect(err).toBeInstanceOf(RouterInMemoryError);
expect(err.code).toBe("ROUTER_IN_MEMORY.ROUTE_COLLISION");
expect(err.cause).toBe(cause);
expect(err.name).toBe("RouterInMemoryError");
expect(err.message).toBe("boom");
});
it("RouterInMemoryError works without options", { timeout: 5000 }, () => {
const err = new RouterInMemoryError(
ROUTER_IN_MEMORY_CODES.INVALID_PREFIX,
"no-opts"
);
expect(err.code).toBe("ROUTER_IN_MEMORY.INVALID_PREFIX");
});
});

View File

@@ -0,0 +1,35 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-router-in-memory`.
*
* Codes follow the `ROUTER_IN_MEMORY.<DOMAIN>` namespace as resolved by
* `@suaveplan/error`'s `defineCodes`. Every code MUST be thrown by a
* production path in this package — the registry is enumerated by the
* conformance + unit tests and each entry is asserted to have a throw site.
*/
export const ROUTER_IN_MEMORY_CODES = defineCodes("ROUTER_IN_MEMORY", [
"ROUTE_COLLISION",
"INVALID_PREFIX",
] as const);
/** Union of every code resolved through {@link ROUTER_IN_MEMORY_CODES}. */
export type RouterInMemoryCode =
(typeof ROUTER_IN_MEMORY_CODES)[keyof typeof ROUTER_IN_MEMORY_CODES];
/**
* Typed error for every `@suaveplan/api-router-in-memory` failure.
*
* Extends `SuaveplanError` so callers can discriminate via
* `error instanceof RouterInMemoryError` or the stable `error.code` property.
*/
export class RouterInMemoryError extends SuaveplanError<RouterInMemoryCode> {
public constructor(
code: RouterInMemoryCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "RouterInMemoryError";
}
}

View File

@@ -0,0 +1,5 @@
export type { RouterInMemoryCode } from "./error-codes.js";
export {
ROUTER_IN_MEMORY_CODES,
RouterInMemoryError,
} from "./error-codes.js";

View File

@@ -0,0 +1,25 @@
// Adapter
export type { InMemoryRouterOptions } from "./adapter/adapter.js";
export { createInMemoryRouter, InMemoryRouter } from "./adapter/adapter.js";
// Blue-green slot
export {
createInMemoryBlueGreenSlot,
InMemoryBlueGreenSlot,
} from "./blue-green-slot/blue-green-slot.js";
// Error codes
export type { RouterInMemoryCode } from "./error-codes/error-codes.js";
export {
ROUTER_IN_MEMORY_CODES,
RouterInMemoryError,
} from "./error-codes/error-codes.js";
// Pipeline facet
export { PipelineRouteTable } from "./pipeline/pipeline.js";
export type {
InMemoryPipelineHandler,
InMemoryPipelineRegistration,
InMemoryPipelineRoute,
InMemoryPipelineRouteOptions,
} from "./pipeline/pipeline.types.js";
// Testing harness
export type { InMemoryRouterHarness } from "./testing/testing.js";
export { createInMemoryRouterHarness } from "./testing/testing.js";

View File

@@ -0,0 +1,7 @@
export { PipelineRouteTable } from "./pipeline.js";
export type {
InMemoryPipelineHandler,
InMemoryPipelineRegistration,
InMemoryPipelineRoute,
InMemoryPipelineRouteOptions,
} from "./pipeline.types.js";

View File

@@ -0,0 +1,68 @@
# `pipeline` — in-memory PipelineRouter facet
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PipelineRouteTable` is the in-memory metadata store that backs `InMemoryRouter`'s pipeline facet. It stores per-route plugin ownership, options, status-code overrides, and handler references; it is the source of truth `InMemoryRouter.matchPipeline(method, url)` consults at request time.
The table is structurally compatible with `@suaveplan/api-types/pipeline-router`'s `PipelineRouter` contract — `match()` returns either `null` (the host's 404 path) or a value assignable to `PipelineRoute`. The table also supports hot-reload teardown via `unregisterPlugin(plugin)` and admin-API listing via `list()`.
## Features
- Plugin-ownership-aware collision detection: same `(method, path-shape)` registered by a different plugin throws `ROUTER_IN_MEMORY.ROUTE_COLLISION`; same plugin re-registering overwrites (the hot-reload path).
- `:param` segment compilation with URI-decoded extraction at match time.
- Trailing-slash normalisation: `/x/` and `/x` resolve to the same shape.
- Case-insensitive method matching: `register({ method: "post" })` is reachable via `match("POST", url)`.
- O(N) match against the registration set — acceptable for the in-process test target; production deployments use the host's compiled router.
## Usage: Basic
```ts
import { PipelineRouteTable } from "@suaveplan/api-router-in-memory/pipeline";
const table = new PipelineRouteTable();
table.register({
plugin: "billing",
method: "GET",
path: "/invoices/:id",
handler: (ctx) => ({ id: (ctx as { params: { id: string } }).params.id }),
});
const match = table.match("GET", "http://h.local/invoices/i_42");
// match.plugin === "billing"; match.params.id === "i_42"
```
## Usage: Advanced
Hot-reload an entire plugin's routes in one cycle:
```ts
import { PipelineRouteTable } from "@suaveplan/api-router-in-memory/pipeline";
const table = new PipelineRouteTable();
const v1 = { plugin: "billing", method: "GET", path: "/x", handler: () => "v1" };
table.register(v1);
// Hot-reload: drop everything owned by billing, register the new version.
table.unregisterPlugin("billing");
table.register({ ...v1, handler: () => "v2" });
const snapshot = table.list();
// snapshot[0].handler now returns "v2"
```
## API Reference
- `class PipelineRouteTable`
- `register(reg: InMemoryPipelineRegistration): void` — register/overwrite a route; throws `ROUTER_IN_MEMORY.ROUTE_COLLISION` on cross-plugin collision.
- `unregister(method: string, path: string): void` — remove one entry; no-op when absent.
- `unregisterPlugin(plugin: string): void` — remove every entry owned by `plugin`.
- `list(): readonly InMemoryPipelineRoute[]` — snapshot of every entry.
- `match(method: string, url: string): InMemoryPipelineRoute | null` — resolve one URL; populates `params` from `:param` segments.
- `size(): number` — coverage helper.
## Implementation Notes
The shape key erases parameter names (`/users/:id` and `/users/:other` collide) so the host's plugin-isolation invariant is preserved. Match iterates in insertion order — every adapter is free to choose, but the in-memory adapter picks first-registered-wins to make the conformance suite deterministic. Path normalisation strips a single trailing slash; collapsing repeated slashes is out of scope (production deployments handle that at the L7 edge).

View File

@@ -0,0 +1,279 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterInMemoryError } from "../error-codes/error-codes.js";
import { PipelineRouteTable } from "./pipeline.js";
describe("PipelineRouteTable", () => {
it("register → match round-trip", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
const handler = () => "ok";
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler,
});
const m = t.match("GET", "/x");
expect(m).not.toBeNull();
expect(m?.plugin).toBe("p");
expect(m?.method).toBe("GET");
expect(m?.path).toBe("/x");
expect(m?.handler).toBe(handler);
expect(m?.statusMap).toEqual({});
expect(m?.options).toEqual({});
});
it("returns null on miss", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
expect(t.match("GET", "/missing")).toBeNull();
});
it("returns null when method differs", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
expect(t.match("POST", "/x")).toBeNull();
});
it("method casing is normalised", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "post",
path: "/x",
handler: () => null,
});
expect(t.match("POST", "/x")).not.toBeNull();
expect(t.match("post", "/x")).not.toBeNull();
expect(t.match("GET", "/x")).toBeNull();
});
it("parses :param segments and decodes URI components", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/u/:id",
handler: () => null,
});
const m = t.match("GET", "/u/u%20space");
expect(m?.params).toEqual({ id: "u space" });
});
it("path normalisation: missing leading slash, trailing slash, root", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "no-slash",
handler: () => null,
});
t.register({
plugin: "p",
method: "GET",
path: "/trailing/",
handler: () => null,
});
t.register({
plugin: "p",
method: "GET",
path: "/",
handler: () => null,
});
expect(t.match("GET", "/no-slash")).not.toBeNull();
expect(t.match("GET", "/trailing")).not.toBeNull();
expect(t.match("GET", "/")).not.toBeNull();
});
it("statusMap and options propagate through match()", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "POST",
path: "/teapot",
handler: () => null,
statusMap: { "P.TEAPOT": 418 },
options: { auth: "required" },
});
const m = t.match("POST", "/teapot");
expect(m?.statusMap).toEqual({ "P.TEAPOT": 418 });
expect(m?.options.auth).toBe("required");
});
it("cross-plugin collision throws ROUTE_COLLISION", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "a",
method: "GET",
path: "/x",
handler: () => null,
});
try {
t.register({
plugin: "b",
method: "GET",
path: "/x",
handler: () => null,
});
expect(false).toBe(true);
} catch (err) {
expect(err).toBeInstanceOf(RouterInMemoryError);
expect((err as RouterInMemoryError).code).toBe(
"ROUTER_IN_MEMORY.ROUTE_COLLISION"
);
}
});
it("same-plugin re-registration overwrites (hot-reload path)", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
const h1 = () => "v1";
const h2 = () => "v2";
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: h1,
});
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: h2,
});
const m = t.match("GET", "/x");
expect(m?.handler).toBe(h2);
expect(t.size()).toBe(1);
});
it("parameter-name erasure: :id and :other collide across plugins", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "a",
method: "GET",
path: "/u/:id",
handler: () => null,
});
expect(() =>
t.register({
plugin: "b",
method: "GET",
path: "/u/:other",
handler: () => null,
})
).toThrow(RouterInMemoryError);
});
it("unregister no-ops on missing entry", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.unregister("GET", "/nope");
expect(t.size()).toBe(0);
});
it("unregisterPlugin removes only that plugin's entries", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "a",
method: "GET",
path: "/a1",
handler: () => null,
});
t.register({
plugin: "b",
method: "GET",
path: "/b1",
handler: () => null,
});
t.unregisterPlugin("a");
expect(t.match("GET", "/a1")).toBeNull();
expect(t.match("GET", "/b1")).not.toBeNull();
});
it("list returns a snapshot of every entry", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/a",
handler: () => null,
});
t.register({
plugin: "p",
method: "POST",
path: "/b",
handler: () => null,
});
expect(t.list()).toHaveLength(2);
});
it("matches a full URL with query string", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
expect(t.match("GET", "http://h.local/x?q=1")).not.toBeNull();
});
it("strips query string from a bare path when URL parsing fails", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
// `http://[::1` is invalid IPv6 host and throws `new URL(...)` even
// with a base — exercises the catch-branch fallback path.
const m = t.match("GET", "http://[::1?q=1");
expect(m).toBeNull();
});
it("strips query string via catch-branch with no `?` token", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
// Same throwing path, but no `?` — exercises the q === -1 branch.
const m = t.match("GET", "http://[::1");
expect(m).toBeNull();
});
it("size reports registered count", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
expect(t.size()).toBe(0);
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
expect(t.size()).toBe(1);
});
});

View File

@@ -0,0 +1,194 @@
import {
ROUTER_IN_MEMORY_CODES,
RouterInMemoryError,
} from "../error-codes/error-codes.js";
import type {
InMemoryPipelineRegistration,
InMemoryPipelineRoute,
InMemoryPipelineRouteOptions,
} from "./pipeline.types.js";
/**
* In-memory metadata store shared by `InMemoryRouter`'s pipeline facet.
*
* Keeps a route table keyed by `${method}:${pattern}` where `pattern` is
* the original registered path with `:param` segments preserved. The
* facet stores ONLY the metadata the host needs (`plugin`, `statusMap`,
* `options`, `handler`) — the abstract router still owns matching of the
* verb-method-based table.
*/
export class PipelineRouteTable {
private readonly entries: Map<string, StoredEntry> = new Map();
/**
* Register a route. Throws `ROUTER_IN_MEMORY.ROUTE_COLLISION` when an
* identical (method, path-shape) tuple is already registered by a
* DIFFERENT plugin. Re-registering the same (plugin, method, path)
* overwrites — that is the hot-reload flow used by the host's reload
* driver.
*/
public register(reg: InMemoryPipelineRegistration): void {
const method = reg.method.toUpperCase();
const path = normalisePath(reg.path);
const shape = shapeKey(method, path);
const existing = this.entries.get(shape);
if (existing !== undefined && existing.plugin !== reg.plugin) {
throw new RouterInMemoryError(
ROUTER_IN_MEMORY_CODES.ROUTE_COLLISION,
`pipeline route ${method} ${path} already registered by plugin "${existing.plugin}"`,
{
context: {
method,
path,
existingPlugin: existing.plugin,
incomingPlugin: reg.plugin,
},
}
);
}
const segments = compile(path);
this.entries.set(shape, {
plugin: reg.plugin,
method,
path,
segments,
handler: reg.handler,
options: reg.options ?? {},
statusMap: reg.statusMap ?? {},
});
}
/** Remove a single (method, path) entry — no-op when absent. */
public unregister(method: string, path: string): void {
this.entries.delete(
shapeKey(method.toUpperCase(), normalisePath(path))
);
}
/** Remove every entry owned by the given plugin (hot-reload teardown). */
public unregisterPlugin(plugin: string): void {
for (const [key, entry] of this.entries) {
if (entry.plugin === plugin) this.entries.delete(key);
}
}
/** Snapshot of every registered entry — used by tests + admin tools. */
public list(): readonly InMemoryPipelineRoute[] {
return [...this.entries.values()].map((e) => ({
plugin: e.plugin,
method: e.method,
path: e.path,
options: e.options,
handler: e.handler,
params: {},
statusMap: e.statusMap,
}));
}
/**
* Resolve `(method, url)` to a `InMemoryPipelineRoute`. URL may be a
* full `http://host/path?q=1` or a bare pathname. Returns `null` when
* no pattern matches; populates `params` from the matched `:param`
* segments.
*/
public match(method: string, url: string): InMemoryPipelineRoute | null {
const m = method.toUpperCase();
const path = pathnameOf(url);
const target = tokenise(path);
for (const entry of this.entries.values()) {
if (entry.method !== m) continue;
const params = matchSegments(entry.segments, target);
if (params === null) continue;
return {
plugin: entry.plugin,
method: entry.method,
path: entry.path,
options: entry.options,
handler: entry.handler,
params,
statusMap: entry.statusMap,
};
}
return null;
}
/** Internal — coverage helper for tests. */
public size(): number {
return this.entries.size;
}
}
interface StoredEntry {
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly segments: readonly Segment[];
readonly handler: InMemoryPipelineRoute["handler"];
readonly options: InMemoryPipelineRouteOptions;
readonly statusMap: Readonly<Record<string, number>>;
}
interface Segment {
readonly kind: "literal" | "param";
readonly value: string;
}
function normalisePath(path: string): string {
if (path === "" || path === "/") return "/";
if (!path.startsWith("/")) return `/${path}`;
return path.endsWith("/") ? path.slice(0, -1) : path;
}
function compile(path: string): readonly Segment[] {
if (path === "/" || path === "") return [];
const parts = path.slice(1).split("/");
return parts.map(
(p): Segment =>
p.startsWith(":")
? { kind: "param", value: p.slice(1) }
: { kind: "literal", value: p }
);
}
/** Collision key — parameter names are erased so `:id` and `:other` clash. */
function shapeKey(method: string, path: string): string {
if (path === "/" || path === "") return `${method}:/`;
const erased = path
.slice(1)
.split("/")
.map((p) => (p.startsWith(":") ? ":" : p))
.join("/");
return `${method}:/${erased}`;
}
function pathnameOf(url: string): string {
try {
return new URL(url, "http://h.local").pathname;
} catch {
const q = url.indexOf("?");
return q === -1 ? url : url.slice(0, q);
}
}
function tokenise(path: string): readonly string[] {
if (path === "/" || path === "") return [];
return path.slice(1).split("/");
}
function matchSegments(
pattern: readonly Segment[],
target: readonly string[]
): Record<string, string> | null {
if (pattern.length !== target.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < pattern.length; i += 1) {
const seg = pattern[i] as Segment;
const t = target[i] as string;
if (seg.kind === "literal") {
if (seg.value !== t) return null;
} else {
params[seg.value] = decodeURIComponent(t);
}
}
return params;
}

View File

@@ -0,0 +1,54 @@
/**
* Structural shapes mirrored from `@suaveplan/api-types/pipeline-router`.
*
* `@suaveplan/api-router-in-memory` keeps the types here redeclared at
* the same structural shape so the compile-time + runtime surfaces
* remain decoupled. Production wiring satisfies the contract's
* `PipelineRouter`/`PipelineRoute` shapes by structural compatibility —
* `InMemoryRouter.matchPipeline` returns a value assignable to the
* contract's `PipelineRoute` because every required field is present at
* the same path-type.
*/
/** See `@suaveplan/api-types/pipeline-router`'s `PipelineRouteOptions`. */
export interface InMemoryPipelineRouteOptions {
readonly auth?: string | null;
readonly csrf?: boolean;
readonly streaming?: boolean;
readonly idempotent?: boolean;
readonly runtimeValidation?: boolean;
readonly auditable?: boolean;
readonly rateLimit?: { rpm: number } | { rps: number };
readonly request?: {
readonly body?: unknown;
readonly params?: unknown;
readonly query?: unknown;
};
readonly responses?: Readonly<Record<string, { readonly body: unknown }>>;
}
/** Per-request callable handler the host pipeline invokes. */
export type InMemoryPipelineHandler = (
ctx: unknown
) => Promise<unknown> | unknown;
/** See `@suaveplan/api-types/pipeline-router`'s `PipelineRoute`. */
export interface InMemoryPipelineRoute {
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly options: InMemoryPipelineRouteOptions;
readonly handler: InMemoryPipelineHandler;
readonly params: Readonly<Record<string, string>>;
readonly statusMap: Readonly<Record<string, number>>;
}
/** Registration descriptor accepted by `InMemoryRouter.registerPipelineRoute`. */
export interface InMemoryPipelineRegistration {
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly handler: InMemoryPipelineHandler;
readonly options?: InMemoryPipelineRouteOptions;
readonly statusMap?: Readonly<Record<string, number>>;
}

View File

@@ -0,0 +1,2 @@
export type { InMemoryRouterHarness } from "./testing.js";
export { createInMemoryRouterHarness } from "./testing.js";

View File

@@ -0,0 +1,53 @@
# `testing` — downstream-consumer test harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createInMemoryRouterHarness()` is the canonical "spin up an in-memory router for a test" entry point. It bundles a fresh `InMemoryRouter` and a pinned `InMemoryBlueGreenSlot` factory so foundation + plugin tests stop hand-rolling router shims.
The harness exists because the testing surface has historically grown by accretion — a handful of plugin tests inline their own one-off router stub, every one slightly different. Centralising the shape here means future Router/PipelineRouter contract changes propagate through one factory rather than dozens of test files.
## Features
- One call returns a router + slot pair ready for the conformance suites or any downstream pipeline test.
- Slot factory captures the plugin name + initial handler shape the host's reload driver expects.
- Pure helper — no global state, no shared mutable references between calls.
## Usage: Basic
```ts
import { createInMemoryRouterHarness } from "@suaveplan/api-router-in-memory/testing";
const { router } = createInMemoryRouterHarness();
router.get("/x", () => null);
// router.match("GET", "/x") returns the registered match.
```
## Usage: Advanced
Drive a hot-reload through the slot the harness produces:
```ts
import { createInMemoryRouterHarness } from "@suaveplan/api-router-in-memory/testing";
const harness = createInMemoryRouterHarness({ plugin: "billing" });
const slot = harness.slot("billing", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
await slot.dispose(draining.slotId, { timeoutMs: 100 });
// slot.live now serves v2; the previous v1 slot has drained.
```
## API Reference
- `interface InMemoryRouterHarness`
- `router: InMemoryRouter`
- `slot(plugin: string, initialHandler: () => unknown): InMemoryBlueGreenSlot`
- `createInMemoryRouterHarness(options?: { plugin?: string }): InMemoryRouterHarness`
## Implementation Notes
The harness is deliberately minimal — it does not pre-register any routes, does not couple to the host's `createTestHost`, and does not expose any assertion helpers. Those concerns belong to the host's `@suaveplan/api-host/testing` createTestHost adapter table, which wires this package as the `router: "in-memory"` option. Downstream tests that need plugin-specific routes call `harness.router.get(...)` after construction.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InMemoryRouter } from "../adapter/adapter.js";
import { InMemoryBlueGreenSlot } from "../blue-green-slot/blue-green-slot.js";
import { createInMemoryRouterHarness } from "./testing.js";
describe("createInMemoryRouterHarness", () => {
it("returns a fresh InMemoryRouter", { timeout: 5000 }, () => {
const h = createInMemoryRouterHarness();
expect(h.router).toBeInstanceOf(InMemoryRouter);
h.router.get("/x", () => null);
expect(h.router.match("GET", "/x")).not.toBeNull();
});
it("forwards plugin option to the underlying router", {
timeout: 5000,
}, () => {
const h = createInMemoryRouterHarness({ plugin: "billing" });
expect(h.router).toBeInstanceOf(InMemoryRouter);
});
it("slot factory produces fresh InMemoryBlueGreenSlot instances", {
timeout: 5000,
}, () => {
const h = createInMemoryRouterHarness();
const a = h.slot("p", () => "v1");
const b = h.slot("p", () => "v1");
expect(a).toBeInstanceOf(InMemoryBlueGreenSlot);
expect(b).toBeInstanceOf(InMemoryBlueGreenSlot);
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,50 @@
import {
createInMemoryRouter,
type InMemoryRouter,
} from "../adapter/adapter.js";
import {
createInMemoryBlueGreenSlot,
type InMemoryBlueGreenSlot,
} from "../blue-green-slot/blue-green-slot.js";
/**
* Bundle returned by `createInMemoryRouterHarness()` — a fresh router +
* slot factory pair ready to be passed to the foundation conformance
* suites or to a custom downstream test.
*/
export interface InMemoryRouterHarness {
/** Fresh `InMemoryRouter` instance. */
readonly router: InMemoryRouter;
/**
* Slot factory pinned to this harness — every call returns a fresh
* `InMemoryBlueGreenSlot` for the supplied plugin + handler.
*/
readonly slot: (
plugin: string,
initialHandler: () => unknown
) => InMemoryBlueGreenSlot;
}
/**
* Build a fresh in-memory router test harness.
*
* Downstream foundation + plugin tests call this to get a ready-to-use
* `Router + Slot` pair without having to manually wire the two pieces.
* The harness is intentionally tiny — the goal is to centralise the
* "spin up an in-memory router for a test" pattern so future surface
* changes propagate through one factory rather than dozens of hand-rolled
* router shims.
*/
export function createInMemoryRouterHarness(
options: { plugin?: string } = {}
): InMemoryRouterHarness {
const router = createInMemoryRouter(options);
return {
router,
slot: (plugin, initialHandler) =>
createInMemoryBlueGreenSlot(
plugin,
initialHandler
) as InMemoryBlueGreenSlot,
};
}

View File

@@ -0,0 +1,10 @@
import { runRouterConformance } from "@suaveplan/api-router";
import { createInMemoryRouter } from "../../src/adapter/adapter.js";
import { InMemoryBlueGreenSlot } from "../../src/blue-green-slot/blue-green-slot.js";
runRouterConformance({
label: "in-memory",
router: () => createInMemoryRouter(),
slot: (plugin, handler) => new InMemoryBlueGreenSlot(plugin, handler),
});

View File

@@ -0,0 +1,28 @@
import { runPipelineRouterConformance } from "@suaveplan/api-router/testing/pipeline-router-conformance";
import { InMemoryRouter } from "../../src/adapter/adapter.js";
/**
* Wire the canonical `PipelineRouter` conformance suite from
* `@suaveplan/api-router` to the `InMemoryRouter` pipeline facet.
*/
runPipelineRouterConformance({
label: "InMemoryRouter pipeline facet",
build: () => {
let r = new InMemoryRouter();
const replay: Array<Parameters<typeof r.registerPipelineRoute>[0]> = [];
return {
router: {
match: (method, url) => r.matchPipeline(method, url),
},
register: (reg) => {
r.registerPipelineRoute(reg);
replay.push(reg);
},
reload: () => {
r = new InMemoryRouter();
for (const reg of replay) r.registerPipelineRoute(reg);
},
};
},
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,52 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-router-in-memory",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"adapter/index": "src/adapter/index.ts",
"blue-green-slot/index": "src/blue-green-slot/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"pipeline/index": "src/pipeline/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/api-router",
"@suaveplan/api-router/testing",
"@suaveplan/api-router/testing/pipeline-router-conformance",
"@suaveplan/api-types",
"@suaveplan/api-types/pipeline-router",
"@suaveplan/error",
"@suaveplan/testing",
"@suaveplan/testing/runner",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});