Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e7307f15 | |||
| d645311208 | |||
| 697b7e92d7 | |||
| 4a17bf39c6 | |||
| 39f0cdf380 | |||
| cc3e335112 | |||
| 15848b9c9c | |||
| fec0770d55 | |||
| 59ccff3453 | |||
| 57d7fd6483 |
55
changelog.md
55
changelog.md
@@ -1,5 +1,60 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-20 - 2.0.1 - fix(readme)
|
||||||
|
update README: rework features, add OpenAPI/Swagger, compression, request validation, examples, and runtime stats
|
||||||
|
|
||||||
|
- Reworked Features section into a markdown table and added items for OpenAPI/Swagger, Request Validation, and Auto Compression.
|
||||||
|
- Updated Quick Start example (removed Guard import) and added a Table of Contents.
|
||||||
|
- Expanded examples with a manual route handling snippet and guidance to bypass decorator routing.
|
||||||
|
- Added runtime example fields: instance.hostname and new stats() usage (uptime, requestsTotal, requestsActive).
|
||||||
|
- Minor legal/formatting updates: fixed LICENSE link casing/path and clarified trademark wording.
|
||||||
|
|
||||||
|
## 2025-12-20 - 2.0.0 - BREAKING CHANGE(request)
|
||||||
|
introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
|
||||||
|
|
||||||
|
- Add RequestContext class implementing lazy, cached body parsing methods: json(), text(), arrayBuffer(), formData.
|
||||||
|
- Remove IRequestContext.body property — handlers and interceptors must call ctx.json()/ctx.text()/... to access the request body (breaking API change).
|
||||||
|
- createContext now returns a RequestContext synchronously and no longer pre-parses or coerces the body.
|
||||||
|
- OpenAPI validator (validateRequest) made async and updated to use ctx.json() for request body validation; createValidationInterceptor now awaits validation.
|
||||||
|
- Updated README and tests to use async handlers and ctx.json() for body access.
|
||||||
|
- Updated npmextra.json: replaced gitzone key with @git.zone/cli, replaced npmci with @ship.zone/szci, and added release registries and accessLevel.
|
||||||
|
|
||||||
|
## 2025-12-08 - 1.4.0 - feat(openapi)
|
||||||
|
Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
|
||||||
|
|
||||||
|
- Introduce a new OpenAPI module providing decorators, types, spec generator, handlers and validation utilities
|
||||||
|
- Add decorators: ApiOperation, ApiParam, ApiQuery, ApiHeader, ApiRequestBody, ApiResponseBody, ApiSecurity, ApiTag
|
||||||
|
- Add OpenApiGenerator to produce OpenAPI 3.1 JSON from registered controllers and route metadata
|
||||||
|
- Add runtime request validation and coercion using @cfworker/json-schema (validate request body, params, query, headers)
|
||||||
|
- Register OpenAPI endpoints and Swagger UI (and ReDoc) handlers when SmartServe.openapi is enabled
|
||||||
|
- Integrate validation interceptor into controller registry compilation so validation runs before other interceptors
|
||||||
|
- Expose openapi exports from the public API (ts/index.ts and decorators index)
|
||||||
|
- Add extensive types (openapi.types.ts and decorator types) and coercion utilities for query/path params
|
||||||
|
- Add tests for OpenAPI functionality (test/test.openapi.ts)
|
||||||
|
- Bump dependencies: @api.global/typedrequest to ^3.2.5 and add @cfworker/json-schema ^4.1.1
|
||||||
|
|
||||||
|
## 2025-12-05 - 1.3.0 - feat(compression)
|
||||||
|
Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API
|
||||||
|
|
||||||
|
- Buffer response bodies before compressing and perform size threshold check after buffering; return uncompressed responses when below threshold.
|
||||||
|
- Set Content-Length to the compressed size and use provider.compress to produce full compressed payloads instead of streaming compression from the middleware.
|
||||||
|
- Add Deno-native brotli support via Deno.compress and use CompressionStream for gzip/deflate; brotli streaming is not attempted in web runtime.
|
||||||
|
- Pass compression threshold from SmartServe configuration into compressResponse so route/global thresholds are honored.
|
||||||
|
- Expose ControllerRegistry.addRoute and dynamicRoutes to allow adding dynamic routes without controller classes.
|
||||||
|
- Add comprehensive compression tests (gzip and brotli) using raw HTTP requests to avoid Node fetch auto-decompression; tests cover large/small responses, @Compress/@NoCompress behavior, and global compression disable.
|
||||||
|
- Change test runner invocation to use verbose mode.
|
||||||
|
|
||||||
|
## 2025-12-05 - 1.2.0 - feat(compression)
|
||||||
|
Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support
|
||||||
|
|
||||||
|
- Introduce a cross-runtime compression provider (Node zlib + Web CompressionStream fallback) with create/get provider APIs (ts/compression/compression.runtime.ts).
|
||||||
|
- Add compression middleware utilities (normalize config, shouldCompressResponse, algorithm selection, streaming/full-body compression) and default configuration (ts/compression/compression.middleware.ts).
|
||||||
|
- Implement Accept-Encoding parsing, encoding selection, and compressibility checks (ts/utils/utils.encoding.ts) and export types/utilities from utils/index.ts.
|
||||||
|
- Add @Compress and @NoCompress decorators and route-level compression metadata support (ts/decorators/decorators.compress.ts, decorators.types.ts, registry updates, and exports).
|
||||||
|
- Integrate compression into SmartServe core: global compression config, applyCompression for custom handlers, WebDAV, static files, and route responses (ts/core/smartserve.classes.smartserve.ts, smartserve.interfaces.ts).
|
||||||
|
- Enhance FileServer to serve pre-compressed variants (.br/.gz) when available, adjust headers/ETag/Length, and avoid using pre-compressed files for range requests (ts/files/file.server.ts).
|
||||||
|
- Expose compression APIs from package entry point and export zlib via plugins for Node provider; update readme.hints.md with configuration examples and notes.
|
||||||
|
|
||||||
## 2025-12-03 - 1.1.2 - fix(deps)
|
## 2025-12-03 - 1.1.2 - fix(deps)
|
||||||
Bump dependency versions for build and runtime tools
|
Bump dependency versions for build and runtime tools
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"gitzone": {
|
"@git.zone/cli": {
|
||||||
"projectType": "npm",
|
"projectType": "npm",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
@@ -9,10 +9,16 @@
|
|||||||
"npmPackagename": "@push.rocks/smartserve",
|
"npmPackagename": "@push.rocks/smartserve",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "push.rocks"
|
"projectDomain": "push.rocks"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": []
|
||||||
"npmAccessLevel": "public"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartserve",
|
"name": "@push.rocks/smartserve",
|
||||||
"version": "1.1.2",
|
"version": "2.0.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a cross platform server module for Node, Deno and Bun",
|
"description": "a cross platform server module for Node, Deno and Bun",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)",
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
"buildDocs": "(tsdoc)"
|
"buildDocs": "(tsdoc)"
|
||||||
},
|
},
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.1.11",
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartenv": "^6.0.0",
|
"@push.rocks/smartenv": "^6.0.0",
|
||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -9,8 +9,11 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest':
|
'@api.global/typedrequest':
|
||||||
specifier: ^3.1.11
|
specifier: ^3.2.5
|
||||||
version: 3.1.11
|
version: 3.2.5
|
||||||
|
'@cfworker/json-schema':
|
||||||
|
specifier: ^4.1.1
|
||||||
|
version: 4.1.1
|
||||||
'@push.rocks/lik':
|
'@push.rocks/lik':
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
@@ -57,8 +60,8 @@ packages:
|
|||||||
'@api.global/typedrequest-interfaces@3.0.19':
|
'@api.global/typedrequest-interfaces@3.0.19':
|
||||||
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
|
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
|
||||||
|
|
||||||
'@api.global/typedrequest@3.1.11':
|
'@api.global/typedrequest@3.2.5':
|
||||||
resolution: {integrity: sha512-j8EO3na0WMw8pFkAfEaEui2a4TaAL1G/dv1CYl8LEPXckSKkl1BCAS1kFOW2xuI9pwZkmSqlo3xpQ3KmkmHaGQ==}
|
resolution: {integrity: sha512-LM/sUTuYnU5xY4gNZrN6ERMiKr+SpDZuSxJkAZz1YazC7ymGfo6uQ8sCnN8eNNQNFqIOkC+BtfYRayfbGwYLLg==}
|
||||||
|
|
||||||
'@api.global/typedserver@3.0.80':
|
'@api.global/typedserver@3.0.80':
|
||||||
resolution: {integrity: sha512-dcp0oXsjBL+XdFg1wUUP08uJQid5bQ0Yv3V3Y3lnI2QCbat0FU+Tsb0TZRnZ4+P150Vj/ITBqJUgDzFsF34grA==}
|
resolution: {integrity: sha512-dcp0oXsjBL+XdFg1wUUP08uJQid5bQ0Yv3V3Y3lnI2QCbat0FU+Tsb0TZRnZ4+P150Vj/ITBqJUgDzFsF34grA==}
|
||||||
@@ -240,6 +243,9 @@ packages:
|
|||||||
'@borewit/text-codec@0.1.1':
|
'@borewit/text-codec@0.1.1':
|
||||||
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
|
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
|
||||||
|
|
||||||
|
'@cfworker/json-schema@4.1.1':
|
||||||
|
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20251128.0':
|
'@cloudflare/workers-types@4.20251128.0':
|
||||||
resolution: {integrity: sha512-gQxQvxLRsFb+mDlaBKGoJwEHWt+ox9telZZEuRMbNUAD6v78XYqZepTI4yPDdKhkRTlqYcDqDhIdAI3HrsGk7w==}
|
resolution: {integrity: sha512-gQxQvxLRsFb+mDlaBKGoJwEHWt+ox9telZZEuRMbNUAD6v78XYqZepTI4yPDdKhkRTlqYcDqDhIdAI3HrsGk7w==}
|
||||||
|
|
||||||
@@ -4276,7 +4282,7 @@ snapshots:
|
|||||||
|
|
||||||
'@api.global/typedrequest-interfaces@3.0.19': {}
|
'@api.global/typedrequest-interfaces@3.0.19': {}
|
||||||
|
|
||||||
'@api.global/typedrequest@3.1.11':
|
'@api.global/typedrequest@3.2.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/isounique': 1.0.5
|
'@push.rocks/isounique': 1.0.5
|
||||||
@@ -4290,7 +4296,7 @@ snapshots:
|
|||||||
|
|
||||||
'@api.global/typedserver@3.0.80':
|
'@api.global/typedserver@3.0.80':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@api.global/typedsocket': 3.0.1
|
'@api.global/typedsocket': 3.0.1
|
||||||
'@cloudflare/workers-types': 4.20251128.0
|
'@cloudflare/workers-types': 4.20251128.0
|
||||||
@@ -4337,7 +4343,7 @@ snapshots:
|
|||||||
|
|
||||||
'@api.global/typedsocket@3.0.1':
|
'@api.global/typedsocket@3.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/isohash': 2.0.1
|
'@push.rocks/isohash': 2.0.1
|
||||||
'@push.rocks/smartjson': 5.2.0
|
'@push.rocks/smartjson': 5.2.0
|
||||||
@@ -4844,6 +4850,8 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/text-codec@0.1.1': {}
|
'@borewit/text-codec@0.1.1': {}
|
||||||
|
|
||||||
|
'@cfworker/json-schema@4.1.1': {}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20251128.0': {}
|
'@cloudflare/workers-types@4.20251128.0': {}
|
||||||
|
|
||||||
'@colors/colors@1.6.0': {}
|
'@colors/colors@1.6.0': {}
|
||||||
@@ -4860,14 +4868,14 @@ snapshots:
|
|||||||
|
|
||||||
'@design.estate/dees-comms@1.0.27':
|
'@design.estate/dees-comms@1.0.27':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
broadcast-channel: 7.2.0
|
broadcast-channel: 7.2.0
|
||||||
|
|
||||||
'@design.estate/dees-domtools@2.3.6':
|
'@design.estate/dees-domtools@2.3.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@design.estate/dees-comms': 1.0.27
|
'@design.estate/dees-comms': 1.0.27
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -5540,7 +5548,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/qenv@6.1.3':
|
'@push.rocks/qenv@6.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@configvault.io/interfaces': 1.0.17
|
'@configvault.io/interfaces': 1.0.17
|
||||||
'@push.rocks/smartfile': 11.2.7
|
'@push.rocks/smartfile': 11.2.7
|
||||||
'@push.rocks/smartlog': 3.1.10
|
'@push.rocks/smartlog': 3.1.10
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ SmartServe is a cross-platform HTTP server for Node.js, Deno, and Bun using:
|
|||||||
- `ts/utils/utils.mime.ts` - MIME type detection
|
- `ts/utils/utils.mime.ts` - MIME type detection
|
||||||
- `ts/utils/utils.etag.ts` - ETag generation
|
- `ts/utils/utils.etag.ts` - ETag generation
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
- `ts/compression/compression.runtime.ts` - Cross-runtime compression (Node.js zlib, Web CompressionStream)
|
||||||
|
- `ts/compression/compression.middleware.ts` - Compression config and helpers
|
||||||
|
- `ts/utils/utils.encoding.ts` - Accept-Encoding parsing
|
||||||
|
- `ts/decorators/decorators.compress.ts` - @Compress and @NoCompress decorators
|
||||||
|
|
||||||
### Protocols
|
### Protocols
|
||||||
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
|
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
|
||||||
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
|
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
|
||||||
@@ -149,9 +155,81 @@ const connections = server.getWebSocketConnections();
|
|||||||
- Bun adapter stores peer ID/tags in `ws.data` for persistence across events
|
- Bun adapter stores peer ID/tags in `ws.data` for persistence across events
|
||||||
- Internal `_connectionCallbacks` passed to adapters for registry communication
|
- Internal `_connectionCallbacks` passed to adapters for registry communication
|
||||||
|
|
||||||
|
## Compression
|
||||||
|
|
||||||
|
SmartServe supports automatic response compression with Brotli and gzip.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
|
||||||
|
// Simple: enable with defaults (compression is ON by default)
|
||||||
|
compression: true,
|
||||||
|
|
||||||
|
// Detailed configuration
|
||||||
|
compression: {
|
||||||
|
enabled: true,
|
||||||
|
algorithms: ['br', 'gzip'], // Brotli preferred, gzip fallback
|
||||||
|
threshold: 1024, // Don't compress < 1KB
|
||||||
|
level: 4, // Compression level
|
||||||
|
compressibleTypes: [ // Custom MIME types
|
||||||
|
'text/',
|
||||||
|
'application/json',
|
||||||
|
'application/javascript',
|
||||||
|
],
|
||||||
|
exclude: ['/api/stream/*'], // Skip certain paths
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pre-compressed static files
|
||||||
|
static: {
|
||||||
|
root: './public',
|
||||||
|
precompressed: true, // Serve index.html.br, index.html.gz if available
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Route Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Controller('/api')
|
||||||
|
class ApiController {
|
||||||
|
@Get('/data')
|
||||||
|
getData() {
|
||||||
|
return { large: 'json' }; // Compressed by default
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/stream')
|
||||||
|
@NoCompress() // Skip compression for SSE/streaming
|
||||||
|
getStream() {
|
||||||
|
return new Response(eventStream, {
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/heavy')
|
||||||
|
@Compress({ level: 11 }) // Force max brotli compression
|
||||||
|
getHeavy() {
|
||||||
|
return massiveData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Runtime Support
|
||||||
|
|
||||||
|
| Runtime | Brotli | gzip | Implementation |
|
||||||
|
|---------|--------|------|----------------|
|
||||||
|
| Node.js | ✅ | ✅ | Native zlib module |
|
||||||
|
| Deno | ⚠️ | ✅ | CompressionStream API |
|
||||||
|
| Bun | ⚠️ | ✅ | CompressionStream API |
|
||||||
|
|
||||||
|
Note: Brotli in Deno/Bun falls back to gzip if CompressionStream doesn't support it.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
|
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
|
||||||
- [x] TypedRouter WebSocket integration
|
- [x] TypedRouter WebSocket integration
|
||||||
|
- [x] Brotli/gzip compression with per-route control
|
||||||
- [ ] HTTP/2 support investigation
|
- [ ] HTTP/2 support investigation
|
||||||
- [ ] Performance benchmarks
|
- [ ] Performance benchmarks
|
||||||
|
|||||||
555
readme.md
555
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartserve
|
# @push.rocks/smartserve
|
||||||
|
|
||||||
A cross-platform HTTP server module for Node.js, Deno, and Bun with decorator-based routing, WebSocket support, static file serving, and WebDAV protocol. 🚀
|
A blazing-fast, cross-platform HTTP server for Node.js, Deno, and Bun with decorator-based routing, OpenAPI/Swagger integration, automatic compression, WebSocket support, static file serving, and WebDAV protocol. 🚀
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -16,21 +16,25 @@ pnpm add @push.rocks/smartserve
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
✨ **Cross-Platform** - Works seamlessly on Node.js, Deno, and Bun
|
| Feature | Description |
|
||||||
🎯 **Decorator-Based Routing** - Clean, expressive `@Route`, `@Get`, `@Post` decorators
|
|---------|-------------|
|
||||||
🛡️ **Guards & Interceptors** - Built-in `@Guard`, `@Transform`, `@Intercept` for auth and transformation
|
| ✨ **Cross-Platform** | Works seamlessly on Node.js, Deno, and Bun with zero config |
|
||||||
📁 **Static File Server** - Streaming, ETags, Range requests, directory listing
|
| 🎯 **Decorator-Based Routing** | Clean, expressive `@Route`, `@Get`, `@Post` decorators |
|
||||||
🌐 **WebDAV Support** - Mount as network drive with full RFC 4918 compliance
|
| 📖 **OpenAPI/Swagger** | Auto-generate OpenAPI 3.1 specs with built-in Swagger UI & ReDoc |
|
||||||
🔌 **WebSocket Ready** - Native WebSocket support across all runtimes
|
| ✅ **Request Validation** | Validate requests against JSON Schema with automatic coercion |
|
||||||
⚡ **Zero Overhead** - Native Web Standards API (Request/Response) on Deno/Bun
|
| 🗜️ **Auto Compression** | Brotli/gzip compression with smart content detection |
|
||||||
🔒 **HTTPS/TLS** - Built-in TLS support with certificate configuration
|
| 🛡️ **Guards & Interceptors** | Built-in `@Guard`, `@Transform`, `@Intercept` for auth & transformation |
|
||||||
|
| 📁 **Static File Server** | Streaming, ETags, Range requests, directory listing, pre-compressed files |
|
||||||
|
| 🌐 **WebDAV Support** | Mount as network drive with full RFC 4918 compliance |
|
||||||
|
| 🔌 **WebSocket Ready** | Native WebSocket support with TypedRouter for type-safe RPC |
|
||||||
|
| ⚡ **Zero Overhead** | Native Web Standards API (Request/Response) on Deno/Bun |
|
||||||
|
| 🔒 **HTTPS/TLS** | Built-in TLS support with certificate configuration |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartServe, Route, Get, Post, Guard, type IRequestContext } from '@push.rocks/smartserve';
|
import { SmartServe, Route, Get, Post, type IRequestContext } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
// Define a controller with decorators
|
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
class UserController {
|
class UserController {
|
||||||
@Get('/hello')
|
@Get('/hello')
|
||||||
@@ -44,12 +48,12 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/users')
|
@Post('/users')
|
||||||
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
return { id: 'new-id', ...ctx.body };
|
const body = await ctx.json();
|
||||||
|
return { id: 'new-id', ...body };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and start server
|
|
||||||
const server = new SmartServe({ port: 3000 });
|
const server = new SmartServe({ port: 3000 });
|
||||||
server.register(UserController);
|
server.register(UserController);
|
||||||
await server.start();
|
await server.start();
|
||||||
@@ -57,6 +61,28 @@ await server.start();
|
|||||||
console.log('🚀 Server running at http://localhost:3000');
|
console.log('🚀 Server running at http://localhost:3000');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Decorators](#decorators)
|
||||||
|
- [Route Decorators](#route-decorators)
|
||||||
|
- [Guards](#guards-authenticationauthorization)
|
||||||
|
- [Transforms](#transforms-response-modification)
|
||||||
|
- [Intercept](#intercept-full-control)
|
||||||
|
- [OpenAPI & Swagger](#openapi--swagger)
|
||||||
|
- [Documenting APIs](#documenting-apis)
|
||||||
|
- [Request Validation](#request-validation)
|
||||||
|
- [Swagger UI & ReDoc](#swagger-ui--redoc)
|
||||||
|
- [Compression](#compression)
|
||||||
|
- [Static File Server](#static-file-server)
|
||||||
|
- [WebDAV Support](#webdav-support)
|
||||||
|
- [WebSocket Support](#websocket-support)
|
||||||
|
- [HTTPS/TLS](#httpstls)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Request Context](#request-context)
|
||||||
|
- [Custom Request Handler](#custom-request-handler)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Decorators
|
## Decorators
|
||||||
|
|
||||||
### Route Decorators
|
### Route Decorators
|
||||||
@@ -67,26 +93,36 @@ import { Route, Get, Post, Put, Delete, Patch, All } from '@push.rocks/smartserv
|
|||||||
@Route('/api/v1') // Base path for all routes in this controller
|
@Route('/api/v1') // Base path for all routes in this controller
|
||||||
class ApiController {
|
class ApiController {
|
||||||
@Get('/items') // GET /api/v1/items
|
@Get('/items') // GET /api/v1/items
|
||||||
listItems() { ... }
|
listItems() {
|
||||||
|
return [{ id: 1, name: 'Item 1' }];
|
||||||
|
}
|
||||||
|
|
||||||
@Get('/items/:id') // GET /api/v1/items/:id - path parameters
|
@Get('/items/:id') // GET /api/v1/items/:id
|
||||||
getItem(ctx: IRequestContext) {
|
getItem(ctx: IRequestContext) {
|
||||||
return { id: ctx.params.id };
|
return { id: ctx.params.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/items') // POST /api/v1/items
|
@Post('/items') // POST /api/v1/items
|
||||||
createItem(ctx: IRequestContext<{ name: string }>) {
|
async createItem(ctx: IRequestContext<{ name: string }>) {
|
||||||
return { created: ctx.body.name };
|
const body = await ctx.json();
|
||||||
|
return { created: body.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('/items/:id') // PUT /api/v1/items/:id
|
@Put('/items/:id') // PUT /api/v1/items/:id
|
||||||
updateItem(ctx: IRequestContext) { ... }
|
async updateItem(ctx: IRequestContext) {
|
||||||
|
const body = await ctx.json();
|
||||||
|
return { updated: ctx.params.id, ...body };
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('/items/:id') // DELETE /api/v1/items/:id
|
@Delete('/items/:id') // DELETE /api/v1/items/:id
|
||||||
deleteItem(ctx: IRequestContext) { ... }
|
deleteItem(ctx: IRequestContext) {
|
||||||
|
return { deleted: ctx.params.id };
|
||||||
|
}
|
||||||
|
|
||||||
@All('/webhook') // Matches ALL HTTP methods
|
@All('/webhook') // Matches ALL HTTP methods
|
||||||
handleWebhook(ctx: IRequestContext) { ... }
|
handleWebhook(ctx: IRequestContext) {
|
||||||
|
return { method: ctx.method };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,9 +131,9 @@ class ApiController {
|
|||||||
Guards protect routes by returning `true` (allow) or `false` (reject with 403):
|
Guards protect routes by returning `true` (allow) or `false` (reject with 403):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Route, Get, Guard, type IRequestContext } from '@push.rocks/smartserve';
|
import { Route, Get, Guard, hasBearerToken, type IRequestContext } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
// Guard function
|
// Custom guard function
|
||||||
const isAuthenticated = (ctx: IRequestContext) => {
|
const isAuthenticated = (ctx: IRequestContext) => {
|
||||||
return ctx.headers.has('Authorization');
|
return ctx.headers.has('Authorization');
|
||||||
};
|
};
|
||||||
@@ -106,7 +142,6 @@ const isAdmin = (ctx: IRequestContext) => {
|
|||||||
return ctx.headers.get('X-Role') === 'admin';
|
return ctx.headers.get('X-Role') === 'admin';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply guard to entire controller
|
|
||||||
@Route('/admin')
|
@Route('/admin')
|
||||||
@Guard(isAuthenticated)
|
@Guard(isAuthenticated)
|
||||||
@Guard(isAdmin) // Multiple guards - all must pass
|
@Guard(isAdmin) // Multiple guards - all must pass
|
||||||
@@ -123,6 +158,16 @@ class AdminController {
|
|||||||
return { level: 'super-secret' };
|
return { level: 'super-secret' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built-in utility guards
|
||||||
|
@Route('/protected')
|
||||||
|
@Guard(hasBearerToken()) // Requires Authorization: Bearer <token>
|
||||||
|
class ProtectedController {
|
||||||
|
@Get('/data')
|
||||||
|
getData() {
|
||||||
|
return { protected: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transforms (Response Modification)
|
### Transforms (Response Modification)
|
||||||
@@ -130,29 +175,24 @@ class AdminController {
|
|||||||
Transforms modify the response before sending:
|
Transforms modify the response before sending:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Route, Get, Transform } from '@push.rocks/smartserve';
|
import { Route, Get, Transform, wrapSuccess, addTimestamp } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
// Transform function
|
|
||||||
const wrapResponse = <T>(data: T) => ({
|
|
||||||
success: true,
|
|
||||||
data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Custom transform
|
||||||
const addVersion = <T extends object>(data: T) => ({
|
const addVersion = <T extends object>(data: T) => ({
|
||||||
...data,
|
...data,
|
||||||
apiVersion: '2.0',
|
apiVersion: '2.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
@Transform(wrapResponse) // Applied to all routes in controller
|
@Transform(wrapSuccess) // Built-in: wraps in { success: true, data: ... }
|
||||||
class ApiController {
|
class ApiController {
|
||||||
@Get('/info')
|
@Get('/info')
|
||||||
@Transform(addVersion) // Stacks with class transform
|
@Transform(addTimestamp) // Built-in: adds timestamp field
|
||||||
|
@Transform(addVersion) // Transforms stack
|
||||||
getInfo() {
|
getInfo() {
|
||||||
return { name: 'MyAPI' };
|
return { name: 'MyAPI' };
|
||||||
}
|
}
|
||||||
// Response: { success: true, data: { name: 'MyAPI', apiVersion: '2.0' }, timestamp: ... }
|
// Response: { success: true, data: { name: 'MyAPI', timestamp: '...', apiVersion: '2.0' } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,16 +205,26 @@ import { Route, Get, Intercept, type IRequestContext } from '@push.rocks/smartse
|
|||||||
|
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
@Intercept({
|
@Intercept({
|
||||||
// Runs before handler
|
// Runs BEFORE handler
|
||||||
request: async (ctx) => {
|
request: async (ctx) => {
|
||||||
console.log(`${ctx.method} ${ctx.path}`);
|
console.log(`📥 ${ctx.method} ${ctx.path}`);
|
||||||
|
|
||||||
// Return Response to short-circuit
|
// Return Response to short-circuit
|
||||||
// Return modified context to continue
|
if (ctx.headers.get('X-Block') === 'true') {
|
||||||
// Return void to continue with original
|
return new Response('Blocked', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data to state for handler access
|
||||||
|
ctx.state.requestTime = Date.now();
|
||||||
|
|
||||||
|
// Return void to continue with original context
|
||||||
},
|
},
|
||||||
// Runs after handler
|
|
||||||
|
// Runs AFTER handler
|
||||||
response: async (data, ctx) => {
|
response: async (data, ctx) => {
|
||||||
return { ...data, processedAt: new Date().toISOString() };
|
const duration = Date.now() - (ctx.state.requestTime as number);
|
||||||
|
console.log(`📤 Response in ${duration}ms`);
|
||||||
|
return { ...data, processedIn: `${duration}ms` };
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
class LoggedController {
|
class LoggedController {
|
||||||
@@ -185,9 +235,242 @@ class LoggedController {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAPI & Swagger
|
||||||
|
|
||||||
|
SmartServe includes first-class OpenAPI 3.1 support with automatic spec generation, Swagger UI, ReDoc, and request validation.
|
||||||
|
|
||||||
|
### Documenting APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
SmartServe,
|
||||||
|
Route,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiRequestBody,
|
||||||
|
ApiResponseBody,
|
||||||
|
ApiTag,
|
||||||
|
ApiSecurity,
|
||||||
|
type IRequestContext,
|
||||||
|
} from '@push.rocks/smartserve';
|
||||||
|
|
||||||
|
// Define JSON Schemas for validation
|
||||||
|
const UserSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
name: { type: 'string', minLength: 1 },
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
},
|
||||||
|
required: ['id', 'name', 'email'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CreateUserSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', minLength: 1 },
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
},
|
||||||
|
required: ['name', 'email'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Route('/api/users')
|
||||||
|
@ApiTag('Users')
|
||||||
|
class UserController {
|
||||||
|
@Get('/')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'List all users',
|
||||||
|
description: 'Returns a paginated list of users',
|
||||||
|
})
|
||||||
|
@ApiQuery('page', {
|
||||||
|
description: 'Page number',
|
||||||
|
schema: { type: 'integer', minimum: 1, default: 1 },
|
||||||
|
})
|
||||||
|
@ApiQuery('limit', {
|
||||||
|
description: 'Items per page',
|
||||||
|
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||||
|
})
|
||||||
|
@ApiResponseBody(200, {
|
||||||
|
description: 'List of users',
|
||||||
|
schema: { type: 'array', items: UserSchema },
|
||||||
|
})
|
||||||
|
listUsers(ctx: IRequestContext) {
|
||||||
|
const page = ctx.query.page ?? '1';
|
||||||
|
const limit = ctx.query.limit ?? '20';
|
||||||
|
return { users: [], page: parseInt(page), limit: parseInt(limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:id')
|
||||||
|
@ApiOperation({ summary: 'Get user by ID' })
|
||||||
|
@ApiParam('id', {
|
||||||
|
description: 'User UUID',
|
||||||
|
schema: { type: 'string', format: 'uuid' },
|
||||||
|
})
|
||||||
|
@ApiResponseBody(200, { description: 'User found', schema: UserSchema })
|
||||||
|
@ApiResponseBody(404, { description: 'User not found' })
|
||||||
|
getUser(ctx: IRequestContext) {
|
||||||
|
return { id: ctx.params.id, name: 'John Doe', email: 'john@example.com' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/')
|
||||||
|
@ApiOperation({ summary: 'Create a new user' })
|
||||||
|
@ApiRequestBody({
|
||||||
|
description: 'User data',
|
||||||
|
schema: CreateUserSchema,
|
||||||
|
})
|
||||||
|
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
|
||||||
|
@ApiResponseBody(400, { description: 'Validation error' })
|
||||||
|
@ApiSecurity('bearerAuth')
|
||||||
|
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
|
const body = await ctx.json();
|
||||||
|
return { id: 'new-uuid', name: body.name, email: body.email };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Validation
|
||||||
|
|
||||||
|
When you define `@ApiRequestBody`, `@ApiParam`, or `@ApiQuery` with schemas, SmartServe **automatically validates** incoming requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
openapi: {
|
||||||
|
enabled: true,
|
||||||
|
info: {
|
||||||
|
title: 'My API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A well-documented API',
|
||||||
|
},
|
||||||
|
validate: true, // 🔥 Enable automatic request validation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.register(UserController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
// Invalid request → 400 Bad Request with details
|
||||||
|
// POST /api/users with { "name": "" }
|
||||||
|
// Response: { "error": "Validation failed", "source": "body", "details": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatic Type Coercion**: Query and path parameters are automatically coerced to their schema types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Get('/items')
|
||||||
|
@ApiQuery('page', { schema: { type: 'integer', default: 1 } })
|
||||||
|
@ApiQuery('active', { schema: { type: 'boolean' } })
|
||||||
|
listItems(ctx: IRequestContext) {
|
||||||
|
// ctx.query.page is coerced to number (1)
|
||||||
|
// ctx.query.active is coerced to boolean
|
||||||
|
return { page: ctx.query.page, active: ctx.query.active };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swagger UI & ReDoc
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
openapi: {
|
||||||
|
enabled: true,
|
||||||
|
info: {
|
||||||
|
title: 'My Awesome API',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'API documentation with interactive testing',
|
||||||
|
contact: {
|
||||||
|
name: 'API Support',
|
||||||
|
email: 'support@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{ url: 'http://localhost:3000', description: 'Development' },
|
||||||
|
{ url: 'https://api.example.com', description: 'Production' },
|
||||||
|
],
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Customize paths
|
||||||
|
specPath: '/openapi.json', // Default: /openapi.json
|
||||||
|
swaggerPath: '/docs', // Default: /docs
|
||||||
|
redocPath: '/redoc', // Default: /redoc
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
// 📖 Swagger UI: http://localhost:3000/docs
|
||||||
|
// 📕 ReDoc: http://localhost:3000/redoc
|
||||||
|
// 📄 OpenAPI: http://localhost:3000/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compression
|
||||||
|
|
||||||
|
SmartServe automatically compresses responses using Brotli or gzip based on client support:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
compression: {
|
||||||
|
enabled: true, // Default: true
|
||||||
|
threshold: 1024, // Min bytes to compress (default: 1KB)
|
||||||
|
level: 6, // Compression level 1-11 for br, 1-9 for gzip
|
||||||
|
preferBrotli: true, // Prefer Brotli over gzip
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Route Compression Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Route, Get, Compress, NoCompress } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
|
@Route('/api')
|
||||||
|
class ApiController {
|
||||||
|
@Get('/large-data')
|
||||||
|
@Compress({ level: 9 }) // Force high compression
|
||||||
|
getLargeData() {
|
||||||
|
return { data: '...massive payload...' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/already-compressed')
|
||||||
|
@NoCompress() // Skip compression (e.g., for pre-compressed content)
|
||||||
|
getCompressed() {
|
||||||
|
return someCompressedBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Compressed Static Files
|
||||||
|
|
||||||
|
Serve `.br` or `.gz` files automatically when available:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
static: {
|
||||||
|
root: './dist',
|
||||||
|
precompressed: true, // Serve main.js.br instead of main.js
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Static File Server
|
## Static File Server
|
||||||
|
|
||||||
Serve static files with streaming, ETags, and directory listing:
|
Serve static files with streaming, ETags, Range requests, and directory listing:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
@@ -195,29 +478,32 @@ const server = new SmartServe({
|
|||||||
static: {
|
static: {
|
||||||
root: './public',
|
root: './public',
|
||||||
index: ['index.html', 'index.htm'],
|
index: ['index.html', 'index.htm'],
|
||||||
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
|
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
|
||||||
etag: true, // Generate ETags
|
etag: true, // Generate ETags for caching
|
||||||
lastModified: true, // Add Last-Modified header
|
lastModified: true, // Add Last-Modified header
|
||||||
cacheControl: 'max-age=3600',
|
cacheControl: 'max-age=3600', // Or function: (path) => 'max-age=...'
|
||||||
extensions: ['.html'], // Try these extensions
|
extensions: ['.html'], // Try these extensions for extensionless URLs
|
||||||
|
precompressed: true, // Serve .br/.gz files when available
|
||||||
directoryListing: {
|
directoryListing: {
|
||||||
showHidden: false,
|
showHidden: false,
|
||||||
sortBy: 'name', // 'name' | 'size' | 'modified'
|
sortBy: 'name', // 'name' | 'size' | 'modified'
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Or simply:
|
Or use the shorthand:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
static: './public', // Shorthand - uses defaults
|
static: './public', // Uses sensible defaults
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## WebDAV Support
|
## WebDAV Support
|
||||||
|
|
||||||
Mount the server as a network drive on macOS, Windows, or Linux:
|
Mount the server as a network drive on macOS, Windows, or Linux:
|
||||||
@@ -228,75 +514,115 @@ const server = new SmartServe({
|
|||||||
webdav: {
|
webdav: {
|
||||||
root: '/path/to/files',
|
root: '/path/to/files',
|
||||||
auth: (ctx) => {
|
auth: (ctx) => {
|
||||||
// Optional Basic auth
|
// Optional: Basic authentication
|
||||||
const auth = ctx.headers.get('Authorization');
|
const auth = ctx.headers.get('Authorization');
|
||||||
if (!auth) return false;
|
if (!auth) return false;
|
||||||
const [, credentials] = auth.split(' ');
|
const [, credentials] = auth.split(' ');
|
||||||
const [user, pass] = atob(credentials).split(':');
|
const [user, pass] = atob(credentials).split(':');
|
||||||
return user === 'admin' && pass === 'secret';
|
return user === 'admin' && pass === 'secret';
|
||||||
},
|
},
|
||||||
locking: true, // Enable RFC 4918 file locking
|
locking: true, // Enable RFC 4918 exclusive write locks
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
// Mount: Connect to Server → http://localhost:8080
|
// 💾 Connect: Finder → Go → Connect to Server → http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Supported WebDAV Methods:**
|
**Supported WebDAV Methods:**
|
||||||
- `OPTIONS` - Capability discovery
|
|
||||||
- `PROPFIND` - Directory listing and file metadata
|
| Method | Description |
|
||||||
- `MKCOL` - Create directory
|
|--------|-------------|
|
||||||
- `COPY` / `MOVE` - Copy and move operations
|
| `OPTIONS` | Capability discovery |
|
||||||
- `LOCK` / `UNLOCK` - Exclusive write locking
|
| `PROPFIND` | Directory listing and file metadata |
|
||||||
- `GET` / `PUT` / `DELETE` - File operations
|
| `MKCOL` | Create directory |
|
||||||
|
| `COPY` | Copy files/directories |
|
||||||
|
| `MOVE` | Move/rename files/directories |
|
||||||
|
| `LOCK` | Acquire exclusive write lock |
|
||||||
|
| `UNLOCK` | Release lock |
|
||||||
|
| `GET` / `PUT` / `DELETE` | File operations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## WebSocket Support
|
## WebSocket Support
|
||||||
|
|
||||||
WebSocket connections are handled natively:
|
WebSocket connections are handled natively across all runtimes:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
websocket: {
|
websocket: {
|
||||||
onOpen: (peer) => {
|
onOpen: (peer) => {
|
||||||
console.log(`Connected: ${peer.id}`);
|
console.log(`🔗 Connected: ${peer.id}`);
|
||||||
peer.send('Welcome!');
|
peer.send('Welcome!');
|
||||||
|
peer.tags.add('authenticated'); // Tag for filtering
|
||||||
},
|
},
|
||||||
onMessage: (peer, message) => {
|
onMessage: (peer, message) => {
|
||||||
console.log(`Received: ${message.text}`);
|
console.log(`📨 ${message.text}`);
|
||||||
peer.send(`Echo: ${message.text}`);
|
peer.send(`Echo: ${message.text}`);
|
||||||
},
|
},
|
||||||
onClose: (peer, code, reason) => {
|
onClose: (peer, code, reason) => {
|
||||||
console.log(`Disconnected: ${peer.id}`);
|
console.log(`👋 Disconnected: ${peer.id}`);
|
||||||
},
|
},
|
||||||
onError: (peer, error) => {
|
onError: (peer, error) => {
|
||||||
console.error(`Error: ${error.message}`);
|
console.error(`❌ Error: ${error.message}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TypedRouter for Type-Safe RPC
|
||||||
|
|
||||||
|
Use `@api.global/typedrequest` for type-safe WebSocket communication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TypedRouter } from '@api.global/typedrequest';
|
||||||
|
|
||||||
|
const typedRouter = new TypedRouter();
|
||||||
|
typedRouter.addTypedHandler(MyTypedRequest, async (request) => {
|
||||||
|
return { result: 'processed' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
websocket: {
|
||||||
|
typedRouter, // Handles message routing automatically
|
||||||
|
onConnectionOpen: (peer) => {
|
||||||
|
peer.tags.add('subscriber');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to tagged connections
|
||||||
|
server.broadcast({ event: 'update' }, (peer) => peer.tags.has('subscriber'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## HTTPS/TLS
|
## HTTPS/TLS
|
||||||
|
|
||||||
Enable HTTPS with certificate configuration:
|
Enable HTTPS with certificate configuration:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 443,
|
port: 443,
|
||||||
tls: {
|
tls: {
|
||||||
cert: fs.readFileSync('./cert.pem'),
|
cert: fs.readFileSync('./cert.pem'),
|
||||||
key: fs.readFileSync('./key.pem'),
|
key: fs.readFileSync('./key.pem'),
|
||||||
// Optional
|
ca: fs.readFileSync('./ca.pem'), // Optional: CA chain
|
||||||
ca: fs.readFileSync('./ca.pem'),
|
minVersion: 'TLSv1.2', // Optional: minimum TLS version
|
||||||
minVersion: 'TLSv1.2',
|
passphrase: 'optional-key-passphrase',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
Built-in HTTP error classes:
|
Built-in HTTP error classes with factory methods:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
|
import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
|
||||||
@@ -324,43 +650,73 @@ HttpError.conflict(message, details); // 409
|
|||||||
HttpError.internal(message, details); // 500
|
HttpError.internal(message, details); // 500
|
||||||
```
|
```
|
||||||
|
|
||||||
Global error handling:
|
### Global Error Handler
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
onError: (error, request) => {
|
onError: (error, request) => {
|
||||||
console.error('Server error:', error);
|
console.error('💥 Server error:', error);
|
||||||
return new Response(JSON.stringify({ error: 'Something went wrong' }), {
|
|
||||||
status: 500,
|
// Return custom error response
|
||||||
headers: { 'Content-Type': 'application/json' },
|
return new Response(
|
||||||
});
|
JSON.stringify({ error: 'Something went wrong', requestId: crypto.randomUUID() }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Request Context
|
## Request Context
|
||||||
|
|
||||||
Every handler receives a typed request context:
|
Every handler receives a typed request context:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IRequestContext<TBody = unknown> {
|
interface IRequestContext<TBody = unknown> {
|
||||||
request: Request; // Original Web Standards Request
|
request: Request; // Original Request (body never consumed by framework)
|
||||||
body: TBody; // Parsed and typed body
|
params: Record<string, string>; // URL path parameters (/users/:id → { id: '123' })
|
||||||
params: Record<string, string>; // URL path parameters
|
query: Record<string, string>; // Query string (?page=1 → { page: '1' })
|
||||||
query: Record<string, string>; // Query string parameters
|
|
||||||
headers: Headers; // Request headers
|
headers: Headers; // Request headers
|
||||||
path: string; // Matched route path
|
path: string; // Matched route path
|
||||||
method: THttpMethod; // HTTP method
|
method: THttpMethod; // GET, POST, PUT, DELETE, etc.
|
||||||
url: URL; // Full URL object
|
url: URL; // Full URL object
|
||||||
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
runtime: 'node' | 'deno' | 'bun';
|
||||||
state: Record<string, unknown>; // Per-request state bag
|
state: Record<string, unknown>; // Per-request state (share data between interceptors)
|
||||||
|
|
||||||
|
// 🔥 Lazy body parsing (cached after first call)
|
||||||
|
json(): Promise<TBody>; // Parse as JSON (typed!)
|
||||||
|
text(): Promise<string>; // Parse as text
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
formData(): Promise<FormData>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Lazy Body Parsing**: The request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like webhook signature verification:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('/webhook')
|
||||||
|
async handleWebhook(ctx: IRequestContext) {
|
||||||
|
// Get raw body for signature verification
|
||||||
|
const rawBody = await ctx.request.text();
|
||||||
|
const signature = ctx.headers.get('X-Signature');
|
||||||
|
|
||||||
|
if (!verifyHmac(rawBody, signature)) {
|
||||||
|
throw HttpError.unauthorized('Invalid signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the body manually
|
||||||
|
const payload = JSON.parse(rawBody);
|
||||||
|
return { processed: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Custom Request Handler
|
## Custom Request Handler
|
||||||
|
|
||||||
Bypass decorator routing entirely:
|
Bypass decorator routing entirely for low-level control:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({ port: 3000 });
|
const server = new SmartServe({ port: 3000 });
|
||||||
@@ -372,12 +728,22 @@ server.setHandler(async (request, connectionInfo) => {
|
|||||||
return new Response('OK', { status: 200 });
|
return new Response('OK', { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api')) {
|
||||||
|
// Handle API routes manually
|
||||||
|
const body = await request.json();
|
||||||
|
return new Response(JSON.stringify({ received: body }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 });
|
return new Response('Not Found', { status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Runtime Detection
|
## Runtime Detection
|
||||||
|
|
||||||
SmartServe automatically detects and optimizes for the current runtime:
|
SmartServe automatically detects and optimizes for the current runtime:
|
||||||
@@ -385,26 +751,37 @@ SmartServe automatically detects and optimizes for the current runtime:
|
|||||||
```typescript
|
```typescript
|
||||||
const instance = await server.start();
|
const instance = await server.start();
|
||||||
|
|
||||||
console.log(instance.runtime); // 'node' | 'deno' | 'bun'
|
console.log(instance.runtime); // 'node' | 'deno' | 'bun'
|
||||||
console.log(instance.port); // 3000
|
console.log(instance.port); // 3000
|
||||||
console.log(instance.secure); // true if TLS enabled
|
console.log(instance.hostname); // '0.0.0.0'
|
||||||
|
console.log(instance.secure); // true if TLS enabled
|
||||||
|
|
||||||
|
// Server statistics
|
||||||
|
const stats = instance.stats();
|
||||||
|
console.log(stats.uptime); // Seconds since start
|
||||||
|
console.log(stats.requestsTotal); // Total requests handled
|
||||||
|
console.log(stats.requestsActive); // Currently processing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
421
test/test.openapi.ts
Normal file
421
test/test.openapi.ts
Normal 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();
|
||||||
239
test/test.ts
239
test/test.ts
@@ -7,9 +7,53 @@ import {
|
|||||||
Guard,
|
Guard,
|
||||||
Transform,
|
Transform,
|
||||||
Intercept,
|
Intercept,
|
||||||
|
Compress,
|
||||||
|
NoCompress,
|
||||||
HttpError,
|
HttpError,
|
||||||
type IRequestContext,
|
type IRequestContext,
|
||||||
} from '../ts/index.js';
|
} from '../ts/index.js';
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const gunzip = promisify(zlib.gunzip);
|
||||||
|
const brotliDecompress = promisify(zlib.brotliDecompress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a raw HTTP request without automatic decompression
|
||||||
|
* (Node.js fetch auto-decompresses, which breaks our tests)
|
||||||
|
*/
|
||||||
|
function rawRequest(options: {
|
||||||
|
port: number;
|
||||||
|
path: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: options.port,
|
||||||
|
path: options.path,
|
||||||
|
method: 'GET',
|
||||||
|
headers: options.headers,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode ?? 200,
|
||||||
|
headers: res.headers,
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.on('error', reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Test controller
|
// Test controller
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
@@ -25,8 +69,9 @@ class TestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/echo')
|
@Post('/echo')
|
||||||
echo(ctx: IRequestContext<{ text: string }>) {
|
async echo(ctx: IRequestContext<{ text: string }>) {
|
||||||
return { echo: ctx.body?.text };
|
const body = await ctx.json();
|
||||||
|
return { echo: body?.text };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +101,48 @@ class WrappedController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Controller with compression decorators
|
||||||
|
@Route('/compression')
|
||||||
|
class CompressionController {
|
||||||
|
@Get('/large')
|
||||||
|
getLargeData() {
|
||||||
|
// Return data larger than default threshold (1024 bytes)
|
||||||
|
return {
|
||||||
|
data: 'x'.repeat(2000),
|
||||||
|
items: Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
name: `Item ${i}`,
|
||||||
|
description: `This is a description for item ${i} with some extra text to make it longer`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/small')
|
||||||
|
getSmallData() {
|
||||||
|
// Return data smaller than threshold
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/no-compress')
|
||||||
|
@NoCompress()
|
||||||
|
getNoCompress() {
|
||||||
|
// Should never be compressed
|
||||||
|
return {
|
||||||
|
data: 'x'.repeat(2000),
|
||||||
|
compressed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/force-compress')
|
||||||
|
@Compress({ level: 9 })
|
||||||
|
getForceCompress() {
|
||||||
|
return {
|
||||||
|
data: 'y'.repeat(2000),
|
||||||
|
compressed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tap.test('SmartServe should create server instance', async () => {
|
tap.test('SmartServe should create server instance', async () => {
|
||||||
const server = new SmartServe({ port: 3456 });
|
const server = new SmartServe({ port: 3456 });
|
||||||
expect(server).toBeInstanceOf(SmartServe);
|
expect(server).toBeInstanceOf(SmartServe);
|
||||||
@@ -205,4 +292,152 @@ tap.test('HttpError should create proper responses', async () => {
|
|||||||
expect(body.details.id).toEqual('123');
|
expect(body.details.id).toEqual('123');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Compression Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('Compression should apply gzip when Accept-Encoding is sent', async () => {
|
||||||
|
const server = new SmartServe({ port: 3470 });
|
||||||
|
server.register(CompressionController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rawRequest({
|
||||||
|
port: 3470,
|
||||||
|
path: '/compression/large',
|
||||||
|
headers: { 'Accept-Encoding': 'gzip' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers['content-encoding']).toEqual('gzip');
|
||||||
|
expect(response.headers['vary']).toInclude('Accept-Encoding');
|
||||||
|
|
||||||
|
// Decompress and verify content
|
||||||
|
const decompressed = await gunzip(response.body);
|
||||||
|
const data = JSON.parse(decompressed.toString());
|
||||||
|
|
||||||
|
expect(data.data).toStartWith('xxx');
|
||||||
|
expect(data.items.length).toEqual(50);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Compression should apply brotli when preferred', async () => {
|
||||||
|
const server = new SmartServe({ port: 3471 });
|
||||||
|
server.register(CompressionController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rawRequest({
|
||||||
|
port: 3471,
|
||||||
|
path: '/compression/large',
|
||||||
|
headers: { 'Accept-Encoding': 'br, gzip' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers['content-encoding']).toEqual('br');
|
||||||
|
|
||||||
|
// Decompress and verify content
|
||||||
|
const decompressed = await brotliDecompress(response.body);
|
||||||
|
const data = JSON.parse(decompressed.toString());
|
||||||
|
|
||||||
|
expect(data.data).toStartWith('xxx');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Compression should skip small responses', async () => {
|
||||||
|
const server = new SmartServe({ port: 3472 });
|
||||||
|
server.register(CompressionController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rawRequest({
|
||||||
|
port: 3472,
|
||||||
|
path: '/compression/small',
|
||||||
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
// Small response should NOT be compressed
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body.toString());
|
||||||
|
expect(data.ok).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Compression should respect @NoCompress decorator', async () => {
|
||||||
|
const server = new SmartServe({ port: 3473 });
|
||||||
|
server.register(CompressionController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rawRequest({
|
||||||
|
port: 3473,
|
||||||
|
path: '/compression/no-compress',
|
||||||
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
// Should NOT be compressed due to @NoCompress
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body.toString());
|
||||||
|
expect(data.compressed).toBeFalse();
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Compression should skip when Accept-Encoding not sent', async () => {
|
||||||
|
const server = new SmartServe({ port: 3474 });
|
||||||
|
server.register(CompressionController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rawRequest({
|
||||||
|
port: 3474,
|
||||||
|
path: '/compression/large',
|
||||||
|
// No Accept-Encoding header
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
// Should NOT be compressed when client doesn't accept it
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body.toString());
|
||||||
|
expect(data.data).toStartWith('xxx');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Compression can be disabled globally', async () => {
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3475,
|
||||||
|
compression: false,
|
||||||
|
});
|
||||||
|
server.register(CompressionController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rawRequest({
|
||||||
|
port: 3475,
|
||||||
|
path: '/compression/large',
|
||||||
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
// Should NOT be compressed when globally disabled
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartserve',
|
name: '@push.rocks/smartserve',
|
||||||
version: '1.1.2',
|
version: '2.0.1',
|
||||||
description: 'a cross platform server module for Node, Deno and Bun'
|
description: 'a cross platform server module for Node, Deno and Bun'
|
||||||
}
|
}
|
||||||
|
|||||||
237
ts/compression/compression.middleware.ts
Normal file
237
ts/compression/compression.middleware.ts
Normal 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);
|
||||||
|
}
|
||||||
323
ts/compression/compression.runtime.ts
Normal file
323
ts/compression/compression.runtime.ts
Normal 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
19
ts/compression/index.ts
Normal 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';
|
||||||
@@ -10,17 +10,119 @@ import type {
|
|||||||
IRequestContext,
|
IRequestContext,
|
||||||
IConnectionInfo,
|
IConnectionInfo,
|
||||||
THttpMethod,
|
THttpMethod,
|
||||||
|
TRuntime,
|
||||||
IInterceptOptions,
|
IInterceptOptions,
|
||||||
TRequestInterceptor,
|
TRequestInterceptor,
|
||||||
TResponseInterceptor,
|
TResponseInterceptor,
|
||||||
IWebSocketPeer,
|
IWebSocketPeer,
|
||||||
IWebSocketConnectionCallbacks,
|
IWebSocketConnectionCallbacks,
|
||||||
} from './smartserve.interfaces.js';
|
} 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 { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
|
||||||
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
|
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
|
||||||
import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
|
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
|
||||||
import { FileServer } from '../files/index.js';
|
import { FileServer } from '../files/index.js';
|
||||||
import { WebDAVHandler } from '../protocols/index.js';
|
import { WebDAVHandler } from '../protocols/index.js';
|
||||||
|
import {
|
||||||
|
normalizeCompressionConfig,
|
||||||
|
shouldCompressResponse,
|
||||||
|
selectCompressionAlgorithm,
|
||||||
|
compressResponse,
|
||||||
|
type ICompressionConfig,
|
||||||
|
} from '../compression/index.js';
|
||||||
|
import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartServe - Cross-platform HTTP server
|
* SmartServe - Cross-platform HTTP server
|
||||||
@@ -43,6 +145,7 @@ export class SmartServe {
|
|||||||
private customHandler: TRequestHandler | null = null;
|
private customHandler: TRequestHandler | null = null;
|
||||||
private fileServer: FileServer | null = null;
|
private fileServer: FileServer | null = null;
|
||||||
private webdavHandler: WebDAVHandler | null = null;
|
private webdavHandler: WebDAVHandler | null = null;
|
||||||
|
private compressionConfig: ICompressionConfig;
|
||||||
|
|
||||||
/** WebSocket connection registry (only active when typedRouter is set) */
|
/** WebSocket connection registry (only active when typedRouter is set) */
|
||||||
private wsConnections: Map<string, IWebSocketPeer> | null = null;
|
private wsConnections: Map<string, IWebSocketPeer> | null = null;
|
||||||
@@ -64,6 +167,9 @@ export class SmartServe {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize compression config (enabled by default)
|
||||||
|
this.compressionConfig = normalizeCompressionConfig(options.compression);
|
||||||
|
|
||||||
// Initialize connection registry only when typedRouter is configured
|
// Initialize connection registry only when typedRouter is configured
|
||||||
if (this.options.websocket?.typedRouter) {
|
if (this.options.websocket?.typedRouter) {
|
||||||
this.wsConnections = new Map();
|
this.wsConnections = new Map();
|
||||||
@@ -111,6 +217,11 @@ export class SmartServe {
|
|||||||
throw new ServerAlreadyRunningError();
|
throw new ServerAlreadyRunningError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register OpenAPI endpoints if configured
|
||||||
|
if (this.options.openapi && this.options.openapi.enabled !== false) {
|
||||||
|
this.setupOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare options with internal callbacks if typedRouter is configured
|
// Prepare options with internal callbacks if typedRouter is configured
|
||||||
let adapterOptions = this.options;
|
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
|
* Create the main request handler
|
||||||
*/
|
*/
|
||||||
@@ -248,7 +382,9 @@ export class SmartServe {
|
|||||||
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
|
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
|
||||||
// Use custom handler if set
|
// Use custom handler if set
|
||||||
if (this.customHandler) {
|
if (this.customHandler) {
|
||||||
return this.customHandler(request, connectionInfo);
|
const response = await this.customHandler(request, connectionInfo);
|
||||||
|
// Apply compression to custom handler responses
|
||||||
|
return this.applyCompression(response, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL and method
|
// Parse URL and method
|
||||||
@@ -258,7 +394,8 @@ export class SmartServe {
|
|||||||
// Handle WebDAV requests first if handler is configured
|
// Handle WebDAV requests first if handler is configured
|
||||||
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
|
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
|
||||||
try {
|
try {
|
||||||
return await this.webdavHandler.handle(request);
|
const response = await this.webdavHandler.handle(request);
|
||||||
|
return this.applyCompression(response, request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
}
|
}
|
||||||
@@ -271,7 +408,8 @@ export class SmartServe {
|
|||||||
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
|
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
|
||||||
if (this.webdavHandler) {
|
if (this.webdavHandler) {
|
||||||
try {
|
try {
|
||||||
return await this.webdavHandler.handle(request);
|
const response = await this.webdavHandler.handle(request);
|
||||||
|
return this.applyCompression(response, request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
}
|
}
|
||||||
@@ -282,7 +420,8 @@ export class SmartServe {
|
|||||||
try {
|
try {
|
||||||
const staticResponse = await this.fileServer.serve(request);
|
const staticResponse = await this.fileServer.serve(request);
|
||||||
if (staticResponse) {
|
if (staticResponse) {
|
||||||
return staticResponse;
|
// Apply compression to static file responses
|
||||||
|
return this.applyCompression(staticResponse, request);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
@@ -300,11 +439,14 @@ export class SmartServe {
|
|||||||
const { route, params } = match;
|
const { route, params } = match;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create request context
|
// Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
|
||||||
const context = await this.createContext(request, url, params, connectionInfo);
|
const context = this.createContext(request, url, params, connectionInfo);
|
||||||
|
|
||||||
// Run interceptors and handler
|
// Run interceptors and handler
|
||||||
return await this.executeRoute(route, context);
|
const response = await this.executeRoute(route, context);
|
||||||
|
|
||||||
|
// Apply compression with route-specific settings
|
||||||
|
return this.applyCompression(response, request, route.compression);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
}
|
}
|
||||||
@@ -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,
|
request: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
connectionInfo: IConnectionInfo
|
connectionInfo: IConnectionInfo
|
||||||
): Promise<IRequestContext> {
|
): IRequestContext {
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const query: Record<string, string> = {};
|
const query: Record<string, string> = {};
|
||||||
url.searchParams.forEach((value, key) => {
|
url.searchParams.forEach((value, key) => {
|
||||||
query[key] = value;
|
query[key] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse body (lazy)
|
return new RequestContext(
|
||||||
let body: any = undefined;
|
|
||||||
const contentType = request.headers.get('content-type');
|
|
||||||
|
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
||||||
if (contentType?.includes('application/json')) {
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
body = null;
|
|
||||||
}
|
|
||||||
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
|
||||||
try {
|
|
||||||
const text = await request.text();
|
|
||||||
body = Object.fromEntries(new URLSearchParams(text));
|
|
||||||
} catch {
|
|
||||||
body = null;
|
|
||||||
}
|
|
||||||
} else if (contentType?.includes('text/')) {
|
|
||||||
try {
|
|
||||||
body = await request.text();
|
|
||||||
} catch {
|
|
||||||
body = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
request,
|
request,
|
||||||
body,
|
|
||||||
params,
|
params,
|
||||||
query,
|
query,
|
||||||
headers: request.headers,
|
request.headers,
|
||||||
path: url.pathname,
|
url.pathname,
|
||||||
method: request.method.toUpperCase() as THttpMethod,
|
request.method.toUpperCase() as THttpMethod,
|
||||||
url,
|
url,
|
||||||
runtime: this.adapter?.name ?? 'node',
|
this.adapter?.name ?? 'node',
|
||||||
state: {},
|
{},
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
|
* Handle errors
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TypedRouter } from '@api.global/typedrequest';
|
import type { TypedRouter } from '@api.global/typedrequest';
|
||||||
|
import type { ICompressionConfig } from '../compression/index.js';
|
||||||
|
import type { IOpenApiOptions } from '../openapi/openapi.types.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HTTP Types
|
// HTTP Types
|
||||||
@@ -22,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
|
|||||||
* Wraps Web Standard Request with additional utilities
|
* Wraps Web Standard Request with additional utilities
|
||||||
*/
|
*/
|
||||||
export interface IRequestContext<TBody = unknown> {
|
export interface IRequestContext<TBody = unknown> {
|
||||||
/** Original Web Standards Request */
|
/** Original Web Standards Request - body stream is never consumed by framework */
|
||||||
readonly request: Request;
|
readonly request: Request;
|
||||||
/** Parsed request body (typed) */
|
|
||||||
readonly body: TBody;
|
|
||||||
/** URL path parameters extracted from route */
|
/** URL path parameters extracted from route */
|
||||||
readonly params: Record<string, string>;
|
readonly params: Record<string, string>;
|
||||||
/** URL query parameters */
|
/** URL query parameters */
|
||||||
@@ -42,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
|
|||||||
readonly runtime: TRuntime;
|
readonly runtime: TRuntime;
|
||||||
/** Route-specific state bag for passing data between interceptors */
|
/** Route-specific state bag for passing data between interceptors */
|
||||||
state: Record<string, unknown>;
|
state: Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily parse request body as JSON.
|
||||||
|
* Result is cached after first call.
|
||||||
|
* @returns Typed body parsed from JSON
|
||||||
|
*/
|
||||||
|
json(): Promise<TBody>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily parse request body as text.
|
||||||
|
* Result is cached after first call.
|
||||||
|
* @returns Body as string
|
||||||
|
*/
|
||||||
|
text(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily parse request body as ArrayBuffer.
|
||||||
|
* Result is cached after first call.
|
||||||
|
* @returns Body as ArrayBuffer
|
||||||
|
*/
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily parse request body as FormData.
|
||||||
|
* Result is cached after first call.
|
||||||
|
* @returns Body as FormData
|
||||||
|
*/
|
||||||
|
formData(): Promise<FormData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -272,6 +300,8 @@ export interface IStaticOptions {
|
|||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
/** Enable directory listing */
|
/** Enable directory listing */
|
||||||
directoryListing?: boolean | IDirectoryListingOptions;
|
directoryListing?: boolean | IDirectoryListingOptions;
|
||||||
|
/** Serve pre-compressed files (.br, .gz) when available */
|
||||||
|
precompressed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,6 +363,10 @@ export interface ISmartServeOptions {
|
|||||||
keepAlive?: IKeepAliveConfig;
|
keepAlive?: IKeepAliveConfig;
|
||||||
/** Global error handler */
|
/** Global error handler */
|
||||||
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
||||||
|
/** Compression configuration (enabled by default) */
|
||||||
|
compression?: ICompressionConfig | boolean;
|
||||||
|
/** OpenAPI documentation and validation configuration */
|
||||||
|
openapi?: IOpenApiOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
115
ts/decorators/decorators.compress.ts
Normal file
115
ts/decorators/decorators.compress.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export function combinePaths(basePath: string, routePath: string): string {
|
|||||||
const route = normalizePath(routePath);
|
const route = normalizePath(routePath);
|
||||||
|
|
||||||
if (!base) return route || '/';
|
if (!base) return route || '/';
|
||||||
if (!route) return base;
|
if (!route || route === '/') return base || '/';
|
||||||
|
|
||||||
return `${base}${route}`;
|
return `${base}${route}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* Controller registry - stores all registered controllers
|
* Controller registry - stores all registered controllers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js';
|
import type { IControllerMetadata, IRegisteredController, ICompiledRoute, IOpenApiRouteMeta } from './decorators.types.js';
|
||||||
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
|
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
|
||||||
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
|
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
|
||||||
|
import { createValidationInterceptor } from '../openapi/openapi.validator.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global registry of all controllers
|
* Global registry of all controllers
|
||||||
@@ -13,6 +14,7 @@ export class ControllerRegistry {
|
|||||||
private static controllers: Map<Function, IControllerMetadata> = new Map();
|
private static controllers: Map<Function, IControllerMetadata> = new Map();
|
||||||
private static instances: Map<Function, any> = new Map();
|
private static instances: Map<Function, any> = new Map();
|
||||||
private static compiledRoutes: ICompiledRoute[] = [];
|
private static compiledRoutes: ICompiledRoute[] = [];
|
||||||
|
private static dynamicRoutes: ICompiledRoute[] = [];
|
||||||
private static routesCompiled = false;
|
private static routesCompiled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,10 +67,33 @@ export class ControllerRegistry {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a dynamic route without needing a controller class
|
||||||
|
*/
|
||||||
|
static addRoute(
|
||||||
|
path: string,
|
||||||
|
method: THttpMethod,
|
||||||
|
handler: (ctx: IRequestContext) => Promise<Response | any>
|
||||||
|
): void {
|
||||||
|
const { regex, paramNames } = this.pathToRegex(path);
|
||||||
|
|
||||||
|
this.dynamicRoutes.push({
|
||||||
|
pattern: path,
|
||||||
|
regex,
|
||||||
|
paramNames,
|
||||||
|
method,
|
||||||
|
handler: async (ctx: IRequestContext) => handler(ctx),
|
||||||
|
interceptors: [],
|
||||||
|
compression: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.routesCompiled = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile all routes for fast matching
|
* Compile all routes for fast matching
|
||||||
*/
|
*/
|
||||||
static compileRoutes(): ICompiledRoute[] {
|
static compileRoutes(enableValidation = true): ICompiledRoute[] {
|
||||||
if (this.routesCompiled) {
|
if (this.routesCompiled) {
|
||||||
return this.compiledRoutes;
|
return this.compiledRoutes;
|
||||||
}
|
}
|
||||||
@@ -81,10 +106,19 @@ export class ControllerRegistry {
|
|||||||
const { regex, paramNames } = this.pathToRegex(fullPath);
|
const { regex, paramNames } = this.pathToRegex(fullPath);
|
||||||
|
|
||||||
// Combine class and method interceptors
|
// Combine class and method interceptors
|
||||||
const interceptors: IInterceptOptions[] = [
|
const interceptors: IInterceptOptions[] = [];
|
||||||
...metadata.classInterceptors,
|
|
||||||
...route.interceptors,
|
// Add OpenAPI validation interceptor first (before other interceptors)
|
||||||
];
|
// This ensures validation happens before any other processing
|
||||||
|
if (enableValidation && route.openapi && this.hasValidationMetadata(route.openapi)) {
|
||||||
|
interceptors.push({
|
||||||
|
request: createValidationInterceptor(route.openapi),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then add class-level and method-level interceptors
|
||||||
|
interceptors.push(...metadata.classInterceptors);
|
||||||
|
interceptors.push(...route.interceptors);
|
||||||
|
|
||||||
// Create bound handler
|
// Create bound handler
|
||||||
const handler = async (ctx: IRequestContext): Promise<any> => {
|
const handler = async (ctx: IRequestContext): Promise<any> => {
|
||||||
@@ -102,10 +136,15 @@ export class ControllerRegistry {
|
|||||||
method: route.method,
|
method: route.method,
|
||||||
handler,
|
handler,
|
||||||
interceptors,
|
interceptors,
|
||||||
|
compression: route.compression,
|
||||||
|
openapi: route.openapi,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add dynamic routes
|
||||||
|
this.compiledRoutes.push(...this.dynamicRoutes);
|
||||||
|
|
||||||
// Sort routes by specificity (more specific paths first)
|
// Sort routes by specificity (more specific paths first)
|
||||||
this.compiledRoutes.sort((a, b) => {
|
this.compiledRoutes.sort((a, b) => {
|
||||||
// Routes without wildcards come first
|
// Routes without wildcards come first
|
||||||
@@ -186,6 +225,18 @@ export class ControllerRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OpenAPI metadata contains validation-relevant information
|
||||||
|
*/
|
||||||
|
private static hasValidationMetadata(openapi: IOpenApiRouteMeta): boolean {
|
||||||
|
return !!(
|
||||||
|
openapi.requestBody ||
|
||||||
|
(openapi.params && openapi.params.size > 0) ||
|
||||||
|
(openapi.query && openapi.query.size > 0) ||
|
||||||
|
(openapi.headers && openapi.headers.size > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all registered controllers (useful for testing)
|
* Clear all registered controllers (useful for testing)
|
||||||
*/
|
*/
|
||||||
@@ -193,6 +244,7 @@ export class ControllerRegistry {
|
|||||||
this.controllers.clear();
|
this.controllers.clear();
|
||||||
this.instances.clear();
|
this.instances.clear();
|
||||||
this.compiledRoutes = [];
|
this.compiledRoutes = [];
|
||||||
|
this.dynamicRoutes = [];
|
||||||
this.routesCompiled = false;
|
this.routesCompiled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
IMethodOptions,
|
IMethodOptions,
|
||||||
IRouteOptions,
|
IRouteOptions,
|
||||||
} from '../core/smartserve.interfaces.js';
|
} from '../core/smartserve.interfaces.js';
|
||||||
|
import type { ICompressionConfig } from '../compression/index.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Metadata Types
|
// Metadata Types
|
||||||
@@ -27,6 +28,18 @@ export interface IControllerMetadata {
|
|||||||
routes: Map<string | symbol, IRouteMetadata>;
|
routes: Map<string | symbol, IRouteMetadata>;
|
||||||
/** Controller class reference */
|
/** Controller class reference */
|
||||||
target?: new (...args: any[]) => any;
|
target?: new (...args: any[]) => any;
|
||||||
|
/** OpenAPI metadata for controller */
|
||||||
|
openapi?: IOpenApiControllerMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route compression options
|
||||||
|
*/
|
||||||
|
export interface IRouteCompressionOptions {
|
||||||
|
/** Whether compression is enabled for this route (undefined = use default) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Override compression level */
|
||||||
|
level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +58,10 @@ export interface IRouteMetadata {
|
|||||||
methodName: string | symbol;
|
methodName: string | symbol;
|
||||||
/** Handler function reference */
|
/** Handler function reference */
|
||||||
handler?: Function;
|
handler?: Function;
|
||||||
|
/** Route-specific compression settings */
|
||||||
|
compression?: IRouteCompressionOptions;
|
||||||
|
/** OpenAPI metadata for this route */
|
||||||
|
openapi?: IOpenApiRouteMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,4 +90,140 @@ export interface ICompiledRoute {
|
|||||||
handler: (ctx: IRequestContext) => Promise<any>;
|
handler: (ctx: IRequestContext) => Promise<any>;
|
||||||
/** Combined interceptors (class + method) */
|
/** Combined interceptors (class + method) */
|
||||||
interceptors: IInterceptOptions[];
|
interceptors: IInterceptOptions[];
|
||||||
|
/** Route-specific compression settings */
|
||||||
|
compression?: IRouteCompressionOptions;
|
||||||
|
/** OpenAPI metadata for this route */
|
||||||
|
openapi?: IOpenApiRouteMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenAPI / JSON Schema Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Schema type for OpenAPI schemas
|
||||||
|
* Supports JSON Schema draft 2020-12 (used by OpenAPI 3.1)
|
||||||
|
*/
|
||||||
|
export type TJsonSchema = {
|
||||||
|
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null' | Array<'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null'>;
|
||||||
|
format?: string;
|
||||||
|
properties?: Record<string, TJsonSchema>;
|
||||||
|
items?: TJsonSchema;
|
||||||
|
required?: string[];
|
||||||
|
enum?: unknown[];
|
||||||
|
const?: unknown;
|
||||||
|
default?: unknown;
|
||||||
|
description?: string;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
|
uniqueItems?: boolean;
|
||||||
|
additionalProperties?: boolean | TJsonSchema;
|
||||||
|
allOf?: TJsonSchema[];
|
||||||
|
anyOf?: TJsonSchema[];
|
||||||
|
oneOf?: TJsonSchema[];
|
||||||
|
not?: TJsonSchema;
|
||||||
|
$ref?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI operation metadata for documentation
|
||||||
|
*/
|
||||||
|
export interface IOpenApiOperationMeta {
|
||||||
|
/** Short summary of what the operation does */
|
||||||
|
summary?: string;
|
||||||
|
/** Longer description with markdown support */
|
||||||
|
description?: string;
|
||||||
|
/** Unique operation identifier (auto-generated if omitted) */
|
||||||
|
operationId?: string;
|
||||||
|
/** Mark operation as deprecated */
|
||||||
|
deprecated?: boolean;
|
||||||
|
/** External documentation */
|
||||||
|
externalDocs?: {
|
||||||
|
url: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI parameter metadata (path, query, header, cookie)
|
||||||
|
*/
|
||||||
|
export interface IOpenApiParamMeta {
|
||||||
|
/** Human-readable description */
|
||||||
|
description?: string;
|
||||||
|
/** Whether parameter is required (path params always required) */
|
||||||
|
required?: boolean;
|
||||||
|
/** JSON Schema for the parameter */
|
||||||
|
schema?: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
/** Multiple examples */
|
||||||
|
examples?: Record<string, { value: unknown; summary?: string; description?: string }>;
|
||||||
|
/** Allow empty value */
|
||||||
|
allowEmptyValue?: boolean;
|
||||||
|
/** Deprecation flag */
|
||||||
|
deprecated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI request body metadata
|
||||||
|
*/
|
||||||
|
export interface IOpenApiRequestBodyMeta {
|
||||||
|
/** Human-readable description */
|
||||||
|
description?: string;
|
||||||
|
/** Whether body is required (default: true) */
|
||||||
|
required?: boolean;
|
||||||
|
/** JSON Schema for the body (assumes application/json) */
|
||||||
|
schema: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI response body metadata
|
||||||
|
*/
|
||||||
|
export interface IOpenApiResponseBodyMeta {
|
||||||
|
/** Human-readable description */
|
||||||
|
description: string;
|
||||||
|
/** JSON Schema for the response (assumes application/json) */
|
||||||
|
schema?: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
/** Response headers */
|
||||||
|
headers?: Record<string, { description?: string; schema?: TJsonSchema }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined OpenAPI metadata for a route method
|
||||||
|
*/
|
||||||
|
export interface IOpenApiRouteMeta {
|
||||||
|
/** Operation details (summary, description, etc.) */
|
||||||
|
operation?: IOpenApiOperationMeta;
|
||||||
|
/** Path parameters by name */
|
||||||
|
params?: Map<string, IOpenApiParamMeta>;
|
||||||
|
/** Query parameters by name */
|
||||||
|
query?: Map<string, IOpenApiParamMeta>;
|
||||||
|
/** Header parameters by name */
|
||||||
|
headers?: Map<string, IOpenApiParamMeta>;
|
||||||
|
/** Request body schema */
|
||||||
|
requestBody?: IOpenApiRequestBodyMeta;
|
||||||
|
/** Response bodies by status code */
|
||||||
|
responses?: Map<number, IOpenApiResponseBodyMeta>;
|
||||||
|
/** Security requirements */
|
||||||
|
security?: Array<Record<string, string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI metadata for a controller class
|
||||||
|
*/
|
||||||
|
export interface IOpenApiControllerMeta {
|
||||||
|
/** Tags for grouping routes in documentation */
|
||||||
|
tags?: string[];
|
||||||
|
/** Security requirements for all routes in controller */
|
||||||
|
security?: Array<Record<string, string[]>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
export type {
|
export type {
|
||||||
IControllerMetadata,
|
IControllerMetadata,
|
||||||
IRouteMetadata,
|
IRouteMetadata,
|
||||||
|
IRouteCompressionOptions,
|
||||||
IRegisteredController,
|
IRegisteredController,
|
||||||
ICompiledRoute,
|
ICompiledRoute,
|
||||||
|
// OpenAPI types
|
||||||
|
TJsonSchema,
|
||||||
|
IOpenApiOperationMeta,
|
||||||
|
IOpenApiParamMeta,
|
||||||
|
IOpenApiRequestBodyMeta,
|
||||||
|
IOpenApiResponseBodyMeta,
|
||||||
|
IOpenApiRouteMeta,
|
||||||
|
IOpenApiControllerMeta,
|
||||||
} from './decorators.types.js';
|
} from './decorators.types.js';
|
||||||
|
|
||||||
// Route decorator
|
// Route decorator
|
||||||
@@ -35,6 +44,9 @@ export {
|
|||||||
addTimestamp,
|
addTimestamp,
|
||||||
} from './decorators.interceptors.js';
|
} from './decorators.interceptors.js';
|
||||||
|
|
||||||
|
// Compression decorators
|
||||||
|
export { Compress, NoCompress } from './decorators.compress.js';
|
||||||
|
|
||||||
// Registry
|
// Registry
|
||||||
export { ControllerRegistry } from './decorators.registry.js';
|
export { ControllerRegistry } from './decorators.registry.js';
|
||||||
|
|
||||||
@@ -45,3 +57,15 @@ export {
|
|||||||
normalizePath,
|
normalizePath,
|
||||||
combinePaths,
|
combinePaths,
|
||||||
} from './decorators.metadata.js';
|
} from './decorators.metadata.js';
|
||||||
|
|
||||||
|
// OpenAPI decorators
|
||||||
|
export {
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiHeader,
|
||||||
|
ApiRequestBody,
|
||||||
|
ApiResponseBody,
|
||||||
|
ApiSecurity,
|
||||||
|
ApiTag,
|
||||||
|
} from '../openapi/openapi.decorators.js';
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import type {
|
|||||||
} from '../core/smartserve.interfaces.js';
|
} from '../core/smartserve.interfaces.js';
|
||||||
import { getMimeType } from '../utils/utils.mime.js';
|
import { getMimeType } from '../utils/utils.mime.js';
|
||||||
import { generateETag } from '../utils/utils.etag.js';
|
import { generateETag } from '../utils/utils.etag.js';
|
||||||
|
import { parseAcceptEncoding } from '../utils/utils.encoding.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-compressed file variant info
|
||||||
|
*/
|
||||||
|
interface IPrecompressedVariant {
|
||||||
|
path: string;
|
||||||
|
stat: plugins.fs.Stats;
|
||||||
|
encoding: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static file server
|
* Static file server
|
||||||
@@ -112,22 +122,43 @@ export class FileServer {
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|
||||||
// Content-Type
|
// Check for pre-compressed variants
|
||||||
|
let actualFilePath = filePath;
|
||||||
|
let actualStat = stat;
|
||||||
|
let contentEncoding: string | undefined;
|
||||||
|
|
||||||
|
if (this.options.precompressed) {
|
||||||
|
const variant = await this.findPrecompressedVariant(filePath, request);
|
||||||
|
if (variant) {
|
||||||
|
actualFilePath = variant.path;
|
||||||
|
actualStat = variant.stat;
|
||||||
|
contentEncoding = variant.encoding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Type (always use original file's MIME type)
|
||||||
const mimeType = getMimeType(filePath);
|
const mimeType = getMimeType(filePath);
|
||||||
headers.set('Content-Type', mimeType);
|
headers.set('Content-Type', mimeType);
|
||||||
|
|
||||||
// Content-Length
|
// Content-Encoding (if serving pre-compressed)
|
||||||
headers.set('Content-Length', stat.size.toString());
|
if (contentEncoding) {
|
||||||
|
headers.set('Content-Encoding', contentEncoding);
|
||||||
|
headers.set('Vary', 'Accept-Encoding');
|
||||||
|
}
|
||||||
|
|
||||||
// Last-Modified
|
// Content-Length (use actual file size, which may differ for compressed)
|
||||||
|
headers.set('Content-Length', actualStat.size.toString());
|
||||||
|
|
||||||
|
// Last-Modified (use original file's time for consistency)
|
||||||
if (this.options.lastModified) {
|
if (this.options.lastModified) {
|
||||||
headers.set('Last-Modified', stat.mtime.toUTCString());
|
headers.set('Last-Modified', stat.mtime.toUTCString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ETag
|
// ETag (include encoding in ETag if compressed)
|
||||||
let etag: string | undefined;
|
let etag: string | undefined;
|
||||||
if (this.options.etag) {
|
if (this.options.etag) {
|
||||||
etag = generateETag(stat);
|
const baseEtag = generateETag(stat);
|
||||||
|
etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag;
|
||||||
headers.set('ETag', etag);
|
headers.set('ETag', etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +184,9 @@ export class FileServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Range requests
|
// Handle Range requests (don't use pre-compressed for range requests)
|
||||||
const rangeHeader = request.headers.get('Range');
|
const rangeHeader = request.headers.get('Range');
|
||||||
if (rangeHeader) {
|
if (rangeHeader && !contentEncoding) {
|
||||||
return this.servePartial(filePath, stat, rangeHeader, headers);
|
return this.servePartial(filePath, stat, rangeHeader, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +195,8 @@ export class FileServer {
|
|||||||
return new Response(null, { status: 200, headers });
|
return new Response(null, { status: 200, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the file
|
// Stream the file (use actualFilePath for pre-compressed)
|
||||||
const stream = plugins.fs.createReadStream(filePath);
|
const stream = plugins.fs.createReadStream(actualFilePath);
|
||||||
const readableStream = this.nodeStreamToWebStream(stream);
|
const readableStream = this.nodeStreamToWebStream(stream);
|
||||||
|
|
||||||
return new Response(readableStream, { status: 200, headers });
|
return new Response(readableStream, { status: 200, headers });
|
||||||
@@ -361,6 +392,46 @@ export class FileServer {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find pre-compressed variant of a file if it exists
|
||||||
|
* Checks for .br and .gz variants based on client Accept-Encoding
|
||||||
|
*/
|
||||||
|
private async findPrecompressedVariant(
|
||||||
|
filePath: string,
|
||||||
|
request: Request
|
||||||
|
): Promise<IPrecompressedVariant | null> {
|
||||||
|
const acceptEncoding = request.headers.get('Accept-Encoding');
|
||||||
|
const preferences = parseAcceptEncoding(acceptEncoding);
|
||||||
|
|
||||||
|
// Supported pre-compressed variants in preference order
|
||||||
|
const variants: Array<{ encoding: string; extension: string }> = [
|
||||||
|
{ encoding: 'br', extension: '.br' },
|
||||||
|
{ encoding: 'gzip', extension: '.gz' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check variants in client preference order
|
||||||
|
for (const pref of preferences) {
|
||||||
|
const variant = variants.find((v) => v.encoding === pref.encoding);
|
||||||
|
if (!variant) continue;
|
||||||
|
|
||||||
|
const variantPath = filePath + variant.extension;
|
||||||
|
try {
|
||||||
|
const variantStat = await plugins.fs.promises.stat(variantPath);
|
||||||
|
if (variantStat.isFile()) {
|
||||||
|
return {
|
||||||
|
path: variantPath,
|
||||||
|
stat: variantStat,
|
||||||
|
encoding: variant.encoding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Variant doesn't exist, continue to next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Node.js stream to Web ReadableStream
|
* Convert Node.js stream to Web ReadableStream
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ export * from './core/index.js';
|
|||||||
// Decorator exports
|
// Decorator exports
|
||||||
export * from './decorators/index.js';
|
export * from './decorators/index.js';
|
||||||
|
|
||||||
|
// Compression exports
|
||||||
|
export * from './compression/index.js';
|
||||||
|
|
||||||
// File server exports
|
// File server exports
|
||||||
export * from './files/index.js';
|
export * from './files/index.js';
|
||||||
|
|
||||||
// Protocol exports (WebDAV, etc.)
|
// Protocol exports (WebDAV, etc.)
|
||||||
export * from './protocols/index.js';
|
export * from './protocols/index.js';
|
||||||
|
|
||||||
|
// OpenAPI exports
|
||||||
|
export * from './openapi/index.js';
|
||||||
|
|
||||||
// Utility exports
|
// Utility exports
|
||||||
export * from './utils/index.js';
|
export * from './utils/index.js';
|
||||||
|
|
||||||
|
|||||||
84
ts/openapi/index.ts
Normal file
84
ts/openapi/index.ts
Normal 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';
|
||||||
110
ts/openapi/openapi.coerce.ts
Normal file
110
ts/openapi/openapi.coerce.ts
Normal 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;
|
||||||
|
}
|
||||||
355
ts/openapi/openapi.decorators.ts
Normal file
355
ts/openapi/openapi.decorators.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
306
ts/openapi/openapi.generator.ts
Normal file
306
ts/openapi/openapi.generator.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
138
ts/openapi/openapi.handlers.ts
Normal file
138
ts/openapi/openapi.handlers.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
488
ts/openapi/openapi.types.ts
Normal file
488
ts/openapi/openapi.types.ts
Normal 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[];
|
||||||
|
}
|
||||||
252
ts/openapi/openapi.validator.ts
Normal file
252
ts/openapi/openapi.validator.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import * as path from 'path';
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
|
||||||
export { path, http, https, fs };
|
export { path, http, https, fs, zlib };
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
||||||
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
||||||
|
export {
|
||||||
|
parseAcceptEncoding,
|
||||||
|
selectEncoding,
|
||||||
|
isCompressible,
|
||||||
|
getDefaultCompressibleTypes,
|
||||||
|
type TCompressionAlgorithm,
|
||||||
|
type IEncodingPreference,
|
||||||
|
} from './utils.encoding.js';
|
||||||
|
|||||||
166
ts/utils/utils.encoding.ts
Normal file
166
ts/utils/utils.encoding.ts
Normal 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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user