@crescendolab/override-proxy
v0.1.3
Published
Override-first local mock + proxy server
Readme
override-proxy
Pluggable local development server that serves rule-based HTTP and WebSocket overrides first, then proxies unmatched traffic to upstream targets.
Key features:
- Override-first: if a rule matches, respond immediately; otherwise proxy.
- Multi-server, route-scoped config with root and subdirectory routes.
- Inline HTTP and WebSocket rules through config imports.
- Raw WebSocket direct proxy or bridge mode with bidirectional message actions.
- CLI entry with config discovery,
serve,validate, and legacy fallback. - Layered environment loading via
dotenvx(.env.localthen.env.default).
Documentation
| Document | Purpose | | ------------------------------------------------------------------------ | -------------------------------------------------- | | README.md | User guide and overview (you are here) | | AGENTS.md | Detailed guide for AI agents | | docs/TOOLS.md | Development commands and verification workflow | | docs/ARCHITECTURE.md | Visual diagrams and code location index | | docs/design/config.md | Config model for multi-server routing | | docs/design/websocket.md | WebSocket proxy and rule semantics | | docs/design/cli.md | CLI behavior | | docs/design/implementation-plan.md | Ordered implementation checklist | | docs/EXAMPLES.md | Copy-paste examples for common scenarios | | docs/PATTERNS.md | Best practices and common pitfalls | | docs/DOC-WRITING-GUIDE.md | Documentation writing standards (for contributors) | | skills/override-proxy/SKILL.md | Codex skill for agent-assisted usage |
Development Tools
The workflow is config-driven: import rule values in config, validate the config, then run focused tests or the built CLI. See docs/TOOLS.md for the current command list.
Codex Skill
This repository includes an installable Codex skill at
skills/override-proxy. Install it from this GitHub repository with the
skill-installer workflow by using repo crescendolab-open/override-proxy and
path skills/override-proxy.
The skill guides agents toward a project-local devDependency setup, then helps them author configs or rules with the same commands the repository will use.
Quick Start
Install it in your app or mock workspace:
pnpm install -D @crescendolab/override-proxyCreate override-proxy.config.ts:
import { defineConfig, rule } from "@crescendolab/override-proxy";
const Ping = rule("GET", "/__ping", (_req, res) => {
res.json({ ok: true, source: "override-proxy" });
});
export default defineConfig({
servers: [
{
port: 4000,
routes: [
{
path: "/",
target: "https://pokeapi.co/api/v2/",
http: { rules: [Ping] },
},
],
},
],
});Validate and serve:
pnpm exec override-proxy validate
pnpm exec override-proxy serve
curl http://localhost:4000/__pingFor repeatable team usage, add package scripts in the consuming project:
{
"scripts": {
"proxy:validate": "override-proxy validate",
"proxy:serve": "override-proxy serve"
}
}Then run pnpm run proxy:validate and pnpm run proxy:serve.
Repository Development
From this source checkout:
pnpm install
pnpm devpnpm dev runs the CLI serve path through nodemon.
Validate a config file without listening:
pnpm exec tsx cli.ts validate
pnpm exec tsx cli.ts validate --config ./override-proxy.config.tsBuild the standalone package entrypoints:
pnpm run build
node dist/cli.js validateRelease workflow:
pnpm changesetEvery user-facing change should include a changeset. After changes land on
main, the Release workflow uses Changesets to open a version PR. Merging that
version PR publishes to npm through pnpm release; the repository must provide
an NPM_TOKEN secret for publishing.
Environment Variables
Load order (first wins, no overwrite): .env.local → .env.default
Sample .env.default (do not put secrets here):
PROXY_TARGET=https://pokeapi.co/api/v2/
PORT=4000
# CORS_ORIGINS=http://localhost:3000,https://your-app.local| Name | Description | Default | | ------------ | ----------------------------------------------- | ---------------------------- | | PROXY_TARGET | Upstream target when no rule matches | https://pokeapi.co/api/v2/ | | PORT | Preferred port (auto-increments if busy) | 4000 | | CORS_ORIGINS | Allowed origins (comma list, empty = allow all) | (empty) |
Put secrets only in
.env.local(ignored by git)..env.defaultis committed and should remain non-sensitive.
CLI And Config Files
The CLI command defaults to serve. In this source checkout, run it through tsx:
pnpm exec tsx cli.ts
pnpm exec tsx cli.ts serve --config ./override-proxy.config.ts
pnpm exec tsx cli.ts validate --config ./override-proxy.config.tsAfter pnpm run build, the package exposes override-proxy from ./dist/cli.js. Installed package usage should go through the consuming project's local dependency:
pnpm exec override-proxy
pnpm exec override-proxy serve --config ./override-proxy.config.ts
pnpm exec override-proxy validate --config ./override-proxy.config.tsWhen consuming the built or installed package, config files can import helpers from @crescendolab/override-proxy. In this source checkout before building, import from local source files such as ./config.js.
Default config discovery checks the current working directory for:
override-proxy.local.config.tsoverride-proxy.local.config.mtsoverride-proxy.local.config.jsoverride-proxy.local.config.mjsoverride-proxy.config.local.tsoverride-proxy.config.local.mtsoverride-proxy.config.local.jsoverride-proxy.config.local.mjsoverride-proxy.config.tsoverride-proxy.config.mtsoverride-proxy.config.jsoverride-proxy.config.mjs
Local config names are ignored by the repository's default .gitignore.
If no config file exists, override-proxy runs in legacy proxy mode using PROXY_TARGET, PORT, and CORS_ORIGINS.
Example multi-route config:
import { defineConfig } from "./config.js";
import { ApiUser } from "./rules/api-user.js";
import { RootFallback } from "./rules/root-fallback.js";
export default defineConfig({
servers: [
{
name: "main",
host: "127.0.0.1",
port: 4000,
routes: [
{
name: "api",
path: "/api",
target: "https://api.example.com",
http: {
rules: [ApiUser],
},
rewrite: { stripPrefix: true },
},
{
name: "root",
path: "/",
target: "https://www.example.com",
http: {
rules: [RootFallback],
},
},
],
},
],
});Routes are matched by pathname with priority, longest segment-aware prefix, declaration order, and root fallback.
Config exports can be objects, factories, or async factories:
import { readFile } from "node:fs/promises";
import { LocalRule } from "./rules/local.js";
export default defineConfig(async () => {
const fixture = JSON.parse(await readFile("./fixtures/user.json", "utf8"));
return {
servers: [
{
routes: [{ path: "/", http: { rules: [LocalRule(fixture)] } }],
},
],
};
});Rule System
Rules are ordinary JavaScript values attached to config. Put them inline or import them from any module; config may be an object, function, or async function, so filesystem reads and other setup belong in userland config code.
Interface:
interface OverrideRule {
name?: string;
enabled?: boolean; // default true
methods: [Method, ...Method[]]; // non-empty, uppercase
test(req: Request): boolean;
handler(
req: Request,
res: Response,
next: NextFunction,
): void | Promise<void>;
}Helper creation styles:
- Overload form:
rule(method: Method | readonly Method[], path: string | RegExp, handler, options?)- Config object form:
rule({ path?: string|RegExp, test?: (req)=>boolean, methods?: readonly Method[], name?, enabled?, handler })Constraints:
- Provide either
pathortest(if both given,testaugments path match logic you control). - If
methodsomitted in config form it defaults to["GET"]. - First matching enabled rule short-circuits.
Authoring patterns:
export const SomeRule = rule(...)and import it from config.- Export arrays for scenario packs, then spread them into
http.rulesorws.rules. - Use
namewhen logs need a stable display value; otherwise the helper derives one frompathwhen possible.
WebSocket Rules
WebSocket support targets raw WebSocket traffic. It does not implement Socket.IO protocol semantics.
Route config supports three modes:
| Mode | Behavior |
| -------- | ------------------------------------------------------------------------ |
| direct | Transparent WebSocket proxy using the upstream target |
| bridge | Accept client socket, optionally connect upstream, and run message rules |
| mock | Accept client socket without opening an upstream connection |
For WebSocket routes, set ws.target to the upstream origin or base path. The
client request path is appended after route rewrites, and bridge mode forwards
the client query string to the upstream URL.
Bridge and mock message rules use wsRule():
import { wsRule } from "../../utils.js";
export const PatchChatMessage = wsRule({
test: (ctx) =>
ctx.direction === "client" && ctx.jsonObject?.["type"] === "message",
handler: (ctx) => {
ctx.emitToClient({ type: "proxy:seen" });
return ctx.forward({
...ctx.jsonObject,
patchedByProxy: true,
});
},
});Each message context includes raw, text, json, jsonObject, direction, route metadata, request headers, and action helpers. Supported actions are forward, skip, emitToClient, emitToUpstream, close, and fail.
Use wsConnectionRule() when the proxy should send messages without waiting for client or upstream traffic, such as welcome events, heartbeat pings, or server-push mocks:
import { wsConnectionRule } from "../../utils.js";
export const Heartbeat = wsConnectionRule({
onConnect: (ctx) => {
ctx.client.send({ type: "proxy:ready" });
ctx.every(30_000, () => {
ctx.client.send({ type: "proxy:ping", at: Date.now() });
});
},
});The connection context exposes typed client and optional upstream peers with send, close, and readyState. Advanced rules can use ctx.raw.client / ctx.raw.upstream for the underlying ws sockets. Timers registered with ctx.every() and disposers returned from onConnect() are cleaned up when the connection closes.
Examples
4.1 Simple path
import { rule } from "../utils.js";
export default rule({
name: "ping",
path: "/__ping",
methods: ["GET"],
handler: (_req, res) => res.json({ ok: true, t: Date.now() }),
});4.2 RegExp capture
import { rule } from "../utils.js";
export default rule({
name: "user-detail",
path: /^\/api\/users\/(\d+)$/,
methods: ["GET"],
handler: (req, res) => {
const match = /^\/api\/users\/(\d+)$/.exec(req.path);
if (!match) {
res.status(404).json({ error: "not_found" });
return;
}
const [, id] = match;
res.json({ id, name: `User ${id}`, from: "override" });
},
});4.3 Custom test
import { rule } from "../utils.js";
export const rules = [
rule({
name: "feature-core",
test: (req) =>
req.method === "GET" &&
req.path === "/feature-controls" &&
req.query["only"] === "core",
handler: (_req, res) =>
res.json({ features: ["core-a", "core-b"], ts: Date.now() }),
}),
];4.4 Disabled rule
export default rule({
name: "temp-off",
path: "/disabled",
enabled: false,
handler: (_r, res) => res.json({ off: true }),
});Built-in Endpoints
| Path | Method | Description |
| ------------- | ------ | ------------------------------------- |
| /__env | GET | Legacy non-sensitive environment info |
| /__override | GET | Config-mode server and route snapshot |
| * | ANY | Route-specific proxy fallback |
Route CORS settings apply to route traffic, not built-in control endpoints.
Logging pattern: [id] -> METHOD path / match ruleName / completion line with status & source.
Development Workflow
- Add or edit rule modules.
- Import the active rules from
override-proxy.config.ts. - Run
pnpm exec tsx cli.ts validate. - Start with
pnpm devand send requests to validate behavior.
Change upstream: set PROXY_TARGET in .env.local
Restrict CORS: CORS_ORIGINS=http://localhost:3000,https://dev.example.com
Project Structure
.
├─ cli.ts
├─ index.ts
├─ config.ts
├─ server-runtime.ts
├─ http-app.ts
├─ ws-direct-proxy.ts
├─ ws-bridge.ts
├─ main.ts
├─ utils.ts
├─ tests/
├─ .env.default
├─ package.json
├─ tsconfig.json
├─ tsconfig.build.json
└─ nodemon.jsonCommon Scenarios
- Simulate latency:
await new Promise(r => setTimeout(r, 800)); - Conditional override:
test: (req)=> req.path === "/api/users" && req.headers["x-mock-mode"] === "1" - Header trigger:
test: (req)=> req.headers["x-mock-mode"] === "1" - WebSocket mock event:
ctx.emitToClient({ type: "proxy:ready" }); return ctx.skip();
Security Notes
- Keep secrets only in
.env.local. - Remove or protect
/__envif exposing externally. - Rules execute arbitrary code: review sources.
- Avoid exposing this service directly to the public Internet.
Extension Ideas
| Feature | Description | | ----------------------- | -------------------------------- | | /__rules | List rules + status + hit counts | | Runtime toggle | Enable/disable via PATCH | | Hot replace | chokidar-based in-process swap | | Fault / delay injection | Simulate 4xx/5xx/timeout | | Stats | hit count / last hit timestamp | | Priority control | Explicit rule ordering |
Rule Organization & Archival
You can still keep rule modules under rules/, but runtime does not scan that directory. Config decides exactly which rule values are active.
11.1 Group Related Rules
- Group by feature / domain / scenario using either subfolders and/or multi-export files.
- Import the packs you want in
override-proxy.config.ts.
11.2 Disable Single Rule
To temporarily disable a single rule without deleting it, add enabled: false to the rule configuration:
export const UserDetail = rule({
methods: ['GET'],
path: /^\/api\/users\/\d+$/,
enabled: false,
handler: (req, res) => res.json({ ... })
});The rule remains in config but won't match requests.
11.3 Disable an Entire Group
Remove that pack from the config array, or branch inside an async config factory:
const rules = process.env["MOCK_PACK"] === "checkout" ? checkoutRules : [];11.4 Shareable by Design
- Committed config and rule modules are instantly shared—teammates restart and get the same overrides.
- Avoid secrets / PII in responses. Use env vars or synthetic placeholders if needed.
- Scenario-oriented packs let you prepare multiple demo states and enable exactly one by config import or factory branch.
11.5 Personal / WIP Rules
- For scratch work you do not want committed, use
override-proxy.local.config.tsand keep it git-ignored.
11.6 Naming Guidance
- Module names: concise, kebab-case domain or scenario (
billing-refunds,chat-surge-test). - Rule
name(shown in logs): stable identifier (PascalCase or kebab-case) reflecting purpose.
11.7 Quick Lifecycle Table
| Action | Steps |
| ------------------- | ------------------------------------- |
| Add feature pack | Create module, import rules in config |
| Disable single rule | Add enabled: false to rule config |
| Disable rule group | Remove pack from config or branch env |
| Share | Push config/rule modules and restart |
11.8 Why Inline over Runtime Scanning?
Inline config keeps runtime simple and makes TypeScript point directly at missing imports, wrong rule shapes, and dead code.
Comparison with MSW
override-proxy and MSW both solve API interception/mocking but sit at different layers: this project is a standalone reverse proxy that applies override rules first and transparently forwards the rest; MSW runs inside your runtime (Service Worker in the browser or a Node process). They are often complementary (team‑wide shared partial overrides via override-proxy; fully deterministic isolated tests & Storybook via MSW).
| Aspect | override-proxy | MSW | When to favor override-proxy | When to favor MSW | | ------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------- | | Deployment form | Standalone Node reverse proxy | In-process (Service Worker / Node) | Need one shared layer for Web, Mobile, backend scripts | Only JS app/tests, want zero base URL changes | | Override strategy | First matching rule short-circuits, rest passthrough | All requests potentially intercepted; passthrough needs opting in | Partial mock + keep real behavior for the rest | Fully controlled, offline, deterministic data | | Upstream realism | Unmatched hits real upstream (reduced mock drift) | All data must be defined/generative | Want to reduce divergence between mock and prod | Want fully stable replayable fixtures | | Team sharing | Point base URL; everyone instantly uses same overrides | Must add handlers per repo | Fast alignment “what’s overridden today” | Single codebase control is enough | | Client languages | Any (JS, iOS, Android, backend) via HTTP | Primarily JavaScript ecosystems | Multi-language integration workflows | Pure JS/UI workflows | | Logging & observability | Centralized request log (latency, status, source, rule) | Distributed per environment | Need mixed real+mock traffic insight | Local test verbosity sufficient | | CORS / network semantics | Real browser/network semantics preserved | Simulated inside SW/Node | Need to validate real cookies/CORS/TLS | Network realism not required | | Adoption cost | Run one process + point base URL | Install lib + configure handlers in each env | Want zero code intrusion | Prefer inline mocks in tests | | Extensibility surface | Natural spot for caching, record/replay, fault/latency injection | Built-in REST/GraphQL/WebSocket already | Need proxy aggregation / caching | Need protocol breadth immediately | | Non-JS test integration | Any stack via HTTP | Requires JS runtime | Mixed polyglot E2E | JS-only test matrix |
Key strengths of this project
- Override‑first with transparent passthrough: author only what you need to change; everything else stays real, reducing maintenance & data drift.
- Cross‑client sharing: any device or language adopts overrides by switching a base URL (or system proxy).
- Low intrusion: no library embedded in the app—easy to adopt or discard.
- Real network conditions: genuine CORS, cookies, caching, TLS; good for integration sanity checks.
- Flexible rules: an override is just an Express handler—inject latency, errors, dynamic data, conditional passthrough.
- Layered env loading: safe defaults in
.env.default, secrets in.env.local(git‑ignored). - Evolution friendly: ideal anchor point for future record & replay, metrics, runtime toggles, chaos/fault injection, priority control.
- Short learning curve: minimal API (
defineConfig()+rule()/wsRule()); experienced Node/Express users are productive immediately.
Typical combined workflow with MSW
- Day-to-day team development: run
override-proxyfor shared partial overrides + live upstream behavior. - Test / CI: use MSW for 100% deterministic, offline, fast tests.
- Demo / Storybook: point at
override-proxyfor realistic hybrid data; fall back to MSW when full offline determinism needed.
Summary:
override-proxyis a shared, real-network, partial-override layer; MSW is an in-process, fully controllable interception layer. They complement rather than exclude each other.
Architecture & Flow (Mermaid)
flowchart LR
subgraph Client
A[Request]
end
A --> B[override-proxy]
B -->|rule match| C[Override handler]
B -->|no match| U[(Upstream API)]
C --> R[Response]
U --> R
R --> A
%% Behaviors: dynamic JSON, latency, error injection
classDef proxy fill:#0d6efd,stroke:#084298,stroke-width:1px,color:#fff;
class B proxy;Complementary Usage with MSW
sequenceDiagram
participant DevApp as Frontend App
participant OP as override-proxy
participant Up as Upstream API
participant MSW as MSW (test env)
Note over DevApp,OP: Local dev (shared partial overrides)
DevApp->>OP: GET /api/items
OP->>OP: Match rule?
alt Rule matches
OP-->>DevApp: Mocked JSON
else No match
OP->>Up: Forward request
Up-->>OP: Real response
OP-->>DevApp: Real JSON
end
Note over DevApp,MSW: Test/CI (fully mocked)
DevApp->>MSW: GET /api/items
MSW-->>DevApp: Deterministic mocked JSONLicense
Apache License 2.0 © 2025 Crescendo Lab. See LICENSE for full text.
Author: Crescendo Lab — 2025
Need extras (rule listing, runtime toggles, latency/error injection)? Open an issue or ask.
