@sockethub/client 5.0.0-alpha.10

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.
@@ -0,0 +1,331 @@
1
+ import { ASFactory, type ASManager } from "@sockethub/activity-streams";
2
+ import type {
3
+ ActivityObject,
4
+ ActivityStream,
5
+ BaseActivityObject,
6
+ } from "@sockethub/schemas";
7
+ import EventEmitter from "eventemitter3";
8
+ import type { Socket } from "socket.io-client";
9
+
10
+ export interface EventMapping {
11
+ credentials: Map<string, ActivityStream>;
12
+ "activity-object": Map<string, BaseActivityObject>;
13
+ connect: Map<string, ActivityStream>;
14
+ join: Map<string, ActivityStream>;
15
+ }
16
+
17
+ interface CustomEmitter extends EventEmitter {
18
+ _emit(s: string, o: unknown, c?: unknown): void;
19
+ connect(): void;
20
+ disconnect(): void;
21
+ connected: boolean;
22
+ id: string;
23
+ }
24
+
25
+ /**
26
+ * SockethubClient - Client library for Sockethub protocol gateway
27
+ *
28
+ * A JavaScript client for connecting to Sockethub servers. Provides a high-level
29
+ * API for sending and receiving ActivityStreams messages over Socket.IO, with
30
+ * automatic state management and reconnection handling.
31
+ *
32
+ * Sockethub acts as a protocol gateway, translating ActivityStreams messages into
33
+ * various protocols (XMPP, IRC, RSS, etc.). This client handles the communication
34
+ * with the Sockethub server, including credential management, connection state,
35
+ * and automatic reconnection.
36
+ *
37
+ * ## Automatic Reconnection & State Replay
38
+ *
39
+ * This client automatically handles transient network disconnections by storing
40
+ * connection state in memory and replaying it when the socket reconnects.
41
+ *
42
+ * ### Security Model
43
+ *
44
+ * **Storage Location:**
45
+ * - All credentials and state are stored ONLY in JavaScript memory (heap)
46
+ * - Nothing is persisted to localStorage, sessionStorage, cookies, or disk
47
+ * - Memory is cleared when the browser tab closes or page refreshes
48
+ *
49
+ * **Replay Triggers:**
50
+ * - Automatic replay occurs on Socket.IO reconnection events
51
+ * - Typically triggered by brief network interruptions (WiFi switching, mobile network blips)
52
+ * - Does NOT occur on page refresh (new SockethubClient instance = empty state)
53
+ *
54
+ * **Server Restart Behavior:**
55
+ * - If server restarts, client socket will reconnect and replay credentials
56
+ * - Server must handle replayed credentials appropriately (validate, reject stale sessions, etc.)
57
+ * - Applications should implement proper session validation server-side
58
+ *
59
+ * **Lifetime:**
60
+ * - Credentials exist only during the browser tab's lifetime
61
+ * - Cleared on page reload, tab close, or manual disconnect
62
+ * - Not accessible across tabs or after browser restart
63
+ *
64
+ * **What Gets Replayed:**
65
+ * - Credentials (username/password/tokens sent via credentials event)
66
+ * - Activity Objects (actor definitions)
67
+ * - Connect commands (platform connections)
68
+ * - Join commands (room/channel joins)
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * // Create client
73
+ * const socket = io('http://localhost:10550');
74
+ * const client = new SockethubClient(socket);
75
+ *
76
+ * // Send credentials - these will be replayed on reconnection
77
+ * client.socket.emit('credentials', {
78
+ * actor: 'user@example.com',
79
+ * object: { username: 'user', password: 'pass' }
80
+ * });
81
+ *
82
+ * // If network disconnects and reconnects, credentials are automatically replayed
83
+ * // If page refreshes, credentials are lost and must be resent
84
+ * ```
85
+ */
86
+ export default class SockethubClient {
87
+ /**
88
+ * In-memory storage for client state that should be replayed on reconnection.
89
+ *
90
+ * Security: Stored ONLY in JavaScript heap memory. Never persisted to disk,
91
+ * localStorage, or any permanent storage. Cleared on page reload.
92
+ */
93
+ private events: EventMapping = {
94
+ credentials: new Map(),
95
+ "activity-object": new Map(),
96
+ connect: new Map(),
97
+ join: new Map(),
98
+ };
99
+ private _socket: Socket;
100
+ public ActivityStreams: ASManager;
101
+ public socket: CustomEmitter;
102
+ public debug = true;
103
+
104
+ constructor(socket: Socket) {
105
+ if (!socket) {
106
+ throw new Error("SockethubClient requires a socket.io instance");
107
+ }
108
+ this._socket = socket;
109
+
110
+ this.socket = this.createPublicEmitter();
111
+ this.registerSocketIOHandlers();
112
+ this.initActivityStreams();
113
+
114
+ this.ActivityStreams.on(
115
+ "activity-object-create",
116
+ (obj: ActivityObject) => {
117
+ socket.emit("activity-object", obj, (err: never) => {
118
+ if (err) {
119
+ console.error("failed to create activity-object ", err);
120
+ } else {
121
+ this.eventActivityObject(obj);
122
+ }
123
+ });
124
+ },
125
+ );
126
+
127
+ socket.on("activity-object", (obj) => {
128
+ this.ActivityStreams.Object.create(obj);
129
+ });
130
+ }
131
+
132
+ initActivityStreams() {
133
+ this.ActivityStreams = ASFactory({ specialObjs: ["credentials"] });
134
+ }
135
+
136
+ /**
137
+ * Clear stored credentials to prevent automatic replay on reconnection.
138
+ *
139
+ * This method removes all stored credentials from memory. Useful for
140
+ * security-sensitive applications that want to prevent automatic credential
141
+ * replay when the socket reconnects.
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * // Clear credentials on disconnect
146
+ * sc.socket.on('disconnect', () => {
147
+ * sc.clearCredentials();
148
+ * });
149
+ * ```
150
+ */
151
+ public clearCredentials(): void {
152
+ this.events.credentials.clear();
153
+ }
154
+
155
+ private createPublicEmitter(): CustomEmitter {
156
+ const socket = new EventEmitter() as CustomEmitter;
157
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
158
+ // @ts-ignore
159
+ socket._emit = socket.emit;
160
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
161
+ // @ts-ignore
162
+ socket.emit = (event, content, callback): void => {
163
+ if (event === "credentials") {
164
+ this.eventCredentials(content);
165
+ } else if (event === "activity-object") {
166
+ this.eventActivityObject(content);
167
+ } else if (event === "message") {
168
+ this.eventMessage(content);
169
+ }
170
+ this._socket.emit(event as string, content, callback);
171
+ };
172
+ socket.connected = false;
173
+ socket.disconnect = () => {
174
+ this._socket.disconnect();
175
+ };
176
+ socket.connect = () => {
177
+ this._socket.connect();
178
+ };
179
+ return socket;
180
+ }
181
+
182
+ private eventActivityObject(content: ActivityObject) {
183
+ if (content.id) {
184
+ this.events["activity-object"].set(content.id, content);
185
+ }
186
+ }
187
+
188
+ private eventCredentials(content: ActivityStream) {
189
+ if (content.object && content.object.type === "credentials") {
190
+ const key: string =
191
+ content.actor.id || (content.actor as unknown as string);
192
+ this.events.credentials.set(key, content);
193
+ }
194
+ }
195
+
196
+ private eventMessage(content: BaseActivityObject) {
197
+ if (!this._socket.connected) {
198
+ return;
199
+ }
200
+ // either stores or delete the specified content onto the storedJoins map,
201
+ // for reply once we're back online.
202
+ const key = SockethubClient.getKey(content as ActivityStream);
203
+ if (content.type === "join" || content.type === "connect") {
204
+ this.events[content.type].set(key, content as ActivityStream);
205
+ } else if (content.type === "leave") {
206
+ this.events.join.delete(key);
207
+ } else if (content.type === "disconnect") {
208
+ this.events.connect.delete(key);
209
+ }
210
+ }
211
+
212
+ private static getKey(content: ActivityStream) {
213
+ const actor = content.actor?.id || content.actor;
214
+ if (!actor) {
215
+ throw new Error(
216
+ `actor property not present for message type: ${content?.type}`,
217
+ );
218
+ }
219
+ const target = content.target
220
+ ? content.target.id || content.target
221
+ : "";
222
+ return `${actor}-${target}`;
223
+ }
224
+
225
+ private log(msg: string, obj?: unknown) {
226
+ if (this.debug) {
227
+ console.log(msg, obj);
228
+ }
229
+ }
230
+
231
+ private registerSocketIOHandlers() {
232
+ // middleware for events which don't deal in AS objects
233
+ const callHandler = (event: string) => {
234
+ return async (obj?: unknown) => {
235
+ if (event === "connect") {
236
+ this.socket.id = this._socket.id;
237
+ this.socket.connected = true;
238
+
239
+ /**
240
+ * Automatic state replay on reconnection.
241
+ *
242
+ * When Socket.IO reconnects after a network interruption, we automatically
243
+ * replay all stored state to restore the session seamlessly:
244
+ *
245
+ * 1. Activity Objects (actor definitions)
246
+ * 2. Credentials (authentication)
247
+ * 3. Connect commands (platform connections)
248
+ * 4. Join commands (room/channel memberships)
249
+ *
250
+ * This allows the client to survive brief network blips without requiring
251
+ * user intervention. However, the server must properly validate replayed
252
+ * credentials as they may be stale or revoked.
253
+ */
254
+ this.replay(
255
+ "activity-object",
256
+ this.events["activity-object"],
257
+ );
258
+ this.replay("credentials", this.events.credentials);
259
+ this.replay("message", this.events.connect);
260
+ this.replay("message", this.events.join);
261
+ } else if (event === "disconnect") {
262
+ this.socket.connected = false;
263
+ }
264
+ this.socket._emit(event, obj);
265
+ };
266
+ };
267
+
268
+ // register for events that give us information on connection status
269
+ this._socket.on("connect", callHandler("connect"));
270
+ this._socket.on("connect_error", callHandler("connect_error"));
271
+ this._socket.on("disconnect", callHandler("disconnect"));
272
+
273
+ // use as middleware to receive incoming Sockethub messages and unpack them
274
+ // using the ActivityStreams library before passing them along to the app.
275
+ this._socket.on("message", (obj) => {
276
+ this.socket._emit("message", this.ActivityStreams.Stream(obj));
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Type guard to check if an object is an ActivityStream with a valid actor.id.
282
+ */
283
+ private hasActorId(
284
+ obj: ActivityStream | ActivityObject,
285
+ ): obj is ActivityStream {
286
+ return (
287
+ "actor" in obj &&
288
+ obj.actor !== null &&
289
+ typeof obj.actor === "object" &&
290
+ "id" in obj.actor &&
291
+ typeof obj.actor.id === "string"
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Replays previously sent events to the server after reconnection.
297
+ *
298
+ * This method is called automatically when the Socket.IO connection is
299
+ * re-established after a transient network interruption. It resends
300
+ * credentials and connection state so the user doesn't need to manually
301
+ * re-authenticate or rejoin channels.
302
+ *
303
+ * Security considerations:
304
+ * - Only replays events stored in memory during this session
305
+ * - Does not replay after page refresh (memory cleared)
306
+ * - Server should validate replayed credentials (may be stale/revoked)
307
+ *
308
+ * @param name - Event name to emit ("credentials", "activity-object", "message")
309
+ * @param asMap - Map of events to replay
310
+ */
311
+ private replay(
312
+ name: string,
313
+ asMap: Map<string, ActivityStream | BaseActivityObject>,
314
+ ) {
315
+ for (const obj of asMap.values()) {
316
+ const expandedObj = this.ActivityStreams.Stream(obj);
317
+ let id = expandedObj?.id;
318
+ if (this.hasActorId(expandedObj)) {
319
+ id = expandedObj.actor.id;
320
+ }
321
+ this.log(`replaying ${name} for ${id}`);
322
+ this._socket.emit(name, expandedObj);
323
+ }
324
+ }
325
+ }
326
+
327
+ // biome-ignore lint/suspicious/noExplicitAny: <explanation>
328
+ ((global: any) => {
329
+ global.SockethubClient = SockethubClient;
330
+ // @ts-ignore
331
+ })(typeof window === "object" ? window : {});