Compare commits

...

2 Commits
v3.0.0 ... main

Author SHA1 Message Date
d75486ac6e v4.0.0
Some checks failed
Default (tags) / security (push) Failing after 19s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 07:59:44 +00:00
0499db71b8 BREAKING CHANGE(socketconnection): Stricter typings, smartserve hooks, connection fixes, and tag API change 2025-12-04 07:59:44 +00:00
12 changed files with 1040 additions and 2865 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2025-12-04 - 4.0.0 - BREAKING CHANGE(socketconnection)
Stricter typings, smartserve hooks, connection fixes, and tag API change
- Add unified WebSocket types and adapter interface (TWebSocket, TMessageEvent, IWebSocketLike) for browser/Node and smartserve peers
- Expose smartserve integration via getSmartserveWebSocketHooks() and adapt smartserve peers to a WebSocket-like interface (readyState getter, message/close/error dispatch)
- Improve SmartsocketClient connection logic: prevent duplicate connect attempts with isConnecting flag, ensure flags are cleared on success/failure, and tighten timeout/reconnect behavior
- Improve message parsing logging to surface JSON parse errors during authentication and normal operation
- Tighten TypeScript message and payload types (use unknown instead of any, Record<string, unknown> for tag payloads)
- SocketConnection API change: getTagById() and removeTagById() are now synchronous (no longer return Promises) — this is a breaking API change
- SocketRequest: log errors when function invocation fails and ensure pending requests are removed on errors
- Bump dependency @push.rocks/smartserve to ^1.1.2 and update README to reflect SmartServe integration changes
## 2025-12-03 - 3.0.0 - BREAKING CHANGE(smartsocket) ## 2025-12-03 - 3.0.0 - BREAKING CHANGE(smartsocket)
Replace setExternalServer with hooks-based SmartServe integration and refactor SocketServer to support standalone and hooks modes Replace setExternalServer with hooks-based SmartServe integration and refactor SocketServer to support standalone and hooks modes

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartsocket", "name": "@push.rocks/smartsocket",
"version": "3.0.0", "version": "4.0.0",
"description": "Provides easy and secure websocket communication mechanisms, including server and client implementation, function call routing, connection management, and tagging.", "description": "Provides easy and secure websocket communication mechanisms, including server and client implementation, function call routing, connection management, and tagging.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
@@ -39,7 +39,7 @@
"@git.zone/tsbundle": "^2.6.3", "@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.0", "@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3", "@git.zone/tstest": "^3.1.3",
"@push.rocks/smartserve": "^1.1.0", "@push.rocks/smartserve": "^1.1.2",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1" "@types/ws": "^8.18.1"
}, },

3768
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -143,20 +143,24 @@ await socketConnection.addTag({
Use smartsocket with `@push.rocks/smartserve` for advanced HTTP/WebSocket handling: Use smartsocket with `@push.rocks/smartserve` for advanced HTTP/WebSocket handling:
```typescript ```typescript
import { Smartserve } from '@push.rocks/smartserve'; import { SmartServe } from '@push.rocks/smartserve';
import { Smartsocket } from '@push.rocks/smartsocket'; import { Smartsocket } from '@push.rocks/smartsocket';
const smartserve = new Smartserve({ port: 3000 }); // Create smartsocket without a port (hooks mode)
const smartsocket = new Smartsocket({ alias: 'myServer' }); const smartsocket = new Smartsocket({ alias: 'myServer' });
// Set smartserve as external server // Get WebSocket hooks and pass them to SmartServe
await smartsocket.setExternalServer('smartserve', smartserve); const wsHooks = smartsocket.getSmartserveWebSocketHooks();
const smartserve = new SmartServe({
port: 3000,
websocket: wsHooks
});
// Get WebSocket hooks for smartserve // Add socket functions as usual
const wsHooks = smartsocket.socketServer.getSmartserveWebSocketHooks(); smartsocket.addSocketFunction(myFunction);
// Configure smartserve with the hooks // Start smartserve (smartsocket hooks mode doesn't need start())
// (see smartserve documentation for integration details) await smartserve.start();
``` ```
### Handling Disconnections ### Handling Disconnections
@@ -223,11 +227,11 @@ const response = await client.serverCall<IGreetRequest>('greet', { name: 'Bob' }
| Method | Description | | Method | Description |
|--------|-------------| |--------|-------------|
| `start()` | Start the WebSocket server | | `start()` | Start the WebSocket server (not needed in hooks mode) |
| `stop()` | Stop the server and close all connections | | `stop()` | Stop the server and close all connections |
| `addSocketFunction(fn)` | Register a function that clients can call | | `addSocketFunction(fn)` | Register a function that clients can call |
| `clientCall(funcName, data, connection)` | Call a function on a specific client | | `clientCall(funcName, data, connection)` | Call a function on a specific client |
| `setExternalServer(type, server)` | Use an external server (smartserve) | | `getSmartserveWebSocketHooks()` | Get hooks for smartserve integration |
### SmartsocketClient ### SmartsocketClient

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartsocket', name: '@push.rocks/smartsocket',
version: '3.0.0', version: '4.0.0',
description: 'Provides easy and secure websocket communication mechanisms, including server and client implementation, function call routing, connection management, and tagging.' description: 'Provides easy and secure websocket communication mechanisms, including server and client implementation, function call routing, connection management, and tagging.'
} }

View File

@@ -13,7 +13,7 @@ export type TMessageType =
/** /**
* Base message interface for all smartsocket messages * Base message interface for all smartsocket messages
*/ */
export interface ISocketMessage<T = any> { export interface ISocketMessage<T = unknown> {
type: TMessageType; type: TMessageType;
id?: string; // For request/response correlation id?: string; // For request/response correlation
payload: T; payload: T;
@@ -44,16 +44,16 @@ export interface IAuthResponsePayload {
/** /**
* Function call payload * Function call payload
*/ */
export interface IFunctionCallPayload { export interface IFunctionCallPayload<T = unknown> {
funcName: string; funcName: string;
funcData: any; funcData: T;
} }
/** /**
* Tag update payload * Tag update payload
*/ */
export interface ITagUpdatePayload { export interface ITagUpdatePayload {
tags: { [key: string]: any }; tags: Record<string, unknown>;
} }
/** /**

View File

@@ -58,7 +58,7 @@ export class Smartsocket {
* Handle a new WebSocket connection * Handle a new WebSocket connection
* Called by SocketServer when a new connection is established * Called by SocketServer when a new connection is established
*/ */
public async handleNewConnection(socket: WebSocket | pluginsTyped.ws.WebSocket) { public async handleNewConnection(socket: pluginsTyped.TWebSocket | pluginsTyped.IWebSocketLike) {
const socketConnection: SocketConnection = new SocketConnection({ const socketConnection: SocketConnection = new SocketConnection({
alias: undefined, alias: undefined,
authenticated: false, authenticated: false,
@@ -76,8 +76,8 @@ export class Smartsocket {
socketConnection.eventSubject.next('disconnected'); socketConnection.eventSubject.next('disconnected');
}; };
socket.addEventListener('close', handleClose); (socket as pluginsTyped.IWebSocketLike).addEventListener('close', handleClose);
socket.addEventListener('error', handleClose); (socket as pluginsTyped.IWebSocketLike).addEventListener('error', handleClose);
try { try {
await socketConnection.authenticate(); await socketConnection.authenticate();

View File

@@ -98,11 +98,18 @@ export class SmartsocketClient {
} }
private isReconnecting = false; private isReconnecting = false;
private isConnecting = false;
/** /**
* connect the client to the server * connect the client to the server
*/ */
public async connect() { public async connect() {
// Prevent duplicate connection attempts
if (this.isConnecting) {
return;
}
this.isConnecting = true;
// Only reset retry counters on fresh connection (not during auto-reconnect) // Only reset retry counters on fresh connection (not during auto-reconnect)
if (!this.isReconnecting) { if (!this.isReconnecting) {
this.currentRetryCount = 0; this.currentRetryCount = 0;
@@ -213,6 +220,7 @@ export class SmartsocketClient {
await this.addTag(oldTagStore[tag]); await this.addTag(oldTagStore[tag]);
} }
this.updateStatus('connected'); this.updateStatus('connected');
this.isConnecting = false;
done.resolve(); done.resolve();
break; break;
@@ -262,6 +270,7 @@ export class SmartsocketClient {
return; return;
} }
this.disconnectRunning = true; this.disconnectRunning = true;
this.isConnecting = false;
this.updateStatus('disconnecting'); this.updateStatus('disconnecting');
this.tagStoreSubscription?.unsubscribe(); this.tagStoreSubscription?.unsubscribe();

View File

@@ -26,7 +26,7 @@ export interface ISocketConnectionConstructorOptions {
authenticated: boolean; authenticated: boolean;
side: TSocketConnectionSide; side: TSocketConnectionSide;
smartsocketHost: Smartsocket | SmartsocketClient; smartsocketHost: Smartsocket | SmartsocketClient;
socket: WebSocket | pluginsTyped.ws.WebSocket; socket: pluginsTyped.TWebSocket | pluginsTyped.IWebSocketLike;
} }
/** /**
@@ -47,7 +47,7 @@ export class SocketConnection {
public side: TSocketConnectionSide; public side: TSocketConnectionSide;
public authenticated: boolean = false; public authenticated: boolean = false;
public smartsocketRef: Smartsocket | SmartsocketClient; public smartsocketRef: Smartsocket | SmartsocketClient;
public socket: WebSocket | pluginsTyped.ws.WebSocket; public socket: pluginsTyped.TWebSocket | pluginsTyped.IWebSocketLike;
public eventSubject = new plugins.smartrx.rxjs.Subject<interfaces.TConnectionStatus>(); public eventSubject = new plugins.smartrx.rxjs.Subject<interfaces.TConnectionStatus>();
public eventStatus: interfaces.TConnectionStatus = 'new'; public eventStatus: interfaces.TConnectionStatus = 'new';
@@ -82,13 +82,13 @@ export class SocketConnection {
public handleMessage(messageData: interfaces.ISocketMessage): void { public handleMessage(messageData: interfaces.ISocketMessage): void {
switch (messageData.type) { switch (messageData.type) {
case 'function': case 'function':
this.handleFunctionCall(messageData); this.handleFunctionCall(messageData as interfaces.ISocketMessage<interfaces.IFunctionCallPayload>);
break; break;
case 'functionResponse': case 'functionResponse':
this.handleFunctionResponse(messageData); this.handleFunctionResponse(messageData as interfaces.ISocketMessage<interfaces.IFunctionCallPayload>);
break; break;
case 'tagUpdate': case 'tagUpdate':
this.handleTagUpdate(messageData); this.handleTagUpdate(messageData as interfaces.ISocketMessage<interfaces.ITagUpdatePayload>);
break; break;
default: default:
// Authentication messages are handled by the server/client classes // Authentication messages are handled by the server/client classes
@@ -96,7 +96,7 @@ export class SocketConnection {
} }
} }
private handleFunctionCall(messageData: interfaces.ISocketMessage): void { private handleFunctionCall(messageData: interfaces.ISocketMessage<interfaces.IFunctionCallPayload>): void {
const requestData: ISocketRequestDataObject<any> = { const requestData: ISocketRequestDataObject<any> = {
funcCallData: { funcCallData: {
funcName: messageData.payload.funcName, funcName: messageData.payload.funcName,
@@ -123,7 +123,7 @@ export class SocketConnection {
} }
} }
private handleFunctionResponse(messageData: interfaces.ISocketMessage): void { private handleFunctionResponse(messageData: interfaces.ISocketMessage<interfaces.IFunctionCallPayload>): void {
const responseData: ISocketRequestDataObject<any> = { const responseData: ISocketRequestDataObject<any> = {
funcCallData: { funcCallData: {
funcName: messageData.payload.funcName, funcName: messageData.payload.funcName,
@@ -141,7 +141,7 @@ export class SocketConnection {
} }
} }
private handleTagUpdate(messageData: interfaces.ISocketMessage): void { private handleTagUpdate(messageData: interfaces.ISocketMessage<interfaces.ITagUpdatePayload>): void {
const tagStoreArg = messageData.payload.tags as interfaces.TTagStore; const tagStoreArg = messageData.payload.tags as interfaces.TTagStore;
if (!plugins.smartjson.deepEqualObjects(this.tagStore, tagStoreArg)) { if (!plugins.smartjson.deepEqualObjects(this.tagStore, tagStoreArg)) {
this.tagStore = tagStoreArg; this.tagStore = tagStoreArg;
@@ -181,17 +181,16 @@ export class SocketConnection {
} }
/** /**
* gets a tag by id * Gets a tag by id
* @param tagIdArg
*/ */
public async getTagById(tagIdArg: interfaces.ITag['id']) { public getTagById(tagIdArg: interfaces.ITag['id']): interfaces.ITag | undefined {
return this.tagStore[tagIdArg]; return this.tagStore[tagIdArg];
} }
/** /**
* removes a tag from a connection * Removes a tag from a connection
*/ */
public async removeTagById(tagIdArg: interfaces.ITag['id']) { public removeTagById(tagIdArg: interfaces.ITag['id']): void {
delete this.tagStore[tagIdArg]; delete this.tagStore[tagIdArg];
this.tagStoreObservable.next(this.tagStore); this.tagStoreObservable.next(this.tagStore);
this.sendMessage({ this.sendMessage({
@@ -209,7 +208,7 @@ export class SocketConnection {
const done = plugins.smartpromise.defer<SocketConnection>(); const done = plugins.smartpromise.defer<SocketConnection>();
// Set up message handler for authentication // Set up message handler for authentication
const messageHandler = (event: MessageEvent | { data: string }) => { const messageHandler = (event: pluginsTyped.TMessageEvent) => {
try { try {
const data = typeof event.data === 'string' ? event.data : event.data.toString(); const data = typeof event.data === 'string' ? event.data : event.data.toString();
const message: interfaces.ISocketMessage = JSON.parse(data); const message: interfaces.ISocketMessage = JSON.parse(data);
@@ -241,11 +240,11 @@ export class SocketConnection {
} }
} }
} catch (err) { } catch (err) {
// Not a valid message, ignore logger.log('warn', `Failed to parse auth message: ${err instanceof Error ? err.message : String(err)}`);
} }
}; };
this.socket.addEventListener('message', messageHandler as any); (this.socket as pluginsTyped.IWebSocketLike).addEventListener('message', messageHandler);
// Request authentication // Request authentication
const requestAuthPayload: interfaces.TAuthRequestMessage = { const requestAuthPayload: interfaces.TAuthRequestMessage = {
@@ -268,17 +267,17 @@ export class SocketConnection {
const done = plugins.smartpromise.defer(); const done = plugins.smartpromise.defer();
if (this.authenticated) { if (this.authenticated) {
// Set up message handler for all messages // Set up message handler for all messages
const messageHandler = (event: MessageEvent | { data: string }) => { const messageHandler = (event: pluginsTyped.TMessageEvent) => {
try { try {
const data = typeof event.data === 'string' ? event.data : event.data.toString(); const data = typeof event.data === 'string' ? event.data : event.data.toString();
const message: interfaces.ISocketMessage = JSON.parse(data); const message: interfaces.ISocketMessage = JSON.parse(data);
this.handleMessage(message); this.handleMessage(message);
} catch (err) { } catch (err) {
// Not a valid JSON message, ignore logger.log('warn', `Failed to parse socket message: ${err instanceof Error ? err.message : String(err)}`);
} }
}; };
this.socket.addEventListener('message', messageHandler as any); (this.socket as pluginsTyped.IWebSocketLike).addEventListener('message', messageHandler);
logger.log( logger.log(
'info', 'info',

View File

@@ -128,6 +128,10 @@ export class SocketRequest<T extends plugins.typedrequestInterfaces.ITypedReques
}; };
this.originSocketConnection.sendMessage(message); this.originSocketConnection.sendMessage(message);
this.smartsocketRef.socketRequests.remove(this); this.smartsocketRef.socketRequests.remove(this);
})
.catch((error) => {
logger.log('error', `Function invocation failed for ${this.funcCallData.funcName}: ${error instanceof Error ? error.message : String(error)}`);
this.smartsocketRef.socketRequests.remove(this);
}); });
} }
} }

View File

@@ -114,7 +114,7 @@ export class SocketServer {
onOpen: async (peer: pluginsTyped.ISmartserveWebSocketPeer) => { onOpen: async (peer: pluginsTyped.ISmartserveWebSocketPeer) => {
// Create a wrapper that adapts ISmartserveWebSocketPeer to WebSocket-like interface // Create a wrapper that adapts ISmartserveWebSocketPeer to WebSocket-like interface
const wsLikeSocket = this.createWsLikeFromPeer(peer); const wsLikeSocket = this.createWsLikeFromPeer(peer);
await this.smartsocket.handleNewConnection(wsLikeSocket as any); await this.smartsocket.handleNewConnection(wsLikeSocket);
}, },
onMessage: async (peer: pluginsTyped.ISmartserveWebSocketPeer, message: pluginsTyped.ISmartserveWebSocketMessage) => { onMessage: async (peer: pluginsTyped.ISmartserveWebSocketPeer, message: pluginsTyped.ISmartserveWebSocketMessage) => {
// Dispatch message to the SocketConnection via the adapter // Dispatch message to the SocketConnection via the adapter
@@ -153,8 +153,8 @@ export class SocketServer {
* Creates a WebSocket-like object from a smartserve peer * Creates a WebSocket-like object from a smartserve peer
* This allows our SocketConnection to work with both native WebSocket and smartserve peers * This allows our SocketConnection to work with both native WebSocket and smartserve peers
*/ */
private createWsLikeFromPeer(peer: pluginsTyped.ISmartserveWebSocketPeer): any { private createWsLikeFromPeer(peer: pluginsTyped.ISmartserveWebSocketPeer): pluginsTyped.IWebSocketLike {
const messageListeners: Array<(event: any) => void> = []; const messageListeners: Array<(event: pluginsTyped.TMessageEvent) => void> = [];
const closeListeners: Array<() => void> = []; const closeListeners: Array<() => void> = [];
const errorListeners: Array<() => void> = []; const errorListeners: Array<() => void> = [];
@@ -174,7 +174,7 @@ export class SocketServer {
}); });
return { return {
readyState: peer.readyState, get readyState() { return peer.readyState; },
send: (data: string) => peer.send(data), send: (data: string) => peer.send(data),
close: (code?: number, reason?: string) => peer.close(code, reason), close: (code?: number, reason?: string) => peer.close(code, reason),
addEventListener: (event: string, listener: any) => { addEventListener: (event: string, listener: any) => {

View File

@@ -13,6 +13,29 @@ export namespace ws {
export type RawData = wsTypes.RawData; export type RawData = wsTypes.RawData;
} }
/**
* Unified WebSocket type supporting both browser and Node.js environments
*/
export type TWebSocket = WebSocket | ws.WebSocket;
/**
* Message event type for WebSocket messages (browser and Node.js compatible)
*/
export type TMessageEvent = MessageEvent | { data: string };
/**
* WebSocket-like interface for adapters (e.g., smartserve peer adapter)
*/
export interface IWebSocketLike {
readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener(event: 'message', listener: (event: TMessageEvent) => void): void;
addEventListener(event: 'close', listener: () => void): void;
addEventListener(event: 'error', listener: () => void): void;
removeEventListener?(event: string, listener: (...args: any[]) => void): void;
}
// smartserve compatibility interface (for setExternalServer) // smartserve compatibility interface (for setExternalServer)
// This mirrors the IWebSocketPeer interface from smartserve // This mirrors the IWebSocketPeer interface from smartserve
export interface ISmartserveWebSocketPeer { export interface ISmartserveWebSocketPeer {