@debros/network-ts-sdk 0.1.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -7
- package/dist/index.d.ts +184 -29
- package/dist/index.js +375 -92
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/auth/client.ts +144 -10
- package/src/core/http.ts +80 -11
- package/src/core/ws.ts +87 -80
- package/src/index.ts +15 -9
- package/src/network/client.ts +58 -4
- package/src/pubsub/client.ts +174 -28
package/src/core/ws.ts
CHANGED
|
@@ -4,10 +4,6 @@ import { SDKError } from "../errors";
|
|
|
4
4
|
export interface WSClientConfig {
|
|
5
5
|
wsURL: string;
|
|
6
6
|
timeout?: number;
|
|
7
|
-
maxReconnectAttempts?: number;
|
|
8
|
-
reconnectDelayMs?: number;
|
|
9
|
-
heartbeatIntervalMs?: number;
|
|
10
|
-
authMode?: "header" | "query";
|
|
11
7
|
authToken?: string;
|
|
12
8
|
WebSocket?: typeof WebSocket;
|
|
13
9
|
}
|
|
@@ -15,54 +11,53 @@ export interface WSClientConfig {
|
|
|
15
11
|
export type WSMessageHandler = (data: string) => void;
|
|
16
12
|
export type WSErrorHandler = (error: Error) => void;
|
|
17
13
|
export type WSCloseHandler = () => void;
|
|
14
|
+
export type WSOpenHandler = () => void;
|
|
18
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Simple WebSocket client with minimal abstractions
|
|
18
|
+
* No complex reconnection, no heartbeats - keep it simple
|
|
19
|
+
*/
|
|
19
20
|
export class WSClient {
|
|
20
21
|
private url: string;
|
|
21
22
|
private timeout: number;
|
|
22
|
-
private maxReconnectAttempts: number;
|
|
23
|
-
private reconnectDelayMs: number;
|
|
24
|
-
private heartbeatIntervalMs: number;
|
|
25
|
-
private authMode: "header" | "query";
|
|
26
23
|
private authToken?: string;
|
|
27
24
|
private WebSocketClass: typeof WebSocket;
|
|
28
25
|
|
|
29
26
|
private ws?: WebSocket;
|
|
30
|
-
private reconnectAttempts = 0;
|
|
31
|
-
private heartbeatInterval?: NodeJS.Timeout;
|
|
32
27
|
private messageHandlers: Set<WSMessageHandler> = new Set();
|
|
33
28
|
private errorHandlers: Set<WSErrorHandler> = new Set();
|
|
34
29
|
private closeHandlers: Set<WSCloseHandler> = new Set();
|
|
35
|
-
private
|
|
30
|
+
private openHandlers: Set<WSOpenHandler> = new Set();
|
|
31
|
+
private isClosed = false;
|
|
36
32
|
|
|
37
33
|
constructor(config: WSClientConfig) {
|
|
38
34
|
this.url = config.wsURL;
|
|
39
35
|
this.timeout = config.timeout ?? 30000;
|
|
40
|
-
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5;
|
|
41
|
-
this.reconnectDelayMs = config.reconnectDelayMs ?? 1000;
|
|
42
|
-
this.heartbeatIntervalMs = config.heartbeatIntervalMs ?? 30000;
|
|
43
|
-
this.authMode = config.authMode ?? "header";
|
|
44
36
|
this.authToken = config.authToken;
|
|
45
37
|
this.WebSocketClass = config.WebSocket ?? WebSocket;
|
|
46
38
|
}
|
|
47
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Connect to WebSocket server
|
|
42
|
+
*/
|
|
48
43
|
connect(): Promise<void> {
|
|
49
44
|
return new Promise((resolve, reject) => {
|
|
50
45
|
try {
|
|
51
46
|
const wsUrl = this.buildWSUrl();
|
|
52
47
|
this.ws = new this.WebSocketClass(wsUrl);
|
|
53
|
-
|
|
54
|
-
// Note: Custom headers via ws library in Node.js are not sent with WebSocket upgrade requests
|
|
55
|
-
// so we rely on query parameters for authentication
|
|
48
|
+
this.isClosed = false;
|
|
56
49
|
|
|
57
50
|
const timeout = setTimeout(() => {
|
|
58
51
|
this.ws?.close();
|
|
59
|
-
reject(
|
|
52
|
+
reject(
|
|
53
|
+
new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT")
|
|
54
|
+
);
|
|
60
55
|
}, this.timeout);
|
|
61
56
|
|
|
62
57
|
this.ws.addEventListener("open", () => {
|
|
63
58
|
clearTimeout(timeout);
|
|
64
|
-
|
|
65
|
-
this.
|
|
59
|
+
console.log("[WSClient] Connected to", this.url);
|
|
60
|
+
this.openHandlers.forEach((handler) => handler());
|
|
66
61
|
resolve();
|
|
67
62
|
});
|
|
68
63
|
|
|
@@ -72,24 +67,16 @@ export class WSClient {
|
|
|
72
67
|
});
|
|
73
68
|
|
|
74
69
|
this.ws.addEventListener("error", (event: Event) => {
|
|
70
|
+
console.error("[WSClient] WebSocket error:", event);
|
|
75
71
|
clearTimeout(timeout);
|
|
76
|
-
const error = new SDKError(
|
|
77
|
-
"WebSocket error",
|
|
78
|
-
500,
|
|
79
|
-
"WS_ERROR",
|
|
80
|
-
event
|
|
81
|
-
);
|
|
72
|
+
const error = new SDKError("WebSocket error", 500, "WS_ERROR", event);
|
|
82
73
|
this.errorHandlers.forEach((handler) => handler(error));
|
|
83
74
|
});
|
|
84
75
|
|
|
85
76
|
this.ws.addEventListener("close", () => {
|
|
86
77
|
clearTimeout(timeout);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.attemptReconnect();
|
|
90
|
-
} else {
|
|
91
|
-
this.closeHandlers.forEach((handler) => handler());
|
|
92
|
-
}
|
|
78
|
+
console.log("[WSClient] Connection closed");
|
|
79
|
+
this.closeHandlers.forEach((handler) => handler());
|
|
93
80
|
});
|
|
94
81
|
} catch (error) {
|
|
95
82
|
reject(error);
|
|
@@ -97,86 +84,106 @@ export class WSClient {
|
|
|
97
84
|
});
|
|
98
85
|
}
|
|
99
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Build WebSocket URL with auth token
|
|
89
|
+
*/
|
|
100
90
|
private buildWSUrl(): string {
|
|
101
91
|
let url = this.url;
|
|
102
|
-
|
|
103
|
-
// Always append auth token as query parameter for compatibility
|
|
104
|
-
// Works in both Node.js and browser environments
|
|
92
|
+
|
|
105
93
|
if (this.authToken) {
|
|
106
94
|
const separator = url.includes("?") ? "&" : "?";
|
|
107
95
|
const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token";
|
|
108
96
|
url += `${separator}${paramName}=${encodeURIComponent(this.authToken)}`;
|
|
109
97
|
}
|
|
110
|
-
|
|
111
|
-
return url;
|
|
112
|
-
}
|
|
113
98
|
|
|
114
|
-
|
|
115
|
-
this.heartbeatInterval = setInterval(() => {
|
|
116
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
117
|
-
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
118
|
-
}
|
|
119
|
-
}, this.heartbeatIntervalMs);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private stopHeartbeat() {
|
|
123
|
-
if (this.heartbeatInterval) {
|
|
124
|
-
clearInterval(this.heartbeatInterval);
|
|
125
|
-
this.heartbeatInterval = undefined;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private attemptReconnect() {
|
|
130
|
-
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
131
|
-
this.reconnectAttempts++;
|
|
132
|
-
const delayMs = this.reconnectDelayMs * this.reconnectAttempts;
|
|
133
|
-
setTimeout(() => {
|
|
134
|
-
this.connect().catch((error) => {
|
|
135
|
-
this.errorHandlers.forEach((handler) => handler(error));
|
|
136
|
-
});
|
|
137
|
-
}, delayMs);
|
|
138
|
-
} else {
|
|
139
|
-
this.closeHandlers.forEach((handler) => handler());
|
|
140
|
-
}
|
|
99
|
+
return url;
|
|
141
100
|
}
|
|
142
101
|
|
|
143
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Register message handler
|
|
104
|
+
*/
|
|
105
|
+
onMessage(handler: WSMessageHandler): () => void {
|
|
144
106
|
this.messageHandlers.add(handler);
|
|
145
107
|
return () => this.messageHandlers.delete(handler);
|
|
146
108
|
}
|
|
147
109
|
|
|
148
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Unregister message handler
|
|
112
|
+
*/
|
|
113
|
+
offMessage(handler: WSMessageHandler): void {
|
|
114
|
+
this.messageHandlers.delete(handler);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register error handler
|
|
119
|
+
*/
|
|
120
|
+
onError(handler: WSErrorHandler): () => void {
|
|
149
121
|
this.errorHandlers.add(handler);
|
|
150
122
|
return () => this.errorHandlers.delete(handler);
|
|
151
123
|
}
|
|
152
124
|
|
|
153
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Unregister error handler
|
|
127
|
+
*/
|
|
128
|
+
offError(handler: WSErrorHandler): void {
|
|
129
|
+
this.errorHandlers.delete(handler);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Register close handler
|
|
134
|
+
*/
|
|
135
|
+
onClose(handler: WSCloseHandler): () => void {
|
|
154
136
|
this.closeHandlers.add(handler);
|
|
155
137
|
return () => this.closeHandlers.delete(handler);
|
|
156
138
|
}
|
|
157
139
|
|
|
158
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Unregister close handler
|
|
142
|
+
*/
|
|
143
|
+
offClose(handler: WSCloseHandler): void {
|
|
144
|
+
this.closeHandlers.delete(handler);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Register open handler
|
|
149
|
+
*/
|
|
150
|
+
onOpen(handler: WSOpenHandler): () => void {
|
|
151
|
+
this.openHandlers.add(handler);
|
|
152
|
+
return () => this.openHandlers.delete(handler);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Send data through WebSocket
|
|
157
|
+
*/
|
|
158
|
+
send(data: string): void {
|
|
159
159
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
160
|
-
throw new SDKError(
|
|
161
|
-
"WebSocket is not connected",
|
|
162
|
-
500,
|
|
163
|
-
"WS_NOT_CONNECTED"
|
|
164
|
-
);
|
|
160
|
+
throw new SDKError("WebSocket is not connected", 500, "WS_NOT_CONNECTED");
|
|
165
161
|
}
|
|
166
162
|
this.ws.send(data);
|
|
167
163
|
}
|
|
168
164
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Close WebSocket connection
|
|
167
|
+
*/
|
|
168
|
+
close(): void {
|
|
169
|
+
if (this.isClosed) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.isClosed = true;
|
|
172
173
|
this.ws?.close();
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Check if WebSocket is connected
|
|
178
|
+
*/
|
|
175
179
|
isConnected(): boolean {
|
|
176
|
-
return this.ws?.readyState === WebSocket.OPEN;
|
|
180
|
+
return !this.isClosed && this.ws?.readyState === WebSocket.OPEN;
|
|
177
181
|
}
|
|
178
182
|
|
|
179
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Update auth token
|
|
185
|
+
*/
|
|
186
|
+
setAuthToken(token?: string): void {
|
|
180
187
|
this.authToken = token;
|
|
181
188
|
}
|
|
182
189
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { DBClient } from "./db/client";
|
|
|
4
4
|
import { PubSubClient } from "./pubsub/client";
|
|
5
5
|
import { NetworkClient } from "./network/client";
|
|
6
6
|
import { WSClientConfig } from "./core/ws";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
StorageAdapter,
|
|
9
|
+
MemoryStorage,
|
|
10
|
+
LocalStorageAdapter,
|
|
11
|
+
} from "./auth/types";
|
|
8
12
|
|
|
9
13
|
export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
|
|
10
14
|
apiKey?: string;
|
|
@@ -38,8 +42,9 @@ export function createClient(config: ClientConfig): Client {
|
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
// Derive WebSocket URL from baseURL if not explicitly provided
|
|
41
|
-
const wsURL =
|
|
42
|
-
config.
|
|
45
|
+
const wsURL =
|
|
46
|
+
config.wsConfig?.wsURL ??
|
|
47
|
+
config.baseURL.replace(/^http/, "ws").replace(/\/$/, "");
|
|
43
48
|
|
|
44
49
|
const db = new DBClient(httpClient);
|
|
45
50
|
const pubsub = new PubSubClient(httpClient, {
|
|
@@ -67,11 +72,7 @@ export { PubSubClient, Subscription } from "./pubsub/client";
|
|
|
67
72
|
export { NetworkClient } from "./network/client";
|
|
68
73
|
export { SDKError } from "./errors";
|
|
69
74
|
export { MemoryStorage, LocalStorageAdapter } from "./auth/types";
|
|
70
|
-
export type {
|
|
71
|
-
StorageAdapter,
|
|
72
|
-
AuthConfig,
|
|
73
|
-
WhoAmI,
|
|
74
|
-
} from "./auth/types";
|
|
75
|
+
export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types";
|
|
75
76
|
export type * from "./db/types";
|
|
76
77
|
export type {
|
|
77
78
|
Message,
|
|
@@ -79,4 +80,9 @@ export type {
|
|
|
79
80
|
ErrorHandler,
|
|
80
81
|
CloseHandler,
|
|
81
82
|
} from "./pubsub/client";
|
|
82
|
-
export type {
|
|
83
|
+
export type {
|
|
84
|
+
PeerInfo,
|
|
85
|
+
NetworkStatus,
|
|
86
|
+
ProxyRequest,
|
|
87
|
+
ProxyResponse,
|
|
88
|
+
} from "./network/client";
|
package/src/network/client.ts
CHANGED
|
@@ -7,9 +7,25 @@ export interface PeerInfo {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export interface NetworkStatus {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
node_id: string;
|
|
11
|
+
connected: boolean;
|
|
12
|
+
peer_count: number;
|
|
13
|
+
database_size: number;
|
|
14
|
+
uptime: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProxyRequest {
|
|
18
|
+
url: string;
|
|
19
|
+
method: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
body?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProxyResponse {
|
|
25
|
+
status_code: number;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
body: string;
|
|
28
|
+
error?: string;
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
export class NetworkClient {
|
|
@@ -35,7 +51,9 @@ export class NetworkClient {
|
|
|
35
51
|
* Get network status.
|
|
36
52
|
*/
|
|
37
53
|
async status(): Promise<NetworkStatus> {
|
|
38
|
-
const response = await this.httpClient.get<NetworkStatus>(
|
|
54
|
+
const response = await this.httpClient.get<NetworkStatus>(
|
|
55
|
+
"/v1/network/status"
|
|
56
|
+
);
|
|
39
57
|
return response;
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -62,4 +80,40 @@ export class NetworkClient {
|
|
|
62
80
|
async disconnect(peerId: string): Promise<void> {
|
|
63
81
|
await this.httpClient.post("/v1/network/disconnect", { peer_id: peerId });
|
|
64
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Proxy an HTTP request through the Anyone network.
|
|
86
|
+
* Requires authentication (API key or JWT).
|
|
87
|
+
*
|
|
88
|
+
* @param request - The proxy request configuration
|
|
89
|
+
* @returns The proxied response
|
|
90
|
+
* @throws {SDKError} If the Anyone proxy is not available or the request fails
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* const response = await client.network.proxyAnon({
|
|
95
|
+
* url: 'https://api.example.com/data',
|
|
96
|
+
* method: 'GET',
|
|
97
|
+
* headers: {
|
|
98
|
+
* 'Accept': 'application/json'
|
|
99
|
+
* }
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* console.log(response.status_code); // 200
|
|
103
|
+
* console.log(response.body); // Response data
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
async proxyAnon(request: ProxyRequest): Promise<ProxyResponse> {
|
|
107
|
+
const response = await this.httpClient.post<ProxyResponse>(
|
|
108
|
+
"/v1/proxy/anon",
|
|
109
|
+
request
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Check if the response contains an error
|
|
113
|
+
if (response.error) {
|
|
114
|
+
throw new Error(`Proxy request failed: ${response.error}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return response;
|
|
118
|
+
}
|
|
65
119
|
}
|
package/src/pubsub/client.ts
CHANGED
|
@@ -4,13 +4,64 @@ import { WSClient, WSClientConfig } from "../core/ws";
|
|
|
4
4
|
export interface Message {
|
|
5
5
|
data: string;
|
|
6
6
|
topic: string;
|
|
7
|
-
timestamp
|
|
7
|
+
timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RawEnvelope {
|
|
11
|
+
data: string; // base64-encoded
|
|
12
|
+
timestamp: number;
|
|
13
|
+
topic: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Cross-platform base64 encoding/decoding utilities
|
|
17
|
+
function base64Encode(str: string): string {
|
|
18
|
+
if (typeof Buffer !== "undefined") {
|
|
19
|
+
return Buffer.from(str).toString("base64");
|
|
20
|
+
} else if (typeof btoa !== "undefined") {
|
|
21
|
+
return btoa(
|
|
22
|
+
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
|
|
23
|
+
String.fromCharCode(parseInt(p1, 16))
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
throw new Error("No base64 encoding method available");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function base64EncodeBytes(bytes: Uint8Array): string {
|
|
31
|
+
if (typeof Buffer !== "undefined") {
|
|
32
|
+
return Buffer.from(bytes).toString("base64");
|
|
33
|
+
} else if (typeof btoa !== "undefined") {
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
36
|
+
binary += String.fromCharCode(bytes[i]);
|
|
37
|
+
}
|
|
38
|
+
return btoa(binary);
|
|
39
|
+
}
|
|
40
|
+
throw new Error("No base64 encoding method available");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64Decode(b64: string): string {
|
|
44
|
+
if (typeof Buffer !== "undefined") {
|
|
45
|
+
return Buffer.from(b64, "base64").toString("utf-8");
|
|
46
|
+
} else if (typeof atob !== "undefined") {
|
|
47
|
+
const binary = atob(b64);
|
|
48
|
+
const bytes = new Uint8Array(binary.length);
|
|
49
|
+
for (let i = 0; i < binary.length; i++) {
|
|
50
|
+
bytes[i] = binary.charCodeAt(i);
|
|
51
|
+
}
|
|
52
|
+
return new TextDecoder().decode(bytes);
|
|
53
|
+
}
|
|
54
|
+
throw new Error("No base64 decoding method available");
|
|
8
55
|
}
|
|
9
56
|
|
|
10
57
|
export type MessageHandler = (message: Message) => void;
|
|
11
58
|
export type ErrorHandler = (error: Error) => void;
|
|
12
59
|
export type CloseHandler = () => void;
|
|
13
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Simple PubSub client - one WebSocket connection per topic
|
|
63
|
+
* No connection pooling, no reference counting - keep it simple
|
|
64
|
+
*/
|
|
14
65
|
export class PubSubClient {
|
|
15
66
|
private httpClient: HttpClient;
|
|
16
67
|
private wsConfig: Partial<WSClientConfig>;
|
|
@@ -21,20 +72,30 @@ export class PubSubClient {
|
|
|
21
72
|
}
|
|
22
73
|
|
|
23
74
|
/**
|
|
24
|
-
* Publish a message to a topic
|
|
75
|
+
* Publish a message to a topic via HTTP
|
|
25
76
|
*/
|
|
26
77
|
async publish(topic: string, data: string | Uint8Array): Promise<void> {
|
|
27
|
-
|
|
28
|
-
|
|
78
|
+
let dataBase64: string;
|
|
79
|
+
if (typeof data === "string") {
|
|
80
|
+
dataBase64 = base64Encode(data);
|
|
81
|
+
} else {
|
|
82
|
+
dataBase64 = base64EncodeBytes(data);
|
|
83
|
+
}
|
|
29
84
|
|
|
30
|
-
await this.httpClient.post(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
85
|
+
await this.httpClient.post(
|
|
86
|
+
"/v1/pubsub/publish",
|
|
87
|
+
{
|
|
88
|
+
topic,
|
|
89
|
+
data_base64: dataBase64,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
timeout: 30000,
|
|
93
|
+
}
|
|
94
|
+
);
|
|
34
95
|
}
|
|
35
96
|
|
|
36
97
|
/**
|
|
37
|
-
* List active topics in the current namespace
|
|
98
|
+
* List active topics in the current namespace
|
|
38
99
|
*/
|
|
39
100
|
async topics(): Promise<string[]> {
|
|
40
101
|
const response = await this.httpClient.get<{ topics: string[] }>(
|
|
@@ -44,8 +105,8 @@ export class PubSubClient {
|
|
|
44
105
|
}
|
|
45
106
|
|
|
46
107
|
/**
|
|
47
|
-
* Subscribe to a topic via WebSocket
|
|
48
|
-
*
|
|
108
|
+
* Subscribe to a topic via WebSocket
|
|
109
|
+
* Creates one WebSocket connection per topic
|
|
49
110
|
*/
|
|
50
111
|
async subscribe(
|
|
51
112
|
topic: string,
|
|
@@ -55,16 +116,23 @@ export class PubSubClient {
|
|
|
55
116
|
onClose?: CloseHandler;
|
|
56
117
|
} = {}
|
|
57
118
|
): Promise<Subscription> {
|
|
58
|
-
|
|
119
|
+
// Build WebSocket URL for this topic
|
|
120
|
+
const wsUrl = new URL(this.wsConfig.wsURL || "ws://127.0.0.1:6001");
|
|
59
121
|
wsUrl.pathname = "/v1/pubsub/ws";
|
|
60
122
|
wsUrl.searchParams.set("topic", topic);
|
|
61
123
|
|
|
124
|
+
const authToken = this.httpClient.getApiKey() ?? this.httpClient.getToken();
|
|
125
|
+
|
|
126
|
+
// Create WebSocket client
|
|
62
127
|
const wsClient = new WSClient({
|
|
63
128
|
...this.wsConfig,
|
|
64
129
|
wsURL: wsUrl.toString(),
|
|
65
|
-
authToken
|
|
130
|
+
authToken,
|
|
66
131
|
});
|
|
67
132
|
|
|
133
|
+
await wsClient.connect();
|
|
134
|
+
|
|
135
|
+
// Create subscription wrapper
|
|
68
136
|
const subscription = new Subscription(wsClient, topic);
|
|
69
137
|
|
|
70
138
|
if (handlers.onMessage) {
|
|
@@ -77,66 +145,144 @@ export class PubSubClient {
|
|
|
77
145
|
subscription.onClose(handlers.onClose);
|
|
78
146
|
}
|
|
79
147
|
|
|
80
|
-
await wsClient.connect();
|
|
81
148
|
return subscription;
|
|
82
149
|
}
|
|
83
150
|
}
|
|
84
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Subscription represents an active WebSocket subscription to a topic
|
|
154
|
+
*/
|
|
85
155
|
export class Subscription {
|
|
86
156
|
private wsClient: WSClient;
|
|
87
157
|
private topic: string;
|
|
88
158
|
private messageHandlers: Set<MessageHandler> = new Set();
|
|
89
159
|
private errorHandlers: Set<ErrorHandler> = new Set();
|
|
90
160
|
private closeHandlers: Set<CloseHandler> = new Set();
|
|
161
|
+
private isClosed = false;
|
|
162
|
+
private wsMessageHandler: ((data: string) => void) | null = null;
|
|
163
|
+
private wsErrorHandler: ((error: Error) => void) | null = null;
|
|
164
|
+
private wsCloseHandler: (() => void) | null = null;
|
|
91
165
|
|
|
92
166
|
constructor(wsClient: WSClient, topic: string) {
|
|
93
167
|
this.wsClient = wsClient;
|
|
94
168
|
this.topic = topic;
|
|
95
169
|
|
|
96
|
-
|
|
170
|
+
// Register message handler
|
|
171
|
+
this.wsMessageHandler = (data) => {
|
|
97
172
|
try {
|
|
173
|
+
// Parse gateway JSON envelope: {data: base64String, timestamp, topic}
|
|
174
|
+
const envelope: RawEnvelope = JSON.parse(data);
|
|
175
|
+
|
|
176
|
+
// Validate envelope structure
|
|
177
|
+
if (!envelope || typeof envelope !== "object") {
|
|
178
|
+
throw new Error("Invalid envelope: not an object");
|
|
179
|
+
}
|
|
180
|
+
if (!envelope.data || typeof envelope.data !== "string") {
|
|
181
|
+
throw new Error("Invalid envelope: missing or invalid data field");
|
|
182
|
+
}
|
|
183
|
+
if (!envelope.topic || typeof envelope.topic !== "string") {
|
|
184
|
+
throw new Error("Invalid envelope: missing or invalid topic field");
|
|
185
|
+
}
|
|
186
|
+
if (typeof envelope.timestamp !== "number") {
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Invalid envelope: missing or invalid timestamp field"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Decode base64 data
|
|
193
|
+
const messageData = base64Decode(envelope.data);
|
|
194
|
+
|
|
98
195
|
const message: Message = {
|
|
99
|
-
topic:
|
|
100
|
-
data:
|
|
101
|
-
timestamp:
|
|
196
|
+
topic: envelope.topic,
|
|
197
|
+
data: messageData,
|
|
198
|
+
timestamp: envelope.timestamp,
|
|
102
199
|
};
|
|
200
|
+
|
|
201
|
+
console.log("[Subscription] Received message on topic:", this.topic);
|
|
103
202
|
this.messageHandlers.forEach((handler) => handler(message));
|
|
104
203
|
} catch (error) {
|
|
204
|
+
console.error("[Subscription] Error processing message:", error);
|
|
105
205
|
this.errorHandlers.forEach((handler) =>
|
|
106
206
|
handler(error instanceof Error ? error : new Error(String(error)))
|
|
107
207
|
);
|
|
108
208
|
}
|
|
109
|
-
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
this.wsClient.onMessage(this.wsMessageHandler);
|
|
110
212
|
|
|
111
|
-
|
|
213
|
+
// Register error handler
|
|
214
|
+
this.wsErrorHandler = (error) => {
|
|
112
215
|
this.errorHandlers.forEach((handler) => handler(error));
|
|
113
|
-
}
|
|
216
|
+
};
|
|
217
|
+
this.wsClient.onError(this.wsErrorHandler);
|
|
114
218
|
|
|
115
|
-
|
|
219
|
+
// Register close handler
|
|
220
|
+
this.wsCloseHandler = () => {
|
|
116
221
|
this.closeHandlers.forEach((handler) => handler());
|
|
117
|
-
}
|
|
222
|
+
};
|
|
223
|
+
this.wsClient.onClose(this.wsCloseHandler);
|
|
118
224
|
}
|
|
119
225
|
|
|
120
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Register message handler
|
|
228
|
+
*/
|
|
229
|
+
onMessage(handler: MessageHandler): () => void {
|
|
121
230
|
this.messageHandlers.add(handler);
|
|
122
231
|
return () => this.messageHandlers.delete(handler);
|
|
123
232
|
}
|
|
124
233
|
|
|
125
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Register error handler
|
|
236
|
+
*/
|
|
237
|
+
onError(handler: ErrorHandler): () => void {
|
|
126
238
|
this.errorHandlers.add(handler);
|
|
127
239
|
return () => this.errorHandlers.delete(handler);
|
|
128
240
|
}
|
|
129
241
|
|
|
130
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Register close handler
|
|
244
|
+
*/
|
|
245
|
+
onClose(handler: CloseHandler): () => void {
|
|
131
246
|
this.closeHandlers.add(handler);
|
|
132
247
|
return () => this.closeHandlers.delete(handler);
|
|
133
248
|
}
|
|
134
249
|
|
|
135
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Close subscription and underlying WebSocket
|
|
252
|
+
*/
|
|
253
|
+
close(): void {
|
|
254
|
+
if (this.isClosed) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.isClosed = true;
|
|
258
|
+
|
|
259
|
+
// Remove handlers from WSClient
|
|
260
|
+
if (this.wsMessageHandler) {
|
|
261
|
+
this.wsClient.offMessage(this.wsMessageHandler);
|
|
262
|
+
this.wsMessageHandler = null;
|
|
263
|
+
}
|
|
264
|
+
if (this.wsErrorHandler) {
|
|
265
|
+
this.wsClient.offError(this.wsErrorHandler);
|
|
266
|
+
this.wsErrorHandler = null;
|
|
267
|
+
}
|
|
268
|
+
if (this.wsCloseHandler) {
|
|
269
|
+
this.wsClient.offClose(this.wsCloseHandler);
|
|
270
|
+
this.wsCloseHandler = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Clear all local handlers
|
|
274
|
+
this.messageHandlers.clear();
|
|
275
|
+
this.errorHandlers.clear();
|
|
276
|
+
this.closeHandlers.clear();
|
|
277
|
+
|
|
278
|
+
// Close WebSocket connection
|
|
136
279
|
this.wsClient.close();
|
|
137
280
|
}
|
|
138
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Check if subscription is active
|
|
284
|
+
*/
|
|
139
285
|
isConnected(): boolean {
|
|
140
|
-
return this.wsClient.isConnected();
|
|
286
|
+
return !this.isClosed && this.wsClient.isConnected();
|
|
141
287
|
}
|
|
142
288
|
}
|