Initial Commit
This commit is contained in:
74
packages/api-stack/api/api-router-in-memory/README.md
Normal file
74
packages/api-stack/api/api-router-in-memory/README.md
Normal 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.
|
||||
|
||||
92
packages/api-stack/api/api-router-in-memory/package.json
Normal file
92
packages/api-stack/api/api-router-in-memory/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { InMemoryRouterOptions } from "./adapter.js";
|
||||
export { createInMemoryRouter, InMemoryRouter } from "./adapter.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createInMemoryBlueGreenSlot,
|
||||
InMemoryBlueGreenSlot,
|
||||
} from "./blue-green-slot.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { RouterInMemoryCode } from "./error-codes.js";
|
||||
export {
|
||||
ROUTER_IN_MEMORY_CODES,
|
||||
RouterInMemoryError,
|
||||
} from "./error-codes.js";
|
||||
25
packages/api-stack/api/api-router-in-memory/src/index.ts
Normal file
25
packages/api-stack/api/api-router-in-memory/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export { PipelineRouteTable } from "./pipeline.js";
|
||||
export type {
|
||||
InMemoryPipelineHandler,
|
||||
InMemoryPipelineRegistration,
|
||||
InMemoryPipelineRoute,
|
||||
InMemoryPipelineRouteOptions,
|
||||
} from "./pipeline.types.js";
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { InMemoryRouterHarness } from "./testing.js";
|
||||
export { createInMemoryRouterHarness } from "./testing.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
52
packages/api-stack/api/api-router-in-memory/vite.config.ts
Normal file
52
packages/api-stack/api/api-router-in-memory/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
25
packages/api-stack/api/api-router-in-memory/vitest.config.ts
Normal file
25
packages/api-stack/api/api-router-in-memory/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user