10 Commits

Author SHA1 Message Date
15848b9c9c v1.3.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 15:38:43 +00:00
fec0770d55 feat(compression): Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API 2025-12-05 15:38:43 +00:00
59ccff3453 v1.2.0
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 12:27:42 +00:00
57d7fd6483 feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support 2025-12-05 12:27:41 +00:00
cef6ce750e v1.1.2
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-03 23:53:47 +00:00
d341fc270d fix(deps): Bump dependency versions for build and runtime tools 2025-12-03 23:53:47 +00:00
a9972ad0ce v1.1.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-03 23:47:46 +00:00
b3c77eb675 fix(adapters): Attach WebSocket peer to typedRouter request localData and add ws dependency 2025-12-03 23:47:46 +00:00
7b8a4ba68c v1.1.0
Some checks failed
Default (tags) / security (push) Successful in 25s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-02 12:13:46 +00:00
643a6cec55 feat(websocket): Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs 2025-12-02 12:13:46 +00:00
24 changed files with 2178 additions and 398 deletions

View File

@@ -1,5 +1,52 @@
# Changelog # Changelog
## 2025-12-05 - 1.3.0 - feat(compression)
Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API
- Buffer response bodies before compressing and perform size threshold check after buffering; return uncompressed responses when below threshold.
- Set Content-Length to the compressed size and use provider.compress to produce full compressed payloads instead of streaming compression from the middleware.
- Add Deno-native brotli support via Deno.compress and use CompressionStream for gzip/deflate; brotli streaming is not attempted in web runtime.
- Pass compression threshold from SmartServe configuration into compressResponse so route/global thresholds are honored.
- Expose ControllerRegistry.addRoute and dynamicRoutes to allow adding dynamic routes without controller classes.
- Add comprehensive compression tests (gzip and brotli) using raw HTTP requests to avoid Node fetch auto-decompression; tests cover large/small responses, @Compress/@NoCompress behavior, and global compression disable.
- Change test runner invocation to use verbose mode.
## 2025-12-05 - 1.2.0 - feat(compression)
Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support
- Introduce a cross-runtime compression provider (Node zlib + Web CompressionStream fallback) with create/get provider APIs (ts/compression/compression.runtime.ts).
- Add compression middleware utilities (normalize config, shouldCompressResponse, algorithm selection, streaming/full-body compression) and default configuration (ts/compression/compression.middleware.ts).
- Implement Accept-Encoding parsing, encoding selection, and compressibility checks (ts/utils/utils.encoding.ts) and export types/utilities from utils/index.ts.
- Add @Compress and @NoCompress decorators and route-level compression metadata support (ts/decorators/decorators.compress.ts, decorators.types.ts, registry updates, and exports).
- Integrate compression into SmartServe core: global compression config, applyCompression for custom handlers, WebDAV, static files, and route responses (ts/core/smartserve.classes.smartserve.ts, smartserve.interfaces.ts).
- Enhance FileServer to serve pre-compressed variants (.br/.gz) when available, adjust headers/ETag/Length, and avoid using pre-compressed files for range requests (ts/files/file.server.ts).
- Expose compression APIs from package entry point and export zlib via plugins for Node provider; update readme.hints.md with configuration examples and notes.
## 2025-12-03 - 1.1.2 - fix(deps)
Bump dependency versions for build and runtime tools
- Update devDependency @git.zone/tsbundle from ^2.0.5 to ^2.6.3
- Update devDependency @types/node from ^20.8.7 to ^24.10.1
- Update dependency @api.global/typedrequest from ^3.0.0 to ^3.1.11
## 2025-12-03 - 1.1.1 - fix(adapters)
Attach WebSocket peer to typedRouter request localData and add ws dependency
- When routing incoming WebSocket messages through TypedRouter (node/deno/bun), the connection peer is now attached to requestObj.localData so typed handlers can access the active connection.
- Add runtime dependency on "ws" to enable WebSocket support in the Node adapter (used by dynamic import in the adapter).
## 2025-12-02 - 1.1.0 - feat(websocket)
Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs
- Add dependency on @api.global/typedrequest and re-export it via plugins
- Introduce typedRouter support in IWebSocketHooks and adapters (Node, Bun, Deno) to route JSON RPC messages through TypedRouter.routeAndAddResponse
- Add internal IWebSocketConnectionCallbacks to register/unregister peers; adapters receive these via a _connectionCallbacks property on websocket options
- Persist per-peer tags and data (peer.tags: Set<string>) across adapters; Bun adapter stores persistent ws.data so tags survive re-wraps
- Add WebSocketConfigError and validate websocket config to prevent using typedRouter together with onMessage (throws if both are set)
- Expose connection-management APIs on SmartServe: getWebSocketConnections(), getWebSocketConnectionsByTag(tag), broadcastWebSocket(data) and broadcastWebSocketByTag(tag, data)
- Update README/hints to document TypedRouter mode, connection registry, peer tagging, and broadcast methods
- Legacy onMessage mode remains supported; typedRouter mode enables automatic JSON routing and connection registry
## 2025-11-29 - 1.0.2 - fix(package) ## 2025-11-29 - 1.0.2 - fix(package)
Update package metadata, scripts and dependency pins Update package metadata, scripts and dependency pins

View File

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

695
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,9 +7,53 @@ import {
Guard, Guard,
Transform, Transform,
Intercept, Intercept,
Compress,
NoCompress,
HttpError, HttpError,
type IRequestContext, type IRequestContext,
} from '../ts/index.js'; } from '../ts/index.js';
import * as zlib from 'zlib';
import * as http from 'http';
import { promisify } from 'util';
const gunzip = promisify(zlib.gunzip);
const brotliDecompress = promisify(zlib.brotliDecompress);
/**
* Make a raw HTTP request without automatic decompression
* (Node.js fetch auto-decompresses, which breaks our tests)
*/
function rawRequest(options: {
port: number;
path: string;
headers?: Record<string, string>;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: 'localhost',
port: options.port,
path: options.path,
method: 'GET',
headers: options.headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode ?? 200,
headers: res.headers,
body: Buffer.concat(chunks),
});
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
}
// Test controller // Test controller
@Route('/api') @Route('/api')
@@ -56,6 +100,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 +291,152 @@ tap.test('HttpError should create proper responses', async () => {
expect(body.details.id).toEqual('123'); expect(body.details.id).toEqual('123');
}); });
// ============================================================================
// Compression Tests
// ============================================================================
tap.test('Compression should apply gzip when Accept-Encoding is sent', async () => {
const server = new SmartServe({ port: 3470 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3470,
path: '/compression/large',
headers: { 'Accept-Encoding': 'gzip' },
});
expect(response.status).toEqual(200);
expect(response.headers['content-encoding']).toEqual('gzip');
expect(response.headers['vary']).toInclude('Accept-Encoding');
// Decompress and verify content
const decompressed = await gunzip(response.body);
const data = JSON.parse(decompressed.toString());
expect(data.data).toStartWith('xxx');
expect(data.items.length).toEqual(50);
} finally {
await server.stop();
}
});
tap.test('Compression should apply brotli when preferred', async () => {
const server = new SmartServe({ port: 3471 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3471,
path: '/compression/large',
headers: { 'Accept-Encoding': 'br, gzip' },
});
expect(response.status).toEqual(200);
expect(response.headers['content-encoding']).toEqual('br');
// Decompress and verify content
const decompressed = await brotliDecompress(response.body);
const data = JSON.parse(decompressed.toString());
expect(data.data).toStartWith('xxx');
} finally {
await server.stop();
}
});
tap.test('Compression should skip small responses', async () => {
const server = new SmartServe({ port: 3472 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3472,
path: '/compression/small',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Small response should NOT be compressed
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.ok).toBeTrue();
} finally {
await server.stop();
}
});
tap.test('Compression should respect @NoCompress decorator', async () => {
const server = new SmartServe({ port: 3473 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3473,
path: '/compression/no-compress',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Should NOT be compressed due to @NoCompress
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.compressed).toBeFalse();
} finally {
await server.stop();
}
});
tap.test('Compression should skip when Accept-Encoding not sent', async () => {
const server = new SmartServe({ port: 3474 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3474,
path: '/compression/large',
// No Accept-Encoding header
});
expect(response.status).toEqual(200);
// Should NOT be compressed when client doesn't accept it
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.data).toStartWith('xxx');
} finally {
await server.stop();
}
});
tap.test('Compression can be disabled globally', async () => {
const server = new SmartServe({
port: 3475,
compression: false,
});
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3475,
path: '/compression/large',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Should NOT be compressed when globally disabled
expect(response.headers['content-encoding']).toBeUndefined();
} finally {
await server.stop();
}
});
export default tap.start(); export default tap.start();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -13,12 +13,21 @@ import type {
IInterceptOptions, IInterceptOptions,
TRequestInterceptor, TRequestInterceptor,
TResponseInterceptor, TResponseInterceptor,
IWebSocketPeer,
IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.js'; } from './smartserve.interfaces.js';
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError } 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';
/** /**
* SmartServe - Cross-platform HTTP server * SmartServe - Cross-platform HTTP server
@@ -41,13 +50,36 @@ export class SmartServe {
private customHandler: TRequestHandler | null = null; private customHandler: TRequestHandler | null = null;
private fileServer: FileServer | null = null; private fileServer: FileServer | null = null;
private webdavHandler: WebDAVHandler | null = null; private webdavHandler: WebDAVHandler | null = null;
private compressionConfig: ICompressionConfig;
/** WebSocket connection registry (only active when typedRouter is set) */
private wsConnections: Map<string, IWebSocketPeer> | null = null;
constructor(options: ISmartServeOptions) { constructor(options: ISmartServeOptions) {
// Validate websocket configuration - mutual exclusivity
if (options.websocket) {
const { typedRouter, onMessage } = options.websocket;
if (typedRouter && onMessage) {
throw new WebSocketConfigError(
'Cannot use both typedRouter and onMessage. ' +
'typedRouter handles message routing automatically.'
);
}
}
this.options = { this.options = {
hostname: '0.0.0.0', hostname: '0.0.0.0',
...options, ...options,
}; };
// Initialize compression config (enabled by default)
this.compressionConfig = normalizeCompressionConfig(options.compression);
// Initialize connection registry only when typedRouter is configured
if (this.options.websocket?.typedRouter) {
this.wsConnections = new Map();
}
// Initialize file server if static options provided // Initialize file server if static options provided
if (this.options.static) { if (this.options.static) {
this.fileServer = new FileServer(this.options.static); this.fileServer = new FileServer(this.options.static);
@@ -90,8 +122,37 @@ export class SmartServe {
throw new ServerAlreadyRunningError(); throw new ServerAlreadyRunningError();
} }
// Prepare options with internal callbacks if typedRouter is configured
let adapterOptions = this.options;
if (this.options.websocket?.typedRouter && this.wsConnections) {
// Clone options and add internal callbacks for adapter communication
const connectionCallbacks: IWebSocketConnectionCallbacks = {
onRegister: (peer: IWebSocketPeer) => {
this.wsConnections!.set(peer.id, peer);
this.options.websocket?.onConnectionOpen?.(peer);
},
onUnregister: (peerId: string) => {
const peer = this.wsConnections!.get(peerId);
if (peer) {
this.wsConnections!.delete(peerId);
this.options.websocket?.onConnectionClose?.(peer);
}
},
};
adapterOptions = {
...this.options,
websocket: {
...this.options.websocket,
// Internal property for adapter communication (not part of public API)
_connectionCallbacks: connectionCallbacks,
} as typeof this.options.websocket & { _connectionCallbacks: IWebSocketConnectionCallbacks },
};
}
// Create adapter for current runtime // Create adapter for current runtime
this.adapter = await AdapterFactory.createAdapter(this.options); this.adapter = await AdapterFactory.createAdapter(adapterOptions);
// Create request handler // Create request handler
const handler = this.createRequestHandler(); const handler = this.createRequestHandler();
@@ -127,6 +188,70 @@ export class SmartServe {
return this.instance !== null; return this.instance !== null;
} }
// ===========================================================================
// WebSocket Connection Management (only available with typedRouter)
// ===========================================================================
/**
* Get all active WebSocket connections
* Only available when typedRouter is configured
*/
getWebSocketConnections(): IWebSocketPeer[] {
if (!this.wsConnections) {
return [];
}
return Array.from(this.wsConnections.values());
}
/**
* Get WebSocket connections filtered by tag
* Only available when typedRouter is configured
*/
getWebSocketConnectionsByTag(tag: string): IWebSocketPeer[] {
if (!this.wsConnections) {
return [];
}
return Array.from(this.wsConnections.values()).filter((peer) => peer.tags.has(tag));
}
/**
* Broadcast message to all WebSocket connections
* Only available when typedRouter is configured
*/
broadcastWebSocket(data: string | object): void {
if (!this.wsConnections) {
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
for (const peer of this.wsConnections.values()) {
try {
peer.send(message);
} catch (error) {
console.error(`Failed to send to peer ${peer.id}:`, error);
}
}
}
/**
* Broadcast message to WebSocket connections with specific tag
* Only available when typedRouter is configured
*/
broadcastWebSocketByTag(tag: string, data: string | object): void {
if (!this.wsConnections) {
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
for (const peer of this.wsConnections.values()) {
if (peer.tags.has(tag)) {
try {
peer.send(message);
} catch (error) {
console.error(`Failed to send to peer ${peer.id}:`, error);
}
}
}
}
/** /**
* Create the main request handler * Create the main request handler
*/ */
@@ -134,7 +259,9 @@ export class SmartServe {
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => { return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
// Use custom handler if set // Use custom handler if set
if (this.customHandler) { if (this.customHandler) {
return this.customHandler(request, connectionInfo); const response = await this.customHandler(request, connectionInfo);
// Apply compression to custom handler responses
return this.applyCompression(response, request);
} }
// Parse URL and method // Parse URL and method
@@ -144,7 +271,8 @@ export class SmartServe {
// Handle WebDAV requests first if handler is configured // Handle WebDAV requests first if handler is configured
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) { if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
try { try {
return await this.webdavHandler.handle(request); const response = await this.webdavHandler.handle(request);
return this.applyCompression(response, request);
} catch (error) { } catch (error) {
return this.handleError(error as Error, request); return this.handleError(error as Error, request);
} }
@@ -157,7 +285,8 @@ export class SmartServe {
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles) // No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
if (this.webdavHandler) { if (this.webdavHandler) {
try { try {
return await this.webdavHandler.handle(request); const response = await this.webdavHandler.handle(request);
return this.applyCompression(response, request);
} catch (error) { } catch (error) {
return this.handleError(error as Error, request); return this.handleError(error as Error, request);
} }
@@ -168,7 +297,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);
@@ -190,7 +320,10 @@ export class SmartServe {
const context = await this.createContext(request, url, params, connectionInfo); const context = await 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);
} }
@@ -342,6 +475,50 @@ export class SmartServe {
}); });
} }
/**
* Apply compression to response if applicable
*/
private async applyCompression(
response: Response,
request: Request,
routeCompression?: IRouteCompressionOptions
): Promise<Response> {
// Check route-level override first
if (routeCompression?.enabled === false) {
return response;
}
// Build effective config (merge route settings with global)
const effectiveConfig: ICompressionConfig = {
...this.compressionConfig,
};
// Route-level compression settings override global
if (routeCompression?.level !== undefined) {
effectiveConfig.level = routeCompression.level;
}
// If route forces compression, ensure it's enabled
if (routeCompression?.enabled === true) {
effectiveConfig.enabled = true;
}
// Check if compression should be applied
if (!shouldCompressResponse(response, request, effectiveConfig)) {
return response;
}
// Select best algorithm
const algorithm = selectCompressionAlgorithm(request, effectiveConfig);
if (algorithm === 'identity') {
return response;
}
// Apply compression
return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold);
}
/** /**
* Handle errors * Handle errors
*/ */

View File

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

View File

@@ -3,6 +3,9 @@
* Uses Web Standards API (Request/Response) for cross-platform compatibility * Uses Web Standards API (Request/Response) for cross-platform compatibility
*/ */
import type { TypedRouter } from '@api.global/typedrequest';
import type { ICompressionConfig } from '../compression/index.js';
// ============================================================================= // =============================================================================
// HTTP Types // HTTP Types
// ============================================================================= // =============================================================================
@@ -168,18 +171,55 @@ export interface IWebSocketPeer {
context: IRequestContext; context: IRequestContext;
/** Custom per-peer data storage */ /** Custom per-peer data storage */
data: Map<string, unknown>; data: Map<string, unknown>;
/** Tags for connection filtering/grouping */
tags: Set<string>;
} }
/** /**
* WebSocket event hooks * WebSocket event hooks
*/ */
export interface IWebSocketHooks { export interface IWebSocketHooks {
/** Called when WebSocket connection opens */
onOpen?: (peer: IWebSocketPeer) => void | Promise<void>; onOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
/** Called when message received. Mutually exclusive with typedRouter. */
onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>; onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>;
/** Called when WebSocket connection closes */
onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>; onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>;
/** Called on WebSocket error */
onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>; onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>;
/** Called when ping received */
onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>; onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
/** Called when pong received */
onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>; onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
/**
* TypedRouter for type-safe RPC over WebSocket.
* Mutually exclusive with onMessage - cannot use both.
* When set, enables connection registry and broadcast methods.
*/
typedRouter?: TypedRouter;
/**
* Called when connection is established and registered.
* Only available when typedRouter is configured.
* Use this to tag connections for filtering.
*/
onConnectionOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
/**
* Called when connection is closed and unregistered.
* Only available when typedRouter is configured.
*/
onConnectionClose?: (peer: IWebSocketPeer) => void | Promise<void>;
}
/**
* Internal callbacks for adapter-to-SmartServe communication
* @internal
*/
export interface IWebSocketConnectionCallbacks {
onRegister: (peer: IWebSocketPeer) => void;
onUnregister: (peerId: string) => void;
} }
// ============================================================================= // =============================================================================
@@ -233,6 +273,8 @@ export interface IStaticOptions {
extensions?: string[]; extensions?: string[];
/** Enable directory listing */ /** Enable directory listing */
directoryListing?: boolean | IDirectoryListingOptions; directoryListing?: boolean | IDirectoryListingOptions;
/** Serve pre-compressed files (.br, .gz) when available */
precompressed?: boolean;
} }
/** /**
@@ -294,6 +336,8 @@ 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;
} }
// ============================================================================= // =============================================================================

View File

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

View File

@@ -13,6 +13,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,6 +66,29 @@ 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
*/ */
@@ -102,10 +126,14 @@ export class ControllerRegistry {
method: route.method, method: route.method,
handler, handler,
interceptors, interceptors,
compression: route.compression,
}); });
} }
} }
// 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
@@ -193,6 +221,7 @@ export class ControllerRegistry {
this.controllers.clear(); this.controllers.clear();
this.instances.clear(); this.instances.clear();
this.compiledRoutes = []; this.compiledRoutes = [];
this.dynamicRoutes = [];
this.routesCompiled = false; this.routesCompiled = false;
} }
} }

View File

@@ -10,6 +10,7 @@ import type {
IMethodOptions, IMethodOptions,
IRouteOptions, IRouteOptions,
} from '../core/smartserve.interfaces.js'; } from '../core/smartserve.interfaces.js';
import type { ICompressionConfig } from '../compression/index.js';
// ============================================================================= // =============================================================================
// Metadata Types // Metadata Types
@@ -29,6 +30,16 @@ export interface IControllerMetadata {
target?: new (...args: any[]) => any; target?: new (...args: any[]) => any;
} }
/**
* Route compression options
*/
export interface IRouteCompressionOptions {
/** Whether compression is enabled for this route (undefined = use default) */
enabled?: boolean;
/** Override compression level */
level?: number;
}
/** /**
* Metadata for individual route methods * Metadata for individual route methods
*/ */
@@ -45,6 +56,8 @@ 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;
} }
/** /**
@@ -73,4 +86,6 @@ 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;
} }

View File

@@ -2,6 +2,7 @@
export type { export type {
IControllerMetadata, IControllerMetadata,
IRouteMetadata, IRouteMetadata,
IRouteCompressionOptions,
IRegisteredController, IRegisteredController,
ICompiledRoute, ICompiledRoute,
} from './decorators.types.js'; } from './decorators.types.js';
@@ -35,6 +36,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';

View File

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

View File

@@ -6,6 +6,9 @@ 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';

View File

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

View File

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

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

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