@sockethub/client 5.0.0-alpha.12 → 5.0.0-alpha.14

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.
@@ -1,1095 +0,0 @@
1
- import { ASFactory, type ASManager } from "@sockethub/activity-streams";
2
- import type {
3
- ActivityObject,
4
- ActivityStream,
5
- BaseActivityObject,
6
- } from "@sockethub/schemas";
7
- import {
8
- addPlatformContext,
9
- addPlatformSchema,
10
- validateActivityStream,
11
- validateCredentials,
12
- } from "@sockethub/schemas";
13
- import EventEmitter from "eventemitter3";
14
- import type { Socket } from "socket.io-client";
15
-
16
- export interface EventMapping {
17
- credentials: Map<string, ActivityStream>;
18
- "activity-object": Map<string, BaseActivityObject>;
19
- connect: Map<string, ActivityStream>;
20
- join: Map<string, ActivityStream>;
21
- }
22
-
23
- type ReplayEventMap = {
24
- "activity-object": BaseActivityObject;
25
- credentials: ActivityStream;
26
- message: ActivityStream;
27
- };
28
-
29
- type InitState = "idle" | "initializing" | "ready" | "init_error" | "closed";
30
-
31
- type ReadyReason = "initial-connect" | "reconnect" | "schemas-update";
32
-
33
- type InitErrorPhase = "schemas-request" | "schemas-apply" | "timeout";
34
-
35
- interface PendingReadyWaiter {
36
- resolve: (info: ClientReadyInfo) => void;
37
- reject: (err: Error) => void;
38
- timer?: ReturnType<typeof setTimeout>;
39
- }
40
-
41
- interface QueuedOutboundEvent {
42
- event: string;
43
- content: unknown;
44
- callback?: unknown;
45
- enqueuedAt: number;
46
- sequence: number;
47
- }
48
-
49
- interface InitializationCycle {
50
- token: number;
51
- reason: ReadyReason;
52
- startedAt: number;
53
- replayOnReady: boolean;
54
- timedOut: boolean;
55
- }
56
-
57
- export interface SockethubClientOptions {
58
- initTimeoutMs?: number;
59
- maxQueuedOutbound?: number;
60
- maxQueuedAgeMs?: number;
61
- }
62
-
63
- interface CustomEmitter extends EventEmitter {
64
- _emit(s: string, o: unknown, c?: unknown): void;
65
- connect(): void;
66
- disconnect(): void;
67
- connected: boolean;
68
- id: string;
69
- }
70
-
71
- interface PlatformRegistrySchemas {
72
- credentials?: object;
73
- messages?: object;
74
- }
75
-
76
- /**
77
- * Server-declared platform metadata used by the client for context generation
78
- * and runtime validation.
79
- */
80
- export interface PlatformRegistryEntry {
81
- id: string;
82
- version: string;
83
- contextUrl: string;
84
- contextVersion: string;
85
- schemaVersion: string;
86
- types: Array<string>;
87
- schemas: PlatformRegistrySchemas;
88
- }
89
-
90
- export interface PlatformRegistryPayload {
91
- version?: string;
92
- contexts?: {
93
- as?: string;
94
- sockethub?: string;
95
- };
96
- platforms?: Array<PlatformRegistryEntry>;
97
- }
98
-
99
- export interface ClientReadyInfo {
100
- state: "ready";
101
- reason: ReadyReason;
102
- sockethubVersion: string;
103
- contexts: {
104
- as: string;
105
- sockethub: string;
106
- };
107
- platforms: Array<{
108
- id: string;
109
- version: string;
110
- contextUrl: string;
111
- contextVersion: string;
112
- schemaVersion: string;
113
- types: Array<string>;
114
- }>;
115
- }
116
-
117
- export interface ClientInitError {
118
- error: string;
119
- phase: InitErrorPhase;
120
- retrying: boolean;
121
- }
122
-
123
- /**
124
- * SockethubClient - Client library for Sockethub protocol gateway
125
- *
126
- * A JavaScript client for connecting to Sockethub servers. Provides a high-level
127
- * API for sending and receiving ActivityStreams messages over Socket.IO, with
128
- * automatic state management and reconnection handling.
129
- *
130
- * Sockethub acts as a protocol gateway, translating ActivityStreams messages into
131
- * various protocols (XMPP, IRC, RSS, etc.). This client handles the communication
132
- * with the Sockethub server, including credential management, connection state,
133
- * and automatic reconnection.
134
- *
135
- * ## Automatic Reconnection & State Replay
136
- *
137
- * This client automatically handles transient network disconnections by storing
138
- * connection state in memory and replaying it when the socket reconnects.
139
- *
140
- * ### Security Model
141
- *
142
- * **Storage Location:**
143
- * - All credentials and state are stored ONLY in JavaScript memory (heap)
144
- * - Nothing is persisted to localStorage, sessionStorage, cookies, or disk
145
- * - Memory is cleared when the browser tab closes or page refreshes
146
- *
147
- * **Replay Triggers:**
148
- * - Automatic replay occurs on Socket.IO reconnection events
149
- * - Typically triggered by brief network interruptions (WiFi switching, mobile network blips)
150
- * - Does NOT occur on page refresh (new SockethubClient instance = empty state)
151
- *
152
- * **Server Restart Behavior:**
153
- * - If server restarts, client socket will reconnect and replay credentials
154
- * - Server must handle replayed credentials appropriately (validate, reject stale sessions, etc.)
155
- * - Applications should implement proper session validation server-side
156
- *
157
- * **Lifetime:**
158
- * - Credentials exist only during the browser tab's lifetime
159
- * - Cleared on page reload, tab close, or manual disconnect
160
- * - Not accessible across tabs or after browser restart
161
- *
162
- * **What Gets Replayed:**
163
- * - Credentials (username/password/tokens sent via credentials event)
164
- * - Activity Objects (actor definitions)
165
- * - Connect commands (platform connections)
166
- * - Join commands (room/channel joins)
167
- *
168
- * @example
169
- * ```typescript
170
- * // Create client
171
- * const socket = io('http://localhost:10550');
172
- * const client = new SockethubClient(socket);
173
- *
174
- * // Wait for schema registry before sending messages
175
- * await client.ready();
176
- *
177
- * // Build canonical @context for a platform
178
- * const ctx = client.contextFor('irc');
179
- *
180
- * // Send credentials - these will be replayed on reconnection
181
- * client.socket.emit('credentials', {
182
- * '@context': ctx,
183
- * type: 'credentials',
184
- * actor: { id: 'user@example.com', type: 'person' },
185
- * object: { type: 'credentials', username: 'user', password: 'pass' }
186
- * });
187
- * ```
188
- */
189
- export default class SockethubClient {
190
- /**
191
- * In-memory storage for client state that should be replayed on reconnection.
192
- *
193
- * Security: Stored ONLY in JavaScript heap memory. Never persisted to disk,
194
- * localStorage, or any permanent storage. Cleared on page reload.
195
- */
196
- private events: EventMapping = {
197
- credentials: new Map(),
198
- "activity-object": new Map(),
199
- connect: new Map(),
200
- join: new Map(),
201
- };
202
- private _socket: Socket;
203
- public ActivityStreams!: ASManager;
204
- public socket!: CustomEmitter;
205
- public debug = true;
206
- private readonly options: Required<SockethubClientOptions>;
207
- private platformRegistry = new Map<string, PlatformRegistryEntry>();
208
- private asContextUrl?: string;
209
- private sockethubContextUrl?: string;
210
- private sockethubVersion?: string;
211
- private initState: InitState = "idle";
212
- private hasReadyOnce = false;
213
- private initCycle?: InitializationCycle;
214
- private initTokenCounter = 0;
215
- private initTimeoutTimer?: ReturnType<typeof setTimeout>;
216
- private waitingWarningTimer?: ReturnType<typeof setInterval>;
217
- private waitingWarningIntervalMs = 10000;
218
- private readyWaiters: Array<PendingReadyWaiter> = [];
219
- private outboundQueue: Array<QueuedOutboundEvent> = [];
220
- private outboundSequence = 0;
221
- private registryFingerprint?: string;
222
- private latestReadyInfo?: ClientReadyInfo;
223
-
224
- constructor(socket: Socket, options: SockethubClientOptions = {}) {
225
- if (!socket) {
226
- throw new Error("SockethubClient requires a socket.io instance");
227
- }
228
- this._socket = socket;
229
- this.options = {
230
- initTimeoutMs: options.initTimeoutMs ?? 5000,
231
- maxQueuedOutbound: options.maxQueuedOutbound ?? 1000,
232
- maxQueuedAgeMs: options.maxQueuedAgeMs ?? 30000,
233
- };
234
-
235
- this.socket = this.createPublicEmitter();
236
- this.registerSocketIOHandlers();
237
- this.initActivityStreams();
238
-
239
- this.ActivityStreams.on(
240
- "activity-object-create",
241
- (obj: ActivityObject) => {
242
- this.socket.emit(
243
- "activity-object",
244
- obj,
245
- (resp?: { error?: string }) => {
246
- if (resp && typeof resp.error === "string") {
247
- console.error(
248
- "failed to create activity-object ",
249
- resp.error,
250
- );
251
- return;
252
- }
253
- this.eventActivityObject(obj);
254
- },
255
- );
256
- },
257
- );
258
-
259
- socket.on("activity-object", (obj) => {
260
- this.ActivityStreams.Object.create(obj);
261
- });
262
-
263
- if (this._socket.connected) {
264
- this.socket.connected = true;
265
- (this.socket as unknown as { id?: string }).id = this._socket.id;
266
- this.socket._emit("connect");
267
- this.startInitialization("initial-connect", true);
268
- }
269
- }
270
-
271
- initActivityStreams() {
272
- this.ActivityStreams = ASFactory({ specialObjs: ["credentials"] });
273
- }
274
-
275
- /**
276
- * Clear stored credentials to prevent automatic replay on reconnection.
277
- *
278
- * This method removes all stored credentials from memory. Useful for
279
- * security-sensitive applications that want to prevent automatic credential
280
- * replay when the socket reconnects.
281
- *
282
- * @example
283
- * ```typescript
284
- * // Clear credentials on disconnect
285
- * sc.socket.on('disconnect', () => {
286
- * sc.clearCredentials();
287
- * });
288
- * ```
289
- */
290
- public clearCredentials(): void {
291
- this.events.credentials.clear();
292
- }
293
-
294
- /**
295
- * Return the platform registry discovered from the server.
296
- */
297
- public getRegisteredPlatforms(): Array<PlatformRegistryEntry> {
298
- return Array.from(this.platformRegistry.values()).map((platform) => ({
299
- ...platform,
300
- types: [...platform.types],
301
- schemas: { ...platform.schemas },
302
- }));
303
- }
304
-
305
- /**
306
- * Indicates whether server-provided schema/context registry data is loaded.
307
- */
308
- public isSchemasReady(): boolean {
309
- return this.isReady();
310
- }
311
-
312
- /**
313
- * Indicates whether the client has completed schema initialization.
314
- */
315
- public isReady(): boolean {
316
- return this.initState === "ready";
317
- }
318
-
319
- /**
320
- * Returns the current client initialization state.
321
- */
322
- public getInitState(): InitState {
323
- return this.initState;
324
- }
325
-
326
- /**
327
- * Return the canonical base contexts learned from the server registry.
328
- */
329
- public getRegisteredBaseContexts(): { as: string; sockethub: string } {
330
- if (!this.asContextUrl || !this.sockethubContextUrl) {
331
- throw new Error(
332
- "Schema registry not loaded yet. Wait for client ready state after connect.",
333
- );
334
- }
335
- return {
336
- as: this.asContextUrl,
337
- sockethub: this.sockethubContextUrl,
338
- };
339
- }
340
-
341
- public getPlatformSchema(
342
- platform: string,
343
- schemaType: "messages" | "credentials" = "messages",
344
- ): object | undefined {
345
- const normalizedPlatform = platform?.trim();
346
- if (!normalizedPlatform) {
347
- return undefined;
348
- }
349
- return this.platformRegistry.get(normalizedPlatform)?.schemas?.[
350
- schemaType
351
- ];
352
- }
353
-
354
- /**
355
- * Wait for schema registry data from the server and return the normalized payload.
356
- * @deprecated Use ready(timeoutMs?) instead.
357
- */
358
- public async waitForSchemas(
359
- timeoutMs = 2000,
360
- ): Promise<PlatformRegistryPayload> {
361
- await this.ready(timeoutMs);
362
- return this.buildPlatformRegistryPayload();
363
- }
364
-
365
- /**
366
- * Wait until the client reaches a ready state.
367
- */
368
- public ready(
369
- timeoutMs = this.options.initTimeoutMs,
370
- ): Promise<ClientReadyInfo> {
371
- if (this.isReady() && this.latestReadyInfo) {
372
- return Promise.resolve(this.latestReadyInfo);
373
- }
374
- return new Promise((resolve, reject) => {
375
- const waiter: PendingReadyWaiter = { resolve, reject };
376
- if (timeoutMs > 0) {
377
- waiter.timer = setTimeout(() => {
378
- this.readyWaiters = this.readyWaiters.filter(
379
- (entry) => entry !== waiter,
380
- );
381
- reject(
382
- new Error(
383
- `SockethubClient ready() timed out after ${timeoutMs}ms`,
384
- ),
385
- );
386
- }, timeoutMs);
387
- }
388
- this.readyWaiters.push(waiter);
389
- if (this.socket.connected && this.initState === "idle") {
390
- this.startInitialization(
391
- this.hasReadyOnce ? "reconnect" : "initial-connect",
392
- true,
393
- );
394
- }
395
- });
396
- }
397
-
398
- /**
399
- * Validate an activity stream against currently registered platform schemas.
400
- * Returns an empty string when valid.
401
- */
402
- public validateActivity(activity: ActivityStream): string {
403
- if (activity.type === "credentials") {
404
- return validateCredentials(activity);
405
- }
406
- return validateActivityStream(activity);
407
- }
408
-
409
- /**
410
- * Build canonical Sockethub contexts for a platform using server-provided schema metadata.
411
- */
412
- public contextFor(platform: string): ActivityStream["@context"] {
413
- if (typeof platform !== "string" || platform.trim().length === 0) {
414
- throw new Error(
415
- "SockethubClient.contextFor(platform) requires a non-empty platform string",
416
- );
417
- }
418
-
419
- if (!this.asContextUrl || !this.sockethubContextUrl) {
420
- throw new Error(
421
- "Schema registry not loaded yet. Wait for client ready state after connect.",
422
- );
423
- }
424
-
425
- const normalizedPlatform = platform.trim();
426
- const entry = this.platformRegistry.get(normalizedPlatform);
427
- if (!entry) {
428
- const names = Array.from(this.platformRegistry.keys()).sort();
429
- throw new Error(
430
- `unknown platform '${normalizedPlatform}'. Registered platforms: ${names.join(", ")}`,
431
- );
432
- }
433
- return [this.asContextUrl, this.sockethubContextUrl, entry.contextUrl];
434
- }
435
-
436
- private createPublicEmitter(): CustomEmitter {
437
- const socket = new EventEmitter() as CustomEmitter;
438
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
439
- // @ts-expect-error
440
- socket._emit = socket.emit;
441
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
442
- // @ts-expect-error
443
- socket.emit = (event, content, callback): void => {
444
- this.handlePublicEmit(event as string, content, callback);
445
- };
446
- socket.connected = false;
447
- socket.disconnect = () => {
448
- this._socket.disconnect();
449
- };
450
- socket.connect = () => {
451
- this._socket.connect();
452
- };
453
- return socket;
454
- }
455
-
456
- /**
457
- * Ask server for the latest platform/context registry via ack callback.
458
- * This keeps client context composition aligned with server schema state.
459
- */
460
- private requestSchemaRegistry() {
461
- const socketLike = this._socket as unknown as Record<string, unknown>;
462
- if (!("io" in socketLike)) {
463
- return;
464
- }
465
- this._socket.emit("schemas", (payload: unknown) => {
466
- this.handleSchemasPayload(payload);
467
- });
468
- }
469
-
470
- /**
471
- * Apply server-provided registry metadata to local runtime state.
472
- * Also registers platform contexts/schemas with @sockethub/schemas validators
473
- * so local validation uses the same canonical sources as the server.
474
- */
475
- private applyPlatformRegistry(
476
- payload: unknown,
477
- ): PlatformRegistryPayload | undefined {
478
- if (!payload || typeof payload !== "object") {
479
- return undefined;
480
- }
481
- const registry = payload as PlatformRegistryPayload;
482
- const asContextUrl = registry.contexts?.as;
483
- const sockethubContextUrl = registry.contexts?.sockethub;
484
- if (
485
- typeof asContextUrl !== "string" ||
486
- typeof sockethubContextUrl !== "string" ||
487
- !Array.isArray(registry.platforms)
488
- ) {
489
- return undefined;
490
- }
491
- this.sockethubVersion =
492
- typeof registry.version === "string" ? registry.version : "unknown";
493
- this.asContextUrl = asContextUrl;
494
- this.sockethubContextUrl = sockethubContextUrl;
495
-
496
- this.platformRegistry.clear();
497
- for (const platform of registry.platforms) {
498
- if (
499
- !platform ||
500
- typeof platform !== "object" ||
501
- typeof platform.id !== "string" ||
502
- typeof platform.version !== "string" ||
503
- typeof platform.contextUrl !== "string"
504
- ) {
505
- continue;
506
- }
507
- this.platformRegistry.set(platform.id, {
508
- ...platform,
509
- version: platform.version,
510
- types: Array.isArray(platform.types) ? platform.types : [],
511
- schemas: platform.schemas || {},
512
- });
513
- addPlatformContext(platform.id, platform.contextUrl);
514
- try {
515
- const credSchema = platform.schemas?.credentials;
516
- if (
517
- credSchema &&
518
- typeof credSchema === "object" &&
519
- !Array.isArray(credSchema)
520
- ) {
521
- addPlatformSchema(credSchema, `${platform.id}/credentials`);
522
- }
523
- const msgSchema = platform.schemas?.messages;
524
- if (
525
- msgSchema &&
526
- typeof msgSchema === "object" &&
527
- !Array.isArray(msgSchema)
528
- ) {
529
- addPlatformSchema(msgSchema, `${platform.id}/messages`);
530
- }
531
- } catch (err) {
532
- const message =
533
- err instanceof Error ? err.message : String(err);
534
- console.warn(
535
- `[SockethubClient] Failed to register schemas for platform ${platform.id}: ${message}`,
536
- );
537
- }
538
- }
539
- const normalizedPayload = this.buildPlatformRegistryPayload();
540
- this.registryFingerprint =
541
- this.computePayloadFingerprint(normalizedPayload);
542
- // Emit normalized registry payload so app code receives a stable shape.
543
- this.socket._emit("schemas", normalizedPayload);
544
- return normalizedPayload;
545
- }
546
-
547
- private eventActivityObject(content: ActivityObject) {
548
- if (content.id) {
549
- this.events["activity-object"].set(content.id, content);
550
- }
551
- }
552
-
553
- private eventCredentials(content: ActivityStream) {
554
- if (content.object && content.object.type === "credentials") {
555
- const key: string =
556
- content.actor.id || (content.actor as unknown as string);
557
- this.events.credentials.set(key, content);
558
- }
559
- }
560
-
561
- private eventMessage(content: BaseActivityObject) {
562
- if (!this._socket.connected) {
563
- return;
564
- }
565
- // either stores or delete the specified content onto the storedJoins map,
566
- // for reply once we're back online.
567
- const key = SockethubClient.getKey(content as ActivityStream);
568
- if (content.type === "join" || content.type === "connect") {
569
- this.events[content.type].set(key, content as ActivityStream);
570
- } else if (content.type === "leave") {
571
- this.events.join.delete(key);
572
- } else if (content.type === "disconnect") {
573
- this.events.connect.delete(key);
574
- }
575
- }
576
-
577
- private static getKey(content: ActivityStream) {
578
- const actor = content.actor?.id || content.actor;
579
- if (!actor) {
580
- throw new Error(
581
- `actor property not present for message type: ${content?.type}`,
582
- );
583
- }
584
- const target = content.target
585
- ? content.target.id || content.target
586
- : "";
587
- return `${actor}-${target}`;
588
- }
589
-
590
- private buildPlatformRegistryPayload(): PlatformRegistryPayload {
591
- return {
592
- version: this.sockethubVersion,
593
- contexts:
594
- this.asContextUrl && this.sockethubContextUrl
595
- ? {
596
- as: this.asContextUrl,
597
- sockethub: this.sockethubContextUrl,
598
- }
599
- : undefined,
600
- platforms: this.getRegisteredPlatforms(),
601
- };
602
- }
603
-
604
- private buildReadyInfo(reason: ReadyReason): ClientReadyInfo | undefined {
605
- if (
606
- !this.sockethubVersion ||
607
- !this.asContextUrl ||
608
- !this.sockethubContextUrl
609
- ) {
610
- return undefined;
611
- }
612
- return {
613
- state: "ready",
614
- reason,
615
- sockethubVersion: this.sockethubVersion,
616
- contexts: {
617
- as: this.asContextUrl,
618
- sockethub: this.sockethubContextUrl,
619
- },
620
- platforms: this.getRegisteredPlatforms().map((platform) => ({
621
- id: platform.id,
622
- version: platform.version,
623
- contextUrl: platform.contextUrl,
624
- contextVersion: platform.contextVersion,
625
- schemaVersion: platform.schemaVersion,
626
- types: [...platform.types],
627
- })),
628
- };
629
- }
630
-
631
- private resolveReadyWaiters(info: ClientReadyInfo) {
632
- const waiters = this.readyWaiters;
633
- this.readyWaiters = [];
634
- for (const waiter of waiters) {
635
- if (waiter.timer) {
636
- clearTimeout(waiter.timer);
637
- }
638
- waiter.resolve(info);
639
- }
640
- }
641
-
642
- private rejectReadyWaiters(err: Error) {
643
- const waiters = this.readyWaiters;
644
- this.readyWaiters = [];
645
- for (const waiter of waiters) {
646
- if (waiter.timer) {
647
- clearTimeout(waiter.timer);
648
- }
649
- waiter.reject(err);
650
- }
651
- }
652
-
653
- private emitInitError(
654
- error: string,
655
- phase: InitErrorPhase,
656
- retrying: boolean,
657
- ) {
658
- this.socket._emit("init_error", {
659
- error,
660
- phase,
661
- retrying,
662
- } satisfies ClientInitError);
663
- }
664
-
665
- private emitClientError(
666
- event: string,
667
- callback: unknown,
668
- errorMessage: string,
669
- ) {
670
- if (typeof callback === "function") {
671
- callback({ error: errorMessage });
672
- return;
673
- }
674
- this.socket._emit("client_error", {
675
- event,
676
- error: errorMessage,
677
- });
678
- }
679
-
680
- private clearInitTimers() {
681
- if (this.initTimeoutTimer) {
682
- clearTimeout(this.initTimeoutTimer);
683
- this.initTimeoutTimer = undefined;
684
- }
685
- if (this.waitingWarningTimer) {
686
- clearInterval(this.waitingWarningTimer);
687
- this.waitingWarningTimer = undefined;
688
- }
689
- }
690
-
691
- private startWaitingWarnings() {
692
- if (this.waitingWarningTimer) {
693
- return;
694
- }
695
- this.waitingWarningTimer = setInterval(() => {
696
- if (this.isReady() || this.initState === "closed") {
697
- this.clearInitTimers();
698
- return;
699
- }
700
- const queueSize = this.outboundQueue.length;
701
- const oldest = this.outboundQueue[0];
702
- const oldestAgeSeconds = oldest
703
- ? ((Date.now() - oldest.enqueuedAt) / 1000).toFixed(1)
704
- : "0.0";
705
- console.warn(
706
- `[SockethubClient] Still waiting for schemas; queued outbound messages: ${queueSize}; oldest queued age: ${oldestAgeSeconds}s.`,
707
- );
708
- }, this.waitingWarningIntervalMs);
709
- }
710
-
711
- private startInitialization(reason: ReadyReason, replayOnReady: boolean) {
712
- if (!this.socket.connected || this.initState === "closed") {
713
- return;
714
- }
715
-
716
- const token = ++this.initTokenCounter;
717
- this.initCycle = {
718
- token,
719
- reason,
720
- startedAt: Date.now(),
721
- replayOnReady,
722
- timedOut: false,
723
- };
724
- this.initState = "initializing";
725
- this.clearInitTimers();
726
-
727
- this.initTimeoutTimer = setTimeout(() => {
728
- if (!this.initCycle || this.initCycle.token !== token) {
729
- return;
730
- }
731
- this.initCycle.timedOut = true;
732
- this.initState = "init_error";
733
- const timeoutMsg = `Initialization timed out after ${this.options.initTimeoutMs}ms waiting for schemas`;
734
- console.warn(
735
- `[SockethubClient] ${timeoutMsg}; queued outbound messages: ${this.outboundQueue.length}. Waiting for schemas event from server.`,
736
- );
737
- this.emitInitError(timeoutMsg, "timeout", false);
738
- this.startWaitingWarnings();
739
- }, this.options.initTimeoutMs);
740
-
741
- try {
742
- // Pull the latest registry from the server for this init cycle.
743
- this.requestSchemaRegistry();
744
- } catch (err) {
745
- this.initState = "init_error";
746
- const message = err instanceof Error ? err.message : String(err);
747
- this.emitInitError(message, "schemas-request", false);
748
- this.startWaitingWarnings();
749
- }
750
- }
751
-
752
- private markReady(reason: ReadyReason) {
753
- const cycle = this.initCycle;
754
- const replayOnReady = Boolean(cycle?.replayOnReady);
755
- this.initCycle = undefined;
756
- this.clearInitTimers();
757
- this.initState = "ready";
758
- this.hasReadyOnce = true;
759
-
760
- const info = this.buildReadyInfo(reason);
761
- if (!info) {
762
- const err = new Error("Failed to build ready payload");
763
- this.initState = "init_error";
764
- this.emitInitError(err.message, "schemas-apply", true);
765
- this.rejectReadyWaiters(err);
766
- return;
767
- }
768
- this.socket._emit("ready", info);
769
- this.latestReadyInfo = info;
770
- this.resolveReadyWaiters(info);
771
-
772
- if (replayOnReady) {
773
- // Replay previously sent state before flushing newly queued outbound events.
774
- this.replay("activity-object", this.events["activity-object"]);
775
- this.replay("credentials", this.events.credentials);
776
- this.replay("message", this.events.connect);
777
- this.replay("message", this.events.join);
778
- }
779
-
780
- this.flushOutboundQueue();
781
- }
782
-
783
- private computePayloadFingerprint(payload: unknown): string | undefined {
784
- if (!payload || typeof payload !== "object") {
785
- return undefined;
786
- }
787
- const registry = payload as PlatformRegistryPayload;
788
- if (
789
- typeof registry.contexts?.as !== "string" ||
790
- typeof registry.contexts?.sockethub !== "string" ||
791
- !Array.isArray(registry.platforms)
792
- ) {
793
- return undefined;
794
- }
795
- const normalizedPlatforms = registry.platforms
796
- .map((platform) => ({
797
- id: platform.id,
798
- version: platform.version,
799
- contextUrl: platform.contextUrl,
800
- contextVersion: platform.contextVersion,
801
- schemaVersion: platform.schemaVersion,
802
- }))
803
- .sort((a, b) => a.id.localeCompare(b.id));
804
- return JSON.stringify({
805
- version: registry.version,
806
- contexts: registry.contexts,
807
- platforms: normalizedPlatforms,
808
- });
809
- }
810
-
811
- private handleSchemasPayload(payload: unknown) {
812
- if (!payload || typeof payload !== "object") {
813
- return;
814
- }
815
- const incomingFingerprint = this.computePayloadFingerprint(payload);
816
- if (
817
- this.initState === "ready" &&
818
- !this.initCycle &&
819
- incomingFingerprint &&
820
- incomingFingerprint === this.registryFingerprint
821
- ) {
822
- return;
823
- }
824
-
825
- if (this.initState === "ready" && !this.initCycle) {
826
- // A server-side schema update arrived while already running.
827
- this.initState = "initializing";
828
- }
829
-
830
- const normalizedPayload = this.applyPlatformRegistry(payload);
831
- if (!normalizedPayload) {
832
- this.initState = "init_error";
833
- this.emitInitError(
834
- "Received invalid schemas payload from server",
835
- "schemas-apply",
836
- true,
837
- );
838
- this.startWaitingWarnings();
839
- return;
840
- }
841
-
842
- if (this.initCycle) {
843
- this.markReady(this.initCycle.reason);
844
- return;
845
- }
846
- this.markReady("schemas-update");
847
- }
848
-
849
- private handlePublicEmit(
850
- event: string,
851
- content: unknown,
852
- callback?: unknown,
853
- ) {
854
- const queuedEvent: QueuedOutboundEvent = {
855
- event,
856
- content,
857
- callback,
858
- enqueuedAt: Date.now(),
859
- sequence: this.outboundSequence++,
860
- };
861
-
862
- if (!this.isReady()) {
863
- // Hold outbound until schemas/context metadata is loaded.
864
- this.enqueueOutbound(queuedEvent);
865
- return;
866
- }
867
- this.sendOutbound(queuedEvent);
868
- }
869
-
870
- private enqueueOutbound(queuedEvent: QueuedOutboundEvent) {
871
- this.outboundQueue.push(queuedEvent);
872
- if (this.outboundQueue.length <= this.options.maxQueuedOutbound) {
873
- return;
874
- }
875
- const dropped = this.outboundQueue.shift();
876
- if (!dropped) {
877
- return;
878
- }
879
- this.emitClientError(
880
- dropped.event,
881
- dropped.callback,
882
- "SockethubClient queue overflow before ready",
883
- );
884
- }
885
-
886
- private flushOutboundQueue() {
887
- if (!this.isReady() || this.outboundQueue.length === 0) {
888
- return;
889
- }
890
- const now = Date.now();
891
- const queued = this.outboundQueue.sort(
892
- (a, b) => a.sequence - b.sequence,
893
- );
894
- this.outboundQueue = [];
895
- for (const entry of queued) {
896
- if (now - entry.enqueuedAt > this.options.maxQueuedAgeMs) {
897
- this.emitClientError(
898
- entry.event,
899
- entry.callback,
900
- `SockethubClient queued message expired after ${this.options.maxQueuedAgeMs}ms before initialization`,
901
- );
902
- continue;
903
- }
904
- this.sendOutbound(entry);
905
- }
906
- }
907
-
908
- private sendOutbound(entry: QueuedOutboundEvent) {
909
- let outgoing = entry.content;
910
- try {
911
- if (entry.event === "credentials" || entry.event === "message") {
912
- // Run canonical expansion/normalization at send time so queued and
913
- // immediate sends follow the exact same path.
914
- outgoing = this.ActivityStreams.Stream(
915
- entry.content as ActivityStream,
916
- );
917
- if (outgoing && typeof outgoing === "object") {
918
- const activity = outgoing as ActivityStream;
919
- if (
920
- !activity["@context"] &&
921
- typeof activity.platform === "string" &&
922
- activity.platform.trim().length > 0
923
- ) {
924
- activity["@context"] = this.contextFor(
925
- activity.platform,
926
- );
927
- }
928
- if (entry.event === "credentials" && !activity.type) {
929
- activity.type = "credentials";
930
- }
931
- if (
932
- activity.actor &&
933
- typeof activity.actor === "object" &&
934
- !activity.actor.type
935
- ) {
936
- activity.actor.type = "person";
937
- }
938
- }
939
- if (this.platformRegistry.size > 0) {
940
- const validationError = this.validateActivity(
941
- outgoing as ActivityStream,
942
- );
943
- if (validationError) {
944
- this.emitClientError(
945
- entry.event,
946
- entry.callback,
947
- `SockethubClient validation failed: ${validationError}`,
948
- );
949
- return;
950
- }
951
- }
952
- }
953
- if (entry.event === "credentials") {
954
- this.eventCredentials(outgoing as ActivityStream);
955
- } else if (entry.event === "message") {
956
- this.eventMessage(outgoing as BaseActivityObject);
957
- }
958
- if (entry.event === "activity-object") {
959
- // Persist only after successful server ACK to avoid replaying
960
- // rejected objects on reconnection.
961
- const originalCallback = entry.callback;
962
- const obj = outgoing as ActivityObject;
963
- this._socket.emit(
964
- entry.event,
965
- outgoing,
966
- (resp?: { error?: string }) => {
967
- if (resp && typeof resp.error === "string") {
968
- if (obj.id) {
969
- this.events["activity-object"].delete(obj.id);
970
- }
971
- } else {
972
- this.eventActivityObject(obj);
973
- }
974
- if (typeof originalCallback === "function") {
975
- originalCallback(resp);
976
- }
977
- },
978
- );
979
- } else {
980
- this._socket.emit(entry.event, outgoing, entry.callback);
981
- }
982
- } catch (err) {
983
- const message = err instanceof Error ? err.message : String(err);
984
- this.emitClientError(entry.event, entry.callback, message);
985
- }
986
- }
987
-
988
- private log(msg: string, obj?: unknown) {
989
- if (this.debug) {
990
- console.log(msg, obj);
991
- }
992
- }
993
-
994
- private registerSocketIOHandlers() {
995
- // register for events that give us information on connection status
996
- this._socket.on("connect", () => {
997
- this.socket.id = this._socket.id;
998
- this.socket.connected = true;
999
- this.socket._emit("connect");
1000
- this.startInitialization(
1001
- this.hasReadyOnce ? "reconnect" : "initial-connect",
1002
- true,
1003
- );
1004
- });
1005
- this._socket.on("connect_error", (obj?: unknown) => {
1006
- this.socket._emit("connect_error", obj);
1007
- });
1008
- this._socket.on("disconnect", (obj?: unknown) => {
1009
- this.socket.connected = false;
1010
- if (this.initState !== "closed") {
1011
- this.initState = "idle";
1012
- }
1013
- this.clearInitTimers();
1014
- this.socket._emit("disconnect", obj);
1015
- });
1016
- this._socket.on("schemas", (payload: unknown) => {
1017
- this.handleSchemasPayload(payload);
1018
- });
1019
-
1020
- // use as middleware to receive incoming Sockethub messages and unpack them
1021
- // using the ActivityStreams library before passing them along to the app.
1022
- this._socket.on("message", (obj) => {
1023
- this.socket._emit("message", this.ActivityStreams.Stream(obj));
1024
- });
1025
- }
1026
-
1027
- /**
1028
- * Type guard to check if an object is an ActivityStream with a valid actor.id.
1029
- */
1030
- private hasActorId(
1031
- obj: ActivityStream | ActivityObject,
1032
- ): obj is ActivityStream {
1033
- return (
1034
- "actor" in obj &&
1035
- obj.actor !== null &&
1036
- typeof obj.actor === "object" &&
1037
- "id" in obj.actor &&
1038
- typeof obj.actor.id === "string"
1039
- );
1040
- }
1041
-
1042
- /**
1043
- * Replays previously sent events to the server after reconnection.
1044
- *
1045
- * This method is called automatically when the Socket.IO connection is
1046
- * re-established after a transient network interruption. It resends
1047
- * credentials and connection state so the user doesn't need to manually
1048
- * re-authenticate or rejoin channels.
1049
- *
1050
- * Security considerations:
1051
- * - Only replays events stored in memory during this session
1052
- * - Does not replay after page refresh (memory cleared)
1053
- * - Server should validate replayed credentials (may be stale/revoked)
1054
- *
1055
- * @param name - Event name to emit ("credentials", "activity-object", "message")
1056
- * @param asMap - Map of events to replay
1057
- */
1058
- private replay<K extends keyof ReplayEventMap>(
1059
- name: K,
1060
- asMap: Map<string, ReplayEventMap[K]>,
1061
- ): void {
1062
- for (const obj of asMap.values()) {
1063
- // activity-objects are raw objects, don't pass through Stream()
1064
- // which is designed for activity streams with actor/object structure
1065
- const isActivityObject = name === "activity-object";
1066
- if (isActivityObject) {
1067
- const expandedObj = obj as BaseActivityObject;
1068
- const id = expandedObj?.id;
1069
- this.log(`replaying ${name} for ${id}`);
1070
- this._socket.emit(name, expandedObj);
1071
- continue;
1072
- }
1073
-
1074
- const expandedObj = this.ActivityStreams.Stream(
1075
- obj as ActivityStream,
1076
- );
1077
- let id = expandedObj?.id;
1078
- if (this.hasActorId(expandedObj)) {
1079
- const actor = (expandedObj as ActivityStream).actor;
1080
- // actor can be a string (JID) or an object with an id field
1081
- id = typeof actor === "string" ? actor : actor.id;
1082
- }
1083
- this.log(`replaying ${name} for ${id}`);
1084
- this._socket.emit(name, expandedObj);
1085
- }
1086
- }
1087
- }
1088
-
1089
- ((global: Record<string, unknown>) => {
1090
- global.SockethubClient = SockethubClient;
1091
- })(
1092
- typeof globalThis === "object"
1093
- ? (globalThis as Record<string, unknown>)
1094
- : {},
1095
- );