# wiretype — Architecture & Module Contracts > Record real API traffic through a proxy → infer a typed model → generate > TypeScript types, zod schemas, MSW v2 handlers, and OpenAPI 3.1. This document is the **binding contract** between modules. Implementations may add internal helpers freely, but the exported signatures below must match exactly. Shared data types live in `src/core/types.ts` (single source of truth — read it first). ## Package layout `wiretype` is a single publishable npm package. Source lives under `src/`, compiled with `tsc` to `dist/`: ``` wiretype/ src/ core/ shape inference, merging, endpoint model, recording store codegen/ 4 emitters: ts / zod / msw / openapi proxy/ zero-dependency recording HTTP proxy + shared capture helpers cli/ `wiretype` CLI (record / gen / ui / list) + viewer server vite/ Vite dev-server integration index.ts public library entry viewer/ single-file HTML viewer served by `wiretype ui` examples/ demo-api/ zero-dep Node demo API to record against docs/ ``` ESM (`"type": "module"`), TypeScript strict, Node >= 20. Built with `tsc`. Tests: vitest, colocated as `src/**/*.test.ts`. The only runtime dependency is `commander` (CLI); `vite` is an optional peer used only by the Vite plugin and imported type-only in source. Use `node:` builtin imports (`node:http`, `node:fs/promises`, `node:crypto`, ...). Import rules inside `src/`: - Relative imports must use the `.js` extension (NodeNext resolution), e.g. `import { inferShape } from './infer.js'`. - Cross-module imports use relative paths, e.g. from `src/codegen` import core via `import type { ApiModel } from '../core/index.js'`. ### package.json wiring - `bin`: `{ "wiretype": "./dist/cli/index.js" }` — `src/cli/index.ts` keeps the `#!/usr/bin/env node` shebang first. - `exports`: - `"."` → `./dist/index.js` (types `./dist/index.d.ts`) — the library entry re-exporting core + codegen + proxy. - `"./vite"` → `./dist/vite/index.js` — the Vite plugin. - `tsconfig` excludes `src/**/*.test.ts` and `src/codegen/fixture.ts` from the build; the fixture is used by tests only. --- ## Core — exported API (`src/core`) ```ts // re-export everything from types.ts export * from './types.js'; /** Infer the Shape of a single JSON value. Never returns a union with duplicates. */ export function inferShape(value: JsonValue, opts?: BuildModelOptions): Shape; /** Merge two shapes into one that accepts both. Commutative, associative-enough. */ export function mergeShapes(a: Shape, b: Shape, opts?: BuildModelOptions): Shape; /** Structural equality (order-insensitive for union variants and object keys). */ export function shapesEqual(a: Shape, b: Shape): boolean; /** * Group a recording's exchanges into endpoints: * - normalize paths (numeric / uuid / long-hash segments -> :params) * - merge request/response body shapes per endpoint per status * - infer query shape, detect enums & string formats */ export function buildApiModel(recording: Recording, opts?: BuildModelOptions): ApiModel; /** Derive operationId + typeName from method and normalized pattern. */ export function operationName(method: string, pattern: string): { operationId: string; typeName: string }; /** * NDJSON-backed store. Layout on disk: * //meta.json RecordingMeta * //exchanges.ndjson one Exchange per line */ export class RecordingStore { constructor(dir: string); // dir default used by callers: ".wiretype" init(name: string, target: string): Promise; // create or open recording append(name: string, exchange: Exchange): Promise; // appends + updates meta load(name: string): Promise; // throws if missing list(): Promise; remove(name: string): Promise; } ``` ### Inference rules (normative) - Numbers with `Number.isInteger` -> `integer`, else `number`. Merging integer+number -> number. - String formats (`uuid`, `date-time`, `date`, `email`, `uri`): a format is kept only while **every** merged sample matches it. - Merging two objects: union of keys; a key missing on either side becomes `optional: true`. Field shapes merge recursively. - Merging different kinds (e.g. string vs object) -> union. Unions are flattened, deduped via `shapesEqual`, and `null` stays a separate variant. - Array elements: merge all element shapes into one. Empty array -> `element: null`; merging with a non-empty array adopts the element shape. - Enum detection (in buildApiModel): only for token-like strings matching `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/`; requires samples >= `enumMinSamples` (4), distinct values <= `enumMaxValues` (8), and repetition (distinct <= ceil(samples / 2)). Never for numbers, booleans, or when a string format was detected. (Emitters still render numeric enums when a hand-built model supplies them.) - Record detection: an object with >= `recordMinKeys` (12) keys whose value shapes are all equal -> `RecordShape`. - Path normalization: a segment becomes a param when it is all-digits (`format: 'integer'`, name from previous segment: `/users/42` -> `:userId`... use singular previous segment + "Id"; fallback `param`), a UUID (`format: 'uuid'`), or a hex/base64-ish token of length >= 16. Two exchanges belong to the same endpoint when method matches and normalized patterns match. ## Codegen — exported API (`src/codegen`) ```ts import type { ApiModel, Shape } from '../core/index.js'; export interface CodegenOptions { /** Header comment banner. Default: "Generated by wiretype — do not edit." */ banner?: string; /** Base URL used in MSW handlers. Default: "*" (match any origin) + pattern. */ mswBaseUrl?: string; } export interface GeneratedFile { path: string; content: string; } export function renderShapeAsTs(shape: Shape, indent?: number): string; // inline TS type text export function renderShapeAsZod(shape: Shape, indent?: number): string; // inline zod expression text export function generateTypes(model: ApiModel, opts?: CodegenOptions): string; // -> types.ts content export function generateZod(model: ApiModel, opts?: CodegenOptions): string; // -> schemas.ts content export function generateMsw(model: ApiModel, opts?: CodegenOptions): string; // -> handlers.ts content export function generateOpenApi(model: ApiModel, opts?: CodegenOptions): Record; // OpenAPI 3.1 doc /** All targets. paths: types.ts, schemas.ts, handlers.ts, openapi.json */ export function generateAll(model: ApiModel, targets?: Array<'ts'|'zod'|'msw'|'openapi'>, opts?: CodegenOptions): GeneratedFile[]; ``` ### Emitter requirements - **types.ts**: for each endpoint emit (when applicable) `export interface {TypeName}Response` (or `type` for non-objects; for multi-status endpoints, suffix non-2xx as `{TypeName}Response{Status}`), `{TypeName}Request`, `{TypeName}Query`, `{TypeName}Params` (params always `string` values). Also emit a summary `export interface ApiEndpoints` mapping `"GET /api/users/:userId"` keys to `{ params, query, request, response }`. Optional fields use `?:`. Formats/enums render as comments or literal unions (enums -> literal union types). - **schemas.ts**: zod v3. `import { z } from 'zod'`. One `export const {operationId}ResponseSchema = z.object({...})` per variant, plus request/query schemas when present. `date-time`/`uuid`/`email`/`uri` formats map to `z.string().datetime()` / `.uuid()` / `.email()` / `.url()`. Enums -> `z.enum([...])` or `z.union([z.literal(...)])` for numbers. Optional -> `.optional()`, null variant -> `.nullable()`. `integer` -> `z.number().int()`. - **handlers.ts**: MSW v2 (`import { http, HttpResponse } from 'msw'`). One handler per endpoint using the 2xx (or lowest) status variant's `sampleBody` as mock data: `http.get('*/api/users/:userId', () => HttpResponse.json(sample, { status: 200 }))`. Path params in MSW syntax (`:userId` works as-is). Export `export const handlers = [ ... ]`. Include a commented alternative for each non-2xx variant. - **openapi.json**: valid OpenAPI 3.1 (info/servers/paths). Shapes -> JSON Schema (integer -> `{type:"integer"}`, formats -> `format`, enums -> `enum`, optional -> omitted from `required`, unions -> `anyOf`, null variant -> include `"null"` in type union per 3.1 style). Path params as `{userId}` with `parameters`. Query params from queryShape fields. Output must be deterministic (stable key ordering: endpoints sorted as in model; object fields in first-seen order preserved by core). ## Proxy — exported API (`src/proxy`) ```ts import type { RecorderOptions, Exchange } from '../core/index.js'; export interface ProxyServerOptions extends RecorderOptions { target: string; // upstream base URL port: number; // listen port onExchange: (ex: Exchange) => void | Promise; // called for every recorded exchange onError?: (err: Error) => void; /** Also print a compact line per request to stdout. Default true. */ quiet?: boolean; } export interface RunningProxy { port: number; close(): Promise; } /** Zero-dependency reverse proxy on node:http. Streams bodies, captures up to maxBodyBytes. */ export function startProxy(opts: ProxyServerOptions): Promise; /** Shared capture helper (used by proxy AND the vite plugin): build an Exchange from raw parts. */ export function buildExchange(input: { method: string; url: string; reqHeaders: Record; reqBody: Buffer; status: number; resHeaders: Record; resBody: Buffer; startedAt: number; endedAt: number; opts?: RecorderOptions; }): Exchange; /** true when path passes include/exclude prefix filters. */ export function shouldRecord(path: string, opts?: RecorderOptions): boolean; ``` Proxy behavior: forward method/headers/body to `target`, stream response back unchanged (support gzip/br passthrough — capture the *decoded* body when content-encoding is gzip/deflate/br using node:zlib, but forward raw bytes). Never crash on upstream errors: respond 502 and call onError. WebSocket upgrade requests: pass through without recording (or reject cleanly) — must not crash. ## CLI — commands (`src/cli`) Binary name: `wiretype` (package.json `bin`). Uses `commander`. ``` wiretype record --target [--port 5050] [--name ] [--dir .wiretype] [--include ] [--exclude ] Starts proxy, appends every exchange to the store via RecordingStore. Prints per-request lines: "GET /api/users 200 12ms (34 exchanges)". Graceful Ctrl-C: close proxy, print summary. wiretype gen [--name ] [--dir .wiretype] [--out wiretype-generated] [--targets ts,zod,msw,openapi] Loads recording -> buildApiModel -> generateAll -> writes files. Prints a per-endpoint summary table (method, pattern, statuses, samples). wiretype list [--dir .wiretype] Table of recordings (name, target, exchanges, updated). wiretype ui [--dir .wiretype] [--port 5099] Serves the viewer (single HTML file at /viewer/index.html, resolved at runtime via new URL('../../viewer/index.html', import.meta.url) from dist/cli/) plus a JSON API (below). Opens no browser automatically; prints the URL. The env var WIRETYPE_VIEWER_HTML overrides the HTML path. ``` ### Viewer JSON API (served by `wiretype ui`) ``` GET /api/recordings -> RecordingMeta[] GET /api/recordings/:name -> Recording (full exchanges) GET /api/recordings/:name/model -> ApiModel GET /api/recordings/:name/generated/:target -> text/plain content :target = ts | zod | msw | openapi ``` CORS: allow *. 404 as `{ "error": "..." }`. Static: `GET /` -> viewer HTML. ## Vite plugin — exported API (`src/vite`, exposed as `wiretype/vite`) ```ts import type { Plugin } from 'vite'; import type { RecorderOptions } from '../core/index.js'; export interface WiretypePluginOptions extends RecorderOptions { /** Upstream API base URL, e.g. "http://localhost:8080". */ target: string; /** Path prefixes to intercept+forward, e.g. ["/api"]. Required. */ prefixes: string[]; /** Recording name. Default "vite". */ name?: string; /** Store directory. Default ".wiretype". */ dir?: string; /** * Master switch. When omitted (the recommended setup), recording * auto-enables if the Vite dev server runs in mode "record" * (`vite --mode record`) OR the WIRETYPE env var is set. Set an explicit * boolean to override. This is what lets users avoid `WIRETYPE=1`: they * add the plugin unconditionally and run `vite --mode record`. */ enabled?: boolean; } export default function wiretypeRecorder(options: WiretypePluginOptions): Plugin; ``` Implementation: `configResolved(config)` captures `config.mode`; the effective enabled flag is `options.enabled ?? (config.mode === 'record' || !!process.env.WIRETYPE)`. `configureServer(server)` adds a middleware BEFORE vite's internals; when enabled, requests matching `prefixes` are forwarded to `target` with node:http, recorded via `buildExchange` + `RecordingStore`, and the response is written back (replacing the user's `server.proxy` entry for those prefixes). When disabled the plugin is inert (calls `next()`), so it is safe to leave in the plugins array permanently. `vite` is an optional peer dependency. ## v0.2 additions — MSW fixtures + localized reports ### MSW fixture separation (codegen) `CodegenOptions` gains `mswFixtures?: boolean` (default false). `emit-msw.ts` and `generateAll` honor it: - false (current behavior): `handlers.ts` inlines each mock body literal. - true: `handlers.ts` becomes thin — each handler imports its mock from `./fixtures/..json` (relative import with `assert { type: 'json' }` omitted; use `import x from './fixtures/..json' with { type: 'json' }`? NO — for portability emit `import x from './fixtures/<...>.json'` and let the bundler resolve; document that consumers may need resolveJsonModule). `generateAll` additionally returns one `GeneratedFile` per fixture at path `fixtures/..json` containing the pretty-printed sample body. This keeps mock DATA in JSON files so refreshing data never touches handler code — the key enabler for `msw-refresh`. CLI: `wiretype gen --msw-fixtures` sets the flag. Deterministic output. ### Localized markdown report (diff) `wiretype diff` gains `--md` and `--lang ` (default en): - `--md`: emit a Markdown drift report to stdout instead of the plain table: a title, a summary line, and one `##` section per severity with a Markdown findings table (Kind | Endpoint | Path | Change). Deterministic. - `--lang`: localize the human-facing strings (severity headings, kind labels, summary sentence, "no drift detected") via an internal message catalog covering `en` and `ko`. Machine fields (endpoint, path, before→after) are not translated. Unknown lang → fall back to `en`. `--json` output is never localized. Skills pass `--lang` to match the conversation language and layer code locations on top of the `--md` output. Add a `src/drift/i18n.ts` with a typed catalog `Record<'en'|'ko', {...}>` and a `renderMarkdownReport(report, lang)` function exported from `src/drift/index.ts`. ## examples/demo-api Zero-dep `server.mjs` (node:http, port 8080, `PORT` env respected) with realistic data — enough variety to exercise ALL inference features: - `GET /api/users?page=&limit=&role=` paginated list; users have uuid ids, ISO dates, optional `avatarUrl`, `role` enum (admin|editor|viewer), nullable `lastLoginAt`. - `GET /api/users/:id` 200 for known ids, 404 `{error, code}` otherwise. - `POST /api/users` 201 echo with generated id; body: name/email/role. - `GET /api/posts/:id` post with nested `author`, `tags: string[]`, `stats: {views, likes}`, and `comments` array of objects. - `PATCH /api/posts/:id` 200 partial update echo. - `GET /api/health` plain text "ok" (non-JSON — must not break inference). - The extra experimental field on `GET /api/users` appears deterministically when `page` is even, so optional detection is observable. `traffic.mjs` fires a scripted set of requests against the recording proxy to drive an end-to-end capture. ## Drift — deterministic schema drift detection (`src/drift`) Shared types live in `src/drift/types.ts` (read first — it defines the semantics: side "a" = what consumers believe, side "b" = observed reality, BREAKING = code written against "a" breaks under "b"). ```ts export * from './types.js'; /** Compare two models endpoint-by-endpoint. Deterministic, pure. */ export function diffModels(a: ApiModel, b: ApiModel, opts?: DiffOptions): DriftReport; /** * Compare two shapes, reporting findings rooted at basePath. * Exposed so agent tooling can compare a single claimed shape against an * observed one without building full models. */ export function diffShapes( a: Shape | null, b: Shape | null, ctx: { endpoint: string; status?: number; basePath?: string }, ): DriftFinding[]; ``` ### Normative severity rules (diff from a → b) Endpoint level: endpoint in a but not b → `endpoint-removed` / breaking; in b but not a → `endpoint-added` / info (both suppressed by `ignoreUnmatchedEndpoints`). Response status in a but not b → `status-removed` / risky; new status in b → `status-added` / risky. Shape level (recursive walk; null variants handled as nullability): - field present in a, absent in b → `field-removed` / **breaking** - field present in b only → `field-added` / info - primitive type changed → `type-changed`; integer→number widening is info, number→integer is info, anything else **breaking** - became nullable (b allows null, a didn't) → `nullability-changed` / **breaking**; became non-nullable → `nullability-changed` / info - field became optional in b → `optionality-changed` / **risky**; became required → `optionality-changed` / info - enum: values added in b → `enum-values-changed` / **risky** (unhandled cases); values removed → `enum-values-changed` / info; enum in a but plain primitive in b → treat as risky `enum-values-changed` (closed set became open) - format lost or changed (uuid → none/other) → `format-changed` / risky; format gained → info - array element / record value / nested object: recurse with path suffixes `[]`, `{}` (record), `.field` - kind mismatch not covered above (object→array etc.) → `type-changed` / **breaking** - `unknown` in a vs anything in b → info (a knew nothing); concrete in a vs `unknown` in b → risky - request body / query drift: same shape rules; when the whole shape appears/disappears use endpoint-level `request-changed` / `query-changed` (request required in b but absent in a → breaking; removed → info) - `params-changed` only for param format changes (integer→uuid etc.) → risky Report ordering: breaking, risky, info; within a group by endpoint then path. Everything deterministic — this is a CI gate. ### CLI ``` wiretype diff [--dir .wiretype] [--json] [--fail-on breaking|risky|info] [--ignore-unmatched] ``` ``/`` resolve in order: (1) a path to a model.json file (an ApiModel), (2) a recording name in --dir (model built on the fly via buildApiModel). Human output: summary line + severity-grouped table (SEVERITY | KIND | ENDPOINT | PATH | A → B). `--json` prints the DriftReport JSON instead. `--fail-on ` exits 1 when any finding at that severity or higher exists (breaking > risky > info) — the CI gate. `wiretype gen` additionally accepts target `model` (allowed in --targets and included in the default set) writing `model.json` — the raw ApiModel, pretty-printed. model.json doubles as the claims interchange format: agent tooling that extracts "what the code believes" emits a partial ApiModel and diffs it against an observed model with --ignore-unmatched. ## Quality bar - `npm run build` clean, `npm test` green, no `any` leaks in public APIs. - Every core inference rule covered by unit tests; codegen emitters snapshot-style tested with a handcrafted ApiModel fixture; proxy tested against an in-process upstream server.