@belysh/socket-bridge-client 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,17 @@
1
1
  # Socket Bridge client
2
2
 
3
- Optional, framework-neutral helper around Socket.IO. React is not required.
3
+ Optional, framework-neutral helper around Socket.IO. React and Vue are not required.
4
4
  The gateway also accepts a plain `socket.io-client`.
5
5
 
6
- The client is distributed as a tarball attached to each GitHub release. See the
7
- [Laravel package README](https://github.com/Belysh/laravel-socket-bridge#readme)
8
- for the matching installation URL. Build from source with `npm ci && npm run build`.
6
+ ```bash
7
+ npm install @belysh/socket-bridge-client
8
+ ```
9
+
10
+ Use the matching release of the
11
+ [Laravel package](https://github.com/Belysh/laravel-socket-bridge#readme).
12
+ Compiled ESM and TypeScript declarations are included. The tested client tarball
13
+ is also attached to each GitHub release. To develop the client itself, build from
14
+ source with `npm ci && npm run build`.
9
15
 
10
16
  ```ts
11
17
  import { createBridge } from '@belysh/socket-bridge-client';
@@ -17,23 +23,28 @@ const bridge = createBridge({
17
23
  onError: (error) => console.error(error.message),
18
24
  });
19
25
 
20
- await bridge.join('private-chat.42'); // Exact Laravel PrivateChannel wire name.
21
- const unsubscribe = bridge.on('chat.message.created', (payload, metadata) => {
26
+ const chat = bridge.private('chat.42').listen('chat.message.created', (payload, metadata) => {
22
27
  renderMessage(payload.message);
23
28
  });
24
29
  bridge.connect();
25
30
 
26
- // HTTP mutations must carry the originating socket ID for Laravel toOthers().
27
- await fetch('/messages', {
28
- method: 'POST',
29
- headers: bridge.headers({ 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }),
30
- body: JSON.stringify({ text: 'Hello' }),
31
- });
31
+ // Call from a UI action after connection. Include the socket ID for toOthers().
32
+ async function sendMessage(text: string) {
33
+ if (!bridge.socket.connected) throw new Error('Realtime connection is not ready.');
34
+ return fetch('/messages', {
35
+ method: 'POST',
36
+ headers: bridge.headers({ 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }),
37
+ body: JSON.stringify({ text }),
38
+ });
39
+ }
32
40
 
33
41
  // A result resolves only after Laravel processes the command, not on enqueue.
34
- // Submit after the socket is connected, for example from a UI event handler.
35
- const commandId = crypto.randomUUID();
36
- const message = await bridge.command('chat.message.create', { text: 'Hello' }, commandId);
42
+ // Keep commandId in your UI state if the outcome is unknown and needs a retry.
43
+ async function sendCommand(text: string, commandId: string) {
44
+ return bridge.command('chat.message.create', { text }, commandId);
45
+ }
46
+
47
+ // When this screen is removed: chat.dispose().
37
48
  ```
38
49
 
39
50
  For bearer authentication, provide `getToken`, or `tokenHeaders` returning an
@@ -74,3 +85,25 @@ Raw Socket.IO integrations can implement the same lifecycle by listening for
74
85
  `bridge.session` and sending `session:refresh` with `{ token }` before expiry;
75
86
  `bridge.disconnect.retryable` distinguishes a recoverable outage/expiry from
76
87
  revocation. Only retry a server disconnect when this flag is `true`.
88
+
89
+ ## Channels, React and Vue (1.1)
90
+
91
+ ```ts
92
+ const orders = bridge.private('orders.42')
93
+ .listen('order.updated', order => updateOrder(order))
94
+ .error(error => showError(error.message));
95
+ // Remove only this screen's listeners/subscription reference:
96
+ orders.dispose();
97
+ ```
98
+
99
+ Channel-scoped listeners require the matching 1.1+ gateway channel metadata;
100
+ legacy global `on()` and manual `join()` remain available. Multiple handles share
101
+ a room and only the last disposal leaves it. `presence()` exposes authoritative
102
+ `here` snapshots and `joining`/`leaving` changes. `BridgeError.details` preserves
103
+ Laravel field validation errors.
104
+
105
+ Optional integrations are exported from `@belysh/socket-bridge-client/react`
106
+ (React 18/19) and `/vue` (Vue 3.5+). They provide `useSocketBridge`,
107
+ `useChannelEvent` and `usePresence`, with reference-counted subscriptions and
108
+ lifecycle cleanup. Neither framework is loaded by the base entry point.
109
+ See [the complete client guide](https://github.com/Belysh/laravel-socket-bridge/blob/main/docs/CLIENT.md).
@@ -0,0 +1,39 @@
1
+ import type { EventMetadata, JsonObject, SocketBridge } from './index.js';
2
+ export interface PresenceMember<T = JsonObject> {
3
+ id: string;
4
+ info: T;
5
+ }
6
+ export type ChannelListener<T = JsonObject> = (payload: T, metadata?: EventMetadata) => void;
7
+ /** One subscription owner. Dispose only releases this owner's listeners and room reference. */
8
+ export declare class BridgeChannel {
9
+ private readonly bridge;
10
+ readonly name: string;
11
+ ready: Promise<void>;
12
+ private disposed;
13
+ private listeners;
14
+ private hereListeners;
15
+ private joiningListeners;
16
+ private leavingListeners;
17
+ private errorListeners;
18
+ private members?;
19
+ private lastError?;
20
+ constructor(bridge: SocketBridge, name: string);
21
+ listen<T = JsonObject>(event: string, listener: ChannelListener<T>): this;
22
+ stopListening<T = JsonObject>(event: string, listener?: ChannelListener<T>): this;
23
+ notification<T = JsonObject>(listener: ChannelListener<T>): this;
24
+ /** Called with each authoritative presence snapshot, including refreshed member information. */
25
+ here(listener: (members: PresenceMember[]) => void): this;
26
+ joining(listener: (member: PresenceMember) => void): this;
27
+ leaving(listener: (member: PresenceMember) => void): this;
28
+ error(listener: (error: Error) => void): this;
29
+ /** Release this handle. Other components subscribed to the same channel remain connected. */
30
+ dispose(): void;
31
+ leave(): void;
32
+ /** @internal */
33
+ receivePresence(members: PresenceMember[]): void;
34
+ /** @internal */
35
+ resetPresence(): void;
36
+ /** @internal */
37
+ receiveError(error: unknown): void;
38
+ private assertActive;
39
+ }
@@ -0,0 +1,116 @@
1
+ const notificationEvent = 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated';
2
+ /** One subscription owner. Dispose only releases this owner's listeners and room reference. */
3
+ export class BridgeChannel {
4
+ bridge;
5
+ name;
6
+ ready = Promise.resolve();
7
+ disposed = false;
8
+ listeners = new Map();
9
+ hereListeners = new Set();
10
+ joiningListeners = new Set();
11
+ leavingListeners = new Set();
12
+ errorListeners = new Set();
13
+ members;
14
+ lastError;
15
+ constructor(bridge, name) {
16
+ this.bridge = bridge;
17
+ this.name = name;
18
+ }
19
+ listen(event, listener) {
20
+ this.assertActive();
21
+ const listeners = this.listeners.get(event) ?? new Map();
22
+ if (!listeners.has(listener)) {
23
+ listeners.set(listener, this.bridge.on(event, (payload, metadata) => {
24
+ // Old gateways cannot safely support scoped listeners. Never fall back to unfiltered delivery.
25
+ if (metadata?.channels?.includes(this.name))
26
+ listener(payload, metadata);
27
+ }));
28
+ this.listeners.set(event, listeners);
29
+ }
30
+ return this;
31
+ }
32
+ stopListening(event, listener) {
33
+ const listeners = this.listeners.get(event);
34
+ if (listener) {
35
+ listeners?.get(listener)?.();
36
+ listeners?.delete(listener);
37
+ }
38
+ else {
39
+ for (const stop of listeners?.values() ?? [])
40
+ stop();
41
+ this.listeners.delete(event);
42
+ }
43
+ return this;
44
+ }
45
+ notification(listener) { return this.listen(notificationEvent, listener); }
46
+ /** Called with each authoritative presence snapshot, including refreshed member information. */
47
+ here(listener) {
48
+ this.assertActive();
49
+ this.hereListeners.add(listener);
50
+ if (this.members)
51
+ listener(this.members);
52
+ return this;
53
+ }
54
+ joining(listener) { this.assertActive(); this.joiningListeners.add(listener); return this; }
55
+ leaving(listener) { this.assertActive(); this.leavingListeners.add(listener); return this; }
56
+ error(listener) {
57
+ this.assertActive();
58
+ this.errorListeners.add(listener);
59
+ if (this.lastError)
60
+ listener(this.lastError);
61
+ return this;
62
+ }
63
+ /** Release this handle. Other components subscribed to the same channel remain connected. */
64
+ dispose() {
65
+ if (this.disposed)
66
+ return;
67
+ this.disposed = true;
68
+ for (const event of this.listeners.keys())
69
+ this.stopListening(event);
70
+ this.hereListeners.clear();
71
+ this.joiningListeners.clear();
72
+ this.leavingListeners.clear();
73
+ this.errorListeners.clear();
74
+ this.members = undefined;
75
+ this.bridge.releaseChannel(this);
76
+ }
77
+ leave() { this.dispose(); }
78
+ /** @internal */
79
+ receivePresence(members) {
80
+ if (this.disposed)
81
+ return;
82
+ const previous = this.members;
83
+ this.members = members;
84
+ for (const listener of this.hereListeners)
85
+ listener(members);
86
+ // A first snapshot establishes a baseline; reconnects must not synthesize everyone joining.
87
+ if (!previous)
88
+ return;
89
+ const old = new Map(previous.map(member => [member.id, member]));
90
+ const current = new Map(members.map(member => [member.id, member]));
91
+ for (const [id, member] of current)
92
+ if (!old.has(id))
93
+ for (const listener of this.joiningListeners)
94
+ listener(member);
95
+ for (const [id, member] of old)
96
+ if (!current.has(id))
97
+ for (const listener of this.leavingListeners)
98
+ listener(member);
99
+ }
100
+ /** @internal */
101
+ resetPresence() {
102
+ this.members = undefined;
103
+ for (const listener of this.hereListeners)
104
+ listener([]);
105
+ }
106
+ /** @internal */
107
+ receiveError(error) {
108
+ if (this.disposed)
109
+ return;
110
+ this.lastError = error instanceof Error ? error : new Error(String(error));
111
+ for (const listener of this.errorListeners)
112
+ listener(this.lastError);
113
+ }
114
+ assertActive() { if (this.disposed)
115
+ throw new Error('This channel handle has been disposed. Create a new handle.'); }
116
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { io, type Socket } from 'socket.io-client';
2
+ import { BridgeChannel } from './channel.js';
3
+ export { BridgeChannel, type PresenceMember } from './channel.js';
2
4
  export type JsonObject = Record<string, unknown>;
3
5
  export interface EventMetadata {
4
6
  id: string;
5
7
  created_at: string;
6
- v: 1;
8
+ v: 1; /** Canonical authorized channel targets; available from gateway 1.1. */
9
+ channels?: string[];
7
10
  }
8
11
  export interface BridgeErrorData {
9
12
  code: string;
@@ -34,13 +37,22 @@ export interface BridgeOptions {
34
37
  export declare class BridgeError extends Error {
35
38
  readonly code: string;
36
39
  readonly commandId?: string | undefined;
37
- constructor(code: string, message: string, commandId?: string | undefined);
40
+ readonly details?: unknown | undefined;
41
+ constructor(code: string, message: string, commandId?: string | undefined, details?: unknown | undefined);
38
42
  }
39
43
  /** A thin helper; the underlying ordinary Socket.IO socket remains available. */
40
44
  export declare class SocketBridge {
41
45
  private readonly options;
42
46
  readonly socket: Socket;
43
47
  private readonly desiredChannels;
48
+ private readonly manualChannels;
49
+ private readonly channels;
50
+ private readonly channelJoins;
51
+ private readonly activeChannels;
52
+ private readonly presenceSnapshots;
53
+ private connectionGeneration;
54
+ private connectionReferences;
55
+ private managedConnection;
44
56
  private readonly pending;
45
57
  private readonly fetcher;
46
58
  private readonly timeout;
@@ -57,10 +69,19 @@ export declare class SocketBridge {
57
69
  private readonly channelRetries;
58
70
  constructor(options: BridgeOptions);
59
71
  connect(): this;
72
+ private beginConnection;
73
+ /** Reference-count a framework consumer without closing a manually connected socket. */
74
+ retainConnection(): () => void;
60
75
  disconnect(): void;
61
76
  destroy(): void;
62
77
  join(channel: string): Promise<void>;
63
78
  leave(channel: string): Promise<void>;
79
+ /** Create an independently disposable subscription to an exact canonical channel. */
80
+ channel(name: string): BridgeChannel;
81
+ private(name: string): BridgeChannel;
82
+ presence(name: string): BridgeChannel;
83
+ /** Internal lifetime hook used by channel handles. */
84
+ releaseChannel(handle: BridgeChannel): void;
64
85
  /** Dedupe is bounded and per listener, so multiple consumers all receive a new event. */
65
86
  on<T = JsonObject>(event: string, listener: (payload: T, metadata?: EventMetadata) => void): () => void;
66
87
  /** Keep id when retrying after an unknown outcome. Accepted ACK is not business success. */
@@ -68,6 +89,7 @@ export declare class SocketBridge {
68
89
  /** Merge into HTTP mutations to enable Laravel InteractsWithSockets / toOthers(). */
69
90
  headers(initial?: HeadersInit): Headers;
70
91
  private obtainToken;
92
+ private subscribeChannel;
71
93
  private restoreSubscriptions;
72
94
  private resync;
73
95
  private isTerminal;
package/dist/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { io } from 'socket.io-client';
2
+ import { BridgeChannel } from './channel.js';
3
+ export { BridgeChannel } from './channel.js';
2
4
  export class BridgeError extends Error {
3
5
  code;
4
6
  commandId;
5
- constructor(code, message, commandId) {
7
+ details;
8
+ constructor(code, message, commandId, details) {
6
9
  super(message);
7
10
  this.code = code;
8
11
  this.commandId = commandId;
12
+ this.details = details;
9
13
  this.name = 'BridgeError';
10
14
  }
11
15
  }
@@ -14,6 +18,14 @@ export class SocketBridge {
14
18
  options;
15
19
  socket;
16
20
  desiredChannels = new Set();
21
+ manualChannels = new Set();
22
+ channels = new Map();
23
+ channelJoins = new Map();
24
+ activeChannels = new Set();
25
+ presenceSnapshots = new Map();
26
+ connectionGeneration = 0;
27
+ connectionReferences = 0;
28
+ managedConnection = false;
17
29
  pending = new Map();
18
30
  fetcher;
19
31
  timeout;
@@ -46,6 +58,9 @@ export class SocketBridge {
46
58
  },
47
59
  });
48
60
  this.socket.on('connect', () => {
61
+ this.connectionGeneration++;
62
+ this.activeChannels.clear();
63
+ this.channelJoins.clear();
49
64
  this.reconnectAttempts = 0;
50
65
  this.retryableDisconnect = false;
51
66
  if (this.reconnectTimer)
@@ -54,7 +69,7 @@ export class SocketBridge {
54
69
  void this.restoreSubscriptions();
55
70
  });
56
71
  this.socket.on('connect_error', (error) => {
57
- const reason = this.authError ?? new BridgeError(error.data?.code ?? 'transport.unavailable', error.message);
72
+ const reason = this.authError ?? new BridgeError(error.data?.code ?? 'transport.unavailable', error.data?.message ?? error.message, undefined, error.data?.details);
58
73
  this.authError = undefined;
59
74
  this.report(reason);
60
75
  if (this.isTerminal(reason))
@@ -69,6 +84,13 @@ export class SocketBridge {
69
84
  this.report(new BridgeError(packet.code, packet.retryable ? 'Realtime connection will be restored.' : 'Realtime access was revoked.'));
70
85
  });
71
86
  this.socket.on('disconnect', () => {
87
+ this.connectionGeneration++;
88
+ this.activeChannels.clear();
89
+ this.channelJoins.clear();
90
+ this.presenceSnapshots.clear();
91
+ for (const handles of this.channels.values())
92
+ for (const handle of handles)
93
+ handle.resetPresence();
72
94
  this.clearRefresh();
73
95
  for (const retry of this.channelRetries.values())
74
96
  clearTimeout(retry.timer);
@@ -82,10 +104,33 @@ export class SocketBridge {
82
104
  this.scheduleRefresh(Math.min(packet.refresh_after_ms, 2_147_483_647));
83
105
  }
84
106
  });
85
- this.socket.on('bridge.subscription.restored', () => { void this.resync(); });
107
+ this.socket.on('bridge.presence', (packet) => {
108
+ if (!packet || typeof packet.channel !== 'string' || !Array.isArray(packet.members) || !this.desiredChannels.has(packet.channel))
109
+ return;
110
+ this.presenceSnapshots.set(packet.channel, packet.members);
111
+ for (const handle of this.channels.get(packet.channel) ?? [])
112
+ handle.receivePresence(packet.members);
113
+ });
114
+ this.socket.on('bridge.subscription.suspended', (packet) => {
115
+ this.activeChannels.delete(packet.channel);
116
+ this.presenceSnapshots.delete(packet.channel);
117
+ for (const handle of this.channels.get(packet.channel) ?? [])
118
+ handle.resetPresence();
119
+ });
120
+ this.socket.on('bridge.subscription.restored', (packet) => {
121
+ if (this.desiredChannels.has(packet.channel))
122
+ this.activeChannels.add(packet.channel);
123
+ void this.resync();
124
+ });
86
125
  this.socket.on('bridge.subscription.revoked', (packet) => {
87
126
  this.forgetChannel(packet.channel);
88
- this.report(new BridgeError('subscription.revoked', `Access to ${packet.channel} was revoked.`));
127
+ this.presenceSnapshots.delete(packet.channel);
128
+ for (const handle of this.channels.get(packet.channel) ?? [])
129
+ handle.resetPresence();
130
+ const error = new BridgeError('subscription.revoked', `Access to ${packet.channel} was revoked.`);
131
+ for (const handle of this.channels.get(packet.channel) ?? [])
132
+ handle.receiveError(error);
133
+ this.report(error);
89
134
  });
90
135
  this.socket.on('bridge.command.result', (packet) => {
91
136
  const item = this.pending.get(packet.command_id);
@@ -96,18 +141,43 @@ export class SocketBridge {
96
141
  if (packet.result.ok)
97
142
  item.resolve(packet.result.data);
98
143
  else
99
- item.reject(new BridgeError(packet.result.error?.code ?? 'command.failed', packet.result.error?.message ?? 'Command failed', packet.command_id));
144
+ item.reject(new BridgeError(packet.result.error?.code ?? 'command.failed', packet.result.error?.message ?? 'Command failed', packet.command_id, packet.result.error?.details));
100
145
  });
101
146
  }
102
147
  connect() {
103
148
  if (this.destroyed)
104
149
  throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
150
+ this.managedConnection = false;
151
+ return this.beginConnection();
152
+ }
153
+ beginConnection() {
105
154
  this.wantConnected = true;
106
155
  this.terminal = false;
107
156
  this.socket.connect();
108
157
  return this;
109
158
  }
159
+ /** Reference-count a framework consumer without closing a manually connected socket. */
160
+ retainConnection() {
161
+ if (this.destroyed)
162
+ throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
163
+ this.connectionReferences++;
164
+ if (!this.wantConnected) {
165
+ this.managedConnection = true;
166
+ this.beginConnection();
167
+ }
168
+ let released = false;
169
+ return () => {
170
+ if (released)
171
+ return;
172
+ released = true;
173
+ this.connectionReferences--;
174
+ if (this.connectionReferences === 0 && this.managedConnection)
175
+ this.disconnect();
176
+ };
177
+ }
110
178
  disconnect() {
179
+ this.connectionGeneration++;
180
+ this.managedConnection = false;
111
181
  this.wantConnected = false;
112
182
  if (this.reconnectTimer)
113
183
  clearTimeout(this.reconnectTimer);
@@ -116,6 +186,8 @@ export class SocketBridge {
116
186
  for (const retry of this.channelRetries.values())
117
187
  clearTimeout(retry.timer);
118
188
  this.channelRetries.clear();
189
+ this.activeChannels.clear();
190
+ this.channelJoins.clear();
119
191
  this.socket.disconnect();
120
192
  }
121
193
  destroy() {
@@ -128,24 +200,64 @@ export class SocketBridge {
128
200
  }
129
201
  this.pending.clear();
130
202
  this.desiredChannels.clear();
203
+ this.manualChannels.clear();
204
+ this.presenceSnapshots.clear();
205
+ for (const handles of [...this.channels.values()])
206
+ for (const handle of [...handles])
207
+ handle.dispose();
208
+ this.channels.clear();
131
209
  }
132
210
  async join(channel) {
211
+ if (this.destroyed)
212
+ throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
213
+ this.manualChannels.add(channel);
133
214
  this.desiredChannels.add(channel);
134
- if (this.socket.connected) {
135
- try {
136
- await this.ack('room:join', { channel });
137
- }
138
- catch (error) {
139
- this.failedSubscription(channel, error);
140
- throw error;
141
- }
142
- }
215
+ if (this.socket.connected)
216
+ await this.subscribeChannel(channel);
143
217
  }
144
218
  async leave(channel) {
219
+ this.manualChannels.delete(channel);
220
+ if (this.channels.get(channel)?.size)
221
+ return;
145
222
  this.forgetChannel(channel);
146
223
  if (this.socket.connected)
147
224
  await this.ack('room:leave', { channel });
148
225
  }
226
+ /** Create an independently disposable subscription to an exact canonical channel. */
227
+ channel(name) {
228
+ if (this.destroyed)
229
+ throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
230
+ if (!/^[a-zA-Z0-9_.:-]{1,200}$/.test(name) || name.startsWith('__'))
231
+ throw new BridgeError('invalid_channel', 'Invalid or reserved channel.');
232
+ const handle = new BridgeChannel(this, name);
233
+ const handles = this.channels.get(name) ?? new Set();
234
+ handles.add(handle);
235
+ this.channels.set(name, handles);
236
+ this.desiredChannels.add(name);
237
+ const cached = this.presenceSnapshots.get(name);
238
+ if (cached)
239
+ handle.receivePresence(cached);
240
+ handle.ready = this.socket.connected ? this.subscribeChannel(name) : Promise.resolve();
241
+ // Listeners can use .error(); merely constructing a channel must not create an unhandled rejection.
242
+ void handle.ready.catch(() => undefined);
243
+ return handle;
244
+ }
245
+ private(name) { return this.channel(name.startsWith('private-') ? name : `private-${name}`); }
246
+ presence(name) { return this.channel(name.startsWith('presence-') ? name : `presence-${name}`); }
247
+ /** Internal lifetime hook used by channel handles. */
248
+ releaseChannel(handle) {
249
+ const handles = this.channels.get(handle.name);
250
+ handles?.delete(handle);
251
+ if (handles?.size)
252
+ return;
253
+ this.channels.delete(handle.name);
254
+ if (this.manualChannels.has(handle.name))
255
+ return;
256
+ this.forgetChannel(handle.name);
257
+ this.presenceSnapshots.delete(handle.name);
258
+ if (!this.destroyed && this.socket.connected)
259
+ void this.ack('room:leave', { channel: handle.name }).catch(error => this.report(error));
260
+ }
149
261
  /** Dedupe is bounded and per listener, so multiple consumers all receive a new event. */
150
262
  on(event, listener) {
151
263
  const seen = new Set();
@@ -180,7 +292,7 @@ export class SocketBridge {
180
292
  return;
181
293
  clearTimeout(item.timer);
182
294
  this.pending.delete(id);
183
- reject(error instanceof BridgeError ? new BridgeError(error.code, error.message, id) : error);
295
+ reject(error instanceof BridgeError ? new BridgeError(error.code, error.message, id, error.details) : error);
184
296
  });
185
297
  });
186
298
  }
@@ -216,17 +328,47 @@ export class SocketBridge {
216
328
  throw new BridgeError('auth.invalid_response', 'Ticket response did not include a token.');
217
329
  return token;
218
330
  }
331
+ subscribeChannel(channel, attempts = 0) {
332
+ if (!this.socket.connected || this.destroyed || this.terminal)
333
+ return Promise.resolve();
334
+ const pending = this.channelJoins.get(channel);
335
+ if (pending)
336
+ return pending;
337
+ if (this.activeChannels.has(channel))
338
+ return Promise.resolve();
339
+ let request;
340
+ request = this.ack('room:join', { channel }).then(() => {
341
+ if (this.channelJoins.get(channel) === request && this.desiredChannels.has(channel))
342
+ this.activeChannels.add(channel);
343
+ }).catch(error => {
344
+ // A leave/rejoin or newer connection supersedes the old request and its eventual error.
345
+ if (this.channelJoins.get(channel) === request) {
346
+ this.failedSubscription(channel, error, attempts);
347
+ for (const handle of this.channels.get(channel) ?? [])
348
+ handle.receiveError(error);
349
+ this.report(error);
350
+ }
351
+ throw error;
352
+ }).finally(() => { if (this.channelJoins.get(channel) === request)
353
+ this.channelJoins.delete(channel); });
354
+ this.channelJoins.set(channel, request);
355
+ return request;
356
+ }
219
357
  async restoreSubscriptions() {
220
- for (const channel of this.desiredChannels) {
358
+ const generation = this.connectionGeneration;
359
+ const current = () => generation === this.connectionGeneration && this.socket.connected && !this.destroyed && !this.terminal;
360
+ for (const channel of [...this.desiredChannels]) {
361
+ if (!current())
362
+ return;
363
+ if (!this.desiredChannels.has(channel))
364
+ continue;
221
365
  try {
222
- await this.ack('room:join', { channel });
223
- }
224
- catch (error) {
225
- this.failedSubscription(channel, error);
226
- this.report(error);
366
+ await this.subscribeChannel(channel);
227
367
  }
368
+ catch { /* subscribeChannel retains transient intent and reports the error. */ }
228
369
  }
229
- await this.resync();
370
+ if (current())
371
+ await this.resync();
230
372
  }
231
373
  async resync() {
232
374
  try {
@@ -240,6 +382,7 @@ export class SocketBridge {
240
382
  return error instanceof BridgeError && ['unauthenticated', 'session_revoked', 'auth.revoked', 'forbidden', 'invalid_channel', 'subscription_cancelled'].includes(error.code);
241
383
  }
242
384
  stopForRevocation() {
385
+ this.connectionGeneration++;
243
386
  this.terminal = true;
244
387
  if (this.reconnectTimer)
245
388
  clearTimeout(this.reconnectTimer);
@@ -297,6 +440,10 @@ export class SocketBridge {
297
440
  }
298
441
  forgetChannel(channel) {
299
442
  this.desiredChannels.delete(channel);
443
+ this.manualChannels.delete(channel);
444
+ this.presenceSnapshots.delete(channel);
445
+ this.activeChannels.delete(channel);
446
+ this.channelJoins.delete(channel);
300
447
  const retry = this.channelRetries.get(channel);
301
448
  if (retry)
302
449
  clearTimeout(retry.timer);
@@ -313,10 +460,7 @@ export class SocketBridge {
313
460
  this.channelRetries.delete(channel);
314
461
  if (!this.desiredChannels.has(channel) || !this.socket.connected || this.destroyed || this.terminal)
315
462
  return;
316
- void this.ack('room:join', { channel }).then(() => this.resync(), next => {
317
- this.failedSubscription(channel, next, attempts + 1);
318
- this.report(next);
319
- });
463
+ void this.subscribeChannel(channel, attempts + 1).then(() => this.resync(), () => undefined);
320
464
  }, this.backoff(attempts));
321
465
  this.channelRetries.set(channel, { attempts, timer });
322
466
  }
@@ -326,7 +470,7 @@ export class SocketBridge {
326
470
  if (error)
327
471
  return reject(new BridgeError('socket.ack_timeout', 'Gateway acknowledgement timed out. The outcome may be unknown.'));
328
472
  if (!response?.ok)
329
- return reject(new BridgeError(response?.error?.code ?? 'socket.rejected', response?.error?.message ?? 'Gateway rejected the request.'));
473
+ return reject(new BridgeError(response?.error?.code ?? 'socket.rejected', response?.error?.message ?? 'Gateway rejected the request.', undefined, response?.error?.details));
330
474
  resolve(response);
331
475
  });
332
476
  });
@@ -0,0 +1,10 @@
1
+ import type { EventMetadata, JsonObject, PresenceMember, SocketBridge } from './index.js';
2
+ /** Share one application-created bridge across components; the last managed consumer disconnects. */
3
+ export declare function useSocketBridge(bridge: SocketBridge): boolean;
4
+ /** Exact canonical channel name, e.g. private-orders.42. Each mounted hook owns one reference. */
5
+ export declare function useChannelEvent<T = JsonObject>(bridge: SocketBridge, channel: string, event: string, listener: (payload: T, metadata?: EventMetadata) => void, onError?: (error: Error) => void): void;
6
+ /** Name without presence- is accepted. Members are authoritative snapshots, not attendance history. */
7
+ export declare function usePresence(bridge: SocketBridge, channel: string): {
8
+ members: PresenceMember[];
9
+ error: Error | null;
10
+ };
package/dist/react.js ADDED
@@ -0,0 +1,34 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ /** Share one application-created bridge across components; the last managed consumer disconnects. */
3
+ export function useSocketBridge(bridge) {
4
+ useEffect(() => bridge.retainConnection(), [bridge]);
5
+ const subscribe = useCallback((notify) => {
6
+ bridge.socket.on('connect', notify);
7
+ bridge.socket.on('disconnect', notify);
8
+ return () => { bridge.socket.off('connect', notify); bridge.socket.off('disconnect', notify); };
9
+ }, [bridge]);
10
+ return useSyncExternalStore(subscribe, () => bridge.socket.connected, () => false);
11
+ }
12
+ /** Exact canonical channel name, e.g. private-orders.42. Each mounted hook owns one reference. */
13
+ export function useChannelEvent(bridge, channel, event, listener, onError) {
14
+ const latest = useRef({ listener, onError });
15
+ useEffect(() => { latest.current = { listener, onError }; }, [listener, onError]);
16
+ useEffect(() => {
17
+ const handle = bridge.channel(channel).listen(event, (payload, metadata) => latest.current.listener(payload, metadata))
18
+ .error(error => latest.current.onError?.(error));
19
+ return () => handle.dispose();
20
+ }, [bridge, channel, event]);
21
+ }
22
+ /** Name without presence- is accepted. Members are authoritative snapshots, not attendance history. */
23
+ export function usePresence(bridge, channel) {
24
+ const [state, setState] = useState({ bridge, channel, members: [], error: null });
25
+ useEffect(() => {
26
+ const handle = bridge.presence(channel)
27
+ .here(members => setState({ bridge, channel, members, error: null }))
28
+ .error(error => setState(current => ({ bridge, channel, members: current.bridge === bridge && current.channel === channel ? current.members : [], error })));
29
+ const reset = () => setState({ bridge, channel, members: [], error: null });
30
+ bridge.socket.on('disconnect', reset);
31
+ return () => { bridge.socket.off('disconnect', reset); handle.dispose(); };
32
+ }, [bridge, channel]);
33
+ return state.bridge === bridge && state.channel === channel ? state : { members: [], error: null };
34
+ }
package/dist/vue.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type MaybeRefOrGetter } from 'vue';
2
+ import type { EventMetadata, JsonObject, SocketBridge } from './index.js';
3
+ /** No connection is opened during component SSR. Scope cleanup releases only this consumer. */
4
+ export declare function useSocketBridge(bridge: SocketBridge): Readonly<import("vue").Ref<boolean, boolean>>;
5
+ /** Reactive canonical name; old listeners and subscriptions are released when it changes. */
6
+ export declare function useChannelEvent<T = JsonObject>(bridge: SocketBridge, channel: MaybeRefOrGetter<string>, event: MaybeRefOrGetter<string>, listener: (payload: T, metadata?: EventMetadata) => void, onError?: (error: Error) => void): void;
7
+ export declare function usePresence(bridge: SocketBridge, channel: MaybeRefOrGetter<string>): {
8
+ members: Readonly<import("vue").Ref<readonly {
9
+ readonly id: string;
10
+ readonly info: {
11
+ readonly [x: string]: Readonly<unknown>;
12
+ };
13
+ }[], readonly {
14
+ readonly id: string;
15
+ readonly info: {
16
+ readonly [x: string]: Readonly<unknown>;
17
+ };
18
+ }[]>>;
19
+ error: Readonly<import("vue").Ref<Error | null, Error | null>>;
20
+ };
package/dist/vue.js ADDED
@@ -0,0 +1,41 @@
1
+ import { getCurrentInstance, getCurrentScope, onMounted, onScopeDispose, readonly, shallowRef, toValue, watch } from 'vue';
2
+ function assertScope() { if (!getCurrentScope())
3
+ throw new Error('Socket Bridge composables require a component setup() or an active effectScope().'); }
4
+ /** No connection is opened during component SSR. Scope cleanup releases only this consumer. */
5
+ export function useSocketBridge(bridge) {
6
+ assertScope();
7
+ const connected = shallowRef(bridge.socket.connected);
8
+ const update = () => { connected.value = bridge.socket.connected; };
9
+ bridge.socket.on('connect', update);
10
+ bridge.socket.on('disconnect', update);
11
+ let release;
12
+ const connect = () => { release = bridge.retainConnection(); update(); };
13
+ if (getCurrentInstance())
14
+ onMounted(connect);
15
+ else
16
+ connect();
17
+ onScopeDispose(() => { bridge.socket.off('connect', update); bridge.socket.off('disconnect', update); release?.(); });
18
+ return readonly(connected);
19
+ }
20
+ /** Reactive canonical name; old listeners and subscriptions are released when it changes. */
21
+ export function useChannelEvent(bridge, channel, event, listener, onError) {
22
+ assertScope();
23
+ watch(() => [toValue(channel), toValue(event)], ([name, eventName], _previous, onCleanup) => {
24
+ const handle = bridge.channel(name).listen(eventName, listener).error(error => onError?.(error));
25
+ onCleanup(() => handle.dispose());
26
+ }, { immediate: true, flush: 'sync' });
27
+ }
28
+ export function usePresence(bridge, channel) {
29
+ assertScope();
30
+ const members = shallowRef([]);
31
+ const error = shallowRef(null);
32
+ const reset = () => { members.value = []; error.value = null; };
33
+ bridge.socket.on('disconnect', reset);
34
+ watch(() => toValue(channel), (name, _previous, onCleanup) => {
35
+ reset();
36
+ const handle = bridge.presence(name).here(value => { members.value = value; error.value = null; }).error(value => { error.value = value; });
37
+ onCleanup(() => handle.dispose());
38
+ }, { immediate: true, flush: 'sync' });
39
+ onScopeDispose(() => bridge.socket.off('disconnect', reset));
40
+ return { members: readonly(members), error: readonly(error) };
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@belysh/socket-bridge-client",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Framework-neutral Socket.IO client for Laravel Socket Bridge",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,14 @@
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js"
13
+ },
14
+ "./react": {
15
+ "types": "./dist/react.d.ts",
16
+ "import": "./dist/react.js"
17
+ },
18
+ "./vue": {
19
+ "types": "./dist/vue.d.ts",
20
+ "import": "./dist/vue.js"
13
21
  }
14
22
  },
15
23
  "files": [
@@ -26,7 +34,13 @@
26
34
  "socket.io-client": "^4.8.1"
27
35
  },
28
36
  "devDependencies": {
29
- "typescript": "^5.9.3"
37
+ "typescript": "^5.9.3",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
40
+ "@types/react": "^19.0.0",
41
+ "@types/react-dom": "^19.0.0",
42
+ "jsdom": "^27.0.0",
43
+ "vue": "^3.5.0"
30
44
  },
31
45
  "engines": {
32
46
  "node": ">=20"
@@ -46,5 +60,17 @@
46
60
  "socket.io",
47
61
  "redis",
48
62
  "realtime"
49
- ]
63
+ ],
64
+ "peerDependencies": {
65
+ "react": "^18.0.0 || ^19.0.0",
66
+ "vue": "^3.5.0"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "react": {
70
+ "optional": true
71
+ },
72
+ "vue": {
73
+ "optional": true
74
+ }
75
+ }
50
76
  }