12 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
31 changed files with 4812 additions and 506 deletions

View File

@@ -1,5 +1,67 @@
# 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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartserve",
"version": "1.1.1",
"version": "2.0.1",
"private": false,
"description": "a cross platform server module for Node, Deno and Bun",
"exports": {
@@ -10,21 +10,22 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --web)",
"test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "(tsdoc)"
},
"devDependencies": {
"@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/tstest": "^3.1.3",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^20.8.7",
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.0.0",
"@api.global/typedrequest": "^3.2.5",
"@cfworker/json-schema": "^4.1.1",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartenv": "^6.0.0",
"@push.rocks/smartlog": "^3.1.10",

701
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.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
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
@@ -149,9 +155,81 @@ const connections = server.getWebSocketConnections();
- 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
- [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
- [ ] Performance benchmarks

543
readme.md
View File

@@ -1,6 +1,6 @@
# @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
@@ -16,21 +16,25 @@ pnpm add @push.rocks/smartserve
## Features
**Cross-Platform** - Works seamlessly on Node.js, Deno, and Bun
🎯 **Decorator-Based Routing** - Clean, expressive `@Route`, `@Get`, `@Post` decorators
🛡️ **Guards & Interceptors** - Built-in `@Guard`, `@Transform`, `@Intercept` for auth and transformation
📁 **Static File Server** - Streaming, ETags, Range requests, directory listing
🌐 **WebDAV Support** - Mount as network drive with full RFC 4918 compliance
🔌 **WebSocket Ready** - Native WebSocket support across all runtimes
**Zero Overhead** - Native Web Standards API (Request/Response) on Deno/Bun
🔒 **HTTPS/TLS** - Built-in TLS support with certificate configuration
| Feature | Description |
|---------|-------------|
| ✨ **Cross-Platform** | Works seamlessly on Node.js, Deno, and Bun with zero config |
| 🎯 **Decorator-Based Routing** | Clean, expressive `@Route`, `@Get`, `@Post` decorators |
| 📖 **OpenAPI/Swagger** | Auto-generate OpenAPI 3.1 specs with built-in Swagger UI & ReDoc |
| ✅ **Request Validation** | Validate requests against JSON Schema with automatic coercion |
| 🗜️ **Auto Compression** | Brotli/gzip compression with smart content detection |
| 🛡️ **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
```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')
class UserController {
@Get('/hello')
@@ -44,12 +48,12 @@ class UserController {
}
@Post('/users')
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
return { id: 'new-id', ...ctx.body };
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return { id: 'new-id', ...body };
}
}
// Create and start server
const server = new SmartServe({ port: 3000 });
server.register(UserController);
await server.start();
@@ -57,6 +61,28 @@ await server.start();
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
### 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
class ApiController {
@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) {
return { id: ctx.params.id };
}
@Post('/items') // POST /api/v1/items
createItem(ctx: IRequestContext<{ name: string }>) {
return { created: ctx.body.name };
async createItem(ctx: IRequestContext<{ name: string }>) {
const body = await ctx.json();
return { created: body.name };
}
@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
deleteItem(ctx: IRequestContext) { ... }
deleteItem(ctx: IRequestContext) {
return { deleted: ctx.params.id };
}
@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):
```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) => {
return ctx.headers.has('Authorization');
};
@@ -106,7 +142,6 @@ const isAdmin = (ctx: IRequestContext) => {
return ctx.headers.get('X-Role') === 'admin';
};
// Apply guard to entire controller
@Route('/admin')
@Guard(isAuthenticated)
@Guard(isAdmin) // Multiple guards - all must pass
@@ -123,6 +158,16 @@ class AdminController {
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)
@@ -130,29 +175,24 @@ class AdminController {
Transforms modify the response before sending:
```typescript
import { Route, Get, Transform } from '@push.rocks/smartserve';
// Transform function
const wrapResponse = <T>(data: T) => ({
success: true,
data,
timestamp: Date.now(),
});
import { Route, Get, Transform, wrapSuccess, addTimestamp } from '@push.rocks/smartserve';
// Custom transform
const addVersion = <T extends object>(data: T) => ({
...data,
apiVersion: '2.0',
});
@Route('/api')
@Transform(wrapResponse) // Applied to all routes in controller
@Transform(wrapSuccess) // Built-in: wraps in { success: true, data: ... }
class ApiController {
@Get('/info')
@Transform(addVersion) // Stacks with class transform
@Transform(addTimestamp) // Built-in: adds timestamp field
@Transform(addVersion) // Transforms stack
getInfo() {
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')
@Intercept({
// Runs before handler
// Runs BEFORE handler
request: async (ctx) => {
console.log(`${ctx.method} ${ctx.path}`);
console.log(`📥 ${ctx.method} ${ctx.path}`);
// Return Response to short-circuit
// Return modified context to continue
// Return void to continue with original
if (ctx.headers.get('X-Block') === 'true') {
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) => {
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 {
@@ -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
Serve static files with streaming, ETags, and directory listing:
Serve static files with streaming, ETags, Range requests, and directory listing:
```typescript
const server = new SmartServe({
@@ -196,10 +479,11 @@ const server = new SmartServe({
root: './public',
index: ['index.html', 'index.htm'],
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
etag: true, // Generate ETags
etag: true, // Generate ETags for caching
lastModified: true, // Add Last-Modified header
cacheControl: 'max-age=3600',
extensions: ['.html'], // Try these extensions
cacheControl: 'max-age=3600', // Or function: (path) => 'max-age=...'
extensions: ['.html'], // Try these extensions for extensionless URLs
precompressed: true, // Serve .br/.gz files when available
directoryListing: {
showHidden: false,
sortBy: 'name', // 'name' | 'size' | 'modified'
@@ -209,15 +493,17 @@ const server = new SmartServe({
});
```
Or simply:
Or use the shorthand:
```typescript
const server = new SmartServe({
port: 3000,
static: './public', // Shorthand - uses defaults
static: './public', // Uses sensible defaults
});
```
---
## WebDAV Support
Mount the server as a network drive on macOS, Windows, or Linux:
@@ -228,75 +514,115 @@ const server = new SmartServe({
webdav: {
root: '/path/to/files',
auth: (ctx) => {
// Optional Basic auth
// Optional: Basic authentication
const auth = ctx.headers.get('Authorization');
if (!auth) return false;
const [, credentials] = auth.split(' ');
const [user, pass] = atob(credentials).split(':');
return user === 'admin' && pass === 'secret';
},
locking: true, // Enable RFC 4918 file locking
locking: true, // Enable RFC 4918 exclusive write locks
},
});
await server.start();
// Mount: Connect to Server → http://localhost:8080
// 💾 Connect: Finder → Go → Connect to Server → http://localhost:8080
```
**Supported WebDAV Methods:**
- `OPTIONS` - Capability discovery
- `PROPFIND` - Directory listing and file metadata
- `MKCOL` - Create directory
- `COPY` / `MOVE` - Copy and move operations
- `LOCK` / `UNLOCK` - Exclusive write locking
- `GET` / `PUT` / `DELETE` - File operations
| Method | Description |
|--------|-------------|
| `OPTIONS` | Capability discovery |
| `PROPFIND` | Directory listing and file metadata |
| `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 connections are handled natively:
WebSocket connections are handled natively across all runtimes:
```typescript
const server = new SmartServe({
port: 3000,
websocket: {
onOpen: (peer) => {
console.log(`Connected: ${peer.id}`);
console.log(`🔗 Connected: ${peer.id}`);
peer.send('Welcome!');
peer.tags.add('authenticated'); // Tag for filtering
},
onMessage: (peer, message) => {
console.log(`Received: ${message.text}`);
console.log(`📨 ${message.text}`);
peer.send(`Echo: ${message.text}`);
},
onClose: (peer, code, reason) => {
console.log(`Disconnected: ${peer.id}`);
console.log(`👋 Disconnected: ${peer.id}`);
},
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
Enable HTTPS with certificate configuration:
```typescript
import * as fs from 'fs';
const server = new SmartServe({
port: 443,
tls: {
cert: fs.readFileSync('./cert.pem'),
key: fs.readFileSync('./key.pem'),
// Optional
ca: fs.readFileSync('./ca.pem'),
minVersion: 'TLSv1.2',
ca: fs.readFileSync('./ca.pem'), // Optional: CA chain
minVersion: 'TLSv1.2', // Optional: minimum TLS version
passphrase: 'optional-key-passphrase',
},
});
```
---
## Error Handling
Built-in HTTP error classes:
Built-in HTTP error classes with factory methods:
```typescript
import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
@@ -324,43 +650,73 @@ HttpError.conflict(message, details); // 409
HttpError.internal(message, details); // 500
```
Global error handling:
### Global Error Handler
```typescript
const server = new SmartServe({
port: 3000,
onError: (error, request) => {
console.error('Server error:', error);
return new Response(JSON.stringify({ error: 'Something went wrong' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
console.error('💥 Server error:', error);
// Return custom error response
return new Response(
JSON.stringify({ error: 'Something went wrong', requestId: crypto.randomUUID() }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
},
});
```
---
## Request Context
Every handler receives a typed request context:
```typescript
interface IRequestContext<TBody = unknown> {
request: Request; // Original Web Standards Request
body: TBody; // Parsed and typed body
params: Record<string, string>; // URL path parameters
query: Record<string, string>; // Query string parameters
request: Request; // Original Request (body never consumed by framework)
params: Record<string, string>; // URL path parameters (/users/:id → { id: '123' })
query: Record<string, string>; // Query string (?page=1 → { page: '1' })
headers: Headers; // Request headers
path: string; // Matched route path
method: THttpMethod; // HTTP method
method: THttpMethod; // GET, POST, PUT, DELETE, etc.
url: URL; // Full URL object
runtime: 'node' | 'deno' | 'bun'; // Current runtime
state: Record<string, unknown>; // Per-request state bag
runtime: 'node' | 'deno' | 'bun';
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
Bypass decorator routing entirely:
Bypass decorator routing entirely for low-level control:
```typescript
const server = new SmartServe({ port: 3000 });
@@ -372,12 +728,22 @@ server.setHandler(async (request, connectionInfo) => {
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 });
});
await server.start();
```
---
## Runtime Detection
SmartServe automatically detects and optimizes for the current runtime:
@@ -387,24 +753,35 @@ const instance = await server.start();
console.log(instance.runtime); // 'node' | 'deno' | 'bun'
console.log(instance.port); // 3000
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
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.
### 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
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.

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,
Transform,
Intercept,
Compress,
NoCompress,
HttpError,
type IRequestContext,
} 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
@Route('/api')
@@ -25,8 +69,9 @@ class TestController {
}
@Post('/echo')
echo(ctx: IRequestContext<{ text: string }>) {
return { echo: ctx.body?.text };
async echo(ctx: IRequestContext<{ text: string }>) {
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 () => {
const server = new SmartServe({ port: 3456 });
expect(server).toBeInstanceOf(SmartServe);
@@ -205,4 +292,152 @@ tap.test('HttpError should create proper responses', async () => {
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();

View File

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

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,17 +10,119 @@ import type {
IRequestContext,
IConnectionInfo,
THttpMethod,
TRuntime,
IInterceptOptions,
TRequestInterceptor,
TResponseInterceptor,
IWebSocketPeer,
IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.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 { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
import { FileServer } from '../files/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
@@ -43,6 +145,7 @@ export class SmartServe {
private customHandler: TRequestHandler | null = null;
private fileServer: FileServer | 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;
@@ -64,6 +167,9 @@ export class SmartServe {
...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();
@@ -111,6 +217,11 @@ export class SmartServe {
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;
@@ -241,6 +352,29 @@ export class SmartServe {
}
}
/**
* 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
*/
@@ -248,7 +382,9 @@ export class SmartServe {
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
// Use custom handler if set
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
@@ -258,7 +394,8 @@ export class SmartServe {
// Handle WebDAV requests first if handler is configured
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
try {
return await this.webdavHandler.handle(request);
const response = await this.webdavHandler.handle(request);
return this.applyCompression(response, request);
} catch (error) {
return this.handleError(error as Error, request);
}
@@ -271,7 +408,8 @@ export class SmartServe {
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
if (this.webdavHandler) {
try {
return await this.webdavHandler.handle(request);
const response = await this.webdavHandler.handle(request);
return this.applyCompression(response, request);
} catch (error) {
return this.handleError(error as Error, request);
}
@@ -282,7 +420,8 @@ export class SmartServe {
try {
const staticResponse = await this.fileServer.serve(request);
if (staticResponse) {
return staticResponse;
// Apply compression to static file responses
return this.applyCompression(staticResponse, request);
}
} catch (error) {
return this.handleError(error as Error, request);
@@ -300,11 +439,14 @@ export class SmartServe {
const { route, params } = match;
try {
// Create request context
const context = await this.createContext(request, url, params, connectionInfo);
// Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
const context = this.createContext(request, url, params, connectionInfo);
// 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) {
return this.handleError(error as Error, request);
}
@@ -312,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,
url: URL,
params: Record<string, string>,
connectionInfo: IConnectionInfo
): Promise<IRequestContext> {
): IRequestContext {
// Parse query params
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Parse body (lazy)
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 {
return new RequestContext(
request,
body,
params,
query,
headers: request.headers,
path: url.pathname,
method: request.method.toUpperCase() as THttpMethod,
request.headers,
url.pathname,
request.method.toUpperCase() as THttpMethod,
url,
runtime: this.adapter?.name ?? 'node',
state: {},
};
this.adapter?.name ?? 'node',
{},
);
}
/**
@@ -456,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
*/

View File

@@ -4,6 +4,8 @@
*/
import type { TypedRouter } from '@api.global/typedrequest';
import type { ICompressionConfig } from '../compression/index.js';
import type { IOpenApiOptions } from '../openapi/openapi.types.js';
// =============================================================================
// HTTP Types
@@ -22,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
* Wraps Web Standard Request with additional utilities
*/
export interface IRequestContext<TBody = unknown> {
/** Original Web Standards Request */
/** Original Web Standards Request - body stream is never consumed by framework */
readonly request: Request;
/** Parsed request body (typed) */
readonly body: TBody;
/** URL path parameters extracted from route */
readonly params: Record<string, string>;
/** URL query parameters */
@@ -42,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
readonly runtime: TRuntime;
/** Route-specific state bag for passing data between interceptors */
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>;
}
// =============================================================================
@@ -272,6 +300,8 @@ export interface IStaticOptions {
extensions?: string[];
/** Enable directory listing */
directoryListing?: boolean | IDirectoryListingOptions;
/** Serve pre-compressed files (.br, .gz) when available */
precompressed?: boolean;
}
/**
@@ -333,6 +363,10 @@ export interface ISmartServeOptions {
keepAlive?: IKeepAliveConfig;
/** Global error handler */
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);
if (!base) return route || '/';
if (!route) return base;
if (!route || route === '/') return base || '/';
return `${base}${route}`;
}

View File

@@ -2,9 +2,10 @@
* 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 { getControllerMetadata, combinePaths } from './decorators.metadata.js';
import { createValidationInterceptor } from '../openapi/openapi.validator.js';
/**
* Global registry of all controllers
@@ -13,6 +14,7 @@ export class ControllerRegistry {
private static controllers: Map<Function, IControllerMetadata> = new Map();
private static instances: Map<Function, any> = new Map();
private static compiledRoutes: ICompiledRoute[] = [];
private static dynamicRoutes: ICompiledRoute[] = [];
private static routesCompiled = false;
/**
@@ -65,10 +67,33 @@ export class ControllerRegistry {
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
*/
static compileRoutes(): ICompiledRoute[] {
static compileRoutes(enableValidation = true): ICompiledRoute[] {
if (this.routesCompiled) {
return this.compiledRoutes;
}
@@ -81,10 +106,19 @@ export class ControllerRegistry {
const { regex, paramNames } = this.pathToRegex(fullPath);
// Combine class and method interceptors
const interceptors: IInterceptOptions[] = [
...metadata.classInterceptors,
...route.interceptors,
];
const interceptors: IInterceptOptions[] = [];
// 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
const handler = async (ctx: IRequestContext): Promise<any> => {
@@ -102,10 +136,15 @@ export class ControllerRegistry {
method: route.method,
handler,
interceptors,
compression: route.compression,
openapi: route.openapi,
});
}
}
// Add dynamic routes
this.compiledRoutes.push(...this.dynamicRoutes);
// Sort routes by specificity (more specific paths first)
this.compiledRoutes.sort((a, b) => {
// 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)
*/
@@ -193,6 +244,7 @@ export class ControllerRegistry {
this.controllers.clear();
this.instances.clear();
this.compiledRoutes = [];
this.dynamicRoutes = [];
this.routesCompiled = false;
}
}

View File

@@ -10,6 +10,7 @@ import type {
IMethodOptions,
IRouteOptions,
} from '../core/smartserve.interfaces.js';
import type { ICompressionConfig } from '../compression/index.js';
// =============================================================================
// Metadata Types
@@ -27,6 +28,18 @@ export interface IControllerMetadata {
routes: Map<string | symbol, IRouteMetadata>;
/** Controller class reference */
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;
/** Handler function reference */
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>;
/** Combined interceptors (class + method) */
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 {
IControllerMetadata,
IRouteMetadata,
IRouteCompressionOptions,
IRegisteredController,
ICompiledRoute,
// OpenAPI types
TJsonSchema,
IOpenApiOperationMeta,
IOpenApiParamMeta,
IOpenApiRequestBodyMeta,
IOpenApiResponseBodyMeta,
IOpenApiRouteMeta,
IOpenApiControllerMeta,
} from './decorators.types.js';
// Route decorator
@@ -35,6 +44,9 @@ export {
addTimestamp,
} from './decorators.interceptors.js';
// Compression decorators
export { Compress, NoCompress } from './decorators.compress.js';
// Registry
export { ControllerRegistry } from './decorators.registry.js';
@@ -45,3 +57,15 @@ export {
normalizePath,
combinePaths,
} 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';
import { getMimeType } from '../utils/utils.mime.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
@@ -112,22 +122,43 @@ export class FileServer {
): Promise<Response> {
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);
headers.set('Content-Type', mimeType);
// Content-Length
headers.set('Content-Length', stat.size.toString());
// Content-Encoding (if serving pre-compressed)
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) {
headers.set('Last-Modified', stat.mtime.toUTCString());
}
// ETag
// ETag (include encoding in ETag if compressed)
let etag: string | undefined;
if (this.options.etag) {
etag = generateETag(stat);
const baseEtag = generateETag(stat);
etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag;
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');
if (rangeHeader) {
if (rangeHeader && !contentEncoding) {
return this.servePartial(filePath, stat, rangeHeader, headers);
}
@@ -164,8 +195,8 @@ export class FileServer {
return new Response(null, { status: 200, headers });
}
// Stream the file
const stream = plugins.fs.createReadStream(filePath);
// Stream the file (use actualFilePath for pre-compressed)
const stream = plugins.fs.createReadStream(actualFilePath);
const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 200, headers });
@@ -361,6 +392,46 @@ export class FileServer {
</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
*/

View File

@@ -6,12 +6,18 @@ export * from './core/index.js';
// Decorator exports
export * from './decorators/index.js';
// Compression exports
export * from './compression/index.js';
// File server exports
export * from './files/index.js';
// Protocol exports (WebDAV, etc.)
export * from './protocols/index.js';
// OpenAPI exports
export * from './openapi/index.js';
// Utility exports
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 https from 'https';
import * as fs from 'fs';
import * as zlib from 'zlib';
export { path, http, https, fs };
export { path, http, https, fs, zlib };
// @push.rocks scope
import * as smartpath from '@push.rocks/smartpath';

View File

@@ -1,2 +1,10 @@
export { getMimeType, isTextMimeType } from './utils.mime.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];
}