Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ccff3453 | |||
| 57d7fd6483 | |||
| cef6ce750e | |||
| d341fc270d | |||
| a9972ad0ce | |||
| b3c77eb675 | |||
| 7b8a4ba68c | |||
| 643a6cec55 |
36
changelog.md
36
changelog.md
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
Update package metadata, scripts and dependency pins
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartserve",
|
||||
"version": "1.0.2",
|
||||
"version": "1.2.0",
|
||||
"private": false,
|
||||
"description": "a cross platform server module for Node, Deno and Bun",
|
||||
"exports": {
|
||||
@@ -16,18 +16,20 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsbundle": "^2.0.5",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^20.8.7",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/ws": "^8.18.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.1.11",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
"@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",
|
||||
"repository": {
|
||||
|
||||
695
pnpm-lock.yaml
generated
695
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
135
readme.hints.md
135
readme.hints.md
@@ -31,6 +31,12 @@ SmartServe is a cross-platform HTTP server for Node.js, Deno, and Bun using:
|
||||
- `ts/utils/utils.mime.ts` - MIME type detection
|
||||
- `ts/utils/utils.etag.ts` - ETag generation
|
||||
|
||||
### Compression
|
||||
- `ts/compression/compression.runtime.ts` - Cross-runtime compression (Node.js zlib, Web CompressionStream)
|
||||
- `ts/compression/compression.middleware.ts` - Compression config and helpers
|
||||
- `ts/utils/utils.encoding.ts` - Accept-Encoding parsing
|
||||
- `ts/decorators/decorators.compress.ts` - @Compress and @NoCompress decorators
|
||||
|
||||
### Protocols
|
||||
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
|
||||
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
|
||||
@@ -93,8 +99,137 @@ await server.start();
|
||||
- LOCK/UNLOCK - Exclusive write locking
|
||||
- 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
|
||||
|
||||
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
|
||||
- [x] TypedRouter WebSocket integration
|
||||
- [x] Brotli/gzip compression with per-route control
|
||||
- [ ] HTTP/2 support investigation
|
||||
- [ ] Performance benchmarks
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartserve',
|
||||
version: '1.0.2',
|
||||
version: '1.2.0',
|
||||
description: 'a cross platform server module for Node, Deno and Bun'
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
// Bun types (for type checking without requiring Bun runtime)
|
||||
@@ -46,9 +46,17 @@ export class BunAdapter extends BaseAdapter {
|
||||
this.stats.requestsActive++;
|
||||
|
||||
try {
|
||||
// Handle WebSocket upgrade
|
||||
// Handle WebSocket upgrade - store data for persistence across events
|
||||
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) {
|
||||
return undefined; // Bun handles the upgrade
|
||||
}
|
||||
@@ -108,33 +116,67 @@ export class BunAdapter extends BaseAdapter {
|
||||
const hooks = this.options.websocket;
|
||||
if (!hooks) return undefined;
|
||||
|
||||
// Get internal callbacks if typedRouter mode
|
||||
const callbacks = (hooks as any)._connectionCallbacks as IWebSocketConnectionCallbacks | undefined;
|
||||
const typedRouter = hooks.typedRouter;
|
||||
|
||||
return {
|
||||
open: (ws: any) => {
|
||||
const peer = this.wrapBunWebSocket(ws);
|
||||
// Register connection if typedRouter mode
|
||||
if (callbacks) {
|
||||
callbacks.onRegister(peer);
|
||||
}
|
||||
hooks.onOpen?.(peer);
|
||||
},
|
||||
message: (ws: any, message: string | ArrayBuffer) => {
|
||||
|
||||
message: async (ws: any, message: string | ArrayBuffer) => {
|
||||
const peer = this.wrapBunWebSocket(ws);
|
||||
const msg = {
|
||||
type: typeof message === 'string' ? 'text' as const : 'binary' as const,
|
||||
text: typeof message === 'string' ? message : undefined,
|
||||
data: message instanceof ArrayBuffer ? new Uint8Array(message) : undefined,
|
||||
size: typeof message === 'string' ? message.length : (message as ArrayBuffer).byteLength,
|
||||
};
|
||||
hooks.onMessage?.(peer, msg);
|
||||
|
||||
// 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 = {
|
||||
type: typeof message === 'string' ? 'text' as const : 'binary' as const,
|
||||
text: typeof message === 'string' ? message : undefined,
|
||||
data: message instanceof ArrayBuffer ? new Uint8Array(message) : undefined,
|
||||
size: typeof message === 'string' ? message.length : (message as ArrayBuffer).byteLength,
|
||||
};
|
||||
hooks.onMessage?.(peer, msg);
|
||||
}
|
||||
},
|
||||
|
||||
close: (ws: any, code: number, reason: string) => {
|
||||
const peer = this.wrapBunWebSocket(ws);
|
||||
// Unregister connection if typedRouter mode
|
||||
if (callbacks) {
|
||||
callbacks.onUnregister(peer.id);
|
||||
}
|
||||
hooks.onClose?.(peer, code, reason);
|
||||
},
|
||||
|
||||
error: (ws: any, error: Error) => {
|
||||
const peer = this.wrapBunWebSocket(ws);
|
||||
hooks.onError?.(peer, error);
|
||||
},
|
||||
|
||||
ping: (ws: any, data: ArrayBuffer) => {
|
||||
const peer = this.wrapBunWebSocket(ws);
|
||||
hooks.onPing?.(peer, new Uint8Array(data));
|
||||
},
|
||||
|
||||
pong: (ws: any, data: ArrayBuffer) => {
|
||||
const peer = this.wrapBunWebSocket(ws);
|
||||
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 {
|
||||
id: ws.data?.id ?? crypto.randomUUID(),
|
||||
url: ws.data?.url ?? '',
|
||||
id: wsData.id,
|
||||
url: wsData.url,
|
||||
get readyState() { return ws.readyState; },
|
||||
protocol: ws.protocol ?? '',
|
||||
extensions: ws.extensions ?? '',
|
||||
@@ -155,7 +205,8 @@ export class BunAdapter extends BaseAdapter {
|
||||
ping: (data?: Uint8Array) => ws.ping(data),
|
||||
terminate: () => ws.terminate(),
|
||||
context: {} as any,
|
||||
data: ws.data?.customData ?? new Map(),
|
||||
data: wsData.customData,
|
||||
tags: wsData.tags, // Reference to persistent Set
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
// Deno types (for type checking without requiring Deno runtime)
|
||||
@@ -98,21 +98,49 @@ export class DenoAdapter extends BaseAdapter {
|
||||
|
||||
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 = () => {
|
||||
// Register connection if typedRouter mode
|
||||
if (callbacks) {
|
||||
callbacks.onRegister(peer);
|
||||
}
|
||||
hooks.onOpen?.(peer);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const message = {
|
||||
type: typeof event.data === 'string' ? 'text' as const : 'binary' as const,
|
||||
text: typeof event.data === 'string' ? event.data : undefined,
|
||||
data: event.data instanceof Uint8Array ? event.data : undefined,
|
||||
size: typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength,
|
||||
};
|
||||
hooks.onMessage?.(peer, message);
|
||||
socket.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 = {
|
||||
type: typeof event.data === 'string' ? 'text' as const : 'binary' as const,
|
||||
text: typeof event.data === 'string' ? event.data : undefined,
|
||||
data: event.data instanceof Uint8Array ? event.data : undefined,
|
||||
size: typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength,
|
||||
};
|
||||
hooks.onMessage?.(peer, message);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
// Unregister connection if typedRouter mode
|
||||
if (callbacks) {
|
||||
callbacks.onUnregister(peer.id);
|
||||
}
|
||||
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);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url: url.pathname,
|
||||
get readyState() { return socket.readyState; },
|
||||
get readyState() { return socket.readyState as 0 | 1 | 2 | 3; },
|
||||
protocol: socket.protocol,
|
||||
extensions: socket.extensions,
|
||||
send: (data: string) => socket.send(data),
|
||||
@@ -134,8 +162,9 @@ export class DenoAdapter extends BaseAdapter {
|
||||
close: (code?: number, reason?: string) => socket.close(code, reason),
|
||||
ping: () => { /* Deno handles ping/pong automatically */ },
|
||||
terminate: () => socket.close(),
|
||||
context: {} as any, // Will be populated with IRequestContext
|
||||
context: {} as any,
|
||||
data: new Map(),
|
||||
tags: new Set<string>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -256,6 +256,10 @@ export class NodeAdapter extends BaseAdapter {
|
||||
|
||||
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) => {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
@@ -265,19 +269,46 @@ export class NodeAdapter extends BaseAdapter {
|
||||
wss.on('connection', (ws: any, request: any) => {
|
||||
const peer = this.wrapNodeWebSocket(ws, request);
|
||||
|
||||
// Register connection if typedRouter mode
|
||||
if (callbacks) {
|
||||
callbacks.onRegister(peer);
|
||||
}
|
||||
|
||||
// Call user's onOpen hook
|
||||
hooks.onOpen?.(peer);
|
||||
|
||||
ws.on('message', (data: Buffer | string) => {
|
||||
const message = {
|
||||
type: typeof data === 'string' ? 'text' as const : 'binary' as const,
|
||||
text: typeof data === 'string' ? data : undefined,
|
||||
data: Buffer.isBuffer(data) ? new Uint8Array(data) : undefined,
|
||||
size: typeof data === 'string' ? data.length : data.length,
|
||||
};
|
||||
hooks.onMessage?.(peer, message);
|
||||
ws.on('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 = {
|
||||
type: typeof data === 'string' ? 'text' as const : 'binary' as const,
|
||||
text: typeof data === 'string' ? data : undefined,
|
||||
data: Buffer.isBuffer(data) ? new Uint8Array(data) : undefined,
|
||||
size: typeof data === 'string' ? data.length : data.length,
|
||||
};
|
||||
hooks.onMessage?.(peer, message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code: number, reason: Buffer) => {
|
||||
// Unregister connection if typedRouter mode
|
||||
if (callbacks) {
|
||||
callbacks.onUnregister(peer.id);
|
||||
}
|
||||
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 {
|
||||
id: crypto.randomUUID(),
|
||||
url: request.url ?? '',
|
||||
@@ -312,6 +343,7 @@ export class NodeAdapter extends BaseAdapter {
|
||||
terminate: () => ws.terminate(),
|
||||
context: {} as any,
|
||||
data: new Map(),
|
||||
tags: new Set<string>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
229
ts/compression/compression.middleware.ts
Normal file
229
ts/compression/compression.middleware.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 size threshold
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
const size = parseInt(contentLength, 10);
|
||||
if (size < (config.threshold ?? DEFAULT_COMPRESSION_CONFIG.threshold)) {
|
||||
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
|
||||
*/
|
||||
export async function compressResponse(
|
||||
response: Response,
|
||||
algorithm: TCompressionAlgorithm,
|
||||
level?: number
|
||||
): Promise<Response> {
|
||||
if (algorithm === 'identity' || !response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const provider = getCompressionProvider();
|
||||
|
||||
// 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.delete('Content-Length'); // Size changes after compression
|
||||
|
||||
// Compress the body stream
|
||||
const compressedBody = provider.compressStream(response.body, algorithm, level);
|
||||
|
||||
return new Response(compressedBody, {
|
||||
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);
|
||||
}
|
||||
309
ts/compression/compression.runtime.ts
Normal file
309
ts/compression/compression.runtime.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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 checkBrotliSupport(): boolean {
|
||||
if (this.brotliSupported === null) {
|
||||
try {
|
||||
// Try to create a brotli stream - not all runtimes support it
|
||||
new CompressionStream('deflate');
|
||||
// Note: CompressionStream doesn't support 'br' in most runtimes yet
|
||||
this.brotliSupported = false;
|
||||
} catch {
|
||||
this.brotliSupported = false;
|
||||
}
|
||||
}
|
||||
return this.brotliSupported;
|
||||
}
|
||||
|
||||
getSupportedAlgorithms(): TCompressionAlgorithm[] {
|
||||
// CompressionStream supports gzip and deflate in most runtimes
|
||||
// Brotli support is limited
|
||||
const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate'];
|
||||
if (this.checkBrotliSupport()) {
|
||||
algorithms.unshift('br');
|
||||
}
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
async compress(
|
||||
data: Uint8Array,
|
||||
algorithm: TCompressionAlgorithm,
|
||||
_level?: number
|
||||
): Promise<Uint8Array> {
|
||||
if (algorithm === 'identity') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Map algorithm to CompressionStream format
|
||||
// Brotli falls back to gzip if not supported
|
||||
let format: CompressionFormat;
|
||||
if (algorithm === 'br') {
|
||||
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip';
|
||||
} else {
|
||||
format = algorithm as CompressionFormat;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = new CompressionStream(format);
|
||||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
|
||||
// Write data and close (cast for type compatibility)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
compressStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
algorithm: TCompressionAlgorithm,
|
||||
_level?: number
|
||||
): ReadableStream<Uint8Array> {
|
||||
if (algorithm === 'identity') {
|
||||
return stream;
|
||||
}
|
||||
|
||||
// Map algorithm to CompressionStream format
|
||||
let format: CompressionFormat;
|
||||
if (algorithm === 'br') {
|
||||
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip';
|
||||
} else {
|
||||
format = algorithm as CompressionFormat;
|
||||
}
|
||||
|
||||
try {
|
||||
const compressionStream = new CompressionStream(format);
|
||||
// Use type assertion for cross-runtime compatibility
|
||||
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';
|
||||
@@ -13,12 +13,21 @@ import type {
|
||||
IInterceptOptions,
|
||||
TRequestInterceptor,
|
||||
TResponseInterceptor,
|
||||
IWebSocketPeer,
|
||||
IWebSocketConnectionCallbacks,
|
||||
} 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 { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
|
||||
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
|
||||
import { FileServer } from '../files/index.js';
|
||||
import { WebDAVHandler } from '../protocols/index.js';
|
||||
import {
|
||||
normalizeCompressionConfig,
|
||||
shouldCompressResponse,
|
||||
selectCompressionAlgorithm,
|
||||
compressResponse,
|
||||
type ICompressionConfig,
|
||||
} from '../compression/index.js';
|
||||
|
||||
/**
|
||||
* SmartServe - Cross-platform HTTP server
|
||||
@@ -41,13 +50,36 @@ export class SmartServe {
|
||||
private customHandler: TRequestHandler | null = null;
|
||||
private fileServer: FileServer | null = null;
|
||||
private webdavHandler: WebDAVHandler | null = null;
|
||||
private compressionConfig: ICompressionConfig;
|
||||
|
||||
/** WebSocket connection registry (only active when typedRouter is set) */
|
||||
private wsConnections: Map<string, IWebSocketPeer> | null = null;
|
||||
|
||||
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 = {
|
||||
hostname: '0.0.0.0',
|
||||
...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
|
||||
if (this.options.static) {
|
||||
this.fileServer = new FileServer(this.options.static);
|
||||
@@ -90,8 +122,37 @@ export class SmartServe {
|
||||
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
|
||||
this.adapter = await AdapterFactory.createAdapter(this.options);
|
||||
this.adapter = await AdapterFactory.createAdapter(adapterOptions);
|
||||
|
||||
// Create request handler
|
||||
const handler = this.createRequestHandler();
|
||||
@@ -127,6 +188,70 @@ export class SmartServe {
|
||||
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
|
||||
*/
|
||||
@@ -134,7 +259,9 @@ export class SmartServe {
|
||||
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
|
||||
// Use custom handler if set
|
||||
if (this.customHandler) {
|
||||
return this.customHandler(request, connectionInfo);
|
||||
const response = await this.customHandler(request, connectionInfo);
|
||||
// Apply compression to custom handler responses
|
||||
return this.applyCompression(response, request);
|
||||
}
|
||||
|
||||
// Parse URL and method
|
||||
@@ -144,7 +271,8 @@ export class SmartServe {
|
||||
// Handle WebDAV requests first if handler is configured
|
||||
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
|
||||
try {
|
||||
return await this.webdavHandler.handle(request);
|
||||
const response = await this.webdavHandler.handle(request);
|
||||
return this.applyCompression(response, request);
|
||||
} catch (error) {
|
||||
return this.handleError(error as Error, request);
|
||||
}
|
||||
@@ -157,7 +285,8 @@ export class SmartServe {
|
||||
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
|
||||
if (this.webdavHandler) {
|
||||
try {
|
||||
return await this.webdavHandler.handle(request);
|
||||
const response = await this.webdavHandler.handle(request);
|
||||
return this.applyCompression(response, request);
|
||||
} catch (error) {
|
||||
return this.handleError(error as Error, request);
|
||||
}
|
||||
@@ -168,7 +297,8 @@ export class SmartServe {
|
||||
try {
|
||||
const staticResponse = await this.fileServer.serve(request);
|
||||
if (staticResponse) {
|
||||
return staticResponse;
|
||||
// Apply compression to static file responses
|
||||
return this.applyCompression(staticResponse, request);
|
||||
}
|
||||
} catch (error) {
|
||||
return this.handleError(error as Error, request);
|
||||
@@ -190,7 +320,10 @@ export class SmartServe {
|
||||
const context = await this.createContext(request, url, params, connectionInfo);
|
||||
|
||||
// Run interceptors and handler
|
||||
return await this.executeRoute(route, context);
|
||||
const response = await this.executeRoute(route, context);
|
||||
|
||||
// Apply compression with route-specific settings
|
||||
return this.applyCompression(response, request, route.compression);
|
||||
} catch (error) {
|
||||
return this.handleError(error as Error, request);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors
|
||||
*/
|
||||
|
||||
@@ -127,3 +127,13 @@ export class ServerNotRunningError extends Error {
|
||||
this.name = 'ServerNotRunningError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when WebSocket configuration is invalid
|
||||
*/
|
||||
export class WebSocketConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WebSocketConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* 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
|
||||
// =============================================================================
|
||||
@@ -168,18 +171,55 @@ export interface IWebSocketPeer {
|
||||
context: IRequestContext;
|
||||
/** Custom per-peer data storage */
|
||||
data: Map<string, unknown>;
|
||||
/** Tags for connection filtering/grouping */
|
||||
tags: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket event hooks
|
||||
*/
|
||||
export interface IWebSocketHooks {
|
||||
/** Called when WebSocket connection opens */
|
||||
onOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
|
||||
/** Called when message received. Mutually exclusive with typedRouter. */
|
||||
onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>;
|
||||
/** Called when WebSocket connection closes */
|
||||
onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>;
|
||||
/** Called on WebSocket error */
|
||||
onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>;
|
||||
/** Called when ping received */
|
||||
onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
|
||||
/** Called when pong received */
|
||||
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[];
|
||||
/** Enable directory listing */
|
||||
directoryListing?: boolean | IDirectoryListingOptions;
|
||||
/** Serve pre-compressed files (.br, .gz) when available */
|
||||
precompressed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +336,8 @@ export interface ISmartServeOptions {
|
||||
keepAlive?: IKeepAliveConfig;
|
||||
/** Global error handler */
|
||||
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
||||
/** Compression configuration (enabled by default) */
|
||||
compression?: ICompressionConfig | boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export class ControllerRegistry {
|
||||
method: route.method,
|
||||
handler,
|
||||
interceptors,
|
||||
compression: route.compression,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
IMethodOptions,
|
||||
IRouteOptions,
|
||||
} from '../core/smartserve.interfaces.js';
|
||||
import type { ICompressionConfig } from '../compression/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// Metadata Types
|
||||
@@ -29,6 +30,16 @@ export interface IControllerMetadata {
|
||||
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
|
||||
*/
|
||||
@@ -45,6 +56,8 @@ export interface IRouteMetadata {
|
||||
methodName: string | symbol;
|
||||
/** Handler function reference */
|
||||
handler?: Function;
|
||||
/** Route-specific compression settings */
|
||||
compression?: IRouteCompressionOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,4 +86,6 @@ export interface ICompiledRoute {
|
||||
handler: (ctx: IRequestContext) => Promise<any>;
|
||||
/** Combined interceptors (class + method) */
|
||||
interceptors: IInterceptOptions[];
|
||||
/** Route-specific compression settings */
|
||||
compression?: IRouteCompressionOptions;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export type {
|
||||
IControllerMetadata,
|
||||
IRouteMetadata,
|
||||
IRouteCompressionOptions,
|
||||
IRegisteredController,
|
||||
ICompiledRoute,
|
||||
} from './decorators.types.js';
|
||||
@@ -35,6 +36,9 @@ export {
|
||||
addTimestamp,
|
||||
} from './decorators.interceptors.js';
|
||||
|
||||
// Compression decorators
|
||||
export { Compress, NoCompress } from './decorators.compress.js';
|
||||
|
||||
// Registry
|
||||
export { ControllerRegistry } from './decorators.registry.js';
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@ import type {
|
||||
} from '../core/smartserve.interfaces.js';
|
||||
import { getMimeType } from '../utils/utils.mime.js';
|
||||
import { generateETag } from '../utils/utils.etag.js';
|
||||
import { parseAcceptEncoding } from '../utils/utils.encoding.js';
|
||||
|
||||
/**
|
||||
* Pre-compressed file variant info
|
||||
*/
|
||||
interface IPrecompressedVariant {
|
||||
path: string;
|
||||
stat: plugins.fs.Stats;
|
||||
encoding: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static file server
|
||||
@@ -112,22 +122,43 @@ export class FileServer {
|
||||
): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
|
||||
// Content-Type
|
||||
// Check for pre-compressed variants
|
||||
let actualFilePath = filePath;
|
||||
let actualStat = stat;
|
||||
let contentEncoding: string | undefined;
|
||||
|
||||
if (this.options.precompressed) {
|
||||
const variant = await this.findPrecompressedVariant(filePath, request);
|
||||
if (variant) {
|
||||
actualFilePath = variant.path;
|
||||
actualStat = variant.stat;
|
||||
contentEncoding = variant.encoding;
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Type (always use original file's MIME type)
|
||||
const mimeType = getMimeType(filePath);
|
||||
headers.set('Content-Type', mimeType);
|
||||
|
||||
// Content-Length
|
||||
headers.set('Content-Length', stat.size.toString());
|
||||
// Content-Encoding (if serving pre-compressed)
|
||||
if (contentEncoding) {
|
||||
headers.set('Content-Encoding', contentEncoding);
|
||||
headers.set('Vary', 'Accept-Encoding');
|
||||
}
|
||||
|
||||
// Last-Modified
|
||||
// Content-Length (use actual file size, which may differ for compressed)
|
||||
headers.set('Content-Length', actualStat.size.toString());
|
||||
|
||||
// Last-Modified (use original file's time for consistency)
|
||||
if (this.options.lastModified) {
|
||||
headers.set('Last-Modified', stat.mtime.toUTCString());
|
||||
}
|
||||
|
||||
// ETag
|
||||
// ETag (include encoding in ETag if compressed)
|
||||
let etag: string | undefined;
|
||||
if (this.options.etag) {
|
||||
etag = generateETag(stat);
|
||||
const baseEtag = generateETag(stat);
|
||||
etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag;
|
||||
headers.set('ETag', etag);
|
||||
}
|
||||
|
||||
@@ -153,9 +184,9 @@ export class FileServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Range requests
|
||||
// Handle Range requests (don't use pre-compressed for range requests)
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
if (rangeHeader) {
|
||||
if (rangeHeader && !contentEncoding) {
|
||||
return this.servePartial(filePath, stat, rangeHeader, headers);
|
||||
}
|
||||
|
||||
@@ -164,8 +195,8 @@ export class FileServer {
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
// Stream the file
|
||||
const stream = plugins.fs.createReadStream(filePath);
|
||||
// Stream the file (use actualFilePath for pre-compressed)
|
||||
const stream = plugins.fs.createReadStream(actualFilePath);
|
||||
const readableStream = this.nodeStreamToWebStream(stream);
|
||||
|
||||
return new Response(readableStream, { status: 200, headers });
|
||||
@@ -361,6 +392,46 @@ export class FileServer {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pre-compressed variant of a file if it exists
|
||||
* Checks for .br and .gz variants based on client Accept-Encoding
|
||||
*/
|
||||
private async findPrecompressedVariant(
|
||||
filePath: string,
|
||||
request: Request
|
||||
): Promise<IPrecompressedVariant | null> {
|
||||
const acceptEncoding = request.headers.get('Accept-Encoding');
|
||||
const preferences = parseAcceptEncoding(acceptEncoding);
|
||||
|
||||
// Supported pre-compressed variants in preference order
|
||||
const variants: Array<{ encoding: string; extension: string }> = [
|
||||
{ encoding: 'br', extension: '.br' },
|
||||
{ encoding: 'gzip', extension: '.gz' },
|
||||
];
|
||||
|
||||
// Check variants in client preference order
|
||||
for (const pref of preferences) {
|
||||
const variant = variants.find((v) => v.encoding === pref.encoding);
|
||||
if (!variant) continue;
|
||||
|
||||
const variantPath = filePath + variant.extension;
|
||||
try {
|
||||
const variantStat = await plugins.fs.promises.stat(variantPath);
|
||||
if (variantStat.isFile()) {
|
||||
return {
|
||||
path: variantPath,
|
||||
stat: variantStat,
|
||||
encoding: variant.encoding,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Variant doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Node.js stream to Web ReadableStream
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,9 @@ export * from './core/index.js';
|
||||
// Decorator exports
|
||||
export * from './decorators/index.js';
|
||||
|
||||
// Compression exports
|
||||
export * from './compression/index.js';
|
||||
|
||||
// File server exports
|
||||
export * from './files/index.js';
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as fs from 'fs';
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
export { path, http, https, fs };
|
||||
export { path, http, https, fs, zlib };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
@@ -13,3 +14,8 @@ import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as lik from '@push.rocks/lik';
|
||||
|
||||
export { smartpath, smartenv, smartlog, lik };
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
export { typedrequest };
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
||||
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
||||
export {
|
||||
parseAcceptEncoding,
|
||||
selectEncoding,
|
||||
isCompressible,
|
||||
getDefaultCompressibleTypes,
|
||||
type TCompressionAlgorithm,
|
||||
type IEncodingPreference,
|
||||
} from './utils.encoding.js';
|
||||
|
||||
166
ts/utils/utils.encoding.ts
Normal file
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