@minisylar/express-typed-router 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,10 @@ Define routes once, infer `params` / `body` / `query`, and generate a clean API
6
6
 
7
7
  ---
8
8
 
9
+ ![Scalar UI showing typed routes with request body schemas, enum values, and live response examples](https://github-production-user-asset-6210df.s3.amazonaws.com/75648051/600638539-d3ac3913-0347-47fd-a370-fb1ab8136ff7.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAVCODYLSA53PQK4ZA%2F20260531%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260531T183343Z&X-Amz-Expires=300&X-Amz-Signature=660c354ad82fd84bb109455957fcdc6572e62cfde2fe5570241c06f5ff25f463&X-Amz-SignedHeaders=host&response-content-type=image%2Fpng)
10
+
11
+ ## Documentation generated from your codebase
12
+
9
13
  ## What you get
10
14
 
11
15
  - **Typed route handlers** — `req.params`, `req.body`, `req.query` inferred from your route + schema
@@ -13,7 +17,7 @@ Define routes once, infer `params` / `body` / `query`, and generate a clean API
13
17
  - **✨ OpenAPI docs** — generated from routes, schemas, and captured responses
14
18
  - **Schema-agnostic** — any Standard Schema-compatible validator (Zod, Yup, Valibot, Arktype, Joi...)
15
19
  - **Express 4 & 5** — common patterns supported
16
- - **Client-friendly output** — generate `generated-types.d.ts` and build any client wrapper
20
+ - **Client-friendly output** — generate `api.types.ts` and build any client wrapper
17
21
 
18
22
  ---
19
23
 
@@ -225,16 +229,16 @@ app.use("/docs", api.docs({ title: "My API", version: "1.0.0" }));
225
229
 
226
230
  All validators work for **request validation**. For **OpenAPI schema generation** (showing field names and types in the spec), some libraries need an extra converter package installed in your project. This library auto-detects them at runtime — install the one you need and it just works, no config required.
227
231
 
228
- | Library | Validation | Docs schema | Extra install |
229
- |---|---|---|---|
230
- | Zod 4 | ✅ | ✅ | none — built-in |
231
- | Zod 3 | ✅ | ✅ | `zod-to-json-schema` |
232
- | Valibot | ✅ | ✅ | `@valibot/to-json-schema` |
233
- | ArkType | ✅ | ✅ | none — built-in |
234
- | Effect | ✅ | ✅ | none — built-in |
235
- | Yup | ✅ | ⚠️ | not supported — no official JSON Schema converter |
236
- | Joi | ✅ | ⚠️ | not supported — no official JSON Schema converter |
237
- | Decoders / ts.data.json / unhoax | ✅ | ⚠️ | not supported — no schema introspection |
232
+ | Library | Validation | Docs schema | Extra install |
233
+ | -------------------------------- | ---------- | ----------- | ------------------------------------------------- |
234
+ | Zod 4 | ✅ | ✅ | none — built-in |
235
+ | Zod 3 | ✅ | ✅ | `zod-to-json-schema` |
236
+ | Valibot | ✅ | ✅ | `@valibot/to-json-schema` |
237
+ | ArkType | ✅ | ✅ | none — built-in |
238
+ | Effect | ✅ | ✅ | none — built-in |
239
+ | Yup | ✅ | ⚠️ | not supported — no official JSON Schema converter |
240
+ | Joi | ✅ | ⚠️ | not supported — no official JSON Schema converter |
241
+ | Decoders / ts.data.json / unhoax | ✅ | ⚠️ | not supported — no schema introspection |
238
242
 
239
243
  > **⚠️ Partial docs** means routes still appear in the spec with paths, methods, and captured response examples — only the request body/query field shapes are missing.
240
244
 
@@ -242,27 +246,63 @@ All validators work for **request validation**. For **OpenAPI schema generation*
242
246
 
243
247
  ## Client types
244
248
 
245
- The library generates an OpenAPI spec. Feed it to `openapi-typescript` to get a `.d.ts` file, then use it with any HTTP client.
249
+ Set `specOutputPath` in your docs options and the library writes `openapi.json` to disk automatically every time the server starts. That file is a standard OpenAPI 3.1 spec — use it with any OpenAPI-compatible tool: code generators, client SDKs, linters, mocking tools, and more.
246
250
 
247
- ### Generate types
251
+ For TypeScript projects, [openapi-typescript](https://github.com/openapi-ts/openapi-typescript) is a great option — it generates a `.d.ts` file from the spec that you can use with any HTTP client.
248
252
 
249
- One-time:
253
+ ### Setup
250
254
 
251
- ```bash
252
- npx openapi-typescript http://localhost:3000/docs/openapi.json -o ./generated-types.d.ts
255
+ **1. Enable spec output:**
256
+
257
+ ```ts
258
+ app.use("/docs", router.docs({
259
+ title: "My API",
260
+ version: "1.0.0",
261
+ specOutputPath: "./openapi.json", // written automatically on every server start
262
+ }));
253
263
  ```
254
264
 
255
- Watch mode types regenerate automatically as routes change (requires `specOutputPath` set above):
265
+ **2. Add the scripts to your `package.json`:**
266
+
267
+ ```json
268
+ {
269
+ "scripts": {
270
+ "dev": "run-p dev:server dev:types",
271
+ "dev:server": "node --watch src/server.ts",
272
+ "dev:types": "nodemon -L --watch openapi.json --exec \"openapi-typescript ./openapi.json -o ./api.types.ts\""
273
+ },
274
+ "devDependencies": {
275
+ "openapi-typescript": "^7.0.0",
276
+ "nodemon": "^3.0.0",
277
+ "npm-run-all2": "^7.0.0"
278
+ }
279
+ }
280
+ ```
281
+
282
+ **3. Run it:**
256
283
 
257
284
  ```bash
258
- npx openapi-typescript ./openapi.json -o ./generated-types.d.ts --watch
285
+ npm run dev
259
286
  ```
260
287
 
288
+ That's it — one command runs everything in parallel:
289
+
290
+ - `dev:server` — runs your server with `node --watch` (Node 18.11+; no `tsx` needed on Node 23.6+). On every save the server restarts and the library **rewrites `openapi.json` automatically**.
291
+ - `dev:types` — `nodemon` watches `openapi.json` and regenerates `api.types.ts` whenever it changes.
292
+
293
+ Edit a route, save, and your client types update on their own.
294
+
295
+ > **Why `nodemon -L`?** On Windows, native file watchers miss in-place file writes — the `-L` flag forces polling so the regen reliably fires. On macOS/Linux you can drop it.
296
+
297
+ > Add `openapi.json` and `api.types.ts` to `.gitignore` — both are generated.
298
+
299
+ **Prefer to keep it manual?** Skip `nodemon` and `npm-run-all2` entirely — just run the server with `node --watch src/server.ts` and regenerate types on demand with `openapi-typescript ./openapi.json -o ./api.types.ts` whenever you change your API.
300
+
261
301
  ### Use with `openapi-fetch`
262
302
 
263
303
  ```ts
264
304
  import createClient from "openapi-fetch";
265
- import type { paths } from "./generated-types";
305
+ import type { paths } from "./api.types";
266
306
 
267
307
  const client = createClient<paths>({ baseUrl: "http://localhost:3000/api" });
268
308
 
@@ -281,7 +321,7 @@ const { data: user } = await client.POST("/users", {
281
321
  If you prefer not to add `openapi-fetch`, use the generated types directly with standard `fetch`:
282
322
 
283
323
  ```ts
284
- import type { paths } from "./generated-types";
324
+ import type { paths } from "./api.types";
285
325
 
286
326
  type Body<
287
327
  P extends keyof paths,
@@ -9,4 +9,4 @@ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=Object.
9
9
  <script id="api-reference" data-url="${w(t)}"><\/script>
10
10
  <script src="${w(n)}"><\/script>
11
11
  </body>
12
- </html>`}async function D(e,t){let n=Object.create(null);for(let t of e){if(t.method===`all`||t.hidden)continue;let e=v(t.path);n[e]||(n[e]={});let r=y(t.path).map(e=>({name:e,in:`path`,required:!0,schema:{type:`string`}}));if(t.querySchema){let e=await S(t.querySchema),n=e.properties??{},i=e.required??[];for(let[e,t]of Object.entries(n))r.push({name:e,in:`query`,required:i.includes(e),schema:t})}let i={summary:t.summary??x(t.method,t.path),tags:t.tags??[b(t.path)],parameters:r};t.description&&(i.description=t.description),t.deprecated&&(i.deprecated=!0),t.bodySchema&&(i.requestBody={required:!0,content:{"application/json":{schema:await S(t.bodySchema)}}});let a={};if(t.responseSamples.size>0)for(let[e,n]of t.responseSamples)a[String(e)]={description:e<400?`Success`:`Error`,content:{"application/json":{example:n}}};else a[200]={description:`Success`};i.responses=a,n[e][t.method]=i}return{openapi:`3.1.0`,info:{title:t.title??`API`,version:t.version??`1.0.0`,...t.description?{description:t.description}:{}},...t.servers?{servers:t.servers}:{},paths:n}}const O=new WeakMap;var k=class{router;routes=[];mountedRouters=[];constructor(){this.router=c.default.Router(),O.set(this.router,this)}useMiddleware(e){return this.router.use(e),this}getRouter(){return this.router}use(e,...t){let n=typeof e==`string`,r=n?e:``,i=n?t:[e,...t];for(let e of i){let t=O.get(e);t&&this.mountedRouters.push({prefix:r,router:t})}return n?this.router.use(e,...i):this.router.use(...i),this}mount(e,t){if(typeof e==`string`){let n=t;this.router.use(e,n.getRouter()),this.mountedRouters.push({prefix:e,router:n})}else this.router.use(e.getRouter()),this.mountedRouters.push({prefix:``,router:e});return this}getRouteMetadata(){let e=this.mountedRouters.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path})));return[...this.routes,...e]}docs(e={}){let t=c.default.Router();return t.get(`/openapi.json`,async(t,n)=>{try{let t=await D(this.getRouteMetadata(),e);e.specOutputPath&&await(await p(`fs/promises`)).writeFile(e.specOutputPath,JSON.stringify(t,null,2),`utf8`).catch(()=>{}),n.json(t)}catch(e){n.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),t.get(`/`,(t,n)=>{let r=`${t.baseUrl}/openapi.json`,i=e.cdnUrl??T;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(E(e.title??`API`,r,i))}),t}get(e,t,n){return this.registerRoute(`get`,e,t,n)}post(e,t,n){return this.registerRoute(`post`,e,t,n)}put(e,t,n){return this.registerRoute(`put`,e,t,n)}patch(e,t,n){return this.registerRoute(`patch`,e,t,n)}delete(e,t,n){return this.registerRoute(`delete`,e,t,n)}options(e,t,n){return this.registerRoute(`options`,e,t,n)}head(e,t,n){return this.registerRoute(`head`,e,t,n)}all(e,t,n){return this.registerRoute(`all`,e,t,n)}registerRoute(e,t,n,r){let i=[],a={method:e,path:t,responseSamples:new Map};if(this.routes.push(a),typeof n==`object`){let e=n;a.bodySchema=e.bodySchema,a.querySchema=e.querySchema,a.tags=e.tags,a.description=e.description,a.summary=e.summary,a.deprecated=e.deprecated,a.responseSchema=e.responseSchema,a.hidden=e.hidden,e.middleware&&i.push(...e.middleware),e.bodySchema&&i.push(this.createBodyValidationMiddleware(e.bodySchema)),e.querySchema&&i.push(this.createQueryValidationMiddleware(e.querySchema)),i.push(r)}else i.push(n);return this.router[e](t,(e,t,n)=>{if(a.hidden||a.responseSamples.size>=10){n();return}let r=t.json;t.json=e=>(a.responseSamples.has(t.statusCode)||a.responseSamples.set(t.statusCode,e),r.call(t,e)),n()},...i),this}createBodyValidationMiddleware(e){return async(t,n,r)=>{try{let i=d(e,t.body),a=i&&typeof i.then==`function`?await i:i;if(a&&`issues`in a&&a.issues){n.status(400).json({error:`Validation failed`,details:a.errors||a.issues});return}t.body=a&&`value`in a?a.value:a,r()}catch(e){f(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):r(e)}}}createQueryValidationMiddleware(e){return async(t,n,r)=>{try{let i=d(e,t.query),a=i&&typeof i.then==`function`?await i:i;if(a&&`issues`in a&&a.issues){n.status(400).json({error:`Validation failed`,details:a.errors||a.issues});return}let o=a&&`value`in a?a.value:a;Object.defineProperty(t,"query",{value:o,writable:!1,enumerable:!0,configurable:!0}),r()}catch(e){f(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):r(e)}}}};function A(){return new k}function j(e){let t=new k;return e?.errorHandler&&t.getRouter().use(e.errorHandler),t}function M(...e){let t=new k;for(let n of e)t=t.useMiddleware(n);return t}function N(e,t={}){let n=(Array.isArray(e)?e:[e]).map(e=>`prefix`in e?e:{prefix:``,router:e}),r=c.default.Router();return r.get(`/openapi.json`,async(e,r)=>{try{let e=await D(n.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path}))),t);r.json(e)}catch(e){r.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),r.get(`/`,(e,n)=>{let r=`${e.baseUrl}/openapi.json`,i=t.cdnUrl??T;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(E(t.title??`API`,r,i))}),r}exports.TypedRouter=k,exports.createDocs=N,exports.createTypedRouter=A,exports.createTypedRouterWithConfig=j,exports.createTypedRouterWithMiddleware=M,exports.isSchemaError=f,exports.parseSchema=u,exports.safeParseSchema=d;
12
+ </html>`}async function D(e,t){let n=Object.create(null);for(let t of e){if(t.method===`all`||t.hidden)continue;let e=v(t.path);n[e]||(n[e]={});let r=y(t.path).map(e=>({name:e,in:`path`,required:!0,schema:{type:`string`}}));if(t.querySchema){let e=await S(t.querySchema),n=e.properties??{},i=e.required??[];for(let[e,t]of Object.entries(n))r.push({name:e,in:`query`,required:i.includes(e),schema:t})}let i={summary:t.summary??x(t.method,t.path),tags:t.tags??[b(t.path)],parameters:r};t.description&&(i.description=t.description),t.deprecated&&(i.deprecated=!0),t.bodySchema&&(i.requestBody={required:!0,content:{"application/json":{schema:await S(t.bodySchema)}}});let a={};if(t.responseSamples.size>0)for(let[e,n]of t.responseSamples)a[String(e)]={description:e<400?`Success`:`Error`,content:{"application/json":{example:n}}};else a[200]={description:`Success`};i.responses=a,n[e][t.method]=i}return{openapi:`3.1.0`,info:{title:t.title??`API`,version:t.version??`1.0.0`,...t.description?{description:t.description}:{}},...t.servers?{servers:t.servers}:{},paths:n}}const O=new WeakMap;var k=class e{router;routes=[];mountedRouters=[];samplingEnabled=!1;constructor(){this.router=c.default.Router(),O.set(this.router,this)}useMiddleware(e){return this.router.use(e),this}getRouter(){return this.router}use(t,...n){let r=typeof t==`string`,i=r?t:``,a=(r?n:[t,...n]).map(t=>{if(t instanceof e)return this.trackMounted(i,t),t.getRouter();let n=O.get(t);return n&&this.trackMounted(i,n),t});return r?this.router.use(t,...a):this.router.use(...a),this}mount(e,t){if(typeof e==`string`){let n=t;this.router.use(e,n.getRouter()),this.trackMounted(e,n)}else this.router.use(e.getRouter()),this.trackMounted(``,e);return this}getRouteMetadata(){let e=this.mountedRouters.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path})));return[...this.routes,...e]}enableSampling(e=new Set){if(!e.has(this)){e.add(this),this.samplingEnabled=!0;for(let{router:t}of this.mountedRouters)t.enableSampling(e)}}trackMounted(e,t){this.mountedRouters.some(n=>n.router===t&&n.prefix===e)||(this.mountedRouters.push({prefix:e,router:t}),this.samplingEnabled&&t.enableSampling())}docs(e={}){this.enableSampling();let t=c.default.Router();if(e.specOutputPath){let t=this,n=e.specOutputPath;setImmediate(async()=>{try{let r=await D(t.getRouteMetadata(),e);await(await p(`fs/promises`)).writeFile(n,JSON.stringify(r,null,2),`utf8`)}catch{}})}return t.get(`/openapi.json`,async(t,n)=>{try{let t=await D(this.getRouteMetadata(),e);n.json(t)}catch(e){n.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),t.get(`/`,(t,n)=>{let r=`${t.baseUrl}/openapi.json`,i=e.cdnUrl??T;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(E(e.title??`API`,r,i))}),t}get(e,t,n){return this.registerRoute(`get`,e,t,n)}post(e,t,n){return this.registerRoute(`post`,e,t,n)}put(e,t,n){return this.registerRoute(`put`,e,t,n)}patch(e,t,n){return this.registerRoute(`patch`,e,t,n)}delete(e,t,n){return this.registerRoute(`delete`,e,t,n)}options(e,t,n){return this.registerRoute(`options`,e,t,n)}head(e,t,n){return this.registerRoute(`head`,e,t,n)}all(e,t,n){return this.registerRoute(`all`,e,t,n)}registerRoute(e,t,n,r){let i=[],a={method:e,path:t,responseSamples:new Map};if(this.routes.push(a),typeof n==`object`){let e=n;a.bodySchema=e.bodySchema,a.querySchema=e.querySchema,a.tags=e.tags,a.description=e.description,a.summary=e.summary,a.deprecated=e.deprecated,a.responseSchema=e.responseSchema,a.hidden=e.hidden,e.middleware&&i.push(...e.middleware),e.bodySchema&&i.push(this.createBodyValidationMiddleware(e.bodySchema)),e.querySchema&&i.push(this.createQueryValidationMiddleware(e.querySchema)),i.push(r)}else i.push(n);let o=0;return this.router[e](t,(e,t,n)=>{if(!this.samplingEnabled||a.hidden||o>=50||a.responseSamples.size>=10){n();return}o++;let r=t.json;t.json=e=>(a.responseSamples.has(t.statusCode)||a.responseSamples.set(t.statusCode,e),r.call(t,e)),n()},...i),this}createBodyValidationMiddleware(e){return async(t,n,r)=>{try{let i=d(e,t.body),a=i&&typeof i.then==`function`?await i:i;if(a&&`issues`in a&&a.issues){n.status(400).json({error:`Validation failed`,details:a.errors||a.issues});return}t.body=a&&`value`in a?a.value:a,r()}catch(e){f(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):r(e)}}}createQueryValidationMiddleware(e){return async(t,n,r)=>{try{let i=d(e,t.query),a=i&&typeof i.then==`function`?await i:i;if(a&&`issues`in a&&a.issues){n.status(400).json({error:`Validation failed`,details:a.errors||a.issues});return}let o=a&&`value`in a?a.value:a;Object.defineProperty(t,"query",{value:o,writable:!1,enumerable:!0,configurable:!0}),r()}catch(e){f(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):r(e)}}}};function A(){return new k}function j(e){let t=new k;return e?.errorHandler&&t.getRouter().use(e.errorHandler),t}function M(...e){let t=new k;for(let n of e)t=t.useMiddleware(n);return t}function N(e,t={}){let n=(Array.isArray(e)?e:[e]).map(e=>`prefix`in e?e:{prefix:``,router:e});for(let{router:e}of n)e.enableSampling();let r=c.default.Router();return r.get(`/openapi.json`,async(e,r)=>{try{let e=await D(n.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path}))),t);r.json(e)}catch(e){r.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),r.get(`/`,(e,n)=>{let r=`${e.baseUrl}/openapi.json`,i=t.cdnUrl??T;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(E(t.title??`API`,r,i))}),r}exports.TypedRouter=k,exports.createDocs=N,exports.createTypedRouter=A,exports.createTypedRouterWithConfig=j,exports.createTypedRouterWithMiddleware=M,exports.isSchemaError=f,exports.parseSchema=u,exports.safeParseSchema=d;
@@ -198,6 +198,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
198
198
  private router;
199
199
  private routes;
200
200
  private mountedRouters;
201
+ private samplingEnabled;
201
202
  constructor();
202
203
  /**
203
204
  * Add typed middleware that extends the request with additional properties
@@ -225,18 +226,18 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
225
226
  * automatically recognised and tracked for .docs() — no extra wiring needed.
226
227
  *
227
228
  * @example
228
- * // v1.routes.ts — keep your exact existing pattern, just use TypedRouter
229
+ * // v1.routes.ts — pass TypedRouter instances directly, no .getRouter() needed
229
230
  * export const v1Routes = createTypedRouter()
230
231
  *
231
- * v1Routes.use('/products', productRoutes.getRouter()) // tracked ✓
232
- * v1Routes.use('/profile', profileRoutes.getRouter()) // tracked ✓
233
- * v1Routes.use('/', callbackRouter) // plain Express, not tracked
232
+ * v1Routes.use('/products', productRoutes) // tracked ✓
233
+ * v1Routes.use('/profile', profileRoutes) // tracked ✓
234
+ * v1Routes.use('/', callbackRouter) // plain Express, also works
234
235
  *
235
236
  * app.use('/v1', v1Routes.getRouter())
236
237
  * app.use('/docs', v1Routes.docs({ title: 'My API' })) // just works
237
238
  */
238
- use(path: string, ...handlers: Array<express.RequestHandler | express.Router>): TypedRouter<Req, Locals>;
239
- use(...handlers: Array<express.RequestHandler | express.Router>): TypedRouter<Req, Locals>;
239
+ use(path: string, ...handlers: Array<express.RequestHandler | express.Router | TypedRouter<any, any>>): TypedRouter<Req, Locals>;
240
+ use(...handlers: Array<express.RequestHandler | express.Router | TypedRouter<any, any>>): TypedRouter<Req, Locals>;
240
241
  /**
241
242
  * Mount a TypedRouter at a path prefix, registering it both on the Express
242
243
  * router and in the docs registry so .docs() picks it up automatically.
@@ -258,6 +259,18 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
258
259
  * Used internally by .docs() and by createDocs() for multi-router merging.
259
260
  */
260
261
  getRouteMetadata(): RouteMetadata[];
262
+ /**
263
+ * Turn on response sampling for this router and every router mounted under
264
+ * it. Called by .docs() and createDocs() so examples are captured only when
265
+ * docs are actually generated. The visited set guards against mount cycles.
266
+ * @internal Public only so createDocs() can reach it; not part of the API.
267
+ */
268
+ enableSampling(visited?: Set<TypedRouter<any, any>>): void;
269
+ /**
270
+ * Record a sub-router for docs, de-duplicating identical (prefix, router)
271
+ * pairs and propagating sampling if docs were already requested.
272
+ */
273
+ private trackMounted;
261
274
  /**
262
275
  * Returns an Express router that serves OpenAPI docs.
263
276
  * Mount it anywhere on your app — routes are auto-discovered.
@@ -355,6 +368,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
355
368
  delete<Path extends string, QuerySchema extends AnyStandardSchema | unknown>(path: Path, options: {
356
369
  querySchema: QuerySchema;
357
370
  }, handler: SchemaRouteHandler<Path, unknown, QuerySchema, Req, Locals>): TypedRouter<Req, Locals>;
371
+ delete<Path extends string>(path: Path, options: DocMeta, handler: SchemaRouteHandler<Path, unknown, unknown, Req, Locals>): TypedRouter<Req, Locals>;
358
372
  delete<Path extends string, M extends TypedMiddleware<any, any>[]>(path: Path, options: DocMeta & {
359
373
  middleware: [...M];
360
374
  }, // Using tuple spread pattern
@@ -372,6 +386,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
372
386
  options<Path extends string, QuerySchema extends AnyStandardSchema | unknown>(path: Path, options: {
373
387
  querySchema: QuerySchema;
374
388
  }, handler: SchemaRouteHandler<Path, unknown, QuerySchema, Req, Locals>): TypedRouter<Req, Locals>;
389
+ options<Path extends string>(path: Path, options: DocMeta, handler: SchemaRouteHandler<Path, unknown, unknown, Req, Locals>): TypedRouter<Req, Locals>;
375
390
  options<Path extends string, M extends TypedMiddleware<any, any>[]>(path: Path, options: DocMeta & {
376
391
  middleware: [...M];
377
392
  }, // Using tuple spread pattern
@@ -389,6 +404,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
389
404
  head<Path extends string, QuerySchema extends AnyStandardSchema | unknown>(path: Path, options: {
390
405
  querySchema: QuerySchema;
391
406
  }, handler: SchemaRouteHandler<Path, unknown, QuerySchema, Req, Locals>): TypedRouter<Req, Locals>;
407
+ head<Path extends string>(path: Path, options: DocMeta, handler: SchemaRouteHandler<Path, unknown, unknown, Req, Locals>): TypedRouter<Req, Locals>;
392
408
  head<Path extends string, M extends TypedMiddleware<any, any>[]>(path: Path, options: DocMeta & {
393
409
  middleware: [...M];
394
410
  }, // Using tuple spread pattern
@@ -198,6 +198,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
198
198
  private router;
199
199
  private routes;
200
200
  private mountedRouters;
201
+ private samplingEnabled;
201
202
  constructor();
202
203
  /**
203
204
  * Add typed middleware that extends the request with additional properties
@@ -225,18 +226,18 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
225
226
  * automatically recognised and tracked for .docs() — no extra wiring needed.
226
227
  *
227
228
  * @example
228
- * // v1.routes.ts — keep your exact existing pattern, just use TypedRouter
229
+ * // v1.routes.ts — pass TypedRouter instances directly, no .getRouter() needed
229
230
  * export const v1Routes = createTypedRouter()
230
231
  *
231
- * v1Routes.use('/products', productRoutes.getRouter()) // tracked ✓
232
- * v1Routes.use('/profile', profileRoutes.getRouter()) // tracked ✓
233
- * v1Routes.use('/', callbackRouter) // plain Express, not tracked
232
+ * v1Routes.use('/products', productRoutes) // tracked ✓
233
+ * v1Routes.use('/profile', profileRoutes) // tracked ✓
234
+ * v1Routes.use('/', callbackRouter) // plain Express, also works
234
235
  *
235
236
  * app.use('/v1', v1Routes.getRouter())
236
237
  * app.use('/docs', v1Routes.docs({ title: 'My API' })) // just works
237
238
  */
238
- use(path: string, ...handlers: Array<express.RequestHandler | express.Router>): TypedRouter<Req, Locals>;
239
- use(...handlers: Array<express.RequestHandler | express.Router>): TypedRouter<Req, Locals>;
239
+ use(path: string, ...handlers: Array<express.RequestHandler | express.Router | TypedRouter<any, any>>): TypedRouter<Req, Locals>;
240
+ use(...handlers: Array<express.RequestHandler | express.Router | TypedRouter<any, any>>): TypedRouter<Req, Locals>;
240
241
  /**
241
242
  * Mount a TypedRouter at a path prefix, registering it both on the Express
242
243
  * router and in the docs registry so .docs() picks it up automatically.
@@ -258,6 +259,18 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
258
259
  * Used internally by .docs() and by createDocs() for multi-router merging.
259
260
  */
260
261
  getRouteMetadata(): RouteMetadata[];
262
+ /**
263
+ * Turn on response sampling for this router and every router mounted under
264
+ * it. Called by .docs() and createDocs() so examples are captured only when
265
+ * docs are actually generated. The visited set guards against mount cycles.
266
+ * @internal Public only so createDocs() can reach it; not part of the API.
267
+ */
268
+ enableSampling(visited?: Set<TypedRouter<any, any>>): void;
269
+ /**
270
+ * Record a sub-router for docs, de-duplicating identical (prefix, router)
271
+ * pairs and propagating sampling if docs were already requested.
272
+ */
273
+ private trackMounted;
261
274
  /**
262
275
  * Returns an Express router that serves OpenAPI docs.
263
276
  * Mount it anywhere on your app — routes are auto-discovered.
@@ -355,6 +368,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
355
368
  delete<Path extends string, QuerySchema extends AnyStandardSchema | unknown>(path: Path, options: {
356
369
  querySchema: QuerySchema;
357
370
  }, handler: SchemaRouteHandler<Path, unknown, QuerySchema, Req, Locals>): TypedRouter<Req, Locals>;
371
+ delete<Path extends string>(path: Path, options: DocMeta, handler: SchemaRouteHandler<Path, unknown, unknown, Req, Locals>): TypedRouter<Req, Locals>;
358
372
  delete<Path extends string, M extends TypedMiddleware<any, any>[]>(path: Path, options: DocMeta & {
359
373
  middleware: [...M];
360
374
  }, // Using tuple spread pattern
@@ -372,6 +386,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
372
386
  options<Path extends string, QuerySchema extends AnyStandardSchema | unknown>(path: Path, options: {
373
387
  querySchema: QuerySchema;
374
388
  }, handler: SchemaRouteHandler<Path, unknown, QuerySchema, Req, Locals>): TypedRouter<Req, Locals>;
389
+ options<Path extends string>(path: Path, options: DocMeta, handler: SchemaRouteHandler<Path, unknown, unknown, Req, Locals>): TypedRouter<Req, Locals>;
375
390
  options<Path extends string, M extends TypedMiddleware<any, any>[]>(path: Path, options: DocMeta & {
376
391
  middleware: [...M];
377
392
  }, // Using tuple spread pattern
@@ -389,6 +404,7 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
389
404
  head<Path extends string, QuerySchema extends AnyStandardSchema | unknown>(path: Path, options: {
390
405
  querySchema: QuerySchema;
391
406
  }, handler: SchemaRouteHandler<Path, unknown, QuerySchema, Req, Locals>): TypedRouter<Req, Locals>;
407
+ head<Path extends string>(path: Path, options: DocMeta, handler: SchemaRouteHandler<Path, unknown, unknown, Req, Locals>): TypedRouter<Req, Locals>;
392
408
  head<Path extends string, M extends TypedMiddleware<any, any>[]>(path: Path, options: DocMeta & {
393
409
  middleware: [...M];
394
410
  }, // Using tuple spread pattern
@@ -9,4 +9,4 @@ import e from"express";import{SchemaError as t}from"@standard-schema/utils";func
9
9
  <script id="api-reference" data-url="${g(t)}"><\/script>
10
10
  <script src="${g(n)}"><\/script>
11
11
  </body>
12
- </html>`}async function y(e,t){let n=Object.create(null);for(let t of e){if(t.method===`all`||t.hidden)continue;let e=u(t.path);n[e]||(n[e]={});let r=d(t.path).map(e=>({name:e,in:`path`,required:!0,schema:{type:`string`}}));if(t.querySchema){let e=await m(t.querySchema),n=e.properties??{},i=e.required??[];for(let[e,t]of Object.entries(n))r.push({name:e,in:`query`,required:i.includes(e),schema:t})}let i={summary:t.summary??p(t.method,t.path),tags:t.tags??[f(t.path)],parameters:r};t.description&&(i.description=t.description),t.deprecated&&(i.deprecated=!0),t.bodySchema&&(i.requestBody={required:!0,content:{"application/json":{schema:await m(t.bodySchema)}}});let a={};if(t.responseSamples.size>0)for(let[e,n]of t.responseSamples)a[String(e)]={description:e<400?`Success`:`Error`,content:{"application/json":{example:n}}};else a[200]={description:`Success`};i.responses=a,n[e][t.method]=i}return{openapi:`3.1.0`,info:{title:t.title??`API`,version:t.version??`1.0.0`,...t.description?{description:t.description}:{}},...t.servers?{servers:t.servers}:{},paths:n}}const b=new WeakMap;var x=class{router;routes=[];mountedRouters=[];constructor(){this.router=e.Router(),b.set(this.router,this)}useMiddleware(e){return this.router.use(e),this}getRouter(){return this.router}use(e,...t){let n=typeof e==`string`,r=n?e:``,i=n?t:[e,...t];for(let e of i){let t=b.get(e);t&&this.mountedRouters.push({prefix:r,router:t})}return n?this.router.use(e,...i):this.router.use(...i),this}mount(e,t){if(typeof e==`string`){let n=t;this.router.use(e,n.getRouter()),this.mountedRouters.push({prefix:e,router:n})}else this.router.use(e.getRouter()),this.mountedRouters.push({prefix:``,router:e});return this}getRouteMetadata(){let e=this.mountedRouters.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path})));return[...this.routes,...e]}docs(t={}){let n=e.Router();return n.get(`/openapi.json`,async(e,n)=>{try{let e=await y(this.getRouteMetadata(),t);t.specOutputPath&&await(await a(`fs/promises`)).writeFile(t.specOutputPath,JSON.stringify(e,null,2),`utf8`).catch(()=>{}),n.json(e)}catch(e){n.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),n.get(`/`,(e,n)=>{let r=`${e.baseUrl}/openapi.json`,i=t.cdnUrl??_;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(v(t.title??`API`,r,i))}),n}get(e,t,n){return this.registerRoute(`get`,e,t,n)}post(e,t,n){return this.registerRoute(`post`,e,t,n)}put(e,t,n){return this.registerRoute(`put`,e,t,n)}patch(e,t,n){return this.registerRoute(`patch`,e,t,n)}delete(e,t,n){return this.registerRoute(`delete`,e,t,n)}options(e,t,n){return this.registerRoute(`options`,e,t,n)}head(e,t,n){return this.registerRoute(`head`,e,t,n)}all(e,t,n){return this.registerRoute(`all`,e,t,n)}registerRoute(e,t,n,r){let i=[],a={method:e,path:t,responseSamples:new Map};if(this.routes.push(a),typeof n==`object`){let e=n;a.bodySchema=e.bodySchema,a.querySchema=e.querySchema,a.tags=e.tags,a.description=e.description,a.summary=e.summary,a.deprecated=e.deprecated,a.responseSchema=e.responseSchema,a.hidden=e.hidden,e.middleware&&i.push(...e.middleware),e.bodySchema&&i.push(this.createBodyValidationMiddleware(e.bodySchema)),e.querySchema&&i.push(this.createQueryValidationMiddleware(e.querySchema)),i.push(r)}else i.push(n);return this.router[e](t,(e,t,n)=>{if(a.hidden||a.responseSamples.size>=10){n();return}let r=t.json;t.json=e=>(a.responseSamples.has(t.statusCode)||a.responseSamples.set(t.statusCode,e),r.call(t,e)),n()},...i),this}createBodyValidationMiddleware(e){return async(t,n,a)=>{try{let i=r(e,t.body),o=i&&typeof i.then==`function`?await i:i;if(o&&`issues`in o&&o.issues){n.status(400).json({error:`Validation failed`,details:o.errors||o.issues});return}t.body=o&&`value`in o?o.value:o,a()}catch(e){i(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):a(e)}}}createQueryValidationMiddleware(e){return async(t,n,a)=>{try{let i=r(e,t.query),o=i&&typeof i.then==`function`?await i:i;if(o&&`issues`in o&&o.issues){n.status(400).json({error:`Validation failed`,details:o.errors||o.issues});return}let s=o&&`value`in o?o.value:o;Object.defineProperty(t,"query",{value:s,writable:!1,enumerable:!0,configurable:!0}),a()}catch(e){i(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):a(e)}}}};function S(){return new x}function C(e){let t=new x;return e?.errorHandler&&t.getRouter().use(e.errorHandler),t}function w(...e){let t=new x;for(let n of e)t=t.useMiddleware(n);return t}function T(t,n={}){let r=(Array.isArray(t)?t:[t]).map(e=>`prefix`in e?e:{prefix:``,router:e}),i=e.Router();return i.get(`/openapi.json`,async(e,t)=>{try{let e=await y(r.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path}))),n);t.json(e)}catch(e){t.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),i.get(`/`,(e,t)=>{let r=`${e.baseUrl}/openapi.json`,i=n.cdnUrl??_;t.setHeader(`Content-Type`,`text/html; charset=utf-8`),t.send(v(n.title??`API`,r,i))}),i}export{x as TypedRouter,T as createDocs,S as createTypedRouter,C as createTypedRouterWithConfig,w as createTypedRouterWithMiddleware,i as isSchemaError,n as parseSchema,r as safeParseSchema};
12
+ </html>`}async function y(e,t){let n=Object.create(null);for(let t of e){if(t.method===`all`||t.hidden)continue;let e=u(t.path);n[e]||(n[e]={});let r=d(t.path).map(e=>({name:e,in:`path`,required:!0,schema:{type:`string`}}));if(t.querySchema){let e=await m(t.querySchema),n=e.properties??{},i=e.required??[];for(let[e,t]of Object.entries(n))r.push({name:e,in:`query`,required:i.includes(e),schema:t})}let i={summary:t.summary??p(t.method,t.path),tags:t.tags??[f(t.path)],parameters:r};t.description&&(i.description=t.description),t.deprecated&&(i.deprecated=!0),t.bodySchema&&(i.requestBody={required:!0,content:{"application/json":{schema:await m(t.bodySchema)}}});let a={};if(t.responseSamples.size>0)for(let[e,n]of t.responseSamples)a[String(e)]={description:e<400?`Success`:`Error`,content:{"application/json":{example:n}}};else a[200]={description:`Success`};i.responses=a,n[e][t.method]=i}return{openapi:`3.1.0`,info:{title:t.title??`API`,version:t.version??`1.0.0`,...t.description?{description:t.description}:{}},...t.servers?{servers:t.servers}:{},paths:n}}const b=new WeakMap;var x=class t{router;routes=[];mountedRouters=[];samplingEnabled=!1;constructor(){this.router=e.Router(),b.set(this.router,this)}useMiddleware(e){return this.router.use(e),this}getRouter(){return this.router}use(e,...n){let r=typeof e==`string`,i=r?e:``,a=(r?n:[e,...n]).map(e=>{if(e instanceof t)return this.trackMounted(i,e),e.getRouter();let n=b.get(e);return n&&this.trackMounted(i,n),e});return r?this.router.use(e,...a):this.router.use(...a),this}mount(e,t){if(typeof e==`string`){let n=t;this.router.use(e,n.getRouter()),this.trackMounted(e,n)}else this.router.use(e.getRouter()),this.trackMounted(``,e);return this}getRouteMetadata(){let e=this.mountedRouters.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path})));return[...this.routes,...e]}enableSampling(e=new Set){if(!e.has(this)){e.add(this),this.samplingEnabled=!0;for(let{router:t}of this.mountedRouters)t.enableSampling(e)}}trackMounted(e,t){this.mountedRouters.some(n=>n.router===t&&n.prefix===e)||(this.mountedRouters.push({prefix:e,router:t}),this.samplingEnabled&&t.enableSampling())}docs(t={}){this.enableSampling();let n=e.Router();if(t.specOutputPath){let e=this,n=t.specOutputPath;setImmediate(async()=>{try{let r=await y(e.getRouteMetadata(),t);await(await a(`fs/promises`)).writeFile(n,JSON.stringify(r,null,2),`utf8`)}catch{}})}return n.get(`/openapi.json`,async(e,n)=>{try{let e=await y(this.getRouteMetadata(),t);n.json(e)}catch(e){n.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),n.get(`/`,(e,n)=>{let r=`${e.baseUrl}/openapi.json`,i=t.cdnUrl??_;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(v(t.title??`API`,r,i))}),n}get(e,t,n){return this.registerRoute(`get`,e,t,n)}post(e,t,n){return this.registerRoute(`post`,e,t,n)}put(e,t,n){return this.registerRoute(`put`,e,t,n)}patch(e,t,n){return this.registerRoute(`patch`,e,t,n)}delete(e,t,n){return this.registerRoute(`delete`,e,t,n)}options(e,t,n){return this.registerRoute(`options`,e,t,n)}head(e,t,n){return this.registerRoute(`head`,e,t,n)}all(e,t,n){return this.registerRoute(`all`,e,t,n)}registerRoute(e,t,n,r){let i=[],a={method:e,path:t,responseSamples:new Map};if(this.routes.push(a),typeof n==`object`){let e=n;a.bodySchema=e.bodySchema,a.querySchema=e.querySchema,a.tags=e.tags,a.description=e.description,a.summary=e.summary,a.deprecated=e.deprecated,a.responseSchema=e.responseSchema,a.hidden=e.hidden,e.middleware&&i.push(...e.middleware),e.bodySchema&&i.push(this.createBodyValidationMiddleware(e.bodySchema)),e.querySchema&&i.push(this.createQueryValidationMiddleware(e.querySchema)),i.push(r)}else i.push(n);let o=0;return this.router[e](t,(e,t,n)=>{if(!this.samplingEnabled||a.hidden||o>=50||a.responseSamples.size>=10){n();return}o++;let r=t.json;t.json=e=>(a.responseSamples.has(t.statusCode)||a.responseSamples.set(t.statusCode,e),r.call(t,e)),n()},...i),this}createBodyValidationMiddleware(e){return async(t,n,a)=>{try{let i=r(e,t.body),o=i&&typeof i.then==`function`?await i:i;if(o&&`issues`in o&&o.issues){n.status(400).json({error:`Validation failed`,details:o.errors||o.issues});return}t.body=o&&`value`in o?o.value:o,a()}catch(e){i(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):a(e)}}}createQueryValidationMiddleware(e){return async(t,n,a)=>{try{let i=r(e,t.query),o=i&&typeof i.then==`function`?await i:i;if(o&&`issues`in o&&o.issues){n.status(400).json({error:`Validation failed`,details:o.errors||o.issues});return}let s=o&&`value`in o?o.value:o;Object.defineProperty(t,"query",{value:s,writable:!1,enumerable:!0,configurable:!0}),a()}catch(e){i(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):a(e)}}}};function S(){return new x}function C(e){let t=new x;return e?.errorHandler&&t.getRouter().use(e.errorHandler),t}function w(...e){let t=new x;for(let n of e)t=t.useMiddleware(n);return t}function T(t,n={}){let r=(Array.isArray(t)?t:[t]).map(e=>`prefix`in e?e:{prefix:``,router:e});for(let{router:e}of r)e.enableSampling();let i=e.Router();return i.get(`/openapi.json`,async(e,t)=>{try{let e=await y(r.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path}))),n);t.json(e)}catch(e){t.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),i.get(`/`,(e,t)=>{let r=`${e.baseUrl}/openapi.json`,i=n.cdnUrl??_;t.setHeader(`Content-Type`,`text/html; charset=utf-8`),t.send(v(n.title??`API`,r,i))}),i}export{x as TypedRouter,T as createDocs,S as createTypedRouter,C as createTypedRouterWithConfig,w as createTypedRouterWithMiddleware,i as isSchemaError,n as parseSchema,r as safeParseSchema};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minisylar/express-typed-router",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "A strongly-typed Express router with Zod validation and automatic type inference for params, body, query, and middleware",
5
5
  "type": "module",
6
6
  "main": "dist/schema-router.cjs",