Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b8a4ba68c | |||
| 643a6cec55 |
12
changelog.md
12
changelog.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartserve",
|
"name": "@push.rocks/smartserve",
|
||||||
"version": "1.0.2",
|
"version": "1.1.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": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@api.global/typedrequest": "^3.0.0",
|
||||||
"@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",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@api.global/typedrequest':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.1.10
|
||||||
'@push.rocks/lik':
|
'@push.rocks/lik':
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
|
|||||||
@@ -93,8 +93,65 @@ 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
|
||||||
|
|
||||||
## 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
|
||||||
- [ ] HTTP/2 support investigation
|
- [ ] HTTP/2 support investigation
|
||||||
- [ ] Performance benchmarks
|
- [ ] Performance benchmarks
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartserve',
|
name: '@push.rocks/smartserve',
|
||||||
version: '1.0.2',
|
version: '1.1.0',
|
||||||
description: 'a cross platform server module for Node, Deno and Bun'
|
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';
|
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,36 @@ 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);
|
||||||
|
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 +153,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 +182,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 +203,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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 @@ 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);
|
||||||
|
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 +131,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 +147,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 +160,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>(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 @@ 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);
|
||||||
|
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 +299,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 +327,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 +341,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>(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ 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 } from '../decorators/index.js';
|
||||||
import { FileServer } from '../files/index.js';
|
import { FileServer } from '../files/index.js';
|
||||||
@@ -42,12 +44,31 @@ export class SmartServe {
|
|||||||
private fileServer: FileServer | null = null;
|
private fileServer: FileServer | null = null;
|
||||||
private webdavHandler: WebDAVHandler | null = null;
|
private webdavHandler: WebDAVHandler | null = null;
|
||||||
|
|
||||||
|
/** 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 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 +111,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 +177,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* 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';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HTTP Types
|
// HTTP Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -168,18 +170,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -13,3 +13,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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user