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

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.
@@ -4,6 +4,12 @@ import type {
4
4
  ActivityStream,
5
5
  BaseActivityObject,
6
6
  } from "@sockethub/schemas";
7
+ import {
8
+ addPlatformContext,
9
+ addPlatformSchema,
10
+ validateActivityStream,
11
+ validateCredentials,
12
+ } from "@sockethub/schemas";
7
13
  import EventEmitter from "eventemitter3";
8
14
  import type { Socket } from "socket.io-client";
9
15
 
@@ -14,6 +20,46 @@ export interface EventMapping {
14
20
  join: Map<string, ActivityStream>;
15
21
  }
16
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
+
17
63
  interface CustomEmitter extends EventEmitter {
18
64
  _emit(s: string, o: unknown, c?: unknown): void;
19
65
  connect(): void;
@@ -22,6 +68,58 @@ interface CustomEmitter extends EventEmitter {
22
68
  id: string;
23
69
  }
24
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
+
25
123
  /**
26
124
  * SockethubClient - Client library for Sockethub protocol gateway
27
125
  *
@@ -73,14 +171,19 @@ interface CustomEmitter extends EventEmitter {
73
171
  * const socket = io('http://localhost:10550');
74
172
  * const client = new SockethubClient(socket);
75
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
+ *
76
180
  * // Send credentials - these will be replayed on reconnection
77
181
  * client.socket.emit('credentials', {
78
- * actor: 'user@example.com',
79
- * object: { username: 'user', password: 'pass' }
182
+ * '@context': ctx,
183
+ * type: 'credentials',
184
+ * actor: { id: 'user@example.com', type: 'person' },
185
+ * object: { type: 'credentials', username: 'user', password: 'pass' }
80
186
  * });
81
- *
82
- * // If network disconnects and reconnects, credentials are automatically replayed
83
- * // If page refreshes, credentials are lost and must be resent
84
187
  * ```
85
188
  */
86
189
  export default class SockethubClient {
@@ -97,15 +200,37 @@ export default class SockethubClient {
97
200
  join: new Map(),
98
201
  };
99
202
  private _socket: Socket;
100
- public ActivityStreams: ASManager;
101
- public socket: CustomEmitter;
203
+ public ActivityStreams!: ASManager;
204
+ public socket!: CustomEmitter;
102
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;
103
223
 
104
- constructor(socket: Socket) {
224
+ constructor(socket: Socket, options: SockethubClientOptions = {}) {
105
225
  if (!socket) {
106
226
  throw new Error("SockethubClient requires a socket.io instance");
107
227
  }
108
228
  this._socket = socket;
229
+ this.options = {
230
+ initTimeoutMs: options.initTimeoutMs ?? 5000,
231
+ maxQueuedOutbound: options.maxQueuedOutbound ?? 1000,
232
+ maxQueuedAgeMs: options.maxQueuedAgeMs ?? 30000,
233
+ };
109
234
 
110
235
  this.socket = this.createPublicEmitter();
111
236
  this.registerSocketIOHandlers();
@@ -114,19 +239,33 @@ export default class SockethubClient {
114
239
  this.ActivityStreams.on(
115
240
  "activity-object-create",
116
241
  (obj: ActivityObject) => {
117
- socket.emit("activity-object", obj, (err: never) => {
118
- if (err) {
119
- console.error("failed to create activity-object ", err);
120
- } else {
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
+ }
121
253
  this.eventActivityObject(obj);
122
- }
123
- });
254
+ },
255
+ );
124
256
  },
125
257
  );
126
258
 
127
259
  socket.on("activity-object", (obj) => {
128
260
  this.ActivityStreams.Object.create(obj);
129
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
+ }
130
269
  }
131
270
 
132
271
  initActivityStreams() {
@@ -152,22 +291,157 @@ export default class SockethubClient {
152
291
  this.events.credentials.clear();
153
292
  }
154
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
+
155
436
  private createPublicEmitter(): CustomEmitter {
156
437
  const socket = new EventEmitter() as CustomEmitter;
157
438
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
158
- // @ts-ignore
439
+ // @ts-expect-error
159
440
  socket._emit = socket.emit;
160
441
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
161
- // @ts-ignore
442
+ // @ts-expect-error
162
443
  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);
444
+ this.handlePublicEmit(event as string, content, callback);
171
445
  };
172
446
  socket.connected = false;
173
447
  socket.disconnect = () => {
@@ -179,6 +453,97 @@ export default class SockethubClient {
179
453
  return socket;
180
454
  }
181
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
+
182
547
  private eventActivityObject(content: ActivityObject) {
183
548
  if (content.id) {
184
549
  this.events["activity-object"].set(content.id, content);
@@ -222,6 +587,404 @@ export default class SockethubClient {
222
587
  return `${actor}-${target}`;
223
588
  }
224
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
+
225
988
  private log(msg: string, obj?: unknown) {
226
989
  if (this.debug) {
227
990
  console.log(msg, obj);
@@ -229,46 +992,30 @@ export default class SockethubClient {
229
992
  }
230
993
 
231
994
  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
995
  // 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"));
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
+ });
272
1019
 
273
1020
  // use as middleware to receive incoming Sockethub messages and unpack them
274
1021
  // using the ActivityStreams library before passing them along to the app.
@@ -308,15 +1055,30 @@ export default class SockethubClient {
308
1055
  * @param name - Event name to emit ("credentials", "activity-object", "message")
309
1056
  * @param asMap - Map of events to replay
310
1057
  */
311
- private replay(
312
- name: string,
313
- asMap: Map<string, ActivityStream | BaseActivityObject>,
314
- ) {
1058
+ private replay<K extends keyof ReplayEventMap>(
1059
+ name: K,
1060
+ asMap: Map<string, ReplayEventMap[K]>,
1061
+ ): void {
315
1062
  for (const obj of asMap.values()) {
316
- const expandedObj = this.ActivityStreams.Stream(obj);
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
+ );
317
1077
  let id = expandedObj?.id;
318
1078
  if (this.hasActorId(expandedObj)) {
319
- id = expandedObj.actor.id;
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;
320
1082
  }
321
1083
  this.log(`replaying ${name} for ${id}`);
322
1084
  this._socket.emit(name, expandedObj);
@@ -324,8 +1086,10 @@ export default class SockethubClient {
324
1086
  }
325
1087
  }
326
1088
 
327
- // biome-ignore lint/suspicious/noExplicitAny: <explanation>
328
- ((global: any) => {
1089
+ ((global: Record<string, unknown>) => {
329
1090
  global.SockethubClient = SockethubClient;
330
- // @ts-ignore
331
- })(typeof window === "object" ? window : {});
1091
+ })(
1092
+ typeof globalThis === "object"
1093
+ ? (globalThis as Record<string, unknown>)
1094
+ : {},
1095
+ );