16 Commits

Author SHA1 Message Date
84e7307f15 v2.0.1
Some checks failed
Default (tags) / security (push) Successful in 28s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-20 06:59:55 +00:00
d645311208 fix(readme): update README: rework features, add OpenAPI/Swagger, compression, request validation, examples, and runtime stats 2025-12-20 06:59:55 +00:00
697b7e92d7 v2.0.0
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-20 06:55:47 +00:00
4a17bf39c6 BREAKING CHANGE(request): introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body 2025-12-20 06:55:47 +00:00
39f0cdf380 v1.4.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 17:43:51 +00:00
cc3e335112 feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI 2025-12-08 17:43:51 +00:00
15848b9c9c v1.3.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 15:38:43 +00:00
fec0770d55 feat(compression): Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API 2025-12-05 15:38:43 +00:00
59ccff3453 v1.2.0
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 12:27:42 +00:00
57d7fd6483 feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support 2025-12-05 12:27:41 +00:00
cef6ce750e v1.1.2
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-03 23:53:47 +00:00
d341fc270d fix(deps): Bump dependency versions for build and runtime tools 2025-12-03 23:53:47 +00:00
a9972ad0ce v1.1.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-03 23:47:46 +00:00
b3c77eb675 fix(adapters): Attach WebSocket peer to typedRouter request localData and add ws dependency 2025-12-03 23:47:46 +00:00
7b8a4ba68c v1.1.0
Some checks failed
Default (tags) / security (push) Successful in 25s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-02 12:13:46 +00:00
643a6cec55 feat(websocket): Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs 2025-12-02 12:13:46 +00:00
35 changed files with 5212 additions and 543 deletions

View File

@@ -1,5 +1,85 @@
# Changelog # Changelog
## 2025-12-20 - 2.0.1 - fix(readme)
update README: rework features, add OpenAPI/Swagger, compression, request validation, examples, and runtime stats
- Reworked Features section into a markdown table and added items for OpenAPI/Swagger, Request Validation, and Auto Compression.
- Updated Quick Start example (removed Guard import) and added a Table of Contents.
- Expanded examples with a manual route handling snippet and guidance to bypass decorator routing.
- Added runtime example fields: instance.hostname and new stats() usage (uptime, requestsTotal, requestsActive).
- Minor legal/formatting updates: fixed LICENSE link casing/path and clarified trademark wording.
## 2025-12-20 - 2.0.0 - BREAKING CHANGE(request)
introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
- Add RequestContext class implementing lazy, cached body parsing methods: json(), text(), arrayBuffer(), formData.
- Remove IRequestContext.body property — handlers and interceptors must call ctx.json()/ctx.text()/... to access the request body (breaking API change).
- createContext now returns a RequestContext synchronously and no longer pre-parses or coerces the body.
- OpenAPI validator (validateRequest) made async and updated to use ctx.json() for request body validation; createValidationInterceptor now awaits validation.
- Updated README and tests to use async handlers and ctx.json() for body access.
- Updated npmextra.json: replaced gitzone key with @git.zone/cli, replaced npmci with @ship.zone/szci, and added release registries and accessLevel.
## 2025-12-08 - 1.4.0 - feat(openapi)
Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
- Introduce a new OpenAPI module providing decorators, types, spec generator, handlers and validation utilities
- Add decorators: ApiOperation, ApiParam, ApiQuery, ApiHeader, ApiRequestBody, ApiResponseBody, ApiSecurity, ApiTag
- Add OpenApiGenerator to produce OpenAPI 3.1 JSON from registered controllers and route metadata
- Add runtime request validation and coercion using @cfworker/json-schema (validate request body, params, query, headers)
- Register OpenAPI endpoints and Swagger UI (and ReDoc) handlers when SmartServe.openapi is enabled
- Integrate validation interceptor into controller registry compilation so validation runs before other interceptors
- Expose openapi exports from the public API (ts/index.ts and decorators index)
- Add extensive types (openapi.types.ts and decorator types) and coercion utilities for query/path params
- Add tests for OpenAPI functionality (test/test.openapi.ts)
- Bump dependencies: @api.global/typedrequest to ^3.2.5 and add @cfworker/json-schema ^4.1.1
## 2025-12-05 - 1.3.0 - feat(compression)
Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API
- Buffer response bodies before compressing and perform size threshold check after buffering; return uncompressed responses when below threshold.
- Set Content-Length to the compressed size and use provider.compress to produce full compressed payloads instead of streaming compression from the middleware.
- Add Deno-native brotli support via Deno.compress and use CompressionStream for gzip/deflate; brotli streaming is not attempted in web runtime.
- Pass compression threshold from SmartServe configuration into compressResponse so route/global thresholds are honored.
- Expose ControllerRegistry.addRoute and dynamicRoutes to allow adding dynamic routes without controller classes.
- Add comprehensive compression tests (gzip and brotli) using raw HTTP requests to avoid Node fetch auto-decompression; tests cover large/small responses, @Compress/@NoCompress behavior, and global compression disable.
- Change test runner invocation to use verbose mode.
## 2025-12-05 - 1.2.0 - feat(compression)
Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support
- Introduce a cross-runtime compression provider (Node zlib + Web CompressionStream fallback) with create/get provider APIs (ts/compression/compression.runtime.ts).
- Add compression middleware utilities (normalize config, shouldCompressResponse, algorithm selection, streaming/full-body compression) and default configuration (ts/compression/compression.middleware.ts).
- Implement Accept-Encoding parsing, encoding selection, and compressibility checks (ts/utils/utils.encoding.ts) and export types/utilities from utils/index.ts.
- Add @Compress and @NoCompress decorators and route-level compression metadata support (ts/decorators/decorators.compress.ts, decorators.types.ts, registry updates, and exports).
- Integrate compression into SmartServe core: global compression config, applyCompression for custom handlers, WebDAV, static files, and route responses (ts/core/smartserve.classes.smartserve.ts, smartserve.interfaces.ts).
- Enhance FileServer to serve pre-compressed variants (.br/.gz) when available, adjust headers/ETag/Length, and avoid using pre-compressed files for range requests (ts/files/file.server.ts).
- Expose compression APIs from package entry point and export zlib via plugins for Node provider; update readme.hints.md with configuration examples and notes.
## 2025-12-03 - 1.1.2 - fix(deps)
Bump dependency versions for build and runtime tools
- Update devDependency @git.zone/tsbundle from ^2.0.5 to ^2.6.3
- Update devDependency @types/node from ^20.8.7 to ^24.10.1
- Update dependency @api.global/typedrequest from ^3.0.0 to ^3.1.11
## 2025-12-03 - 1.1.1 - fix(adapters)
Attach WebSocket peer to typedRouter request localData and add ws dependency
- When routing incoming WebSocket messages through TypedRouter (node/deno/bun), the connection peer is now attached to requestObj.localData so typed handlers can access the active connection.
- Add runtime dependency on "ws" to enable WebSocket support in the Node adapter (used by dynamic import in the adapter).
## 2025-12-02 - 1.1.0 - feat(websocket)
Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs
- Add dependency on @api.global/typedrequest and re-export it via plugins
- Introduce typedRouter support in IWebSocketHooks and adapters (Node, Bun, Deno) to route JSON RPC messages through TypedRouter.routeAndAddResponse
- Add internal IWebSocketConnectionCallbacks to register/unregister peers; adapters receive these via a _connectionCallbacks property on websocket options
- Persist per-peer tags and data (peer.tags: Set<string>) across adapters; Bun adapter stores persistent ws.data so tags survive re-wraps
- Add WebSocketConfigError and validate websocket config to prevent using typedRouter together with onMessage (throws if both are set)
- Expose connection-management APIs on SmartServe: getWebSocketConnections(), getWebSocketConnectionsByTag(tag), broadcastWebSocket(data) and broadcastWebSocketByTag(tag, data)
- Update README/hints to document TypedRouter mode, connection registry, peer tagging, and broadcast methods
- Legacy onMessage mode remains supported; typedRouter mode enables automatic JSON routing and connection registry
## 2025-11-29 - 1.0.2 - fix(package) ## 2025-11-29 - 1.0.2 - fix(package)
Update package metadata, scripts and dependency pins Update package metadata, scripts and dependency pins

View File

@@ -1,5 +1,5 @@
{ {
"gitzone": { "@git.zone/cli": {
"projectType": "npm", "projectType": "npm",
"module": { "module": {
"githost": "code.foss.global", "githost": "code.foss.global",
@@ -9,10 +9,16 @@
"npmPackagename": "@push.rocks/smartserve", "npmPackagename": "@push.rocks/smartserve",
"license": "MIT", "license": "MIT",
"projectDomain": "push.rocks" "projectDomain": "push.rocks"
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
} }
}, },
"npmci": { "@ship.zone/szci": {
"npmGlobalTools": [], "npmGlobalTools": []
"npmAccessLevel": "public"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartserve", "name": "@push.rocks/smartserve",
"version": "1.0.2", "version": "2.0.1",
"private": false, "private": false,
"description": "a cross platform server module for Node, Deno and Bun", "description": "a cross platform server module for Node, Deno and Bun",
"exports": { "exports": {
@@ -10,24 +10,27 @@
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/ --web)", "test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)", "build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "(tsdoc)" "buildDocs": "(tsdoc)"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^3.1.2", "@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsbundle": "^2.0.5", "@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.0", "@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3", "@git.zone/tstest": "^3.1.3",
"@push.rocks/tapbundle": "^6.0.3", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^20.8.7", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1" "@types/ws": "^8.18.1"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.2.5",
"@cfworker/json-schema": "^4.1.1",
"@push.rocks/lik": "^6.2.2", "@push.rocks/lik": "^6.2.2",
"@push.rocks/smartenv": "^6.0.0", "@push.rocks/smartenv": "^6.0.0",
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartpath": "^6.0.0" "@push.rocks/smartpath": "^6.0.0",
"ws": "^8.18.0"
}, },
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"repository": { "repository": {

703
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,12 @@ SmartServe is a cross-platform HTTP server for Node.js, Deno, and Bun using:
- `ts/utils/utils.mime.ts` - MIME type detection - `ts/utils/utils.mime.ts` - MIME type detection
- `ts/utils/utils.etag.ts` - ETag generation - `ts/utils/utils.etag.ts` - ETag generation
### Compression
- `ts/compression/compression.runtime.ts` - Cross-runtime compression (Node.js zlib, Web CompressionStream)
- `ts/compression/compression.middleware.ts` - Compression config and helpers
- `ts/utils/utils.encoding.ts` - Accept-Encoding parsing
- `ts/decorators/decorators.compress.ts` - @Compress and @NoCompress decorators
### Protocols ### Protocols
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler - `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses) - `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
@@ -93,8 +99,137 @@ await server.start();
- LOCK/UNLOCK - Exclusive write locking - LOCK/UNLOCK - Exclusive write locking
- GET/HEAD/PUT/DELETE - Standard file operations - GET/HEAD/PUT/DELETE - Standard file operations
## TypedRouter WebSocket Integration
SmartServe supports first-class TypedRouter integration for type-safe RPC over WebSockets.
### Usage
```typescript
import { SmartServe } from '@push.rocks/smartserve';
import { TypedRouter, TypedHandler } from '@api.global/typedrequest';
const router = new TypedRouter();
router.addTypedHandler(new TypedHandler('echo', async (data) => {
return { echoed: data.message };
}));
const server = new SmartServe({
port: 3000,
websocket: {
typedRouter: router,
onConnectionOpen: (peer) => {
peer.tags.add('authenticated');
console.log(`Client connected: ${peer.id}`);
},
onConnectionClose: (peer) => {
console.log(`Client disconnected: ${peer.id}`);
},
},
});
await server.start();
// Broadcast to all connections
server.broadcastWebSocket({ type: 'notification', message: 'Hello!' });
// Broadcast to specific tag
server.broadcastWebSocketByTag('authenticated', { type: 'secure-message' });
// Get all connections
const connections = server.getWebSocketConnections();
```
### Key Features
- **TypedRouter mode**: Set `typedRouter` for automatic JSON-RPC routing (mutually exclusive with `onMessage`)
- **Connection registry**: Active only when `typedRouter` is set
- **Peer tags**: Use `peer.tags.add/has/delete` for connection filtering
- **Broadcast methods**: `broadcastWebSocket()` and `broadcastWebSocketByTag()`
- **Lifecycle hooks**: `onConnectionOpen` and `onConnectionClose` (alongside existing `onOpen`/`onClose`)
### Architecture Notes
- `typedRouter` and `onMessage` are mutually exclusive (throws `WebSocketConfigError` if both set)
- Connection registry only active when `typedRouter` is configured
- Bun adapter stores peer ID/tags in `ws.data` for persistence across events
- Internal `_connectionCallbacks` passed to adapters for registry communication
## Compression
SmartServe supports automatic response compression with Brotli and gzip.
### Configuration
```typescript
const server = new SmartServe({
port: 3000,
// Simple: enable with defaults (compression is ON by default)
compression: true,
// Detailed configuration
compression: {
enabled: true,
algorithms: ['br', 'gzip'], // Brotli preferred, gzip fallback
threshold: 1024, // Don't compress < 1KB
level: 4, // Compression level
compressibleTypes: [ // Custom MIME types
'text/',
'application/json',
'application/javascript',
],
exclude: ['/api/stream/*'], // Skip certain paths
},
// Pre-compressed static files
static: {
root: './public',
precompressed: true, // Serve index.html.br, index.html.gz if available
},
});
```
### Per-Route Control
```typescript
@Controller('/api')
class ApiController {
@Get('/data')
getData() {
return { large: 'json' }; // Compressed by default
}
@Get('/stream')
@NoCompress() // Skip compression for SSE/streaming
getStream() {
return new Response(eventStream, {
headers: { 'Content-Type': 'text/event-stream' }
});
}
@Get('/heavy')
@Compress({ level: 11 }) // Force max brotli compression
getHeavy() {
return massiveData;
}
}
```
### Cross-Runtime Support
| Runtime | Brotli | gzip | Implementation |
|---------|--------|------|----------------|
| Node.js | ✅ | ✅ | Native zlib module |
| Deno | ⚠️ | ✅ | CompressionStream API |
| Bun | ⚠️ | ✅ | CompressionStream API |
Note: Brotli in Deno/Bun falls back to gzip if CompressionStream doesn't support it.
## TODO ## TODO
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK) - [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
- [x] TypedRouter WebSocket integration
- [x] Brotli/gzip compression with per-route control
- [ ] HTTP/2 support investigation - [ ] HTTP/2 support investigation
- [ ] Performance benchmarks - [ ] Performance benchmarks

555
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartserve # @push.rocks/smartserve
A cross-platform HTTP server module for Node.js, Deno, and Bun with decorator-based routing, WebSocket support, static file serving, and WebDAV protocol. 🚀 A blazing-fast, cross-platform HTTP server for Node.js, Deno, and Bun with decorator-based routing, OpenAPI/Swagger integration, automatic compression, WebSocket support, static file serving, and WebDAV protocol. 🚀
## Issue Reporting and Security ## Issue Reporting and Security
@@ -16,21 +16,25 @@ pnpm add @push.rocks/smartserve
## Features ## Features
**Cross-Platform** - Works seamlessly on Node.js, Deno, and Bun | Feature | Description |
🎯 **Decorator-Based Routing** - Clean, expressive `@Route`, `@Get`, `@Post` decorators |---------|-------------|
🛡️ **Guards & Interceptors** - Built-in `@Guard`, `@Transform`, `@Intercept` for auth and transformation | ✨ **Cross-Platform** | Works seamlessly on Node.js, Deno, and Bun with zero config |
📁 **Static File Server** - Streaming, ETags, Range requests, directory listing | 🎯 **Decorator-Based Routing** | Clean, expressive `@Route`, `@Get`, `@Post` decorators |
🌐 **WebDAV Support** - Mount as network drive with full RFC 4918 compliance | 📖 **OpenAPI/Swagger** | Auto-generate OpenAPI 3.1 specs with built-in Swagger UI & ReDoc |
🔌 **WebSocket Ready** - Native WebSocket support across all runtimes | ✅ **Request Validation** | Validate requests against JSON Schema with automatic coercion |
**Zero Overhead** - Native Web Standards API (Request/Response) on Deno/Bun | 🗜️ **Auto Compression** | Brotli/gzip compression with smart content detection |
🔒 **HTTPS/TLS** - Built-in TLS support with certificate configuration | 🛡️ **Guards & Interceptors** | Built-in `@Guard`, `@Transform`, `@Intercept` for auth & transformation |
| 📁 **Static File Server** | Streaming, ETags, Range requests, directory listing, pre-compressed files |
| 🌐 **WebDAV Support** | Mount as network drive with full RFC 4918 compliance |
| 🔌 **WebSocket Ready** | Native WebSocket support with TypedRouter for type-safe RPC |
| ⚡ **Zero Overhead** | Native Web Standards API (Request/Response) on Deno/Bun |
| 🔒 **HTTPS/TLS** | Built-in TLS support with certificate configuration |
## Quick Start ## Quick Start
```typescript ```typescript
import { SmartServe, Route, Get, Post, Guard, type IRequestContext } from '@push.rocks/smartserve'; import { SmartServe, Route, Get, Post, type IRequestContext } from '@push.rocks/smartserve';
// Define a controller with decorators
@Route('/api') @Route('/api')
class UserController { class UserController {
@Get('/hello') @Get('/hello')
@@ -44,12 +48,12 @@ class UserController {
} }
@Post('/users') @Post('/users')
createUser(ctx: IRequestContext<{ name: string; email: string }>) { async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
return { id: 'new-id', ...ctx.body }; const body = await ctx.json();
return { id: 'new-id', ...body };
} }
} }
// Create and start server
const server = new SmartServe({ port: 3000 }); const server = new SmartServe({ port: 3000 });
server.register(UserController); server.register(UserController);
await server.start(); await server.start();
@@ -57,6 +61,28 @@ await server.start();
console.log('🚀 Server running at http://localhost:3000'); console.log('🚀 Server running at http://localhost:3000');
``` ```
## Table of Contents
- [Decorators](#decorators)
- [Route Decorators](#route-decorators)
- [Guards](#guards-authenticationauthorization)
- [Transforms](#transforms-response-modification)
- [Intercept](#intercept-full-control)
- [OpenAPI & Swagger](#openapi--swagger)
- [Documenting APIs](#documenting-apis)
- [Request Validation](#request-validation)
- [Swagger UI & ReDoc](#swagger-ui--redoc)
- [Compression](#compression)
- [Static File Server](#static-file-server)
- [WebDAV Support](#webdav-support)
- [WebSocket Support](#websocket-support)
- [HTTPS/TLS](#httpstls)
- [Error Handling](#error-handling)
- [Request Context](#request-context)
- [Custom Request Handler](#custom-request-handler)
---
## Decorators ## Decorators
### Route Decorators ### Route Decorators
@@ -67,26 +93,36 @@ import { Route, Get, Post, Put, Delete, Patch, All } from '@push.rocks/smartserv
@Route('/api/v1') // Base path for all routes in this controller @Route('/api/v1') // Base path for all routes in this controller
class ApiController { class ApiController {
@Get('/items') // GET /api/v1/items @Get('/items') // GET /api/v1/items
listItems() { ... } listItems() {
return [{ id: 1, name: 'Item 1' }];
}
@Get('/items/:id') // GET /api/v1/items/:id - path parameters @Get('/items/:id') // GET /api/v1/items/:id
getItem(ctx: IRequestContext) { getItem(ctx: IRequestContext) {
return { id: ctx.params.id }; return { id: ctx.params.id };
} }
@Post('/items') // POST /api/v1/items @Post('/items') // POST /api/v1/items
createItem(ctx: IRequestContext<{ name: string }>) { async createItem(ctx: IRequestContext<{ name: string }>) {
return { created: ctx.body.name }; const body = await ctx.json();
return { created: body.name };
} }
@Put('/items/:id') // PUT /api/v1/items/:id @Put('/items/:id') // PUT /api/v1/items/:id
updateItem(ctx: IRequestContext) { ... } async updateItem(ctx: IRequestContext) {
const body = await ctx.json();
return { updated: ctx.params.id, ...body };
}
@Delete('/items/:id') // DELETE /api/v1/items/:id @Delete('/items/:id') // DELETE /api/v1/items/:id
deleteItem(ctx: IRequestContext) { ... } deleteItem(ctx: IRequestContext) {
return { deleted: ctx.params.id };
}
@All('/webhook') // Matches ALL HTTP methods @All('/webhook') // Matches ALL HTTP methods
handleWebhook(ctx: IRequestContext) { ... } handleWebhook(ctx: IRequestContext) {
return { method: ctx.method };
}
} }
``` ```
@@ -95,9 +131,9 @@ class ApiController {
Guards protect routes by returning `true` (allow) or `false` (reject with 403): Guards protect routes by returning `true` (allow) or `false` (reject with 403):
```typescript ```typescript
import { Route, Get, Guard, type IRequestContext } from '@push.rocks/smartserve'; import { Route, Get, Guard, hasBearerToken, type IRequestContext } from '@push.rocks/smartserve';
// Guard function // Custom guard function
const isAuthenticated = (ctx: IRequestContext) => { const isAuthenticated = (ctx: IRequestContext) => {
return ctx.headers.has('Authorization'); return ctx.headers.has('Authorization');
}; };
@@ -106,7 +142,6 @@ const isAdmin = (ctx: IRequestContext) => {
return ctx.headers.get('X-Role') === 'admin'; return ctx.headers.get('X-Role') === 'admin';
}; };
// Apply guard to entire controller
@Route('/admin') @Route('/admin')
@Guard(isAuthenticated) @Guard(isAuthenticated)
@Guard(isAdmin) // Multiple guards - all must pass @Guard(isAdmin) // Multiple guards - all must pass
@@ -123,6 +158,16 @@ class AdminController {
return { level: 'super-secret' }; return { level: 'super-secret' };
} }
} }
// Built-in utility guards
@Route('/protected')
@Guard(hasBearerToken()) // Requires Authorization: Bearer <token>
class ProtectedController {
@Get('/data')
getData() {
return { protected: true };
}
}
``` ```
### Transforms (Response Modification) ### Transforms (Response Modification)
@@ -130,29 +175,24 @@ class AdminController {
Transforms modify the response before sending: Transforms modify the response before sending:
```typescript ```typescript
import { Route, Get, Transform } from '@push.rocks/smartserve'; import { Route, Get, Transform, wrapSuccess, addTimestamp } from '@push.rocks/smartserve';
// Transform function
const wrapResponse = <T>(data: T) => ({
success: true,
data,
timestamp: Date.now(),
});
// Custom transform
const addVersion = <T extends object>(data: T) => ({ const addVersion = <T extends object>(data: T) => ({
...data, ...data,
apiVersion: '2.0', apiVersion: '2.0',
}); });
@Route('/api') @Route('/api')
@Transform(wrapResponse) // Applied to all routes in controller @Transform(wrapSuccess) // Built-in: wraps in { success: true, data: ... }
class ApiController { class ApiController {
@Get('/info') @Get('/info')
@Transform(addVersion) // Stacks with class transform @Transform(addTimestamp) // Built-in: adds timestamp field
@Transform(addVersion) // Transforms stack
getInfo() { getInfo() {
return { name: 'MyAPI' }; return { name: 'MyAPI' };
} }
// Response: { success: true, data: { name: 'MyAPI', apiVersion: '2.0' }, timestamp: ... } // Response: { success: true, data: { name: 'MyAPI', timestamp: '...', apiVersion: '2.0' } }
} }
``` ```
@@ -165,16 +205,26 @@ import { Route, Get, Intercept, type IRequestContext } from '@push.rocks/smartse
@Route('/api') @Route('/api')
@Intercept({ @Intercept({
// Runs before handler // Runs BEFORE handler
request: async (ctx) => { request: async (ctx) => {
console.log(`${ctx.method} ${ctx.path}`); console.log(`📥 ${ctx.method} ${ctx.path}`);
// Return Response to short-circuit // Return Response to short-circuit
// Return modified context to continue if (ctx.headers.get('X-Block') === 'true') {
// Return void to continue with original return new Response('Blocked', { status: 403 });
}
// Add data to state for handler access
ctx.state.requestTime = Date.now();
// Return void to continue with original context
}, },
// Runs after handler
// Runs AFTER handler
response: async (data, ctx) => { response: async (data, ctx) => {
return { ...data, processedAt: new Date().toISOString() }; const duration = Date.now() - (ctx.state.requestTime as number);
console.log(`📤 Response in ${duration}ms`);
return { ...data, processedIn: `${duration}ms` };
}, },
}) })
class LoggedController { class LoggedController {
@@ -185,9 +235,242 @@ class LoggedController {
} }
``` ```
---
## OpenAPI & Swagger
SmartServe includes first-class OpenAPI 3.1 support with automatic spec generation, Swagger UI, ReDoc, and request validation.
### Documenting APIs
```typescript
import {
SmartServe,
Route,
Get,
Post,
ApiOperation,
ApiParam,
ApiQuery,
ApiRequestBody,
ApiResponseBody,
ApiTag,
ApiSecurity,
type IRequestContext,
} from '@push.rocks/smartserve';
// Define JSON Schemas for validation
const UserSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['id', 'name', 'email'],
} as const;
const CreateUserSchema = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
} as const;
@Route('/api/users')
@ApiTag('Users')
class UserController {
@Get('/')
@ApiOperation({
summary: 'List all users',
description: 'Returns a paginated list of users',
})
@ApiQuery('page', {
description: 'Page number',
schema: { type: 'integer', minimum: 1, default: 1 },
})
@ApiQuery('limit', {
description: 'Items per page',
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
})
@ApiResponseBody(200, {
description: 'List of users',
schema: { type: 'array', items: UserSchema },
})
listUsers(ctx: IRequestContext) {
const page = ctx.query.page ?? '1';
const limit = ctx.query.limit ?? '20';
return { users: [], page: parseInt(page), limit: parseInt(limit) };
}
@Get('/:id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiParam('id', {
description: 'User UUID',
schema: { type: 'string', format: 'uuid' },
})
@ApiResponseBody(200, { description: 'User found', schema: UserSchema })
@ApiResponseBody(404, { description: 'User not found' })
getUser(ctx: IRequestContext) {
return { id: ctx.params.id, name: 'John Doe', email: 'john@example.com' };
}
@Post('/')
@ApiOperation({ summary: 'Create a new user' })
@ApiRequestBody({
description: 'User data',
schema: CreateUserSchema,
})
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
@ApiResponseBody(400, { description: 'Validation error' })
@ApiSecurity('bearerAuth')
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return { id: 'new-uuid', name: body.name, email: body.email };
}
}
```
### Request Validation
When you define `@ApiRequestBody`, `@ApiParam`, or `@ApiQuery` with schemas, SmartServe **automatically validates** incoming requests:
```typescript
const server = new SmartServe({
port: 3000,
openapi: {
enabled: true,
info: {
title: 'My API',
version: '1.0.0',
description: 'A well-documented API',
},
validate: true, // 🔥 Enable automatic request validation
},
});
server.register(UserController);
await server.start();
// Invalid request → 400 Bad Request with details
// POST /api/users with { "name": "" }
// Response: { "error": "Validation failed", "source": "body", "details": [...] }
```
**Automatic Type Coercion**: Query and path parameters are automatically coerced to their schema types:
```typescript
@Get('/items')
@ApiQuery('page', { schema: { type: 'integer', default: 1 } })
@ApiQuery('active', { schema: { type: 'boolean' } })
listItems(ctx: IRequestContext) {
// ctx.query.page is coerced to number (1)
// ctx.query.active is coerced to boolean
return { page: ctx.query.page, active: ctx.query.active };
}
```
### Swagger UI & ReDoc
```typescript
const server = new SmartServe({
port: 3000,
openapi: {
enabled: true,
info: {
title: 'My Awesome API',
version: '2.0.0',
description: 'API documentation with interactive testing',
contact: {
name: 'API Support',
email: 'support@example.com',
},
},
servers: [
{ url: 'http://localhost:3000', description: 'Development' },
{ url: 'https://api.example.com', description: 'Production' },
],
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
// Customize paths
specPath: '/openapi.json', // Default: /openapi.json
swaggerPath: '/docs', // Default: /docs
redocPath: '/redoc', // Default: /redoc
},
});
await server.start();
// 📖 Swagger UI: http://localhost:3000/docs
// 📕 ReDoc: http://localhost:3000/redoc
// 📄 OpenAPI: http://localhost:3000/openapi.json
```
---
## Compression
SmartServe automatically compresses responses using Brotli or gzip based on client support:
```typescript
const server = new SmartServe({
port: 3000,
compression: {
enabled: true, // Default: true
threshold: 1024, // Min bytes to compress (default: 1KB)
level: 6, // Compression level 1-11 for br, 1-9 for gzip
preferBrotli: true, // Prefer Brotli over gzip
},
});
```
### Per-Route Compression Control
```typescript
import { Route, Get, Compress, NoCompress } from '@push.rocks/smartserve';
@Route('/api')
class ApiController {
@Get('/large-data')
@Compress({ level: 9 }) // Force high compression
getLargeData() {
return { data: '...massive payload...' };
}
@Get('/already-compressed')
@NoCompress() // Skip compression (e.g., for pre-compressed content)
getCompressed() {
return someCompressedBuffer;
}
}
```
### Pre-Compressed Static Files
Serve `.br` or `.gz` files automatically when available:
```typescript
const server = new SmartServe({
port: 3000,
static: {
root: './dist',
precompressed: true, // Serve main.js.br instead of main.js
},
});
```
---
## Static File Server ## Static File Server
Serve static files with streaming, ETags, and directory listing: Serve static files with streaming, ETags, Range requests, and directory listing:
```typescript ```typescript
const server = new SmartServe({ const server = new SmartServe({
@@ -195,29 +478,32 @@ const server = new SmartServe({
static: { static: {
root: './public', root: './public',
index: ['index.html', 'index.htm'], index: ['index.html', 'index.htm'],
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore' dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
etag: true, // Generate ETags etag: true, // Generate ETags for caching
lastModified: true, // Add Last-Modified header lastModified: true, // Add Last-Modified header
cacheControl: 'max-age=3600', cacheControl: 'max-age=3600', // Or function: (path) => 'max-age=...'
extensions: ['.html'], // Try these extensions extensions: ['.html'], // Try these extensions for extensionless URLs
precompressed: true, // Serve .br/.gz files when available
directoryListing: { directoryListing: {
showHidden: false, showHidden: false,
sortBy: 'name', // 'name' | 'size' | 'modified' sortBy: 'name', // 'name' | 'size' | 'modified'
sortOrder: 'asc', sortOrder: 'asc',
}, },
}, },
}); });
``` ```
Or simply: Or use the shorthand:
```typescript ```typescript
const server = new SmartServe({ const server = new SmartServe({
port: 3000, port: 3000,
static: './public', // Shorthand - uses defaults static: './public', // Uses sensible defaults
}); });
``` ```
---
## WebDAV Support ## WebDAV Support
Mount the server as a network drive on macOS, Windows, or Linux: Mount the server as a network drive on macOS, Windows, or Linux:
@@ -228,75 +514,115 @@ const server = new SmartServe({
webdav: { webdav: {
root: '/path/to/files', root: '/path/to/files',
auth: (ctx) => { auth: (ctx) => {
// Optional Basic auth // Optional: Basic authentication
const auth = ctx.headers.get('Authorization'); const auth = ctx.headers.get('Authorization');
if (!auth) return false; if (!auth) return false;
const [, credentials] = auth.split(' '); const [, credentials] = auth.split(' ');
const [user, pass] = atob(credentials).split(':'); const [user, pass] = atob(credentials).split(':');
return user === 'admin' && pass === 'secret'; return user === 'admin' && pass === 'secret';
}, },
locking: true, // Enable RFC 4918 file locking locking: true, // Enable RFC 4918 exclusive write locks
}, },
}); });
await server.start(); await server.start();
// Mount: Connect to Server → http://localhost:8080 // 💾 Connect: Finder → Go → Connect to Server → http://localhost:8080
``` ```
**Supported WebDAV Methods:** **Supported WebDAV Methods:**
- `OPTIONS` - Capability discovery
- `PROPFIND` - Directory listing and file metadata | Method | Description |
- `MKCOL` - Create directory |--------|-------------|
- `COPY` / `MOVE` - Copy and move operations | `OPTIONS` | Capability discovery |
- `LOCK` / `UNLOCK` - Exclusive write locking | `PROPFIND` | Directory listing and file metadata |
- `GET` / `PUT` / `DELETE` - File operations | `MKCOL` | Create directory |
| `COPY` | Copy files/directories |
| `MOVE` | Move/rename files/directories |
| `LOCK` | Acquire exclusive write lock |
| `UNLOCK` | Release lock |
| `GET` / `PUT` / `DELETE` | File operations |
---
## WebSocket Support ## WebSocket Support
WebSocket connections are handled natively: WebSocket connections are handled natively across all runtimes:
```typescript ```typescript
const server = new SmartServe({ const server = new SmartServe({
port: 3000, port: 3000,
websocket: { websocket: {
onOpen: (peer) => { onOpen: (peer) => {
console.log(`Connected: ${peer.id}`); console.log(`🔗 Connected: ${peer.id}`);
peer.send('Welcome!'); peer.send('Welcome!');
peer.tags.add('authenticated'); // Tag for filtering
}, },
onMessage: (peer, message) => { onMessage: (peer, message) => {
console.log(`Received: ${message.text}`); console.log(`📨 ${message.text}`);
peer.send(`Echo: ${message.text}`); peer.send(`Echo: ${message.text}`);
}, },
onClose: (peer, code, reason) => { onClose: (peer, code, reason) => {
console.log(`Disconnected: ${peer.id}`); console.log(`👋 Disconnected: ${peer.id}`);
}, },
onError: (peer, error) => { onError: (peer, error) => {
console.error(`Error: ${error.message}`); console.error(`Error: ${error.message}`);
}, },
}, },
}); });
``` ```
### TypedRouter for Type-Safe RPC
Use `@api.global/typedrequest` for type-safe WebSocket communication:
```typescript
import { TypedRouter } from '@api.global/typedrequest';
const typedRouter = new TypedRouter();
typedRouter.addTypedHandler(MyTypedRequest, async (request) => {
return { result: 'processed' };
});
const server = new SmartServe({
port: 3000,
websocket: {
typedRouter, // Handles message routing automatically
onConnectionOpen: (peer) => {
peer.tags.add('subscriber');
},
},
});
// Broadcast to tagged connections
server.broadcast({ event: 'update' }, (peer) => peer.tags.has('subscriber'));
```
---
## HTTPS/TLS ## HTTPS/TLS
Enable HTTPS with certificate configuration: Enable HTTPS with certificate configuration:
```typescript ```typescript
import * as fs from 'fs';
const server = new SmartServe({ const server = new SmartServe({
port: 443, port: 443,
tls: { tls: {
cert: fs.readFileSync('./cert.pem'), cert: fs.readFileSync('./cert.pem'),
key: fs.readFileSync('./key.pem'), key: fs.readFileSync('./key.pem'),
// Optional ca: fs.readFileSync('./ca.pem'), // Optional: CA chain
ca: fs.readFileSync('./ca.pem'), minVersion: 'TLSv1.2', // Optional: minimum TLS version
minVersion: 'TLSv1.2', passphrase: 'optional-key-passphrase',
}, },
}); });
``` ```
---
## Error Handling ## Error Handling
Built-in HTTP error classes: Built-in HTTP error classes with factory methods:
```typescript ```typescript
import { HttpError, type IRequestContext } from '@push.rocks/smartserve'; import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
@@ -324,43 +650,73 @@ HttpError.conflict(message, details); // 409
HttpError.internal(message, details); // 500 HttpError.internal(message, details); // 500
``` ```
Global error handling: ### Global Error Handler
```typescript ```typescript
const server = new SmartServe({ const server = new SmartServe({
port: 3000, port: 3000,
onError: (error, request) => { onError: (error, request) => {
console.error('Server error:', error); console.error('💥 Server error:', error);
return new Response(JSON.stringify({ error: 'Something went wrong' }), {
status: 500, // Return custom error response
headers: { 'Content-Type': 'application/json' }, return new Response(
}); JSON.stringify({ error: 'Something went wrong', requestId: crypto.randomUUID() }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}, },
}); });
``` ```
---
## Request Context ## Request Context
Every handler receives a typed request context: Every handler receives a typed request context:
```typescript ```typescript
interface IRequestContext<TBody = unknown> { interface IRequestContext<TBody = unknown> {
request: Request; // Original Web Standards Request request: Request; // Original Request (body never consumed by framework)
body: TBody; // Parsed and typed body params: Record<string, string>; // URL path parameters (/users/:id → { id: '123' })
params: Record<string, string>; // URL path parameters query: Record<string, string>; // Query string (?page=1 → { page: '1' })
query: Record<string, string>; // Query string parameters
headers: Headers; // Request headers headers: Headers; // Request headers
path: string; // Matched route path path: string; // Matched route path
method: THttpMethod; // HTTP method method: THttpMethod; // GET, POST, PUT, DELETE, etc.
url: URL; // Full URL object url: URL; // Full URL object
runtime: 'node' | 'deno' | 'bun'; // Current runtime runtime: 'node' | 'deno' | 'bun';
state: Record<string, unknown>; // Per-request state bag state: Record<string, unknown>; // Per-request state (share data between interceptors)
// 🔥 Lazy body parsing (cached after first call)
json(): Promise<TBody>; // Parse as JSON (typed!)
text(): Promise<string>; // Parse as text
arrayBuffer(): Promise<ArrayBuffer>;
formData(): Promise<FormData>;
} }
``` ```
**Lazy Body Parsing**: The request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like webhook signature verification:
```typescript
@Post('/webhook')
async handleWebhook(ctx: IRequestContext) {
// Get raw body for signature verification
const rawBody = await ctx.request.text();
const signature = ctx.headers.get('X-Signature');
if (!verifyHmac(rawBody, signature)) {
throw HttpError.unauthorized('Invalid signature');
}
// Parse the body manually
const payload = JSON.parse(rawBody);
return { processed: true };
}
```
---
## Custom Request Handler ## Custom Request Handler
Bypass decorator routing entirely: Bypass decorator routing entirely for low-level control:
```typescript ```typescript
const server = new SmartServe({ port: 3000 }); const server = new SmartServe({ port: 3000 });
@@ -372,12 +728,22 @@ server.setHandler(async (request, connectionInfo) => {
return new Response('OK', { status: 200 }); return new Response('OK', { status: 200 });
} }
if (url.pathname.startsWith('/api')) {
// Handle API routes manually
const body = await request.json();
return new Response(JSON.stringify({ received: body }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', { status: 404 }); return new Response('Not Found', { status: 404 });
}); });
await server.start(); await server.start();
``` ```
---
## Runtime Detection ## Runtime Detection
SmartServe automatically detects and optimizes for the current runtime: SmartServe automatically detects and optimizes for the current runtime:
@@ -385,26 +751,37 @@ SmartServe automatically detects and optimizes for the current runtime:
```typescript ```typescript
const instance = await server.start(); const instance = await server.start();
console.log(instance.runtime); // 'node' | 'deno' | 'bun' console.log(instance.runtime); // 'node' | 'deno' | 'bun'
console.log(instance.port); // 3000 console.log(instance.port); // 3000
console.log(instance.secure); // true if TLS enabled console.log(instance.hostname); // '0.0.0.0'
console.log(instance.secure); // true if TLS enabled
// Server statistics
const stats = instance.stats();
console.log(stats.uptime); // Seconds since start
console.log(stats.requestsTotal); // Total requests handled
console.log(stats.requestsActive); // Currently processing
``` ```
---
## License and Legal Information ## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks ### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH. This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information ### Company Information
Task Venture Capital GmbH Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

421
test/test.openapi.ts Normal file
View File

@@ -0,0 +1,421 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import {
SmartServe,
Route,
Get,
Post,
Delete,
ApiTag,
ApiOperation,
ApiParam,
ApiQuery,
ApiRequestBody,
ApiResponseBody,
OpenApiGenerator,
ControllerRegistry,
type IRequestContext,
} from '../ts/index.js';
// Clean up registry before tests
tap.test('setup - clear controller registry', async () => {
ControllerRegistry.clear();
});
// =============================================================================
// OpenAPI Test Controllers
// =============================================================================
const UserSchema = {
type: 'object' as const,
properties: {
id: { type: 'string' as const, format: 'uuid' },
name: { type: 'string' as const, minLength: 1 },
email: { type: 'string' as const, format: 'email' },
},
required: ['id', 'name', 'email'],
};
const CreateUserSchema = {
type: 'object' as const,
properties: {
name: { type: 'string' as const, minLength: 1 },
email: { type: 'string' as const, format: 'email' },
},
required: ['name', 'email'],
};
@Route('/api/users')
@ApiTag('Users')
class OpenApiUserController {
@Get('/')
@ApiOperation({ summary: 'List all users' })
@ApiQuery('limit', {
description: 'Maximum number of results',
schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 },
})
@ApiQuery('offset', {
description: 'Offset for pagination',
schema: { type: 'integer', default: 0 },
})
@ApiResponseBody(200, {
description: 'List of users',
schema: { type: 'array', items: UserSchema },
})
listUsers(ctx: IRequestContext) {
// Query params should be coerced to numbers
const limit = ctx.query.limit as unknown as number;
const offset = ctx.query.offset as unknown as number;
return {
users: [],
pagination: { limit, offset },
};
}
@Get('/:id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiParam('id', {
description: 'User UUID',
schema: { type: 'string', format: 'uuid' },
})
@ApiResponseBody(200, { description: 'User found', schema: UserSchema })
@ApiResponseBody(404, { description: 'User not found' })
getUser(ctx: IRequestContext) {
return {
id: ctx.params.id,
name: 'Test User',
email: 'test@example.com',
};
}
@Post('/')
@ApiOperation({ summary: 'Create a new user' })
@ApiRequestBody({
description: 'User creation payload',
schema: CreateUserSchema,
})
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
@ApiResponseBody(400, { description: 'Validation error' })
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return {
id: 'new-uuid',
name: body.name,
email: body.email,
};
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete user', deprecated: true })
@ApiParam('id', { description: 'User UUID' })
@ApiResponseBody(204, { description: 'User deleted' })
deleteUser(ctx: IRequestContext) {
return null;
}
}
// =============================================================================
// OpenAPI Spec Generation Tests
// =============================================================================
tap.test('OpenAPI spec should be generated from controllers', async () => {
// Register controller
ControllerRegistry.clear();
const instance = new OpenApiUserController();
ControllerRegistry.registerInstance(instance);
// Generate spec
const generator = new OpenApiGenerator({
info: {
title: 'Test API',
version: '1.0.0',
description: 'A test API',
},
servers: [{ url: 'http://localhost:3000' }],
});
const spec = generator.generate();
// Check basic structure
expect(spec.openapi).toEqual('3.1.0');
expect(spec.info.title).toEqual('Test API');
expect(spec.info.version).toEqual('1.0.0');
// Check paths
expect(spec.paths['/api/users']).toBeDefined();
expect(spec.paths['/api/users/{id}']).toBeDefined();
// Check GET /api/users operation
const listUsersOp = spec.paths['/api/users'].get;
expect(listUsersOp).toBeDefined();
expect(listUsersOp!.summary).toEqual('List all users');
expect(listUsersOp!.tags).toInclude('Users');
// Check query parameters
const queryParams = listUsersOp!.parameters?.filter((p: any) => p.in === 'query');
expect(queryParams?.length).toEqual(2);
const limitParam = queryParams?.find((p: any) => p.name === 'limit');
expect(limitParam).toBeDefined();
expect(limitParam?.schema?.type).toEqual('integer');
expect(limitParam?.schema?.default).toEqual(10);
// Check GET /api/users/{id} operation
const getUserOp = spec.paths['/api/users/{id}'].get;
expect(getUserOp).toBeDefined();
expect(getUserOp!.summary).toEqual('Get user by ID');
// Check path parameters
const pathParams = getUserOp!.parameters?.filter((p: any) => p.in === 'path');
expect(pathParams?.length).toEqual(1);
expect(pathParams?.[0].name).toEqual('id');
expect(pathParams?.[0].required).toBeTrue();
// Check POST /api/users operation
const createUserOp = spec.paths['/api/users'].post;
expect(createUserOp).toBeDefined();
expect(createUserOp!.requestBody).toBeDefined();
expect(createUserOp!.requestBody?.content['application/json']).toBeDefined();
// Check responses
expect(createUserOp!.responses['201']).toBeDefined();
expect(createUserOp!.responses['400']).toBeDefined();
// Check DELETE operation deprecation
const deleteUserOp = spec.paths['/api/users/{id}'].delete;
expect(deleteUserOp).toBeDefined();
expect(deleteUserOp!.deprecated).toBeTrue();
});
// =============================================================================
// Server Integration Tests
// =============================================================================
tap.test('SmartServe should serve OpenAPI spec endpoint', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3500,
openapi: {
info: {
title: 'OpenAPI Test Server',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// Fetch the OpenAPI spec
const response = await fetch('http://localhost:3500/openapi.json');
expect(response.status).toEqual(200);
expect(response.headers.get('content-type')).toInclude('application/json');
const spec = await response.json();
expect(spec.openapi).toEqual('3.1.0');
expect(spec.info.title).toEqual('OpenAPI Test Server');
expect(spec.paths['/api/users']).toBeDefined();
} finally {
await server.stop();
}
});
tap.test('SmartServe should serve Swagger UI', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3501,
openapi: {
info: {
title: 'Swagger UI Test',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// Fetch the Swagger UI
const response = await fetch('http://localhost:3501/docs');
expect(response.status).toEqual(200);
expect(response.headers.get('content-type')).toInclude('text/html');
const html = await response.text();
expect(html).toInclude('swagger-ui');
expect(html).toInclude('/openapi.json');
} finally {
await server.stop();
}
});
// =============================================================================
// Validation Tests
// =============================================================================
tap.test('OpenAPI validation should reject invalid request body', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3502,
openapi: {
info: {
title: 'Validation Test',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// Send invalid body (missing required fields)
const response = await fetch('http://localhost:3502/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test' }), // Missing email
});
expect(response.status).toEqual(400);
const error = await response.json();
expect(error.error).toEqual('Validation failed');
expect(error.details).toBeDefined();
} finally {
await server.stop();
}
});
tap.test('OpenAPI validation should accept valid request body', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3503,
openapi: {
info: {
title: 'Validation Test',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// Send valid body
const response = await fetch('http://localhost:3503/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'John Doe',
email: 'john@example.com',
}),
});
expect(response.status).toEqual(200);
const data = await response.json();
expect(data.name).toEqual('John Doe');
expect(data.email).toEqual('john@example.com');
} finally {
await server.stop();
}
});
tap.test('OpenAPI should coerce query parameters', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3504,
openapi: {
info: {
title: 'Coercion Test',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// Query params should be coerced from strings to numbers
const response = await fetch('http://localhost:3504/api/users?limit=25&offset=50');
expect(response.status).toEqual(200);
const data = await response.json();
// The handler returns the limit/offset values
expect(data.pagination.limit).toEqual(25);
expect(data.pagination.offset).toEqual(50);
// They should be numbers, not strings
expect(typeof data.pagination.limit).toEqual('number');
expect(typeof data.pagination.offset).toEqual('number');
} finally {
await server.stop();
}
});
tap.test('OpenAPI should apply default values to query parameters', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3505,
openapi: {
info: {
title: 'Default Values Test',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// No query params - should get defaults
const response = await fetch('http://localhost:3505/api/users');
expect(response.status).toEqual(200);
const data = await response.json();
// Default values from schema
expect(data.pagination.limit).toEqual(10);
expect(data.pagination.offset).toEqual(0);
} finally {
await server.stop();
}
});
tap.test('OpenAPI can be disabled', async () => {
ControllerRegistry.clear();
const server = new SmartServe({
port: 3506,
openapi: {
enabled: false,
info: {
title: 'Disabled Test',
version: '1.0.0',
},
},
});
server.register(OpenApiUserController);
await server.start();
try {
// OpenAPI endpoints should not be registered
const response = await fetch('http://localhost:3506/openapi.json');
expect(response.status).toEqual(404);
} finally {
await server.stop();
}
});
// Cleanup
tap.test('cleanup - clear controller registry', async () => {
ControllerRegistry.clear();
});
export default tap.start();

View File

@@ -7,9 +7,53 @@ import {
Guard, Guard,
Transform, Transform,
Intercept, Intercept,
Compress,
NoCompress,
HttpError, HttpError,
type IRequestContext, type IRequestContext,
} from '../ts/index.js'; } from '../ts/index.js';
import * as zlib from 'zlib';
import * as http from 'http';
import { promisify } from 'util';
const gunzip = promisify(zlib.gunzip);
const brotliDecompress = promisify(zlib.brotliDecompress);
/**
* Make a raw HTTP request without automatic decompression
* (Node.js fetch auto-decompresses, which breaks our tests)
*/
function rawRequest(options: {
port: number;
path: string;
headers?: Record<string, string>;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: 'localhost',
port: options.port,
path: options.path,
method: 'GET',
headers: options.headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode ?? 200,
headers: res.headers,
body: Buffer.concat(chunks),
});
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
}
// Test controller // Test controller
@Route('/api') @Route('/api')
@@ -25,8 +69,9 @@ class TestController {
} }
@Post('/echo') @Post('/echo')
echo(ctx: IRequestContext<{ text: string }>) { async echo(ctx: IRequestContext<{ text: string }>) {
return { echo: ctx.body?.text }; const body = await ctx.json();
return { echo: body?.text };
} }
} }
@@ -56,6 +101,48 @@ class WrappedController {
} }
} }
// Controller with compression decorators
@Route('/compression')
class CompressionController {
@Get('/large')
getLargeData() {
// Return data larger than default threshold (1024 bytes)
return {
data: 'x'.repeat(2000),
items: Array.from({ length: 50 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: `This is a description for item ${i} with some extra text to make it longer`,
})),
};
}
@Get('/small')
getSmallData() {
// Return data smaller than threshold
return { ok: true };
}
@Get('/no-compress')
@NoCompress()
getNoCompress() {
// Should never be compressed
return {
data: 'x'.repeat(2000),
compressed: false,
};
}
@Get('/force-compress')
@Compress({ level: 9 })
getForceCompress() {
return {
data: 'y'.repeat(2000),
compressed: true,
};
}
}
tap.test('SmartServe should create server instance', async () => { tap.test('SmartServe should create server instance', async () => {
const server = new SmartServe({ port: 3456 }); const server = new SmartServe({ port: 3456 });
expect(server).toBeInstanceOf(SmartServe); expect(server).toBeInstanceOf(SmartServe);
@@ -205,4 +292,152 @@ tap.test('HttpError should create proper responses', async () => {
expect(body.details.id).toEqual('123'); expect(body.details.id).toEqual('123');
}); });
// ============================================================================
// Compression Tests
// ============================================================================
tap.test('Compression should apply gzip when Accept-Encoding is sent', async () => {
const server = new SmartServe({ port: 3470 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3470,
path: '/compression/large',
headers: { 'Accept-Encoding': 'gzip' },
});
expect(response.status).toEqual(200);
expect(response.headers['content-encoding']).toEqual('gzip');
expect(response.headers['vary']).toInclude('Accept-Encoding');
// Decompress and verify content
const decompressed = await gunzip(response.body);
const data = JSON.parse(decompressed.toString());
expect(data.data).toStartWith('xxx');
expect(data.items.length).toEqual(50);
} finally {
await server.stop();
}
});
tap.test('Compression should apply brotli when preferred', async () => {
const server = new SmartServe({ port: 3471 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3471,
path: '/compression/large',
headers: { 'Accept-Encoding': 'br, gzip' },
});
expect(response.status).toEqual(200);
expect(response.headers['content-encoding']).toEqual('br');
// Decompress and verify content
const decompressed = await brotliDecompress(response.body);
const data = JSON.parse(decompressed.toString());
expect(data.data).toStartWith('xxx');
} finally {
await server.stop();
}
});
tap.test('Compression should skip small responses', async () => {
const server = new SmartServe({ port: 3472 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3472,
path: '/compression/small',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Small response should NOT be compressed
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.ok).toBeTrue();
} finally {
await server.stop();
}
});
tap.test('Compression should respect @NoCompress decorator', async () => {
const server = new SmartServe({ port: 3473 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3473,
path: '/compression/no-compress',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Should NOT be compressed due to @NoCompress
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.compressed).toBeFalse();
} finally {
await server.stop();
}
});
tap.test('Compression should skip when Accept-Encoding not sent', async () => {
const server = new SmartServe({ port: 3474 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3474,
path: '/compression/large',
// No Accept-Encoding header
});
expect(response.status).toEqual(200);
// Should NOT be compressed when client doesn't accept it
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.data).toStartWith('xxx');
} finally {
await server.stop();
}
});
tap.test('Compression can be disabled globally', async () => {
const server = new SmartServe({
port: 3475,
compression: false,
});
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3475,
path: '/compression/large',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Should NOT be compressed when globally disabled
expect(response.headers['content-encoding']).toBeUndefined();
} finally {
await server.stop();
}
});
export default tap.start(); export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartserve', name: '@push.rocks/smartserve',
version: '1.0.2', version: '2.0.1',
description: 'a cross platform server module for Node, Deno and Bun' description: 'a cross platform server module for Node, Deno and Bun'
} }

View File

@@ -1,4 +1,4 @@
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js'; import type { ISmartServeInstance, IConnectionInfo, IWebSocketPeer, IWebSocketConnectionCallbacks } from '../core/smartserve.interfaces.js';
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js'; import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
// Bun types (for type checking without requiring Bun runtime) // Bun types (for type checking without requiring Bun runtime)
@@ -46,9 +46,17 @@ export class BunAdapter extends BaseAdapter {
this.stats.requestsActive++; this.stats.requestsActive++;
try { try {
// Handle WebSocket upgrade // Handle WebSocket upgrade - store data for persistence across events
if (this.options.websocket && request.headers.get('upgrade') === 'websocket') { if (this.options.websocket && request.headers.get('upgrade') === 'websocket') {
const upgraded = server.upgrade(request); const peerId = crypto.randomUUID();
const upgraded = server.upgrade(request, {
data: {
id: peerId,
url: new URL(request.url).pathname,
customData: new Map(),
tags: new Set<string>(),
},
});
if (upgraded) { if (upgraded) {
return undefined; // Bun handles the upgrade return undefined; // Bun handles the upgrade
} }
@@ -108,33 +116,67 @@ export class BunAdapter extends BaseAdapter {
const hooks = this.options.websocket; const hooks = this.options.websocket;
if (!hooks) return undefined; if (!hooks) return undefined;
// Get internal callbacks if typedRouter mode
const callbacks = (hooks as any)._connectionCallbacks as IWebSocketConnectionCallbacks | undefined;
const typedRouter = hooks.typedRouter;
return { return {
open: (ws: any) => { open: (ws: any) => {
const peer = this.wrapBunWebSocket(ws); const peer = this.wrapBunWebSocket(ws);
// Register connection if typedRouter mode
if (callbacks) {
callbacks.onRegister(peer);
}
hooks.onOpen?.(peer); hooks.onOpen?.(peer);
}, },
message: (ws: any, message: string | ArrayBuffer) => {
message: async (ws: any, message: string | ArrayBuffer) => {
const peer = this.wrapBunWebSocket(ws); const peer = this.wrapBunWebSocket(ws);
const msg = {
type: typeof message === 'string' ? 'text' as const : 'binary' as const, // If typedRouter is configured, route through it
text: typeof message === 'string' ? message : undefined, if (typedRouter && typeof message === 'string') {
data: message instanceof ArrayBuffer ? new Uint8Array(message) : undefined, try {
size: typeof message === 'string' ? message.length : (message as ArrayBuffer).byteLength, const requestObj = JSON.parse(message);
}; // Attach peer to localData so TypedHandlers can access the connection
hooks.onMessage?.(peer, msg); requestObj.localData = { ...requestObj.localData, peer };
const response = await typedRouter.routeAndAddResponse(requestObj);
if (response) {
peer.send(JSON.stringify(response));
}
} catch (error) {
console.error('TypedRouter message handling error:', error);
}
} else {
// Legacy mode: use onMessage hook
const msg = {
type: typeof message === 'string' ? 'text' as const : 'binary' as const,
text: typeof message === 'string' ? message : undefined,
data: message instanceof ArrayBuffer ? new Uint8Array(message) : undefined,
size: typeof message === 'string' ? message.length : (message as ArrayBuffer).byteLength,
};
hooks.onMessage?.(peer, msg);
}
}, },
close: (ws: any, code: number, reason: string) => { close: (ws: any, code: number, reason: string) => {
const peer = this.wrapBunWebSocket(ws); const peer = this.wrapBunWebSocket(ws);
// Unregister connection if typedRouter mode
if (callbacks) {
callbacks.onUnregister(peer.id);
}
hooks.onClose?.(peer, code, reason); hooks.onClose?.(peer, code, reason);
}, },
error: (ws: any, error: Error) => { error: (ws: any, error: Error) => {
const peer = this.wrapBunWebSocket(ws); const peer = this.wrapBunWebSocket(ws);
hooks.onError?.(peer, error); hooks.onError?.(peer, error);
}, },
ping: (ws: any, data: ArrayBuffer) => { ping: (ws: any, data: ArrayBuffer) => {
const peer = this.wrapBunWebSocket(ws); const peer = this.wrapBunWebSocket(ws);
hooks.onPing?.(peer, new Uint8Array(data)); hooks.onPing?.(peer, new Uint8Array(data));
}, },
pong: (ws: any, data: ArrayBuffer) => { pong: (ws: any, data: ArrayBuffer) => {
const peer = this.wrapBunWebSocket(ws); const peer = this.wrapBunWebSocket(ws);
hooks.onPong?.(peer, new Uint8Array(data)); hooks.onPong?.(peer, new Uint8Array(data));
@@ -142,10 +184,18 @@ export class BunAdapter extends BaseAdapter {
}; };
} }
private wrapBunWebSocket(ws: any): any { private wrapBunWebSocket(ws: any): IWebSocketPeer {
// IMPORTANT: Use persistent data from ws.data since Bun re-wraps on each event
const wsData = ws.data || {
id: crypto.randomUUID(),
url: '',
customData: new Map(),
tags: new Set<string>(),
};
return { return {
id: ws.data?.id ?? crypto.randomUUID(), id: wsData.id,
url: ws.data?.url ?? '', url: wsData.url,
get readyState() { return ws.readyState; }, get readyState() { return ws.readyState; },
protocol: ws.protocol ?? '', protocol: ws.protocol ?? '',
extensions: ws.extensions ?? '', extensions: ws.extensions ?? '',
@@ -155,7 +205,8 @@ export class BunAdapter extends BaseAdapter {
ping: (data?: Uint8Array) => ws.ping(data), ping: (data?: Uint8Array) => ws.ping(data),
terminate: () => ws.terminate(), terminate: () => ws.terminate(),
context: {} as any, context: {} as any,
data: ws.data?.customData ?? new Map(), data: wsData.customData,
tags: wsData.tags, // Reference to persistent Set
}; };
} }
} }

View File

@@ -1,4 +1,4 @@
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js'; import type { ISmartServeInstance, IConnectionInfo, IWebSocketPeer, IWebSocketConnectionCallbacks } from '../core/smartserve.interfaces.js';
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js'; import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
// Deno types (for type checking without requiring Deno runtime) // Deno types (for type checking without requiring Deno runtime)
@@ -98,21 +98,49 @@ export class DenoAdapter extends BaseAdapter {
const peer = this.createWebSocketPeer(socket, request); const peer = this.createWebSocketPeer(socket, request);
// Get internal callbacks if typedRouter mode
const callbacks = (hooks as any)._connectionCallbacks as IWebSocketConnectionCallbacks | undefined;
const typedRouter = hooks.typedRouter;
socket.onopen = () => { socket.onopen = () => {
// Register connection if typedRouter mode
if (callbacks) {
callbacks.onRegister(peer);
}
hooks.onOpen?.(peer); hooks.onOpen?.(peer);
}; };
socket.onmessage = (event) => { socket.onmessage = async (event) => {
const message = { // If typedRouter is configured, route through it
type: typeof event.data === 'string' ? 'text' as const : 'binary' as const, if (typedRouter && typeof event.data === 'string') {
text: typeof event.data === 'string' ? event.data : undefined, try {
data: event.data instanceof Uint8Array ? event.data : undefined, const requestObj = JSON.parse(event.data);
size: typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength, // Attach peer to localData so TypedHandlers can access the connection
}; requestObj.localData = { ...requestObj.localData, peer };
hooks.onMessage?.(peer, message); const response = await typedRouter.routeAndAddResponse(requestObj);
if (response) {
peer.send(JSON.stringify(response));
}
} catch (error) {
console.error('TypedRouter message handling error:', error);
}
} else {
// Legacy mode: use onMessage hook
const message = {
type: typeof event.data === 'string' ? 'text' as const : 'binary' as const,
text: typeof event.data === 'string' ? event.data : undefined,
data: event.data instanceof Uint8Array ? event.data : undefined,
size: typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength,
};
hooks.onMessage?.(peer, message);
}
}; };
socket.onclose = (event) => { socket.onclose = (event) => {
// Unregister connection if typedRouter mode
if (callbacks) {
callbacks.onUnregister(peer.id);
}
hooks.onClose?.(peer, event.code, event.reason); hooks.onClose?.(peer, event.code, event.reason);
}; };
@@ -121,12 +149,12 @@ export class DenoAdapter extends BaseAdapter {
}; };
} }
private createWebSocketPeer(socket: WebSocket, request: Request): any { private createWebSocketPeer(socket: WebSocket, request: Request): IWebSocketPeer {
const url = this.parseUrl(request); const url = this.parseUrl(request);
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
url: url.pathname, url: url.pathname,
get readyState() { return socket.readyState; }, get readyState() { return socket.readyState as 0 | 1 | 2 | 3; },
protocol: socket.protocol, protocol: socket.protocol,
extensions: socket.extensions, extensions: socket.extensions,
send: (data: string) => socket.send(data), send: (data: string) => socket.send(data),
@@ -134,8 +162,9 @@ export class DenoAdapter extends BaseAdapter {
close: (code?: number, reason?: string) => socket.close(code, reason), close: (code?: number, reason?: string) => socket.close(code, reason),
ping: () => { /* Deno handles ping/pong automatically */ }, ping: () => { /* Deno handles ping/pong automatically */ },
terminate: () => socket.close(), terminate: () => socket.close(),
context: {} as any, // Will be populated with IRequestContext context: {} as any,
data: new Map(), data: new Map(),
tags: new Set<string>(),
}; };
} }
} }

View File

@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js'; import type { ISmartServeInstance, IConnectionInfo, IWebSocketPeer, IWebSocketConnectionCallbacks } from '../core/smartserve.interfaces.js';
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js'; import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
/** /**
@@ -256,6 +256,10 @@ export class NodeAdapter extends BaseAdapter {
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
// Get internal callbacks if typedRouter mode
const callbacks = (hooks as any)._connectionCallbacks as IWebSocketConnectionCallbacks | undefined;
const typedRouter = hooks.typedRouter;
this.server.on('upgrade', (request, socket, head) => { this.server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => { wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request); wss.emit('connection', ws, request);
@@ -265,19 +269,46 @@ export class NodeAdapter extends BaseAdapter {
wss.on('connection', (ws: any, request: any) => { wss.on('connection', (ws: any, request: any) => {
const peer = this.wrapNodeWebSocket(ws, request); const peer = this.wrapNodeWebSocket(ws, request);
// Register connection if typedRouter mode
if (callbacks) {
callbacks.onRegister(peer);
}
// Call user's onOpen hook
hooks.onOpen?.(peer); hooks.onOpen?.(peer);
ws.on('message', (data: Buffer | string) => { ws.on('message', async (data: Buffer | string) => {
const message = { // If typedRouter is configured, route through it
type: typeof data === 'string' ? 'text' as const : 'binary' as const, if (typedRouter) {
text: typeof data === 'string' ? data : undefined, try {
data: Buffer.isBuffer(data) ? new Uint8Array(data) : undefined, const messageText = typeof data === 'string' ? data : data.toString('utf8');
size: typeof data === 'string' ? data.length : data.length, const requestObj = JSON.parse(messageText);
}; // Attach peer to localData so TypedHandlers can access the connection
hooks.onMessage?.(peer, message); requestObj.localData = { ...requestObj.localData, peer };
const response = await typedRouter.routeAndAddResponse(requestObj);
if (response) {
peer.send(JSON.stringify(response));
}
} catch (error) {
console.error('TypedRouter message handling error:', error);
}
} else {
// Legacy mode: use onMessage hook
const message = {
type: typeof data === 'string' ? 'text' as const : 'binary' as const,
text: typeof data === 'string' ? data : undefined,
data: Buffer.isBuffer(data) ? new Uint8Array(data) : undefined,
size: typeof data === 'string' ? data.length : data.length,
};
hooks.onMessage?.(peer, message);
}
}); });
ws.on('close', (code: number, reason: Buffer) => { ws.on('close', (code: number, reason: Buffer) => {
// Unregister connection if typedRouter mode
if (callbacks) {
callbacks.onUnregister(peer.id);
}
hooks.onClose?.(peer, code, reason.toString()); hooks.onClose?.(peer, code, reason.toString());
}); });
@@ -298,7 +329,7 @@ export class NodeAdapter extends BaseAdapter {
} }
} }
private wrapNodeWebSocket(ws: any, request: any): any { private wrapNodeWebSocket(ws: any, request: any): IWebSocketPeer {
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
url: request.url ?? '', url: request.url ?? '',
@@ -312,6 +343,7 @@ export class NodeAdapter extends BaseAdapter {
terminate: () => ws.terminate(), terminate: () => ws.terminate(),
context: {} as any, context: {} as any,
data: new Map(), data: new Map(),
tags: new Set<string>(),
}; };
} }
} }

View File

@@ -0,0 +1,237 @@
/**
* Compression middleware for HTTP responses
*/
import type { TCompressionAlgorithm } from '../utils/index.js';
import { selectEncoding, isCompressible } from '../utils/index.js';
import { getCompressionProvider } from './compression.runtime.js';
// =============================================================================
// Configuration Interface
// =============================================================================
/**
* Compression configuration
*/
export interface ICompressionConfig {
/** Enable compression (default: true) */
enabled?: boolean;
/** Preferred algorithms in order (default: ['br', 'gzip']) */
algorithms?: TCompressionAlgorithm[];
/** Minimum size in bytes to compress (default: 1024) */
threshold?: number;
/** Compression level (1-11 for brotli, 1-9 for gzip, default: 4 for brotli, 6 for gzip) */
level?: number;
/** MIME types to compress (default: text/*, application/json, etc.) */
compressibleTypes?: string[];
/** Skip compression for these paths (glob patterns) */
exclude?: string[];
}
/**
* Default compression configuration
*/
export const DEFAULT_COMPRESSION_CONFIG: Required<ICompressionConfig> = {
enabled: true,
algorithms: ['br', 'gzip'],
threshold: 1024,
level: 4,
compressibleTypes: [], // Empty means use defaults from isCompressible
exclude: [],
};
/**
* Normalize compression config from boolean or partial config
*/
export function normalizeCompressionConfig(
config: ICompressionConfig | boolean | undefined
): ICompressionConfig {
if (config === false) {
return { enabled: false };
}
if (config === true || config === undefined) {
return { ...DEFAULT_COMPRESSION_CONFIG };
}
return {
...DEFAULT_COMPRESSION_CONFIG,
...config,
};
}
// =============================================================================
// Compression Helpers
// =============================================================================
/**
* Check if response should be compressed (preliminary check)
* Note: Final threshold check happens in compressResponse after buffering
*/
export function shouldCompressResponse(
response: Response,
request: Request,
config: ICompressionConfig
): boolean {
// Disabled
if (config.enabled === false) {
return false;
}
// Already compressed
if (response.headers.has('Content-Encoding')) {
return false;
}
// No body to compress
if (!response.body) {
return false;
}
// Check content type
const contentType = response.headers.get('Content-Type');
const customTypes = config.compressibleTypes?.length ? config.compressibleTypes : undefined;
if (!isCompressible(contentType, customTypes)) {
return false;
}
// Check excluded paths
if (config.exclude?.length) {
const url = new URL(request.url);
const pathname = url.pathname;
for (const pattern of config.exclude) {
if (matchGlobPattern(pattern, pathname)) {
return false;
}
}
}
// Check client accepts compression
const acceptEncoding = request.headers.get('Accept-Encoding');
if (!acceptEncoding) {
return false;
}
return true;
}
/**
* Simple glob pattern matching (supports * and **)
*/
function matchGlobPattern(pattern: string, path: string): boolean {
const regexPattern = pattern
.replace(/\*\*/g, '.*')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '.');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
/**
* Select the best compression algorithm based on client and server support
*/
export function selectCompressionAlgorithm(
request: Request,
config: ICompressionConfig
): TCompressionAlgorithm {
const acceptEncoding = request.headers.get('Accept-Encoding');
const serverAlgorithms = config.algorithms ?? DEFAULT_COMPRESSION_CONFIG.algorithms;
// Get runtime-supported algorithms
const provider = getCompressionProvider();
const runtimeAlgorithms = provider.getSupportedAlgorithms();
// Filter server config to only include runtime-supported algorithms
const supported = serverAlgorithms.filter((alg) =>
runtimeAlgorithms.includes(alg)
);
if (supported.length === 0) {
return 'identity';
}
return selectEncoding(acceptEncoding, supported);
}
/**
* Compress a Response object
* Uses buffered compression for reliability (streaming can have flushing issues)
*/
export async function compressResponse(
response: Response,
algorithm: TCompressionAlgorithm,
level?: number,
threshold?: number
): Promise<Response> {
if (algorithm === 'identity' || !response.body) {
return response;
}
// Read the entire body first (required for proper compression)
const originalBody = new Uint8Array(await response.arrayBuffer());
// Check threshold - if body is too small, return uncompressed
const effectiveThreshold = threshold ?? DEFAULT_COMPRESSION_CONFIG.threshold;
if (originalBody.byteLength < effectiveThreshold) {
// Return original response with the body we read
return new Response(originalBody as unknown as BodyInit, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const provider = getCompressionProvider();
// Compress the body
const compressedBody = await provider.compress(originalBody, algorithm, level);
// Clone headers and modify
const headers = new Headers(response.headers);
headers.set('Content-Encoding', algorithm);
headers.set('Vary', appendVaryHeader(headers.get('Vary'), 'Accept-Encoding'));
headers.set('Content-Length', compressedBody.byteLength.toString());
return new Response(compressedBody as unknown as BodyInit, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Append value to Vary header
*/
function appendVaryHeader(existing: string | null, value: string): string {
if (!existing) {
return value;
}
const values = existing.split(',').map((v) => v.trim().toLowerCase());
if (values.includes(value.toLowerCase())) {
return existing;
}
return `${existing}, ${value}`;
}
// =============================================================================
// Full-Body Compression (for small responses)
// =============================================================================
/**
* Compress entire response body at once (for small/known-size responses)
*/
export async function compressResponseBody(
body: Uint8Array,
algorithm: TCompressionAlgorithm,
level?: number
): Promise<Uint8Array> {
if (algorithm === 'identity') {
return body;
}
const provider = getCompressionProvider();
return provider.compress(body, algorithm, level);
}

View File

@@ -0,0 +1,323 @@
/**
* Cross-runtime compression abstraction
*
* Uses:
* - Node.js: zlib module (native, full brotli support)
* - Deno/Bun: CompressionStream API (Web Standard)
*/
import * as plugins from '../plugins.js';
import type { TCompressionAlgorithm } from '../utils/index.js';
import type { Transform } from 'stream';
// =============================================================================
// Compression Provider Interface
// =============================================================================
export interface ICompressionProvider {
/**
* Compress data to Uint8Array
*/
compress(
data: Uint8Array,
algorithm: TCompressionAlgorithm,
level?: number
): Promise<Uint8Array>;
/**
* Compress a ReadableStream
*/
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
level?: number
): ReadableStream<Uint8Array>;
/**
* Get supported algorithms for this runtime
*/
getSupportedAlgorithms(): TCompressionAlgorithm[];
}
// =============================================================================
// Node.js Compression Provider (using zlib)
// =============================================================================
class NodeCompressionProvider implements ICompressionProvider {
getSupportedAlgorithms(): TCompressionAlgorithm[] {
return ['br', 'gzip', 'deflate'];
}
async compress(
data: Uint8Array,
algorithm: TCompressionAlgorithm,
level?: number
): Promise<Uint8Array> {
if (algorithm === 'identity') {
return data;
}
return new Promise((resolve, reject) => {
const buffer = Buffer.from(data);
const callback = (err: Error | null, result: Buffer) => {
if (err) reject(err);
else resolve(new Uint8Array(result));
};
switch (algorithm) {
case 'br':
plugins.zlib.brotliCompress(
buffer,
{
params: {
[plugins.zlib.constants.BROTLI_PARAM_QUALITY]: level ?? 4,
},
},
callback
);
break;
case 'gzip':
plugins.zlib.gzip(buffer, { level: level ?? 6 }, callback);
break;
case 'deflate':
plugins.zlib.deflate(buffer, { level: level ?? 6 }, callback);
break;
default:
resolve(data);
}
});
}
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
level?: number
): ReadableStream<Uint8Array> {
if (algorithm === 'identity') {
return stream;
}
// Create zlib transform stream
let zlibStream: Transform;
switch (algorithm) {
case 'br':
zlibStream = plugins.zlib.createBrotliCompress({
params: {
[plugins.zlib.constants.BROTLI_PARAM_QUALITY]: level ?? 4,
},
});
break;
case 'gzip':
zlibStream = plugins.zlib.createGzip({ level: level ?? 6 });
break;
case 'deflate':
zlibStream = plugins.zlib.createDeflate({ level: level ?? 6 });
break;
default:
return stream;
}
// Convert Web ReadableStream to Node stream, compress, and back
const reader = stream.getReader();
return new ReadableStream<Uint8Array>({
async start(controller) {
// Pipe data through zlib
zlibStream.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
zlibStream.on('end', () => {
controller.close();
});
zlibStream.on('error', (err) => {
controller.error(err);
});
// Read from source and write to zlib
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
zlibStream.end();
break;
}
zlibStream.write(Buffer.from(value));
}
} catch (err) {
controller.error(err);
}
},
cancel() {
reader.cancel();
zlibStream.destroy();
},
});
}
}
// =============================================================================
// Web Standard Compression Provider (Deno/Bun/Browser)
// =============================================================================
class WebStandardCompressionProvider implements ICompressionProvider {
private _brotliSupported: boolean | null = null;
private _isDeno: boolean;
constructor() {
this._isDeno = typeof (globalThis as any).Deno !== 'undefined';
}
/**
* Check if brotli is supported via Deno.compress API
*/
private hasDenoBrotli(): boolean {
if (this._brotliSupported === null) {
if (this._isDeno) {
// Deno 1.37+ has Deno.compress/decompress with brotli support
const Deno = (globalThis as any).Deno;
this._brotliSupported = typeof Deno?.compress === 'function';
} else {
this._brotliSupported = false;
}
}
return this._brotliSupported;
}
getSupportedAlgorithms(): TCompressionAlgorithm[] {
// CompressionStream supports gzip and deflate
const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate'];
// Deno has native brotli via Deno.compress
if (this.hasDenoBrotli()) {
algorithms.unshift('br');
}
return algorithms;
}
async compress(
data: Uint8Array,
algorithm: TCompressionAlgorithm,
_level?: number
): Promise<Uint8Array> {
if (algorithm === 'identity') {
return data;
}
// Use Deno's native brotli if available
if (algorithm === 'br' && this.hasDenoBrotli()) {
try {
const Deno = (globalThis as any).Deno;
return await Deno.compress(data, 'br');
} catch {
// Fall through to return original
return data;
}
}
// Use CompressionStream for gzip/deflate
if (algorithm === 'gzip' || algorithm === 'deflate') {
try {
const stream = new CompressionStream(algorithm);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Write data and close
await writer.write(data as unknown as BufferSource);
await writer.close();
// Collect compressed chunks
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Concatenate chunks
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
} catch {
// Compression failed, return original
return data;
}
}
// Unsupported algorithm
return data;
}
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
_level?: number
): ReadableStream<Uint8Array> {
if (algorithm === 'identity') {
return stream;
}
// Brotli streaming not supported in Web Standard (Deno.compress is not streaming)
// Only gzip/deflate work with CompressionStream
if (algorithm !== 'gzip' && algorithm !== 'deflate') {
return stream;
}
try {
const compressionStream = new CompressionStream(algorithm);
return stream.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
} catch {
// Compression not supported, return original stream
return stream;
}
}
}
// =============================================================================
// Factory & Singleton
// =============================================================================
let compressionProvider: ICompressionProvider | null = null;
/**
* Create appropriate compression provider for the current runtime
*/
export function createCompressionProvider(): ICompressionProvider {
// Check for Node.js (has zlib module)
if (typeof process !== 'undefined' && process.versions?.node) {
return new NodeCompressionProvider();
}
// Check for Deno
if (typeof (globalThis as any).Deno !== 'undefined') {
return new WebStandardCompressionProvider();
}
// Check for Bun
if (typeof (globalThis as any).Bun !== 'undefined') {
return new WebStandardCompressionProvider();
}
// Fallback to Web Standard
return new WebStandardCompressionProvider();
}
/**
* Get the singleton compression provider
*/
export function getCompressionProvider(): ICompressionProvider {
if (!compressionProvider) {
compressionProvider = createCompressionProvider();
}
return compressionProvider;
}

19
ts/compression/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Compression module exports
*/
export {
getCompressionProvider,
createCompressionProvider,
type ICompressionProvider,
} from './compression.runtime.js';
export {
shouldCompressResponse,
selectCompressionAlgorithm,
compressResponse,
compressResponseBody,
normalizeCompressionConfig,
DEFAULT_COMPRESSION_CONFIG,
type ICompressionConfig,
} from './compression.middleware.js';

View File

@@ -10,15 +10,119 @@ import type {
IRequestContext, IRequestContext,
IConnectionInfo, IConnectionInfo,
THttpMethod, THttpMethod,
TRuntime,
IInterceptOptions, IInterceptOptions,
TRequestInterceptor, TRequestInterceptor,
TResponseInterceptor, TResponseInterceptor,
IWebSocketPeer,
IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.js'; } from './smartserve.interfaces.js';
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError } from './smartserve.errors.js';
// =============================================================================
// RequestContext - Implements lazy body parsing
// =============================================================================
interface IBodyCache<T> {
done: boolean;
value?: T;
error?: Error;
}
/**
* Request context implementation with lazy body parsing.
* Body is never consumed automatically - use json(), text(), etc. when needed.
*/
class RequestContext<TBody = unknown> implements IRequestContext<TBody> {
private _jsonCache: IBodyCache<TBody> = { done: false };
private _textCache: IBodyCache<string> = { done: false };
private _arrayBufferCache: IBodyCache<ArrayBuffer> = { done: false };
private _formDataCache: IBodyCache<FormData> = { done: false };
constructor(
public readonly request: Request,
public readonly params: Record<string, string>,
public readonly query: Record<string, string>,
public readonly headers: Headers,
public readonly path: string,
public readonly method: THttpMethod,
public readonly url: URL,
public readonly runtime: TRuntime,
public state: Record<string, unknown> = {},
) {}
async json(): Promise<TBody> {
if (this._jsonCache.done) {
if (this._jsonCache.error) throw this._jsonCache.error;
return this._jsonCache.value!;
}
try {
const value = await this.request.json() as TBody;
this._jsonCache = { done: true, value };
return value;
} catch (e) {
this._jsonCache = { done: true, error: e as Error };
throw e;
}
}
async text(): Promise<string> {
if (this._textCache.done) {
if (this._textCache.error) throw this._textCache.error;
return this._textCache.value!;
}
try {
const value = await this.request.text();
this._textCache = { done: true, value };
return value;
} catch (e) {
this._textCache = { done: true, error: e as Error };
throw e;
}
}
async arrayBuffer(): Promise<ArrayBuffer> {
if (this._arrayBufferCache.done) {
if (this._arrayBufferCache.error) throw this._arrayBufferCache.error;
return this._arrayBufferCache.value!;
}
try {
const value = await this.request.arrayBuffer();
this._arrayBufferCache = { done: true, value };
return value;
} catch (e) {
this._arrayBufferCache = { done: true, error: e as Error };
throw e;
}
}
async formData(): Promise<FormData> {
if (this._formDataCache.done) {
if (this._formDataCache.error) throw this._formDataCache.error;
return this._formDataCache.value!;
}
try {
const value = await this.request.formData();
this._formDataCache = { done: true, value };
return value;
} catch (e) {
this._formDataCache = { done: true, error: e as Error };
throw e;
}
}
}
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js'; import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js'; import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
import { FileServer } from '../files/index.js'; import { FileServer } from '../files/index.js';
import { WebDAVHandler } from '../protocols/index.js'; import { WebDAVHandler } from '../protocols/index.js';
import {
normalizeCompressionConfig,
shouldCompressResponse,
selectCompressionAlgorithm,
compressResponse,
type ICompressionConfig,
} from '../compression/index.js';
import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.js';
/** /**
* SmartServe - Cross-platform HTTP server * SmartServe - Cross-platform HTTP server
@@ -41,13 +145,36 @@ export class SmartServe {
private customHandler: TRequestHandler | null = null; private customHandler: TRequestHandler | null = null;
private fileServer: FileServer | null = null; private fileServer: FileServer | null = null;
private webdavHandler: WebDAVHandler | null = null; private webdavHandler: WebDAVHandler | null = null;
private compressionConfig: ICompressionConfig;
/** WebSocket connection registry (only active when typedRouter is set) */
private wsConnections: Map<string, IWebSocketPeer> | null = null;
constructor(options: ISmartServeOptions) { constructor(options: ISmartServeOptions) {
// Validate websocket configuration - mutual exclusivity
if (options.websocket) {
const { typedRouter, onMessage } = options.websocket;
if (typedRouter && onMessage) {
throw new WebSocketConfigError(
'Cannot use both typedRouter and onMessage. ' +
'typedRouter handles message routing automatically.'
);
}
}
this.options = { this.options = {
hostname: '0.0.0.0', hostname: '0.0.0.0',
...options, ...options,
}; };
// Initialize compression config (enabled by default)
this.compressionConfig = normalizeCompressionConfig(options.compression);
// Initialize connection registry only when typedRouter is configured
if (this.options.websocket?.typedRouter) {
this.wsConnections = new Map();
}
// Initialize file server if static options provided // Initialize file server if static options provided
if (this.options.static) { if (this.options.static) {
this.fileServer = new FileServer(this.options.static); this.fileServer = new FileServer(this.options.static);
@@ -90,8 +217,42 @@ export class SmartServe {
throw new ServerAlreadyRunningError(); throw new ServerAlreadyRunningError();
} }
// Register OpenAPI endpoints if configured
if (this.options.openapi && this.options.openapi.enabled !== false) {
this.setupOpenApi();
}
// Prepare options with internal callbacks if typedRouter is configured
let adapterOptions = this.options;
if (this.options.websocket?.typedRouter && this.wsConnections) {
// Clone options and add internal callbacks for adapter communication
const connectionCallbacks: IWebSocketConnectionCallbacks = {
onRegister: (peer: IWebSocketPeer) => {
this.wsConnections!.set(peer.id, peer);
this.options.websocket?.onConnectionOpen?.(peer);
},
onUnregister: (peerId: string) => {
const peer = this.wsConnections!.get(peerId);
if (peer) {
this.wsConnections!.delete(peerId);
this.options.websocket?.onConnectionClose?.(peer);
}
},
};
adapterOptions = {
...this.options,
websocket: {
...this.options.websocket,
// Internal property for adapter communication (not part of public API)
_connectionCallbacks: connectionCallbacks,
} as typeof this.options.websocket & { _connectionCallbacks: IWebSocketConnectionCallbacks },
};
}
// Create adapter for current runtime // Create adapter for current runtime
this.adapter = await AdapterFactory.createAdapter(this.options); this.adapter = await AdapterFactory.createAdapter(adapterOptions);
// Create request handler // Create request handler
const handler = this.createRequestHandler(); const handler = this.createRequestHandler();
@@ -127,6 +288,93 @@ export class SmartServe {
return this.instance !== null; return this.instance !== null;
} }
// ===========================================================================
// WebSocket Connection Management (only available with typedRouter)
// ===========================================================================
/**
* Get all active WebSocket connections
* Only available when typedRouter is configured
*/
getWebSocketConnections(): IWebSocketPeer[] {
if (!this.wsConnections) {
return [];
}
return Array.from(this.wsConnections.values());
}
/**
* Get WebSocket connections filtered by tag
* Only available when typedRouter is configured
*/
getWebSocketConnectionsByTag(tag: string): IWebSocketPeer[] {
if (!this.wsConnections) {
return [];
}
return Array.from(this.wsConnections.values()).filter((peer) => peer.tags.has(tag));
}
/**
* Broadcast message to all WebSocket connections
* Only available when typedRouter is configured
*/
broadcastWebSocket(data: string | object): void {
if (!this.wsConnections) {
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
for (const peer of this.wsConnections.values()) {
try {
peer.send(message);
} catch (error) {
console.error(`Failed to send to peer ${peer.id}:`, error);
}
}
}
/**
* Broadcast message to WebSocket connections with specific tag
* Only available when typedRouter is configured
*/
broadcastWebSocketByTag(tag: string, data: string | object): void {
if (!this.wsConnections) {
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
for (const peer of this.wsConnections.values()) {
if (peer.tags.has(tag)) {
try {
peer.send(message);
} catch (error) {
console.error(`Failed to send to peer ${peer.id}:`, error);
}
}
}
}
/**
* Setup OpenAPI documentation endpoints
*/
private setupOpenApi(): void {
const openapi = this.options.openapi!;
const specPath = openapi.specPath ?? '/openapi.json';
const docsPath = openapi.docsPath ?? '/docs';
// Create generator options
const generatorOptions = {
info: openapi.info,
servers: openapi.servers,
securitySchemes: openapi.securitySchemes,
tags: openapi.tags,
};
// Register OpenAPI spec endpoint
ControllerRegistry.addRoute(specPath, 'GET', createOpenApiHandler(generatorOptions));
// Register Swagger UI endpoint
ControllerRegistry.addRoute(docsPath, 'GET', createSwaggerUiHandler(specPath, openapi.info.title));
}
/** /**
* Create the main request handler * Create the main request handler
*/ */
@@ -134,7 +382,9 @@ export class SmartServe {
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => { return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
// Use custom handler if set // Use custom handler if set
if (this.customHandler) { if (this.customHandler) {
return this.customHandler(request, connectionInfo); const response = await this.customHandler(request, connectionInfo);
// Apply compression to custom handler responses
return this.applyCompression(response, request);
} }
// Parse URL and method // Parse URL and method
@@ -144,7 +394,8 @@ export class SmartServe {
// Handle WebDAV requests first if handler is configured // Handle WebDAV requests first if handler is configured
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) { if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
try { try {
return await this.webdavHandler.handle(request); const response = await this.webdavHandler.handle(request);
return this.applyCompression(response, request);
} catch (error) { } catch (error) {
return this.handleError(error as Error, request); return this.handleError(error as Error, request);
} }
@@ -157,7 +408,8 @@ export class SmartServe {
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles) // No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
if (this.webdavHandler) { if (this.webdavHandler) {
try { try {
return await this.webdavHandler.handle(request); const response = await this.webdavHandler.handle(request);
return this.applyCompression(response, request);
} catch (error) { } catch (error) {
return this.handleError(error as Error, request); return this.handleError(error as Error, request);
} }
@@ -168,7 +420,8 @@ export class SmartServe {
try { try {
const staticResponse = await this.fileServer.serve(request); const staticResponse = await this.fileServer.serve(request);
if (staticResponse) { if (staticResponse) {
return staticResponse; // Apply compression to static file responses
return this.applyCompression(staticResponse, request);
} }
} catch (error) { } catch (error) {
return this.handleError(error as Error, request); return this.handleError(error as Error, request);
@@ -186,11 +439,14 @@ export class SmartServe {
const { route, params } = match; const { route, params } = match;
try { try {
// Create request context // Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
const context = await this.createContext(request, url, params, connectionInfo); const context = this.createContext(request, url, params, connectionInfo);
// Run interceptors and handler // Run interceptors and handler
return await this.executeRoute(route, context); const response = await this.executeRoute(route, context);
// Apply compression with route-specific settings
return this.applyCompression(response, request, route.compression);
} catch (error) { } catch (error) {
return this.handleError(error as Error, request); return this.handleError(error as Error, request);
} }
@@ -198,59 +454,32 @@ export class SmartServe {
} }
/** /**
* Create request context from Request object * Create request context from Request object.
* Body is NOT parsed here - use ctx.json(), ctx.text(), etc. for lazy parsing.
*/ */
private async createContext( private createContext(
request: Request, request: Request,
url: URL, url: URL,
params: Record<string, string>, params: Record<string, string>,
connectionInfo: IConnectionInfo connectionInfo: IConnectionInfo
): Promise<IRequestContext> { ): IRequestContext {
// Parse query params // Parse query params
const query: Record<string, string> = {}; const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => { url.searchParams.forEach((value, key) => {
query[key] = value; query[key] = value;
}); });
// Parse body (lazy) return new RequestContext(
let body: any = undefined;
const contentType = request.headers.get('content-type');
if (request.method !== 'GET' && request.method !== 'HEAD') {
if (contentType?.includes('application/json')) {
try {
body = await request.json();
} catch {
body = null;
}
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
try {
const text = await request.text();
body = Object.fromEntries(new URLSearchParams(text));
} catch {
body = null;
}
} else if (contentType?.includes('text/')) {
try {
body = await request.text();
} catch {
body = null;
}
}
}
return {
request, request,
body,
params, params,
query, query,
headers: request.headers, request.headers,
path: url.pathname, url.pathname,
method: request.method.toUpperCase() as THttpMethod, request.method.toUpperCase() as THttpMethod,
url, url,
runtime: this.adapter?.name ?? 'node', this.adapter?.name ?? 'node',
state: {}, {},
}; );
} }
/** /**
@@ -342,6 +571,50 @@ export class SmartServe {
}); });
} }
/**
* Apply compression to response if applicable
*/
private async applyCompression(
response: Response,
request: Request,
routeCompression?: IRouteCompressionOptions
): Promise<Response> {
// Check route-level override first
if (routeCompression?.enabled === false) {
return response;
}
// Build effective config (merge route settings with global)
const effectiveConfig: ICompressionConfig = {
...this.compressionConfig,
};
// Route-level compression settings override global
if (routeCompression?.level !== undefined) {
effectiveConfig.level = routeCompression.level;
}
// If route forces compression, ensure it's enabled
if (routeCompression?.enabled === true) {
effectiveConfig.enabled = true;
}
// Check if compression should be applied
if (!shouldCompressResponse(response, request, effectiveConfig)) {
return response;
}
// Select best algorithm
const algorithm = selectCompressionAlgorithm(request, effectiveConfig);
if (algorithm === 'identity') {
return response;
}
// Apply compression
return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold);
}
/** /**
* Handle errors * Handle errors
*/ */

View File

@@ -127,3 +127,13 @@ export class ServerNotRunningError extends Error {
this.name = 'ServerNotRunningError'; this.name = 'ServerNotRunningError';
} }
} }
/**
* Error thrown when WebSocket configuration is invalid
*/
export class WebSocketConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'WebSocketConfigError';
}
}

View File

@@ -3,6 +3,10 @@
* Uses Web Standards API (Request/Response) for cross-platform compatibility * Uses Web Standards API (Request/Response) for cross-platform compatibility
*/ */
import type { TypedRouter } from '@api.global/typedrequest';
import type { ICompressionConfig } from '../compression/index.js';
import type { IOpenApiOptions } from '../openapi/openapi.types.js';
// ============================================================================= // =============================================================================
// HTTP Types // HTTP Types
// ============================================================================= // =============================================================================
@@ -20,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
* Wraps Web Standard Request with additional utilities * Wraps Web Standard Request with additional utilities
*/ */
export interface IRequestContext<TBody = unknown> { export interface IRequestContext<TBody = unknown> {
/** Original Web Standards Request */ /** Original Web Standards Request - body stream is never consumed by framework */
readonly request: Request; readonly request: Request;
/** Parsed request body (typed) */
readonly body: TBody;
/** URL path parameters extracted from route */ /** URL path parameters extracted from route */
readonly params: Record<string, string>; readonly params: Record<string, string>;
/** URL query parameters */ /** URL query parameters */
@@ -40,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
readonly runtime: TRuntime; readonly runtime: TRuntime;
/** Route-specific state bag for passing data between interceptors */ /** Route-specific state bag for passing data between interceptors */
state: Record<string, unknown>; state: Record<string, unknown>;
/**
* Lazily parse request body as JSON.
* Result is cached after first call.
* @returns Typed body parsed from JSON
*/
json(): Promise<TBody>;
/**
* Lazily parse request body as text.
* Result is cached after first call.
* @returns Body as string
*/
text(): Promise<string>;
/**
* Lazily parse request body as ArrayBuffer.
* Result is cached after first call.
* @returns Body as ArrayBuffer
*/
arrayBuffer(): Promise<ArrayBuffer>;
/**
* Lazily parse request body as FormData.
* Result is cached after first call.
* @returns Body as FormData
*/
formData(): Promise<FormData>;
} }
// ============================================================================= // =============================================================================
@@ -168,18 +198,55 @@ export interface IWebSocketPeer {
context: IRequestContext; context: IRequestContext;
/** Custom per-peer data storage */ /** Custom per-peer data storage */
data: Map<string, unknown>; data: Map<string, unknown>;
/** Tags for connection filtering/grouping */
tags: Set<string>;
} }
/** /**
* WebSocket event hooks * WebSocket event hooks
*/ */
export interface IWebSocketHooks { export interface IWebSocketHooks {
/** Called when WebSocket connection opens */
onOpen?: (peer: IWebSocketPeer) => void | Promise<void>; onOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
/** Called when message received. Mutually exclusive with typedRouter. */
onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>; onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>;
/** Called when WebSocket connection closes */
onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>; onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>;
/** Called on WebSocket error */
onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>; onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>;
/** Called when ping received */
onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>; onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
/** Called when pong received */
onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>; onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
/**
* TypedRouter for type-safe RPC over WebSocket.
* Mutually exclusive with onMessage - cannot use both.
* When set, enables connection registry and broadcast methods.
*/
typedRouter?: TypedRouter;
/**
* Called when connection is established and registered.
* Only available when typedRouter is configured.
* Use this to tag connections for filtering.
*/
onConnectionOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
/**
* Called when connection is closed and unregistered.
* Only available when typedRouter is configured.
*/
onConnectionClose?: (peer: IWebSocketPeer) => void | Promise<void>;
}
/**
* Internal callbacks for adapter-to-SmartServe communication
* @internal
*/
export interface IWebSocketConnectionCallbacks {
onRegister: (peer: IWebSocketPeer) => void;
onUnregister: (peerId: string) => void;
} }
// ============================================================================= // =============================================================================
@@ -233,6 +300,8 @@ export interface IStaticOptions {
extensions?: string[]; extensions?: string[];
/** Enable directory listing */ /** Enable directory listing */
directoryListing?: boolean | IDirectoryListingOptions; directoryListing?: boolean | IDirectoryListingOptions;
/** Serve pre-compressed files (.br, .gz) when available */
precompressed?: boolean;
} }
/** /**
@@ -294,6 +363,10 @@ export interface ISmartServeOptions {
keepAlive?: IKeepAliveConfig; keepAlive?: IKeepAliveConfig;
/** Global error handler */ /** Global error handler */
onError?: (error: Error, request?: Request) => Response | Promise<Response>; onError?: (error: Error, request?: Request) => Response | Promise<Response>;
/** Compression configuration (enabled by default) */
compression?: ICompressionConfig | boolean;
/** OpenAPI documentation and validation configuration */
openapi?: IOpenApiOptions;
} }
// ============================================================================= // =============================================================================

View File

@@ -0,0 +1,115 @@
/**
* Compression control decorators (@Compress, @NoCompress)
*
* These decorators allow per-route control over compression:
* - @Compress(options?) - Force compression with optional settings
* - @NoCompress() - Disable compression for this route (useful for SSE, streaming)
*/
import { getControllerMetadata } from './decorators.metadata.js';
import type { IRouteCompressionOptions } from './decorators.types.js';
/**
* Set compression options for a route
*/
function setRouteCompression(
target: any,
methodName: string | symbol,
options: IRouteCompressionOptions
): void {
const metadata = getControllerMetadata(target.constructor);
let route = metadata.routes.get(methodName);
if (!route) {
// Create placeholder route (will be completed by @Get/@Post/etc.)
route = {
method: 'GET',
path: '',
methodName,
interceptors: [],
options: {},
};
metadata.routes.set(methodName, route);
}
route.compression = options;
}
/**
* @Compress decorator - Force compression on a route with optional settings
*
* @example
* ```typescript
* @Controller('/api')
* class ApiController {
* @Get('/heavy-data')
* @Compress({ level: 11 }) // Max brotli compression
* getHeavyData() {
* return massiveJsonData;
* }
*
* @Get('/data')
* @Compress() // Use default settings
* getData() {
* return jsonData;
* }
* }
* ```
*/
export function Compress(options?: { level?: number }) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
setRouteCompression(this, context.name, {
enabled: true,
level: options?.level,
});
});
return target;
};
}
/**
* @NoCompress decorator - Disable compression for a route
*
* Useful for:
* - Server-Sent Events (SSE)
* - Streaming responses
* - Already-compressed content
* - Real-time data where latency is critical
*
* @example
* ```typescript
* @Controller('/api')
* class EventController {
* @Get('/events')
* @NoCompress() // SSE should not be compressed
* getEvents() {
* return new Response(eventStream, {
* headers: { 'Content-Type': 'text/event-stream' }
* });
* }
*
* @Get('/video/:id')
* @NoCompress() // Already compressed media
* getVideo() {
* return videoStream;
* }
* }
* ```
*/
export function NoCompress() {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
setRouteCompression(this, context.name, {
enabled: false,
});
});
return target;
};
}

View File

@@ -135,7 +135,7 @@ export function combinePaths(basePath: string, routePath: string): string {
const route = normalizePath(routePath); const route = normalizePath(routePath);
if (!base) return route || '/'; if (!base) return route || '/';
if (!route) return base; if (!route || route === '/') return base || '/';
return `${base}${route}`; return `${base}${route}`;
} }

View File

@@ -2,9 +2,10 @@
* Controller registry - stores all registered controllers * Controller registry - stores all registered controllers
*/ */
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js'; import type { IControllerMetadata, IRegisteredController, ICompiledRoute, IOpenApiRouteMeta } from './decorators.types.js';
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js'; import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
import { getControllerMetadata, combinePaths } from './decorators.metadata.js'; import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
import { createValidationInterceptor } from '../openapi/openapi.validator.js';
/** /**
* Global registry of all controllers * Global registry of all controllers
@@ -13,6 +14,7 @@ export class ControllerRegistry {
private static controllers: Map<Function, IControllerMetadata> = new Map(); private static controllers: Map<Function, IControllerMetadata> = new Map();
private static instances: Map<Function, any> = new Map(); private static instances: Map<Function, any> = new Map();
private static compiledRoutes: ICompiledRoute[] = []; private static compiledRoutes: ICompiledRoute[] = [];
private static dynamicRoutes: ICompiledRoute[] = [];
private static routesCompiled = false; private static routesCompiled = false;
/** /**
@@ -65,10 +67,33 @@ export class ControllerRegistry {
return result; return result;
} }
/**
* Add a dynamic route without needing a controller class
*/
static addRoute(
path: string,
method: THttpMethod,
handler: (ctx: IRequestContext) => Promise<Response | any>
): void {
const { regex, paramNames } = this.pathToRegex(path);
this.dynamicRoutes.push({
pattern: path,
regex,
paramNames,
method,
handler: async (ctx: IRequestContext) => handler(ctx),
interceptors: [],
compression: undefined,
});
this.routesCompiled = false;
}
/** /**
* Compile all routes for fast matching * Compile all routes for fast matching
*/ */
static compileRoutes(): ICompiledRoute[] { static compileRoutes(enableValidation = true): ICompiledRoute[] {
if (this.routesCompiled) { if (this.routesCompiled) {
return this.compiledRoutes; return this.compiledRoutes;
} }
@@ -81,10 +106,19 @@ export class ControllerRegistry {
const { regex, paramNames } = this.pathToRegex(fullPath); const { regex, paramNames } = this.pathToRegex(fullPath);
// Combine class and method interceptors // Combine class and method interceptors
const interceptors: IInterceptOptions[] = [ const interceptors: IInterceptOptions[] = [];
...metadata.classInterceptors,
...route.interceptors, // Add OpenAPI validation interceptor first (before other interceptors)
]; // This ensures validation happens before any other processing
if (enableValidation && route.openapi && this.hasValidationMetadata(route.openapi)) {
interceptors.push({
request: createValidationInterceptor(route.openapi),
});
}
// Then add class-level and method-level interceptors
interceptors.push(...metadata.classInterceptors);
interceptors.push(...route.interceptors);
// Create bound handler // Create bound handler
const handler = async (ctx: IRequestContext): Promise<any> => { const handler = async (ctx: IRequestContext): Promise<any> => {
@@ -102,10 +136,15 @@ export class ControllerRegistry {
method: route.method, method: route.method,
handler, handler,
interceptors, interceptors,
compression: route.compression,
openapi: route.openapi,
}); });
} }
} }
// Add dynamic routes
this.compiledRoutes.push(...this.dynamicRoutes);
// Sort routes by specificity (more specific paths first) // Sort routes by specificity (more specific paths first)
this.compiledRoutes.sort((a, b) => { this.compiledRoutes.sort((a, b) => {
// Routes without wildcards come first // Routes without wildcards come first
@@ -186,6 +225,18 @@ export class ControllerRegistry {
}; };
} }
/**
* Check if OpenAPI metadata contains validation-relevant information
*/
private static hasValidationMetadata(openapi: IOpenApiRouteMeta): boolean {
return !!(
openapi.requestBody ||
(openapi.params && openapi.params.size > 0) ||
(openapi.query && openapi.query.size > 0) ||
(openapi.headers && openapi.headers.size > 0)
);
}
/** /**
* Clear all registered controllers (useful for testing) * Clear all registered controllers (useful for testing)
*/ */
@@ -193,6 +244,7 @@ export class ControllerRegistry {
this.controllers.clear(); this.controllers.clear();
this.instances.clear(); this.instances.clear();
this.compiledRoutes = []; this.compiledRoutes = [];
this.dynamicRoutes = [];
this.routesCompiled = false; this.routesCompiled = false;
} }
} }

View File

@@ -10,6 +10,7 @@ import type {
IMethodOptions, IMethodOptions,
IRouteOptions, IRouteOptions,
} from '../core/smartserve.interfaces.js'; } from '../core/smartserve.interfaces.js';
import type { ICompressionConfig } from '../compression/index.js';
// ============================================================================= // =============================================================================
// Metadata Types // Metadata Types
@@ -27,6 +28,18 @@ export interface IControllerMetadata {
routes: Map<string | symbol, IRouteMetadata>; routes: Map<string | symbol, IRouteMetadata>;
/** Controller class reference */ /** Controller class reference */
target?: new (...args: any[]) => any; target?: new (...args: any[]) => any;
/** OpenAPI metadata for controller */
openapi?: IOpenApiControllerMeta;
}
/**
* Route compression options
*/
export interface IRouteCompressionOptions {
/** Whether compression is enabled for this route (undefined = use default) */
enabled?: boolean;
/** Override compression level */
level?: number;
} }
/** /**
@@ -45,6 +58,10 @@ export interface IRouteMetadata {
methodName: string | symbol; methodName: string | symbol;
/** Handler function reference */ /** Handler function reference */
handler?: Function; handler?: Function;
/** Route-specific compression settings */
compression?: IRouteCompressionOptions;
/** OpenAPI metadata for this route */
openapi?: IOpenApiRouteMeta;
} }
/** /**
@@ -73,4 +90,140 @@ export interface ICompiledRoute {
handler: (ctx: IRequestContext) => Promise<any>; handler: (ctx: IRequestContext) => Promise<any>;
/** Combined interceptors (class + method) */ /** Combined interceptors (class + method) */
interceptors: IInterceptOptions[]; interceptors: IInterceptOptions[];
/** Route-specific compression settings */
compression?: IRouteCompressionOptions;
/** OpenAPI metadata for this route */
openapi?: IOpenApiRouteMeta;
}
// =============================================================================
// OpenAPI / JSON Schema Types
// =============================================================================
/**
* JSON Schema type for OpenAPI schemas
* Supports JSON Schema draft 2020-12 (used by OpenAPI 3.1)
*/
export type TJsonSchema = {
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null' | Array<'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null'>;
format?: string;
properties?: Record<string, TJsonSchema>;
items?: TJsonSchema;
required?: string[];
enum?: unknown[];
const?: unknown;
default?: unknown;
description?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
minItems?: number;
maxItems?: number;
uniqueItems?: boolean;
additionalProperties?: boolean | TJsonSchema;
allOf?: TJsonSchema[];
anyOf?: TJsonSchema[];
oneOf?: TJsonSchema[];
not?: TJsonSchema;
$ref?: string;
[key: string]: unknown;
};
/**
* OpenAPI operation metadata for documentation
*/
export interface IOpenApiOperationMeta {
/** Short summary of what the operation does */
summary?: string;
/** Longer description with markdown support */
description?: string;
/** Unique operation identifier (auto-generated if omitted) */
operationId?: string;
/** Mark operation as deprecated */
deprecated?: boolean;
/** External documentation */
externalDocs?: {
url: string;
description?: string;
};
}
/**
* OpenAPI parameter metadata (path, query, header, cookie)
*/
export interface IOpenApiParamMeta {
/** Human-readable description */
description?: string;
/** Whether parameter is required (path params always required) */
required?: boolean;
/** JSON Schema for the parameter */
schema?: TJsonSchema;
/** Example value */
example?: unknown;
/** Multiple examples */
examples?: Record<string, { value: unknown; summary?: string; description?: string }>;
/** Allow empty value */
allowEmptyValue?: boolean;
/** Deprecation flag */
deprecated?: boolean;
}
/**
* OpenAPI request body metadata
*/
export interface IOpenApiRequestBodyMeta {
/** Human-readable description */
description?: string;
/** Whether body is required (default: true) */
required?: boolean;
/** JSON Schema for the body (assumes application/json) */
schema: TJsonSchema;
/** Example value */
example?: unknown;
}
/**
* OpenAPI response body metadata
*/
export interface IOpenApiResponseBodyMeta {
/** Human-readable description */
description: string;
/** JSON Schema for the response (assumes application/json) */
schema?: TJsonSchema;
/** Example value */
example?: unknown;
/** Response headers */
headers?: Record<string, { description?: string; schema?: TJsonSchema }>;
}
/**
* Combined OpenAPI metadata for a route method
*/
export interface IOpenApiRouteMeta {
/** Operation details (summary, description, etc.) */
operation?: IOpenApiOperationMeta;
/** Path parameters by name */
params?: Map<string, IOpenApiParamMeta>;
/** Query parameters by name */
query?: Map<string, IOpenApiParamMeta>;
/** Header parameters by name */
headers?: Map<string, IOpenApiParamMeta>;
/** Request body schema */
requestBody?: IOpenApiRequestBodyMeta;
/** Response bodies by status code */
responses?: Map<number, IOpenApiResponseBodyMeta>;
/** Security requirements */
security?: Array<Record<string, string[]>>;
}
/**
* OpenAPI metadata for a controller class
*/
export interface IOpenApiControllerMeta {
/** Tags for grouping routes in documentation */
tags?: string[];
/** Security requirements for all routes in controller */
security?: Array<Record<string, string[]>>;
} }

View File

@@ -2,8 +2,17 @@
export type { export type {
IControllerMetadata, IControllerMetadata,
IRouteMetadata, IRouteMetadata,
IRouteCompressionOptions,
IRegisteredController, IRegisteredController,
ICompiledRoute, ICompiledRoute,
// OpenAPI types
TJsonSchema,
IOpenApiOperationMeta,
IOpenApiParamMeta,
IOpenApiRequestBodyMeta,
IOpenApiResponseBodyMeta,
IOpenApiRouteMeta,
IOpenApiControllerMeta,
} from './decorators.types.js'; } from './decorators.types.js';
// Route decorator // Route decorator
@@ -35,6 +44,9 @@ export {
addTimestamp, addTimestamp,
} from './decorators.interceptors.js'; } from './decorators.interceptors.js';
// Compression decorators
export { Compress, NoCompress } from './decorators.compress.js';
// Registry // Registry
export { ControllerRegistry } from './decorators.registry.js'; export { ControllerRegistry } from './decorators.registry.js';
@@ -45,3 +57,15 @@ export {
normalizePath, normalizePath,
combinePaths, combinePaths,
} from './decorators.metadata.js'; } from './decorators.metadata.js';
// OpenAPI decorators
export {
ApiOperation,
ApiParam,
ApiQuery,
ApiHeader,
ApiRequestBody,
ApiResponseBody,
ApiSecurity,
ApiTag,
} from '../openapi/openapi.decorators.js';

View File

@@ -10,6 +10,16 @@ import type {
} from '../core/smartserve.interfaces.js'; } from '../core/smartserve.interfaces.js';
import { getMimeType } from '../utils/utils.mime.js'; import { getMimeType } from '../utils/utils.mime.js';
import { generateETag } from '../utils/utils.etag.js'; import { generateETag } from '../utils/utils.etag.js';
import { parseAcceptEncoding } from '../utils/utils.encoding.js';
/**
* Pre-compressed file variant info
*/
interface IPrecompressedVariant {
path: string;
stat: plugins.fs.Stats;
encoding: string;
}
/** /**
* Static file server * Static file server
@@ -112,22 +122,43 @@ export class FileServer {
): Promise<Response> { ): Promise<Response> {
const headers = new Headers(); const headers = new Headers();
// Content-Type // Check for pre-compressed variants
let actualFilePath = filePath;
let actualStat = stat;
let contentEncoding: string | undefined;
if (this.options.precompressed) {
const variant = await this.findPrecompressedVariant(filePath, request);
if (variant) {
actualFilePath = variant.path;
actualStat = variant.stat;
contentEncoding = variant.encoding;
}
}
// Content-Type (always use original file's MIME type)
const mimeType = getMimeType(filePath); const mimeType = getMimeType(filePath);
headers.set('Content-Type', mimeType); headers.set('Content-Type', mimeType);
// Content-Length // Content-Encoding (if serving pre-compressed)
headers.set('Content-Length', stat.size.toString()); if (contentEncoding) {
headers.set('Content-Encoding', contentEncoding);
headers.set('Vary', 'Accept-Encoding');
}
// Last-Modified // Content-Length (use actual file size, which may differ for compressed)
headers.set('Content-Length', actualStat.size.toString());
// Last-Modified (use original file's time for consistency)
if (this.options.lastModified) { if (this.options.lastModified) {
headers.set('Last-Modified', stat.mtime.toUTCString()); headers.set('Last-Modified', stat.mtime.toUTCString());
} }
// ETag // ETag (include encoding in ETag if compressed)
let etag: string | undefined; let etag: string | undefined;
if (this.options.etag) { if (this.options.etag) {
etag = generateETag(stat); const baseEtag = generateETag(stat);
etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag;
headers.set('ETag', etag); headers.set('ETag', etag);
} }
@@ -153,9 +184,9 @@ export class FileServer {
} }
} }
// Handle Range requests // Handle Range requests (don't use pre-compressed for range requests)
const rangeHeader = request.headers.get('Range'); const rangeHeader = request.headers.get('Range');
if (rangeHeader) { if (rangeHeader && !contentEncoding) {
return this.servePartial(filePath, stat, rangeHeader, headers); return this.servePartial(filePath, stat, rangeHeader, headers);
} }
@@ -164,8 +195,8 @@ export class FileServer {
return new Response(null, { status: 200, headers }); return new Response(null, { status: 200, headers });
} }
// Stream the file // Stream the file (use actualFilePath for pre-compressed)
const stream = plugins.fs.createReadStream(filePath); const stream = plugins.fs.createReadStream(actualFilePath);
const readableStream = this.nodeStreamToWebStream(stream); const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 200, headers }); return new Response(readableStream, { status: 200, headers });
@@ -361,6 +392,46 @@ export class FileServer {
</html>`; </html>`;
} }
/**
* Find pre-compressed variant of a file if it exists
* Checks for .br and .gz variants based on client Accept-Encoding
*/
private async findPrecompressedVariant(
filePath: string,
request: Request
): Promise<IPrecompressedVariant | null> {
const acceptEncoding = request.headers.get('Accept-Encoding');
const preferences = parseAcceptEncoding(acceptEncoding);
// Supported pre-compressed variants in preference order
const variants: Array<{ encoding: string; extension: string }> = [
{ encoding: 'br', extension: '.br' },
{ encoding: 'gzip', extension: '.gz' },
];
// Check variants in client preference order
for (const pref of preferences) {
const variant = variants.find((v) => v.encoding === pref.encoding);
if (!variant) continue;
const variantPath = filePath + variant.extension;
try {
const variantStat = await plugins.fs.promises.stat(variantPath);
if (variantStat.isFile()) {
return {
path: variantPath,
stat: variantStat,
encoding: variant.encoding,
};
}
} catch {
// Variant doesn't exist, continue to next
}
}
return null;
}
/** /**
* Convert Node.js stream to Web ReadableStream * Convert Node.js stream to Web ReadableStream
*/ */

View File

@@ -6,12 +6,18 @@ export * from './core/index.js';
// Decorator exports // Decorator exports
export * from './decorators/index.js'; export * from './decorators/index.js';
// Compression exports
export * from './compression/index.js';
// File server exports // File server exports
export * from './files/index.js'; export * from './files/index.js';
// Protocol exports (WebDAV, etc.) // Protocol exports (WebDAV, etc.)
export * from './protocols/index.js'; export * from './protocols/index.js';
// OpenAPI exports
export * from './openapi/index.js';
// Utility exports // Utility exports
export * from './utils/index.js'; export * from './utils/index.js';

84
ts/openapi/index.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* OpenAPI Module for SmartServe
*
* Provides:
* - Decorators for API documentation and validation
* - OpenAPI 3.1 specification generation
* - Swagger UI / ReDoc handlers
* - Request validation using JSON Schema
*/
// Type exports
export type {
// OpenAPI Spec types
IOpenApiSpec,
IOpenApiInfo,
IOpenApiContact,
IOpenApiLicense,
IOpenApiServer,
IOpenApiServerVariable,
IOpenApiPathItem,
IOpenApiOperation,
IOpenApiParameter,
IOpenApiRequestBody,
IOpenApiMediaType,
IOpenApiResponse,
IOpenApiExample,
IOpenApiComponents,
IOpenApiSecurityScheme,
IOpenApiSecuritySchemeApiKey,
IOpenApiSecuritySchemeHttp,
IOpenApiSecuritySchemeOAuth2,
IOpenApiSecuritySchemeOpenIdConnect,
IOpenApiOAuthFlows,
IOpenApiOAuthFlow,
IOpenApiSecurityRequirement,
IOpenApiTag,
IOpenApiExternalDocs,
// Options types
IOpenApiGeneratorOptions,
IOpenApiOptions,
} from './openapi.types.js';
export type {
IValidationError,
IValidationResult,
} from './openapi.validator.js';
// Decorators
export {
ApiOperation,
ApiParam,
ApiQuery,
ApiHeader,
ApiRequestBody,
ApiResponseBody,
ApiSecurity,
ApiTag,
} from './openapi.decorators.js';
// Generator
export { OpenApiGenerator } from './openapi.generator.js';
// Handlers
export {
createOpenApiHandler,
createSwaggerUiHandler,
createReDocHandler,
} from './openapi.handlers.js';
// Validation
export {
validateSchema,
validateParam,
validateRequest,
createValidationInterceptor,
createValidationErrorResponse,
} from './openapi.validator.js';
// Coercion
export {
coerceValue,
coerceQueryParams,
coercePathParams,
} from './openapi.coerce.js';

View File

@@ -0,0 +1,110 @@
/**
* Type coercion utilities for query parameters
* Converts string values from query params to proper types based on JSON Schema
*/
import type { TJsonSchema, IOpenApiParamMeta } from '../decorators/decorators.types.js';
/**
* Coerce a single value based on JSON Schema type
*/
export function coerceValue(value: string | undefined, schema: TJsonSchema): unknown {
if (value === undefined || value === '') {
// Return default if available
if (schema.default !== undefined) {
return schema.default;
}
return undefined;
}
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
switch (type) {
case 'integer': {
const num = parseInt(value, 10);
return isNaN(num) ? value : num;
}
case 'number': {
const num = parseFloat(value);
return isNaN(num) ? value : num;
}
case 'boolean': {
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0' || value === '') return false;
return value;
}
case 'array': {
// Handle comma-separated values
const items = value.split(',').map(item => item.trim());
if (schema.items) {
return items.map(item => coerceValue(item, schema.items as TJsonSchema));
}
return items;
}
case 'null': {
if (value === 'null' || value === '') return null;
return value;
}
case 'object': {
// Attempt to parse JSON
try {
return JSON.parse(value);
} catch {
return value;
}
}
case 'string':
default:
return value;
}
}
/**
* Coerce all query parameters based on their schemas
*/
export function coerceQueryParams(
query: Record<string, string>,
schemas: Map<string, IOpenApiParamMeta>
): Record<string, unknown> {
const result: Record<string, unknown> = { ...query };
for (const [name, meta] of schemas) {
if (meta.schema) {
const rawValue = query[name];
const coercedValue = coerceValue(rawValue, meta.schema);
if (coercedValue !== undefined) {
result[name] = coercedValue;
} else if (meta.schema.default !== undefined) {
// Apply default when value is missing
result[name] = meta.schema.default;
}
}
}
return result;
}
/**
* Coerce path parameters based on their schemas
*/
export function coercePathParams(
params: Record<string, string>,
schemas: Map<string, IOpenApiParamMeta>
): Record<string, unknown> {
const result: Record<string, unknown> = { ...params };
for (const [name, meta] of schemas) {
if (meta.schema && params[name] !== undefined) {
result[name] = coerceValue(params[name], meta.schema);
}
}
return result;
}

View File

@@ -0,0 +1,355 @@
/**
* OpenAPI decorators for documentation and validation
*
* These decorators serve dual purposes:
* 1. Generate OpenAPI 3.1 specification
* 2. Validate requests at runtime
*/
import { getControllerMetadata } from '../decorators/decorators.metadata.js';
import type {
TJsonSchema,
IOpenApiOperationMeta,
IOpenApiParamMeta,
IOpenApiRequestBodyMeta,
IOpenApiResponseBodyMeta,
IOpenApiRouteMeta,
} from '../decorators/decorators.types.js';
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get or create OpenAPI metadata for a route
*/
function getRouteOpenApi(target: any, methodName: string | symbol): IOpenApiRouteMeta {
const metadata = getControllerMetadata(target.constructor);
let route = metadata.routes.get(methodName);
if (!route) {
// Create placeholder route (will be completed by @Get/@Post/etc.)
route = {
method: 'GET',
path: '',
methodName,
interceptors: [],
options: {},
};
metadata.routes.set(methodName, route);
}
if (!route.openapi) {
route.openapi = {};
}
return route.openapi;
}
/**
* Get or create OpenAPI metadata for a controller
*/
function getControllerOpenApi(target: any) {
const metadata = getControllerMetadata(target);
if (!metadata.openapi) {
metadata.openapi = {};
}
return metadata.openapi;
}
// =============================================================================
// Method Decorators
// =============================================================================
/**
* @ApiOperation - Document the operation with summary and description
*
* @example
* ```typescript
* @Get('/:id')
* @ApiOperation({
* summary: 'Get user by ID',
* description: 'Retrieves a single user by their unique identifier',
* operationId: 'getUserById',
* })
* getUser(ctx: IRequestContext) { ... }
* ```
*/
export function ApiOperation(options: IOpenApiOperationMeta) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const openapi = getRouteOpenApi(this, context.name);
openapi.operation = { ...openapi.operation, ...options };
});
return target;
};
}
/**
* @ApiParam - Document and validate a path parameter
*
* @example
* ```typescript
* @Get('/:id')
* @ApiParam('id', {
* description: 'User UUID',
* schema: { type: 'string', format: 'uuid' }
* })
* getUser(ctx: IRequestContext) { ... }
* ```
*/
export function ApiParam(name: string, options: IOpenApiParamMeta = {}) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const openapi = getRouteOpenApi(this, context.name);
if (!openapi.params) {
openapi.params = new Map();
}
// Path params are always required
openapi.params.set(name, {
...options,
required: true,
});
});
return target;
};
}
/**
* @ApiQuery - Document and validate a query parameter
*
* Supports type coercion: string query values are converted to the
* appropriate type based on the schema (integer, number, boolean, array).
*
* @example
* ```typescript
* @Get('/')
* @ApiQuery('limit', {
* description: 'Maximum number of results',
* schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 }
* })
* @ApiQuery('active', {
* description: 'Filter by active status',
* schema: { type: 'boolean' }
* })
* listUsers(ctx: IRequestContext) {
* // ctx.query.limit is coerced to number
* // ctx.query.active is coerced to boolean
* }
* ```
*/
export function ApiQuery(name: string, options: IOpenApiParamMeta = {}) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const openapi = getRouteOpenApi(this, context.name);
if (!openapi.query) {
openapi.query = new Map();
}
openapi.query.set(name, options);
});
return target;
};
}
/**
* @ApiHeader - Document and validate a header parameter
*
* @example
* ```typescript
* @Get('/')
* @ApiHeader('X-Request-ID', {
* description: 'Unique request identifier',
* schema: { type: 'string', format: 'uuid' }
* })
* getData(ctx: IRequestContext) { ... }
* ```
*/
export function ApiHeader(name: string, options: IOpenApiParamMeta = {}) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const openapi = getRouteOpenApi(this, context.name);
if (!openapi.headers) {
openapi.headers = new Map();
}
openapi.headers.set(name, options);
});
return target;
};
}
/**
* @ApiRequestBody - Document and validate the request body
*
* The request body is validated against the provided JSON Schema.
* Invalid requests receive a 400 response before the handler runs.
*
* @example
* ```typescript
* const CreateUserSchema = {
* type: 'object',
* properties: {
* name: { type: 'string', minLength: 1 },
* email: { type: 'string', format: 'email' },
* },
* required: ['name', 'email'],
* };
*
* @Post('/')
* @ApiRequestBody({
* description: 'User creation payload',
* schema: CreateUserSchema
* })
* createUser(ctx: IRequestContext<{ name: string; email: string }>) {
* // ctx.body is guaranteed to match the schema
* }
* ```
*/
export function ApiRequestBody(options: IOpenApiRequestBodyMeta) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const openapi = getRouteOpenApi(this, context.name);
openapi.requestBody = options;
});
return target;
};
}
/**
* @ApiResponseBody - Document a response for a specific status code
*
* Multiple @ApiResponseBody decorators can be used for different status codes.
*
* @example
* ```typescript
* @Get('/:id')
* @ApiResponseBody(200, {
* description: 'User found',
* schema: UserSchema
* })
* @ApiResponseBody(404, {
* description: 'User not found'
* })
* getUser(ctx: IRequestContext) { ... }
* ```
*/
export function ApiResponseBody(status: number, options: Omit<IOpenApiResponseBodyMeta, 'status'>) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const openapi = getRouteOpenApi(this, context.name);
if (!openapi.responses) {
openapi.responses = new Map();
}
openapi.responses.set(status, options as IOpenApiResponseBodyMeta);
});
return target;
};
}
/**
* @ApiSecurity - Specify security requirements for a route
*
* Can be used on classes (applies to all routes) or methods.
*
* @example
* ```typescript
* // Method-level security
* @Post('/')
* @ApiSecurity('bearerAuth')
* createUser(ctx: IRequestContext) { ... }
*
* // With OAuth scopes
* @Delete('/:id')
* @ApiSecurity('oauth2', ['users:delete'])
* deleteUser(ctx: IRequestContext) { ... }
* ```
*/
export function ApiSecurity(name: string, scopes: string[] = []) {
// Can be both class and method decorator
return function <T extends (new (...args: any[]) => any) | ((this: any, ...args: any[]) => any)>(
target: T,
context: ClassDecoratorContext | ClassMethodDecoratorContext
): T {
if (context.kind === 'class') {
// Class decorator
const controllerOpenApi = getControllerOpenApi(target);
if (!controllerOpenApi.security) {
controllerOpenApi.security = [];
}
controllerOpenApi.security.push({ [name]: scopes });
} else if (context.kind === 'method') {
// Method decorator
context.addInitializer(function (this: any) {
const openapi = getRouteOpenApi(this, context.name);
if (!openapi.security) {
openapi.security = [];
}
openapi.security.push({ [name]: scopes });
});
}
return target;
};
}
// =============================================================================
// Class Decorators
// =============================================================================
/**
* @ApiTag - Group routes under a tag in the documentation
*
* Multiple tags can be specified. Applied to all routes in the controller.
*
* @example
* ```typescript
* @Route('/api/users')
* @ApiTag('Users')
* @ApiTag('Admin')
* class UserController { ... }
* ```
*/
export function ApiTag(...tags: string[]) {
return function <TClass extends new (...args: any[]) => any>(
target: TClass,
context: ClassDecoratorContext<TClass>
): TClass {
if (context.kind !== 'class') {
throw new Error('@ApiTag can only decorate classes');
}
const controllerOpenApi = getControllerOpenApi(target);
if (!controllerOpenApi.tags) {
controllerOpenApi.tags = [];
}
controllerOpenApi.tags.push(...tags);
return target;
};
}

View File

@@ -0,0 +1,306 @@
/**
* OpenAPI Specification Generator
*
* Generates OpenAPI 3.1 specification from registered controllers and their metadata
*/
import { ControllerRegistry } from '../decorators/decorators.registry.js';
import { combinePaths } from '../decorators/decorators.metadata.js';
import type {
IControllerMetadata,
IRouteMetadata,
IOpenApiRouteMeta,
IOpenApiControllerMeta,
TJsonSchema,
} from '../decorators/decorators.types.js';
import type {
IOpenApiSpec,
IOpenApiPathItem,
IOpenApiOperation,
IOpenApiParameter,
IOpenApiRequestBody,
IOpenApiResponse,
IOpenApiGeneratorOptions,
IOpenApiSecurityScheme,
IOpenApiTag,
} from './openapi.types.js';
/**
* OpenAPI Specification Generator
*/
export class OpenApiGenerator {
private options: IOpenApiGeneratorOptions;
private schemas: Map<string, TJsonSchema> = new Map();
constructor(options: IOpenApiGeneratorOptions) {
this.options = options;
}
/**
* Register a reusable schema in components/schemas
*/
addSchema(name: string, schema: TJsonSchema): this {
this.schemas.set(name, schema);
return this;
}
/**
* Generate the complete OpenAPI specification
*/
generate(): IOpenApiSpec {
const spec: IOpenApiSpec = {
openapi: '3.1.0',
info: this.options.info,
servers: this.options.servers ?? [],
paths: {},
components: {
schemas: Object.fromEntries(this.schemas),
securitySchemes: this.options.securitySchemes ?? {},
},
tags: this.options.tags ?? [],
};
if (this.options.externalDocs) {
spec.externalDocs = this.options.externalDocs;
}
// Collect all unique tags
const collectedTags = new Set<string>();
// Get all registered controllers
const controllers = ControllerRegistry.getControllers();
for (const { metadata } of controllers) {
this.processController(spec, metadata, collectedTags);
}
// Add collected tags that aren't already in spec.tags
const existingTagNames = new Set(spec.tags?.map(t => t.name) ?? []);
for (const tag of collectedTags) {
if (!existingTagNames.has(tag)) {
spec.tags!.push({ name: tag });
}
}
return spec;
}
/**
* Generate and return as JSON string
*/
toJSON(pretty = true): string {
return JSON.stringify(this.generate(), null, pretty ? 2 : 0);
}
/**
* Process a single controller and add its routes to the spec
*/
private processController(
spec: IOpenApiSpec,
metadata: IControllerMetadata,
collectedTags: Set<string>
): void {
const controllerOpenApi = metadata.openapi ?? {};
// Collect controller tags
if (controllerOpenApi.tags) {
for (const tag of controllerOpenApi.tags) {
collectedTags.add(tag);
}
}
// Process each route
for (const [, route] of metadata.routes) {
const fullPath = combinePaths(metadata.basePath, route.path);
const openApiPath = this.convertPathToOpenApi(fullPath);
// Initialize path if not exists
if (!spec.paths[openApiPath]) {
spec.paths[openApiPath] = {};
}
// Build operation
const operation = this.buildOperation(route, controllerOpenApi, collectedTags);
// Add to appropriate HTTP method
const method = route.method.toLowerCase();
if (method === 'all') {
// 'ALL' applies to all standard methods
const methods = ['get', 'post', 'put', 'delete', 'patch'] as const;
for (const m of methods) {
(spec.paths[openApiPath] as any)[m] = { ...operation };
}
} else {
(spec.paths[openApiPath] as any)[method] = operation;
}
}
}
/**
* Build an OpenAPI operation from route metadata
*/
private buildOperation(
route: IRouteMetadata,
controllerMeta: IOpenApiControllerMeta,
collectedTags: Set<string>
): IOpenApiOperation {
const routeOpenApi = route.openapi ?? {};
const operationMeta = routeOpenApi.operation ?? {};
const operation: IOpenApiOperation = {
summary: operationMeta.summary ?? `${route.method} ${route.path}`,
description: operationMeta.description,
operationId: operationMeta.operationId ?? this.generateOperationId(route),
deprecated: operationMeta.deprecated,
tags: controllerMeta.tags ? [...controllerMeta.tags] : [],
parameters: [],
responses: {},
};
if (operationMeta.externalDocs) {
operation.externalDocs = operationMeta.externalDocs;
}
// Collect tags
for (const tag of operation.tags ?? []) {
collectedTags.add(tag);
}
// Add path parameters (auto-detect from path pattern)
const pathParams = this.extractPathParams(route.path);
for (const paramName of pathParams) {
const paramMeta = routeOpenApi.params?.get(paramName);
operation.parameters!.push({
name: paramName,
in: 'path',
required: true,
description: paramMeta?.description ?? `Path parameter: ${paramName}`,
schema: paramMeta?.schema ?? { type: 'string' },
example: paramMeta?.example,
deprecated: paramMeta?.deprecated,
});
}
// Add query parameters
if (routeOpenApi.query) {
for (const [name, meta] of routeOpenApi.query) {
operation.parameters!.push({
name,
in: 'query',
required: meta.required ?? false,
description: meta.description,
schema: meta.schema ?? { type: 'string' },
example: meta.example,
allowEmptyValue: meta.allowEmptyValue,
deprecated: meta.deprecated,
});
}
}
// Add header parameters
if (routeOpenApi.headers) {
for (const [name, meta] of routeOpenApi.headers) {
operation.parameters!.push({
name,
in: 'header',
required: meta.required ?? false,
description: meta.description,
schema: meta.schema ?? { type: 'string' },
example: meta.example,
deprecated: meta.deprecated,
});
}
}
// Remove empty parameters array
if (operation.parameters!.length === 0) {
delete operation.parameters;
}
// Add request body
if (routeOpenApi.requestBody) {
operation.requestBody = {
description: routeOpenApi.requestBody.description,
required: routeOpenApi.requestBody.required !== false,
content: {
'application/json': {
schema: routeOpenApi.requestBody.schema,
example: routeOpenApi.requestBody.example,
},
},
};
}
// Add responses
if (routeOpenApi.responses && routeOpenApi.responses.size > 0) {
for (const [status, responseMeta] of routeOpenApi.responses) {
const response: IOpenApiResponse = {
description: responseMeta.description,
};
if (responseMeta.schema) {
response.content = {
'application/json': {
schema: responseMeta.schema,
example: responseMeta.example,
},
};
}
if (responseMeta.headers) {
response.headers = {};
for (const [headerName, headerMeta] of Object.entries(responseMeta.headers)) {
response.headers[headerName] = {
description: headerMeta.description,
schema: headerMeta.schema,
} as IOpenApiParameter;
}
}
operation.responses[status.toString()] = response;
}
} else {
// Default response
operation.responses['200'] = {
description: 'Successful response',
};
}
// Add security requirements
const security = routeOpenApi.security ?? controllerMeta.security;
if (security && security.length > 0) {
operation.security = security;
}
return operation;
}
/**
* Convert Express-style path to OpenAPI path format
* :id -> {id}
*/
private convertPathToOpenApi(path: string): string {
return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
}
/**
* Extract parameter names from path pattern
*/
private extractPathParams(path: string): string[] {
const matches = path.matchAll(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
return Array.from(matches, m => m[1]);
}
/**
* Generate a unique operation ID from route metadata
*/
private generateOperationId(route: IRouteMetadata): string {
const methodName = String(route.methodName);
// Convert camelCase to snake_case and sanitize
return methodName
.replace(/[^a-zA-Z0-9]/g, '_')
.replace(/([a-z])([A-Z])/g, '$1_$2')
.toLowerCase();
}
}

View File

@@ -0,0 +1,138 @@
/**
* Request handlers for OpenAPI specification and Swagger UI
*/
import type { IRequestContext } from '../core/smartserve.interfaces.js';
import { OpenApiGenerator } from './openapi.generator.js';
import type { IOpenApiGeneratorOptions } from './openapi.types.js';
/**
* Create a handler that serves the OpenAPI JSON specification
*/
export function createOpenApiHandler(options: IOpenApiGeneratorOptions) {
let cachedSpec: string | null = null;
return async (ctx: IRequestContext): Promise<Response> => {
// Generate spec on first request (lazy loading)
if (!cachedSpec) {
const generator = new OpenApiGenerator(options);
cachedSpec = generator.toJSON();
}
return new Response(cachedSpec, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
});
};
}
/**
* Create a handler that serves Swagger UI
*
* Loads Swagger UI from unpkg CDN - no bundled assets needed
*/
export function createSwaggerUiHandler(specUrl: string = '/openapi.json', title: string = 'API Documentation') {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: "${escapeHtml(specUrl)}",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "BaseLayout",
validatorUrl: null,
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'],
defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 1,
displayRequestDuration: true,
filter: true,
showExtensions: true,
showCommonExtensions: true,
});
};
</script>
</body>
</html>`;
return async (ctx: IRequestContext): Promise<Response> => {
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=86400',
},
});
};
}
/**
* Create a handler that serves ReDoc UI (alternative to Swagger UI)
*
* ReDoc provides a clean, responsive documentation layout
*/
export function createReDocHandler(specUrl: string = '/openapi.json', title: string = 'API Documentation') {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; }
</style>
</head>
<body>
<redoc spec-url="${escapeHtml(specUrl)}"></redoc>
<script src="https://unpkg.com/redoc@latest/bundles/redoc.standalone.js"></script>
</body>
</html>`;
return async (ctx: IRequestContext): Promise<Response> => {
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=86400',
},
});
};
}
/**
* Escape HTML special characters to prevent XSS
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

488
ts/openapi/openapi.types.ts Normal file
View File

@@ -0,0 +1,488 @@
/**
* OpenAPI 3.1 Specification Types
* Based on https://spec.openapis.org/oas/v3.1.0
*/
import type { TJsonSchema } from '../decorators/decorators.types.js';
// =============================================================================
// OpenAPI Specification Root
// =============================================================================
/**
* OpenAPI 3.1 Specification
*/
export interface IOpenApiSpec {
/** OpenAPI specification version */
openapi: '3.1.0';
/** API metadata */
info: IOpenApiInfo;
/** JSON Schema dialect (OpenAPI 3.1 default) */
jsonSchemaDialect?: string;
/** Server information */
servers?: IOpenApiServer[];
/** Available paths and operations */
paths: Record<string, IOpenApiPathItem>;
/** Webhooks */
webhooks?: Record<string, IOpenApiPathItem>;
/** Reusable components */
components?: IOpenApiComponents;
/** Security requirements */
security?: IOpenApiSecurityRequirement[];
/** Tags for organization */
tags?: IOpenApiTag[];
/** External documentation */
externalDocs?: IOpenApiExternalDocs;
}
// =============================================================================
// Info Object
// =============================================================================
/**
* API metadata
*/
export interface IOpenApiInfo {
/** API title */
title: string;
/** API version */
version: string;
/** API description (markdown supported) */
description?: string;
/** Terms of service URL */
termsOfService?: string;
/** Contact information */
contact?: IOpenApiContact;
/** License information */
license?: IOpenApiLicense;
/** Short summary */
summary?: string;
}
/**
* Contact information
*/
export interface IOpenApiContact {
name?: string;
url?: string;
email?: string;
}
/**
* License information
*/
export interface IOpenApiLicense {
name: string;
url?: string;
identifier?: string;
}
// =============================================================================
// Server Object
// =============================================================================
/**
* Server information
*/
export interface IOpenApiServer {
/** Server URL */
url: string;
/** Server description */
description?: string;
/** Server variables */
variables?: Record<string, IOpenApiServerVariable>;
}
/**
* Server variable
*/
export interface IOpenApiServerVariable {
default: string;
description?: string;
enum?: string[];
}
// =============================================================================
// Path & Operation Objects
// =============================================================================
/**
* Path item with operations
*/
export interface IOpenApiPathItem {
/** Reference to another path item */
$ref?: string;
/** Summary */
summary?: string;
/** Description */
description?: string;
/** GET operation */
get?: IOpenApiOperation;
/** PUT operation */
put?: IOpenApiOperation;
/** POST operation */
post?: IOpenApiOperation;
/** DELETE operation */
delete?: IOpenApiOperation;
/** OPTIONS operation */
options?: IOpenApiOperation;
/** HEAD operation */
head?: IOpenApiOperation;
/** PATCH operation */
patch?: IOpenApiOperation;
/** TRACE operation */
trace?: IOpenApiOperation;
/** Servers for this path */
servers?: IOpenApiServer[];
/** Parameters for all operations */
parameters?: IOpenApiParameter[];
}
/**
* API operation
*/
export interface IOpenApiOperation {
/** Tags for grouping */
tags?: string[];
/** Short summary */
summary?: string;
/** Detailed description */
description?: string;
/** External documentation */
externalDocs?: IOpenApiExternalDocs;
/** Unique operation ID */
operationId?: string;
/** Operation parameters */
parameters?: IOpenApiParameter[];
/** Request body */
requestBody?: IOpenApiRequestBody;
/** Responses */
responses: Record<string, IOpenApiResponse>;
/** Callbacks */
callbacks?: Record<string, Record<string, IOpenApiPathItem>>;
/** Deprecation flag */
deprecated?: boolean;
/** Security requirements */
security?: IOpenApiSecurityRequirement[];
/** Servers for this operation */
servers?: IOpenApiServer[];
}
// =============================================================================
// Parameter Object
// =============================================================================
/**
* Operation parameter
*/
export interface IOpenApiParameter {
/** Parameter name */
name: string;
/** Parameter location */
in: 'query' | 'header' | 'path' | 'cookie';
/** Description */
description?: string;
/** Required flag (path params always required) */
required?: boolean;
/** Deprecation flag */
deprecated?: boolean;
/** Allow empty value */
allowEmptyValue?: boolean;
/** Parameter schema */
schema?: TJsonSchema;
/** Example value */
example?: unknown;
/** Multiple examples */
examples?: Record<string, IOpenApiExample>;
/** Serialization style */
style?: 'matrix' | 'label' | 'form' | 'simple' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';
/** Explode arrays/objects */
explode?: boolean;
/** Allow reserved characters */
allowReserved?: boolean;
/** Content type mapping */
content?: Record<string, IOpenApiMediaType>;
}
// =============================================================================
// Request Body Object
// =============================================================================
/**
* Request body
*/
export interface IOpenApiRequestBody {
/** Description */
description?: string;
/** Content by media type */
content: Record<string, IOpenApiMediaType>;
/** Required flag */
required?: boolean;
}
// =============================================================================
// Media Type Object
// =============================================================================
/**
* Media type content
*/
export interface IOpenApiMediaType {
/** Content schema */
schema?: TJsonSchema;
/** Example value */
example?: unknown;
/** Multiple examples */
examples?: Record<string, IOpenApiExample>;
/** Encoding for multipart */
encoding?: Record<string, IOpenApiEncoding>;
}
/**
* Encoding for multipart content
*/
export interface IOpenApiEncoding {
contentType?: string;
headers?: Record<string, IOpenApiParameter>;
style?: string;
explode?: boolean;
allowReserved?: boolean;
}
// =============================================================================
// Response Object
// =============================================================================
/**
* Operation response
*/
export interface IOpenApiResponse {
/** Response description (required) */
description: string;
/** Response headers */
headers?: Record<string, IOpenApiParameter>;
/** Response content */
content?: Record<string, IOpenApiMediaType>;
/** Links */
links?: Record<string, IOpenApiLink>;
}
/**
* Response link
*/
export interface IOpenApiLink {
operationRef?: string;
operationId?: string;
parameters?: Record<string, unknown>;
requestBody?: unknown;
description?: string;
server?: IOpenApiServer;
}
// =============================================================================
// Example Object
// =============================================================================
/**
* Example value
*/
export interface IOpenApiExample {
/** Short summary */
summary?: string;
/** Description */
description?: string;
/** Example value */
value?: unknown;
/** External URL */
externalValue?: string;
}
// =============================================================================
// Components Object
// =============================================================================
/**
* Reusable components
*/
export interface IOpenApiComponents {
/** Reusable schemas */
schemas?: Record<string, TJsonSchema>;
/** Reusable responses */
responses?: Record<string, IOpenApiResponse>;
/** Reusable parameters */
parameters?: Record<string, IOpenApiParameter>;
/** Reusable examples */
examples?: Record<string, IOpenApiExample>;
/** Reusable request bodies */
requestBodies?: Record<string, IOpenApiRequestBody>;
/** Reusable headers */
headers?: Record<string, IOpenApiParameter>;
/** Security schemes */
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
/** Reusable links */
links?: Record<string, IOpenApiLink>;
/** Reusable callbacks */
callbacks?: Record<string, Record<string, IOpenApiPathItem>>;
/** Path items */
pathItems?: Record<string, IOpenApiPathItem>;
}
// =============================================================================
// Security Scheme Object
// =============================================================================
/**
* Security scheme definition
*/
export type IOpenApiSecurityScheme =
| IOpenApiSecuritySchemeApiKey
| IOpenApiSecuritySchemeHttp
| IOpenApiSecuritySchemeOAuth2
| IOpenApiSecuritySchemeOpenIdConnect
| IOpenApiSecuritySchemeMutualTLS;
/**
* API key security scheme
*/
export interface IOpenApiSecuritySchemeApiKey {
type: 'apiKey';
description?: string;
name: string;
in: 'query' | 'header' | 'cookie';
}
/**
* HTTP security scheme (bearer, basic, etc.)
*/
export interface IOpenApiSecuritySchemeHttp {
type: 'http';
description?: string;
scheme: string;
bearerFormat?: string;
}
/**
* OAuth2 security scheme
*/
export interface IOpenApiSecuritySchemeOAuth2 {
type: 'oauth2';
description?: string;
flows: IOpenApiOAuthFlows;
}
/**
* OpenID Connect security scheme
*/
export interface IOpenApiSecuritySchemeOpenIdConnect {
type: 'openIdConnect';
description?: string;
openIdConnectUrl: string;
}
/**
* Mutual TLS security scheme
*/
export interface IOpenApiSecuritySchemeMutualTLS {
type: 'mutualTLS';
description?: string;
}
/**
* OAuth2 flows
*/
export interface IOpenApiOAuthFlows {
implicit?: IOpenApiOAuthFlow;
password?: IOpenApiOAuthFlow;
clientCredentials?: IOpenApiOAuthFlow;
authorizationCode?: IOpenApiOAuthFlow;
}
/**
* OAuth2 flow
*/
export interface IOpenApiOAuthFlow {
authorizationUrl?: string;
tokenUrl?: string;
refreshUrl?: string;
scopes: Record<string, string>;
}
/**
* Security requirement
*/
export type IOpenApiSecurityRequirement = Record<string, string[]>;
// =============================================================================
// Tag & External Docs Objects
// =============================================================================
/**
* Tag for grouping operations
*/
export interface IOpenApiTag {
/** Tag name */
name: string;
/** Tag description */
description?: string;
/** External documentation */
externalDocs?: IOpenApiExternalDocs;
}
/**
* External documentation
*/
export interface IOpenApiExternalDocs {
/** Documentation URL */
url: string;
/** Description */
description?: string;
}
// =============================================================================
// Generator Options
// =============================================================================
/**
* Options for OpenAPI spec generation
*/
export interface IOpenApiGeneratorOptions {
/** API info (required) */
info: IOpenApiInfo;
/** Server URLs */
servers?: IOpenApiServer[];
/** Security scheme definitions */
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
/** Global tags */
tags?: IOpenApiTag[];
/** External documentation */
externalDocs?: IOpenApiExternalDocs;
}
/**
* Options for OpenAPI in SmartServe
*/
export interface IOpenApiOptions {
/** Enable OpenAPI (default: true when options provided) */
enabled?: boolean;
/** Path for OpenAPI JSON spec (default: /openapi.json) */
specPath?: string;
/** Path for Swagger UI (default: /docs) */
docsPath?: string;
/** Enable runtime validation (default: true) */
validation?: boolean;
/** API info */
info: {
title: string;
version: string;
description?: string;
termsOfService?: string;
contact?: IOpenApiContact;
license?: IOpenApiLicense;
};
/** Server URLs */
servers?: IOpenApiServer[];
/** Security schemes */
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
/** Global tags */
tags?: IOpenApiTag[];
}

View File

@@ -0,0 +1,252 @@
/**
* JSON Schema validation utilities using @cfworker/json-schema
*/
import { Validator } from '@cfworker/json-schema';
import type { TJsonSchema, IOpenApiRouteMeta, IOpenApiParamMeta } from '../decorators/decorators.types.js';
import type { IRequestContext } from '../core/smartserve.interfaces.js';
import { coerceQueryParams, coercePathParams } from './openapi.coerce.js';
/**
* Validation error detail
*/
export interface IValidationError {
/** JSON pointer path to the error location */
path: string;
/** Human-readable error message */
message: string;
/** JSON Schema keyword that failed */
keyword?: string;
}
/**
* Validation result
*/
export interface IValidationResult {
/** Whether validation passed */
valid: boolean;
/** Array of validation errors */
errors: IValidationError[];
}
/**
* Validate data against a JSON Schema
*/
export function validateSchema(data: unknown, schema: TJsonSchema): IValidationResult {
const validator = new Validator(schema as any, '2020-12', false);
const result = validator.validate(data);
return {
valid: result.valid,
errors: result.errors.map(err => ({
path: err.instanceLocation || '/',
message: err.error,
keyword: err.keyword,
})),
};
}
/**
* Validate a single parameter value
*/
export function validateParam(
name: string,
value: unknown,
meta: IOpenApiParamMeta,
location: 'path' | 'query' | 'header'
): IValidationResult {
// Check required
if (meta.required && (value === undefined || value === '')) {
return {
valid: false,
errors: [{
path: `/${name}`,
message: `${location} parameter "${name}" is required`,
keyword: 'required',
}],
};
}
// Skip validation if no value and not required
if (value === undefined || value === '') {
return { valid: true, errors: [] };
}
// Validate against schema
if (meta.schema) {
const result = validateSchema(value, meta.schema);
// Prefix errors with parameter name
return {
valid: result.valid,
errors: result.errors.map(err => ({
...err,
path: `/${name}${err.path === '/' ? '' : err.path}`,
})),
};
}
return { valid: true, errors: [] };
}
/**
* Create a 400 Bad Request response for validation errors
*/
export function createValidationErrorResponse(
errors: IValidationError[],
source: 'body' | 'params' | 'query' | 'headers'
): Response {
const body = {
error: 'Validation failed',
source,
details: errors.map(e => ({
path: e.path,
message: e.message,
})),
};
return new Response(JSON.stringify(body), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
/**
* Validate the full request based on OpenAPI metadata
* Returns a Response if validation fails, undefined if valid
*/
export async function validateRequest(
ctx: IRequestContext,
openapi: IOpenApiRouteMeta
): Promise<{
valid: boolean;
response?: Response;
coercedParams?: Record<string, unknown>;
coercedQuery?: Record<string, unknown>;
}> {
const allErrors: Array<{ errors: IValidationError[]; source: string }> = [];
// Coerce and validate path parameters
let coercedParams: Record<string, unknown> = ctx.params;
if (openapi.params && openapi.params.size > 0) {
coercedParams = coercePathParams(ctx.params, openapi.params);
for (const [name, meta] of openapi.params) {
const result = validateParam(name, coercedParams[name], meta, 'path');
if (!result.valid) {
allErrors.push({ errors: result.errors, source: 'params' });
}
}
}
// Coerce and validate query parameters
let coercedQuery: Record<string, unknown> = ctx.query;
if (openapi.query && openapi.query.size > 0) {
coercedQuery = coerceQueryParams(ctx.query, openapi.query);
for (const [name, meta] of openapi.query) {
const result = validateParam(name, coercedQuery[name], meta, 'query');
if (!result.valid) {
allErrors.push({ errors: result.errors, source: 'query' });
}
}
}
// Validate header parameters
if (openapi.headers && openapi.headers.size > 0) {
for (const [name, meta] of openapi.headers) {
const value = ctx.headers.get(name);
const result = validateParam(name, value, meta, 'header');
if (!result.valid) {
allErrors.push({ errors: result.errors, source: 'headers' });
}
}
}
// Validate request body (lazy parsing via ctx.json())
if (openapi.requestBody) {
const required = openapi.requestBody.required !== false;
let body: unknown;
try {
body = await ctx.json();
} catch {
body = undefined;
}
if (required && (body === undefined || body === null)) {
allErrors.push({
errors: [{
path: '/',
message: 'Request body is required',
keyword: 'required',
}],
source: 'body',
});
} else if (body !== undefined && body !== null) {
const result = validateSchema(body, openapi.requestBody.schema);
if (!result.valid) {
allErrors.push({ errors: result.errors, source: 'body' });
}
}
}
// Return first error source as response
if (allErrors.length > 0) {
const firstError = allErrors[0];
return {
valid: false,
response: createValidationErrorResponse(
firstError.errors,
firstError.source as 'body' | 'params' | 'query' | 'headers'
),
};
}
return {
valid: true,
coercedParams,
coercedQuery,
};
}
/**
* Create a validation request interceptor for a route
*/
export function createValidationInterceptor(openapi: IOpenApiRouteMeta) {
return async (ctx: IRequestContext): Promise<IRequestContext | Response | void> => {
const result = await validateRequest(ctx, openapi);
if (!result.valid && result.response) {
return result.response;
}
// Return modified context with coerced values
if (result.coercedParams || result.coercedQuery) {
// Create a new context with coerced values
// We use Object.defineProperty to update readonly properties
const newCtx = Object.create(ctx);
if (result.coercedParams) {
Object.defineProperty(newCtx, 'params', {
value: result.coercedParams,
writable: false,
enumerable: true,
});
}
if (result.coercedQuery) {
Object.defineProperty(newCtx, 'query', {
value: result.coercedQuery,
writable: false,
enumerable: true,
});
}
return newCtx;
}
return;
};
}

View File

@@ -3,8 +3,9 @@ import * as path from 'path';
import * as http from 'http'; import * as http from 'http';
import * as https from 'https'; import * as https from 'https';
import * as fs from 'fs'; import * as fs from 'fs';
import * as zlib from 'zlib';
export { path, http, https, fs }; export { path, http, https, fs, zlib };
// @push.rocks scope // @push.rocks scope
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
@@ -13,3 +14,8 @@ import * as smartlog from '@push.rocks/smartlog';
import * as lik from '@push.rocks/lik'; import * as lik from '@push.rocks/lik';
export { smartpath, smartenv, smartlog, lik }; export { smartpath, smartenv, smartlog, lik };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };

View File

@@ -1,2 +1,10 @@
export { getMimeType, isTextMimeType } from './utils.mime.js'; export { getMimeType, isTextMimeType } from './utils.mime.js';
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js'; export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
export {
parseAcceptEncoding,
selectEncoding,
isCompressible,
getDefaultCompressibleTypes,
type TCompressionAlgorithm,
type IEncodingPreference,
} from './utils.encoding.js';

166
ts/utils/utils.encoding.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* HTTP encoding utilities for Accept-Encoding parsing and compression algorithm selection
*/
// =============================================================================
// Types
// =============================================================================
/**
* Supported compression algorithms
*/
export type TCompressionAlgorithm = 'br' | 'gzip' | 'deflate' | 'identity';
/**
* Parsed encoding preference with quality value
*/
export interface IEncodingPreference {
encoding: string;
q: number;
}
// =============================================================================
// Accept-Encoding Parsing
// =============================================================================
/**
* Parse Accept-Encoding header with quality values
*
* @example
* parseAcceptEncoding('gzip, deflate, br;q=1.0, identity;q=0.5')
* // Returns: [{ encoding: 'br', q: 1.0 }, { encoding: 'gzip', q: 1.0 }, { encoding: 'deflate', q: 1.0 }, { encoding: 'identity', q: 0.5 }]
*/
export function parseAcceptEncoding(header: string | null): IEncodingPreference[] {
if (!header) {
return [{ encoding: 'identity', q: 1.0 }];
}
const preferences: IEncodingPreference[] = [];
for (const part of header.split(',')) {
const trimmed = part.trim();
if (!trimmed) continue;
const [encoding, ...params] = trimmed.split(';');
let q = 1.0;
for (const param of params) {
const [key, value] = param.trim().split('=');
if (key?.toLowerCase() === 'q' && value) {
q = parseFloat(value) || 0;
}
}
if (q > 0) {
preferences.push({ encoding: encoding.trim().toLowerCase(), q });
}
}
// Sort by quality (descending)
return preferences.sort((a, b) => b.q - a.q);
}
/**
* Select best encoding from client preferences and server support
*/
export function selectEncoding(
acceptEncoding: string | null,
supported: TCompressionAlgorithm[]
): TCompressionAlgorithm {
const preferences = parseAcceptEncoding(acceptEncoding);
for (const pref of preferences) {
if (pref.encoding === '*') {
// Wildcard - use first supported (brotli preferred)
return supported[0] ?? 'identity';
}
if (supported.includes(pref.encoding as TCompressionAlgorithm)) {
return pref.encoding as TCompressionAlgorithm;
}
}
return 'identity';
}
// =============================================================================
// Compressibility Check
// =============================================================================
/**
* Default MIME type patterns that should be compressed
*/
const DEFAULT_COMPRESSIBLE_TYPES = [
'text/',
'application/json',
'application/javascript',
'application/xml',
'application/xhtml+xml',
'application/rss+xml',
'application/atom+xml',
'application/x-javascript',
'application/ld+json',
'application/manifest+json',
'application/vnd.api+json',
'image/svg+xml',
'image/x-icon',
'font/ttf',
'font/otf',
];
/**
* MIME types that should never be compressed (already compressed)
*/
const NEVER_COMPRESS = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'video/',
'audio/',
'application/zip',
'application/gzip',
'application/x-gzip',
'application/x-bzip2',
'application/x-7z-compressed',
'application/x-rar-compressed',
'application/wasm',
'font/woff',
'font/woff2',
];
/**
* Check if content type should be compressed
*/
export function isCompressible(
contentType: string | null,
customTypes?: string[]
): boolean {
if (!contentType) return false;
const lowerType = contentType.toLowerCase().split(';')[0].trim();
// Never compress already-compressed formats
for (const pattern of NEVER_COMPRESS) {
if (pattern.endsWith('/')) {
if (lowerType.startsWith(pattern)) return false;
} else {
if (lowerType === pattern) return false;
}
}
const types = customTypes ?? DEFAULT_COMPRESSIBLE_TYPES;
return types.some(pattern =>
pattern.endsWith('/')
? lowerType.startsWith(pattern)
: lowerType.includes(pattern)
);
}
/**
* Get default compressible types (for configuration reference)
*/
export function getDefaultCompressibleTypes(): string[] {
return [...DEFAULT_COMPRESSIBLE_TYPES];
}