From 711ecba117929122f750d3a7d7c143afbd35e538 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Thu, 17 Sep 2026 11:49:21 +0000 Subject: [PATCH] Fix Web Sockets. Form into library. Move demo to demo folder. Split demo between sse and websocket. --- demo/sse.ts | 37 +++++++++++++++++++++++++++++ demo/ws.ts | 37 +++++++++++++++++++++++++++++ package-lock.json | 16 ++++++------- package.json | 13 ++++++++--- src/index.ts | 55 +++----------------------------------------- src/shared/client.ts | 11 ++++++--- src/sse/client.ts | 35 ++++++++++++++-------------- src/sse/index.ts | 2 +- src/ws/client.ts | 23 ++++++++++-------- tsconfig.json | 1 - 10 files changed, 134 insertions(+), 96 deletions(-) create mode 100644 demo/sse.ts create mode 100644 demo/ws.ts diff --git a/demo/sse.ts b/demo/sse.ts new file mode 100644 index 0000000..03f007b --- /dev/null +++ b/demo/sse.ts @@ -0,0 +1,37 @@ +import { PrivateKey } from "@xo-cash/primitives"; +import { toExtendedJson } from "@xo-cash/utils"; + +import { SSEClient } from '../src/sse/client.js'; + +const testSSE = async () => { + const privateKey = PrivateKey.fromString('c440ce6ac5b63ae7bc4af9891b80b7a88ccdeebba5e43246885e9ec56f912ac2'); + const client = new SSEClient('https://v2.sync.xo.harvmaster.com', privateKey, { + onMessage: (message) => { + console.log(toExtendedJson(message)); + }, + onError: (error) => { + console.error(error); + }, + }); + + // Connect to the sync server + await client.connect(); + + // Subscribe to the resource + await client.subscribe('test'); + + // Write a new value to the resource + await client.write('test', {'message': 'Hello, world! this is the minimal sync client using SSE'}); + + // Read the current value of the resource + const data = await client.read('test'); + console.log(data); + + // If the user presses Ctrl+C, disconnect the client and exit the program + process.on('SIGINT', async () => { + await client.disconnect(); + process.exit(0); + }); +} + +testSSE(); diff --git a/demo/ws.ts b/demo/ws.ts new file mode 100644 index 0000000..a0edd2d --- /dev/null +++ b/demo/ws.ts @@ -0,0 +1,37 @@ +import { PrivateKey } from "@xo-cash/primitives"; +import { toExtendedJson } from "@xo-cash/utils"; + +import { WsClient } from '../src/ws/client.js'; + +const testWS = async () => { + const privateKey = PrivateKey.fromString('c440ce6ac5b63ae7bc4af9891b80b7a88ccdeebba5e43246885e9ec56f912ac2'); + const client = new WsClient('https://v2.sync.xo.harvmaster.com', privateKey, { + onMessage: (message) => { + console.log(toExtendedJson(message)); + }, + onError: (error) => { + console.error(error); + }, + }); + + // Connect to the sync server + await client.connect(); + + // Subscribe to the resource + client.subscribe('test'); + + // Write a new value to the resource + await client.write('test', {'message': 'Hello, world! this is the minimal sync client using WS'}); + + // Read the current value of the resource + const data = await client.read('test'); + console.log(data); + + // If the user presses Ctrl+C, disconnect the client and exit the program + process.on('SIGINT', async () => { + await client.disconnect(); + process.exit(0); + }); +} + +testWS(); diff --git a/package-lock.json b/package-lock.json index 0b353f3..208a53d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^26.6.1", "tsx": "^4.23.1", "typescript": "^7.0.2" } @@ -472,13 +472,13 @@ } }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz", + "integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -989,9 +989,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index f914614..282cf8b 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,25 @@ { - "name": "minimal-sync", + "name": "@xo-cash/sync-client", "version": "1.0.0", - "description": "", + "description": "A minimal sync client for the XO Cash Sync Server", "main": "index.js", "type": "module", "scripts": { + "build": "tsc", "start": "tsx src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^26.6.1", "tsx": "^4.23.1", "typescript": "^7.0.2" }, diff --git a/src/index.ts b/src/index.ts index 153de2a..bd05d84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,53 +1,4 @@ -import { PrivateKey } from "@xo-cash/primitives"; +export * from './shared/client.js'; -import { SSEClient } from "./sse/client.js"; -import { WsClient } from './ws/client.js'; - -const testSSE = async () => { - const privateKey = PrivateKey.fromString('c440ce6ac5b63ae7bc4af9891b80b7a88ccdeebba5e43246885e9ec56f912ac2'); - const client = new SSEClient('https://v2.sync.xo.harvmaster.com', privateKey, { - onMessage: (message) => { - console.log(message); - }, - onError: (error) => { - console.error(error); - }, - }); - - await client.subscribe('test'); - - // await client.connect(); - - await client.write('test', {'message': 'Hello, world! this is the minimal sync client using SSE'}); - - const data = await client.read('test'); - console.log(data); - - // await client.disconnect(); -} - -const testWS = async () => { - const privateKey = PrivateKey.fromString('c440ce6ac5b63ae7bc4af9891b80b7a88ccdeebba5e43246885e9ec56f912ac2'); - const client = new WsClient('wss://v2.sync.xo.harvmaster.com', privateKey, { - onMessage: (message) => { - console.log(message); - }, - onError: (error) => { - console.error(error); - }, - }); - - await client.connect(); - await client.subscribe('test'); - - await client.write('test', {'message': 'Hello, world! this is the minimal sync client using WS'}); - - const data = await client.read('test'); - console.log(data); - - // await client.unsubscribe('test'); - // await client.disconnect(); -} - -testSSE(); -// testWS(); \ No newline at end of file +export * from './sse/client.js'; +export * from './ws/client.js'; diff --git a/src/shared/client.ts b/src/shared/client.ts index 9d2d520..02b0acb 100644 --- a/src/shared/client.ts +++ b/src/shared/client.ts @@ -79,7 +79,7 @@ export abstract class SyncClient { // Create the payload const timestamp = Date.now(); - const payload = `${timestamp}${resourceId}${toExtendedJson(value)}`; + const payload = `${timestamp}:${resourceId}:${toExtendedJson(value)}`; // Sign the payload const { publicKey, signature } = this.signPayload(derivedKey, payload); @@ -103,7 +103,12 @@ export abstract class SyncClient { static signRequest(privateKey: Uint8Array, path: string, body: unknown): AuthenticatedRequestHeaders { // Create the payload const timestamp = Date.now(); - const payload = `${timestamp}:${path}:${toExtendedJson(body)}`; + + // Convert the body to a string if it isn't already a string + const bodyStr = typeof body === 'string' ? body : toExtendedJson(body); + + // Create the payload as `Timestamp:Path:Body` + const payload = `${timestamp}:${path}:${bodyStr}`; // Sign the payload const { publicKey, signature } = this.signPayload(privateKey, payload); @@ -114,4 +119,4 @@ export abstract class SyncClient { 'X-Signature': signature, }; } -} \ No newline at end of file +} diff --git a/src/sse/client.ts b/src/sse/client.ts index d658367..73b26dd 100644 --- a/src/sse/client.ts +++ b/src/sse/client.ts @@ -1,17 +1,18 @@ -import { SSESession, toExtendedJson } from '@xo-cash/utils'; +import { SSESession, toExtendedJson, fromExtendedJson } from '@xo-cash/utils'; import { PrivateKey } from '@xo-cash/primitives' import { SyncClient } from '../shared/client.js'; export type SSEClientOptions = { - onMessage: (message: string) => void; + onMessage: (message: unknown) => void; onError: (error: Error) => void; }; export class SSEClient extends SyncClient { - private sseSession: SSESession; + private readonly url: string; + public sseSession: SSESession; private readonly subscriptions: Set = new Set(); - private readonly messageListeners = new Set<(message: string) => void>(); + private readonly messageListeners = new Set<(message: unknown) => void>(); private readonly errorListeners = new Set<(error: Error) => void>(); private privateKey: PrivateKey; @@ -21,9 +22,10 @@ export class SSEClient extends SyncClient { return `${this.url}/data/subscribe`; } - constructor(private readonly url: string, privateKey: PrivateKey, options: Partial = {}) { + constructor(url: string, privateKey: PrivateKey, options: Partial = {}) { super(); + this.url = url; this.privateKey = privateKey; this.sseSession = new SSESession(`${this.url}/data/subscribe`, { method: 'POST', @@ -53,15 +55,12 @@ export class SSEClient extends SyncClient { // Re-create the session with the additional resource ID in the subscription list this.sseSession = new SSESession(this.subscriptionUrl, { onRequest: async (request) => { - // Create the request authentication headers - const requestBody = toExtendedJson(request.body); - // Get the path from the URL - const url = new URL(this.url); + const url = new URL(this.subscriptionUrl); const path = url.pathname; // Sign the request body - const authHeaders = SyncClient.signRequest(this.privateKey.toBytes(), path, requestBody); + const authHeaders = SyncClient.signRequest(this.privateKey.toBytes(), path, request.body); // Initialize the request headers if they don't exist request.headers ??= {}; @@ -78,14 +77,14 @@ export class SSEClient extends SyncClient { headers: { 'Content-Type': 'application/json', }, - body: body, + body, method: 'POST', }); // Create a listener to re-emit the messages this.sseSession.on('message', (message) => { for (const listener of this.messageListeners) { - listener(message.data); + listener(fromExtendedJson(message.data)); } }); @@ -108,7 +107,7 @@ export class SSEClient extends SyncClient { const valueBytes = this.textEncoder.encode(valueStr); const resource = SyncClient.signWriteRequest(this.privateKey.toBytes(), resourceId, valueBytes); - const bodyStr = toExtendedJson({ resources: [resource] }); + const body = toExtendedJson({ resources: [resource] }); const res = await fetch(url, { method: 'POST', @@ -116,10 +115,10 @@ export class SSEClient extends SyncClient { // Headers arent required unless we have payment service running headers: { 'Content-Type': 'application/json', - // ...SSEClient.authenticateRequest(this.privateKey, bodyStr), + ...SyncClient.signRequest(this.privateKey.toBytes(), '/data/write', body), }, - body: bodyStr, + body, }); if (!res.ok) { @@ -127,7 +126,7 @@ export class SSEClient extends SyncClient { throw new Error(`Failed to write resource [${resourceId}] (${res.statusText}): ${JSON.stringify(error)}`); } - return res.json(); + return fromExtendedJson(await res.text()); } // Send a POST /data/read request to the server @@ -139,7 +138,7 @@ export class SSEClient extends SyncClient { method: 'POST', headers: { 'Content-Type': 'application/json', - // ...SSEClient.authenticateRequest(this.privateKey, body), + ...SyncClient.signRequest(this.privateKey.toBytes(), '/data/get', body), }, body, }); @@ -148,7 +147,7 @@ export class SSEClient extends SyncClient { throw new Error(`Failed to read resource: ${res.statusText}`); } - return res.json(); + return fromExtendedJson(await res.text()); } // Destroy the current SSE Session, then re-create it with the additional resource ID in the subscription list diff --git a/src/sse/index.ts b/src/sse/index.ts index 8a23853..37fd896 100644 --- a/src/sse/index.ts +++ b/src/sse/index.ts @@ -1 +1 @@ -export * from './client.js'; \ No newline at end of file +export * from './client.js'; diff --git a/src/ws/client.ts b/src/ws/client.ts index 84d399a..c936f75 100644 --- a/src/ws/client.ts +++ b/src/ws/client.ts @@ -40,18 +40,22 @@ export type WsClientOptions = { * here to keep the transport demo easy to follow. */ export class WsClient extends SyncClient { + private readonly url: string; + private readonly privateKey: PrivateKey; private socket: WebSocket | undefined; private readonly pendingRequests = new Map(); private readonly messageListeners = new Set<(message: WsMessage) => void>(); private readonly errorListeners = new Set<(error: Error) => void>(); constructor( - private readonly url: string, - private readonly privateKey: PrivateKey, + url: string, + privateKey: PrivateKey, options: Partial = {}, ) { super(); + this.url = url; + this.privateKey = privateKey; this.messageListeners.add(options.onMessage ?? (() => {})); this.errorListeners.add(options.onError ?? (() => {})); } @@ -72,7 +76,7 @@ export class WsClient extends SyncClient { this.handleMessage(String(event.data)); }; - socket.onerror = () => { + socket.onerror = (ev) => { const error = new Error('WebSocket connection failed'); this.emitError(error); reject(error); @@ -134,15 +138,14 @@ export class WsClient extends SyncClient { * been handed to the socket; future updates arrive through `onMessage`. */ async subscribe(resourceId: string): Promise { - this.send({ - path: '/data/subscribe', - body: { resourceId: [resourceId] }, - }); + await this.request('/data/subscribe', { resourceId: [resourceId] }); } - /** Remove one resource topic without closing the shared connection. */ - async unsubscribe(resourceId: string): Promise { - return this.request('/data/unsubscribe', { resourceId: [resourceId] }); + /** + * Unsubscribe from one resource topic on the current connection. + */ + async unsubscribe(resourceId: string): Promise { + await this.request('/data/unsubscribe', { resourceId: [resourceId] }); } /** Send a request and wait for the response carrying the same ID. */ diff --git a/tsconfig.json b/tsconfig.json index 9356736..8a2b93d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { // File Layout - "rootDir": "./src", "outDir": "./dist", // Environment Settings