@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.
- package/LICENSE +165 -0
- package/README.md +127 -0
- package/dist/sockethub-client.browser.js +631 -0
- package/dist/sockethub-client.js +602 -0
- package/dist/sockethub-client.min.js +19 -0
- package/package.json +62 -0
- package/src/sockethub-client.test.ts +325 -0
- package/src/sockethub-client.ts +331 -0
|
@@ -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 : {});
|