@laplace.live/event-bridge-sdk 1.0.24 → 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
@@ -112,8 +112,45 @@ enum ConnectionState {
112
112
  - `isConnectedToBridge()`: Check if the client is connected to the bridge. (Deprecated: use `getConnectionState()` instead)
113
113
  - `getConnectionState()`: Get the current connection state.
114
114
  - `getClientId()`: Get the client ID assigned by the bridge.
115
+ - `getInfo(signal?)`: Fetch `/info` (configured rooms + instance metadata) from a LAPLACE Event Fetcher, using the client's url/token. No active connection required. Resolves to `null` when discovery is unsupported.
115
116
  - `send(event)`: Send an event to the bridge.
116
117
 
118
+ ## Room Discovery
119
+
120
+ When the server is a [LAPLACE Event Fetcher](https://chat.laplace.live) in bridge mode, it exposes a `/info` HTTP endpoint listing the configured rooms. The SDK can fetch it without an active WebSocket connection — useful for letting users pick which rooms to receive.
121
+
122
+ Use the `client.getInfo()` method (reuses the client's `url`/`token`):
123
+
124
+ ```typescript
125
+ const client = new LaplaceEventBridgeClient({ url: 'ws://localhost:9696', token })
126
+
127
+ const info = await client.getInfo()
128
+ if (info) {
129
+ console.log(`Fetcher v${info.version} exposes ${info.rooms.length} room(s)`)
130
+ for (const room of info.rooms) {
131
+ console.log(`${room.roomId}: ${room.username ?? 'unknown'}`)
132
+ }
133
+ } else {
134
+ // Plain Event Bridge server or an older fetcher — fall back to manual entry.
135
+ }
136
+ ```
137
+
138
+ Or the standalone `fetchInfo()` function (no client object needed):
139
+
140
+ ```typescript
141
+ import { fetchInfo } from '@laplace.live/event-bridge-sdk'
142
+
143
+ const info = await fetchInfo({ url: 'ws://localhost:9696', token, signal })
144
+ ```
145
+
146
+ Both resolve to `null` (never throw) when `/info` is unavailable — an old fetcher, a plain Event Bridge server, an aborted request, or any network/parse error — so callers can silently fall back to manual room entry.
147
+
148
+ The returned shapes are `FetcherInfo` and `FetcherRoom`. Both are exported from the SDK, so import them directly rather than redeclaring your own:
149
+
150
+ ```typescript
151
+ import type { FetcherInfo, FetcherRoom } from '@laplace.live/event-bridge-sdk'
152
+ ```
153
+
117
154
  ## Type Inference
118
155
 
119
156
  The SDK automatically infers the event type based on the event.type property. This means you don't need to use explicit type parameters or type guards when setting up event handlers.
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- var K;((A)=>{A.DISCONNECTED="disconnected";A.CONNECTING="connecting";A.CONNECTED="connected";A.RECONNECTING="reconnecting"})(K||={});class N{ws=null;eventHandlers=new Map;anyEventHandlers=[];connectionStateHandlers=[];reconnectTimer=null;reconnectAttempts=0;clientId=null;serverVersion=null;connectionState="disconnected";lastPingTime=null;pingMonitorTimer=null;options={url:"ws://localhost:9696",token:"",reconnect:!0,reconnectInterval:3000,maxReconnectAttempts:1000,pingTimeout:90000};constructor(q={}){this.options={...this.options,...q}}connect(){return new Promise((q,z)=>{try{if(this.ws)this.ws.close();this.setConnectionState("connecting");let w=this.options.url,E=[];if(this.options.token){E.push("laplace-event-bridge-role-client",this.options.token);let A=new URL(w);A.searchParams.set("token",this.options.token),w=A.toString()}this.ws=new WebSocket(w,E),this.ws.onopen=()=>{this.setConnectionState("connected"),this.reconnectAttempts=0,q()},this.ws.onmessage=(A)=>{try{let B=JSON.parse(A.data);if(B.type==="ping"){this.lastPingTime=Date.now(),this.ws?.send(JSON.stringify({type:"pong",timestamp:Date.now(),respondingTo:B.timestamp}));return}if(B.type==="established"){this.clientId=B.clientId,this.serverVersion=B.version;let G=(()=>{let F=new URL(w);if(F.searchParams.has("token"))F.searchParams.set("token","***");return F.toString()})();if(console.log(`Welcome to LAPLACE Event Bridge ${B.version?`v${B.version}`:"(unknown version)"}: ${G} with client ID ${this.clientId||"unknown"}`),this.shouldMonitorPing())this.startPingMonitoring()}this.processEvent(B)}catch(B){console.error("Failed to parse event data:",B)}},this.ws.onerror=(A)=>{console.error("WebSocket error:",A),z(A)},this.ws.onclose=()=>{if(console.log("Disconnected from LAPLACE Event Bridge"),this.stopPingMonitoring(),this.lastPingTime=null,this.options.reconnect&&this.reconnectAttempts<this.options.maxReconnectAttempts){this.reconnectAttempts++,this.setConnectionState("reconnecting");let A=this.options.reconnectInterval,B=1.5,G=60000,F=Math.min(A*B**(this.reconnectAttempts-1),G),H=Math.round(F);console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.options.maxReconnectAttempts}) in ${H}ms...`),this.reconnectTimer=setTimeout(()=>{this.connect().catch((J)=>{console.error("Reconnection failed:",J)})},H)}else this.setConnectionState("disconnected")}}catch(w){this.setConnectionState("disconnected"),z(w)}})}disconnect(){if(this.reconnectTimer)clearTimeout(this.reconnectTimer),this.reconnectTimer=null;if(this.stopPingMonitoring(),this.ws)this.ws.close(),this.ws=null;this.setConnectionState("disconnected"),this.clientId=null,this.serverVersion=null,this.lastPingTime=null}on(q,z){let w=this.eventHandlers.get(q)||[];return w.push(z),this.eventHandlers.set(q,w),()=>this.off(q,z)}onAny(q){return this.anyEventHandlers.push(q),()=>this.offAny(q)}onConnectionStateChange(q){return this.connectionStateHandlers.push(q),q(this.connectionState),()=>this.offConnectionStateChange(q)}off(q,z){let w=this.eventHandlers.get(q);if(!w)return;let E=w.indexOf(z);if(E!==-1)w.splice(E,1);if(w.length===0)this.eventHandlers.delete(q)}offAny(q){let z=this.anyEventHandlers.indexOf(q);if(z!==-1)this.anyEventHandlers.splice(z,1)}offConnectionStateChange(q){let z=this.connectionStateHandlers.indexOf(q);if(z!==-1)this.connectionStateHandlers.splice(z,1)}isConnectedToBridge(){return this.connectionState==="connected"}getConnectionState(){return this.connectionState}getClientId(){return this.clientId}send(q){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw Error("Not connected to LAPLACE Event Bridge");this.ws.send(JSON.stringify(q))}setConnectionState(q){if(this.connectionState!==q){this.connectionState=q;for(let z of this.connectionStateHandlers)try{z(q)}catch(w){console.error("Error in connection state change handler:",w)}}}processEvent(q){let z=this.eventHandlers.get(q.type);if(z)for(let w of z)try{w(q)}catch(E){console.error(`Error in event handler for type ${q.type}:`,E)}for(let w of this.anyEventHandlers)try{w(q)}catch(E){console.error("Error in any event handler:",E)}}shouldMonitorPing(){if(!this.serverVersion)return!1;let q=this.serverVersion.split(".").map((A)=>parseInt(A,10));if(q.length<3||q.some(Number.isNaN))return console.warn(`Invalid server version format: ${this.serverVersion}`),!1;let[z=0,w=0,E=0]=q;if(z>4)return!0;if(z===4){if(w>0)return!0;if(w===0&&E>=3)return!0}return!1}startPingMonitoring(){this.stopPingMonitoring(),console.log(`Ping monitoring enabled (timeout: ${this.options.pingTimeout}ms)`),this.lastPingTime=Date.now(),this.pingMonitorTimer=setInterval(()=>{if(!this.lastPingTime)return;let q=Date.now()-this.lastPingTime;if(q>this.options.pingTimeout){if(console.warn(`Ping timeout detected (${q}ms since last ping). Reconnecting...`),this.stopPingMonitoring(),this.ws)this.ws.close()}},this.options.pingTimeout/3)}stopPingMonitoring(){if(this.pingMonitorTimer)clearInterval(this.pingMonitorTimer),this.pingMonitorTimer=null}}export{N as LaplaceEventBridgeClient,K as ConnectionState};
1
+ var W;((E)=>{E.DISCONNECTED="disconnected";E.CONNECTING="connecting";E.CONNECTED="connected";E.RECONNECTING="reconnecting"})(W||={});class X{ws=null;eventHandlers=new Map;anyEventHandlers=[];connectionStateHandlers=[];reconnectTimer=null;reconnectAttempts=0;clientId=null;serverVersion=null;connectionState="disconnected";lastPingTime=null;pingMonitorTimer=null;options={url:"ws://localhost:9696",token:"",reconnect:!0,reconnectInterval:3000,maxReconnectAttempts:1000,pingTimeout:90000};constructor(z={}){this.options={...this.options,...z}}connect(){return new Promise((z,A)=>{try{if(this.ws)this.ws.close();this.setConnectionState("connecting");let B=this.options.url,G=[];if(this.options.token){G.push("laplace-event-bridge-role-client",this.options.token);let E=new URL(B);E.searchParams.set("token",this.options.token),B=E.toString()}this.ws=new WebSocket(B,G),this.ws.onopen=()=>{this.setConnectionState("connected"),this.reconnectAttempts=0,z()},this.ws.onmessage=(E)=>{try{let F=JSON.parse(E.data);if(F.type==="ping"){this.lastPingTime=Date.now(),this.ws?.send(JSON.stringify({type:"pong",timestamp:Date.now(),respondingTo:F.timestamp}));return}if(F.type==="established"){this.clientId=F.clientId,this.serverVersion=F.version;let K=(()=>{let Q=new URL(B);if(Q.searchParams.has("token"))Q.searchParams.set("token","***");return Q.toString()})();if(console.log(`Welcome to LAPLACE Event Bridge ${F.version?`v${F.version}`:"(unknown version)"}: ${K} with client ID ${this.clientId||"unknown"}`),this.shouldMonitorPing())this.startPingMonitoring()}this.processEvent(F)}catch(F){console.error("Failed to parse event data:",F)}},this.ws.onerror=(E)=>{console.error("WebSocket error:",E),A(E)},this.ws.onclose=()=>{if(console.log("Disconnected from LAPLACE Event Bridge"),this.stopPingMonitoring(),this.lastPingTime=null,this.options.reconnect&&this.reconnectAttempts<this.options.maxReconnectAttempts){this.reconnectAttempts++,this.setConnectionState("reconnecting");let E=this.options.reconnectInterval,F=1.5,K=60000,Q=Math.min(E*F**(this.reconnectAttempts-1),K),R=Math.round(Q);console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.options.maxReconnectAttempts}) in ${R}ms...`),this.reconnectTimer=setTimeout(()=>{this.connect().catch((V)=>{console.error("Reconnection failed:",V)})},R)}else this.setConnectionState("disconnected")}}catch(B){this.setConnectionState("disconnected"),A(B)}})}disconnect(){if(this.reconnectTimer)clearTimeout(this.reconnectTimer),this.reconnectTimer=null;if(this.stopPingMonitoring(),this.ws)this.ws.close(),this.ws=null;this.setConnectionState("disconnected"),this.clientId=null,this.serverVersion=null,this.lastPingTime=null}on(z,A){let B=this.eventHandlers.get(z)||[];return B.push(A),this.eventHandlers.set(z,B),()=>this.off(z,A)}onAny(z){return this.anyEventHandlers.push(z),()=>this.offAny(z)}onConnectionStateChange(z){return this.connectionStateHandlers.push(z),z(this.connectionState),()=>this.offConnectionStateChange(z)}off(z,A){let B=this.eventHandlers.get(z);if(!B)return;let G=B.indexOf(A);if(G!==-1)B.splice(G,1);if(B.length===0)this.eventHandlers.delete(z)}offAny(z){let A=this.anyEventHandlers.indexOf(z);if(A!==-1)this.anyEventHandlers.splice(A,1)}offConnectionStateChange(z){let A=this.connectionStateHandlers.indexOf(z);if(A!==-1)this.connectionStateHandlers.splice(A,1)}isConnectedToBridge(){return this.connectionState==="connected"}getConnectionState(){return this.connectionState}getClientId(){return this.clientId}getInfo(z){return Z({url:this.options.url,token:this.options.token,signal:z})}send(z){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw Error("Not connected to LAPLACE Event Bridge");this.ws.send(JSON.stringify(z))}setConnectionState(z){if(this.connectionState!==z){this.connectionState=z;for(let A of this.connectionStateHandlers)try{A(z)}catch(B){console.error("Error in connection state change handler:",B)}}}processEvent(z){let A=this.eventHandlers.get(z.type);if(A)for(let B of A)try{B(z)}catch(G){console.error(`Error in event handler for type ${z.type}:`,G)}for(let B of this.anyEventHandlers)try{B(z)}catch(G){console.error("Error in any event handler:",G)}}shouldMonitorPing(){if(!this.serverVersion)return!1;let z=this.serverVersion.split(".").map((E)=>parseInt(E,10));if(z.length<3||z.some(Number.isNaN))return console.warn(`Invalid server version format: ${this.serverVersion}`),!1;let[A=0,B=0,G=0]=z;if(A>4)return!0;if(A===4){if(B>0)return!0;if(B===0&&G>=3)return!0}return!1}startPingMonitoring(){this.stopPingMonitoring(),console.log(`Ping monitoring enabled (timeout: ${this.options.pingTimeout}ms)`),this.lastPingTime=Date.now(),this.pingMonitorTimer=setInterval(()=>{if(!this.lastPingTime)return;let z=Date.now()-this.lastPingTime;if(z>this.options.pingTimeout){if(console.warn(`Ping timeout detected (${z}ms since last ping). Reconnecting...`),this.stopPingMonitoring(),this.ws)this.ws.close()}},this.options.pingTimeout/3)}stopPingMonitoring(){if(this.pingMonitorTimer)clearInterval(this.pingMonitorTimer),this.pingMonitorTimer=null}}function Y(z){let A;if(z.startsWith("wss://"))A=`https://${z.slice(6)}`;else if(z.startsWith("ws://"))A=`http://${z.slice(5)}`;else if(z.startsWith("http://")||z.startsWith("https://"))A=z;else A=`${/:443(\/|$)/.test(z)?"https":"http"}://${z}`;let B=A.length;while(B>0&&A.charCodeAt(B-1)===47)B--;return A.slice(0,B)}async function Z(z){let{url:A,token:B,signal:G}=z;if(!A)return null;try{let E={};if(B)E.authorization=`Bearer ${B}`;let F=await fetch(`${Y(A)}/info`,{method:"GET",headers:E,signal:G});if(!F.ok)return null;let K=await F.json();if(!K?.success||!Array.isArray(K?.data?.rooms))return null;return K.data}catch{return null}}export{Z as fetchInfo,X as LaplaceEventBridgeClient,W as ConnectionState};
package/index.ts CHANGED
@@ -63,6 +63,41 @@ export interface ConnectionOptions {
63
63
  pingTimeout?: number
64
64
  }
65
65
 
66
+ export interface FetcherRoom {
67
+ /** 0 when the room was resolved, otherwise an error code (e.g. 404). */
68
+ status: number
69
+ uid: number
70
+ /** Canonical room id. Matches the `origin` field on incoming events. */
71
+ roomId: number
72
+ shortRoomId: number
73
+ username: string | null
74
+ }
75
+
76
+ export interface FetcherInfo {
77
+ version: string
78
+ uptime: string
79
+ connectedAt: number
80
+ websocketBridge: boolean
81
+ websocketClients: number
82
+ rooms: FetcherRoom[]
83
+ }
84
+
85
+ export interface FetchInfoOptions {
86
+ /** Same ws/wss URL style as ConnectionOptions.url, e.g. 'ws://localhost:9696'. */
87
+ url: string
88
+ /** Auth token; sent as `Authorization: Bearer <token>` when present. */
89
+ token?: string
90
+ /** Optional AbortSignal for cancellation (e.g. a modal closing). */
91
+ signal?: AbortSignal
92
+ }
93
+
94
+ /** The `/info` response envelope. Internal — callers receive the unwrapped `data` (FetcherInfo). */
95
+ interface FetcherInfoResponse {
96
+ success: boolean
97
+ status: number
98
+ data: FetcherInfo
99
+ }
100
+
66
101
  export class LaplaceEventBridgeClient {
67
102
  private ws: WebSocket | null = null
68
103
  private eventHandlers = new Map<string, EventHandler<any>[]>()
@@ -345,6 +380,17 @@ export class LaplaceEventBridgeClient {
345
380
  return this.clientId
346
381
  }
347
382
 
383
+ /**
384
+ * Fetch `/info` (configured rooms + instance metadata) from the connected
385
+ * LAPLACE Event Fetcher, using this client's configured url and token. Does
386
+ * NOT require connect(). Resolves to null when discovery is unsupported (a
387
+ * plain Event Bridge server or an older fetcher) — see {@link fetchInfo}.
388
+ * @param signal Optional AbortSignal to cancel the request.
389
+ */
390
+ public getInfo(signal?: AbortSignal): Promise<FetcherInfo | null> {
391
+ return fetchInfo({ url: this.options.url, token: this.options.token, signal })
392
+ }
393
+
348
394
  /**
349
395
  * Send an event to the bridge
350
396
  * @param event The event to send
@@ -467,3 +513,79 @@ export class LaplaceEventBridgeClient {
467
513
  }
468
514
  }
469
515
  }
516
+
517
+ /**
518
+ * Convert a ws/wss URL to the http/https URL of the same endpoint so we can
519
+ * reach HTTP routes like `/info`. Swaps the scheme (wss→https, ws→http) and
520
+ * preserves host, port, and any path (so a fetcher reverse-proxied under a
521
+ * sub-path still resolves). http(s) URLs pass through; a bare host with no
522
+ * scheme defaults to http, or https when it targets port 443. Any trailing
523
+ * slash is stripped so callers can append a path.
524
+ */
525
+ function wsToHttp(url: string): string {
526
+ let httpUrl: string
527
+ if (url.startsWith('wss://')) {
528
+ httpUrl = `https://${url.slice('wss://'.length)}`
529
+ } else if (url.startsWith('ws://')) {
530
+ httpUrl = `http://${url.slice('ws://'.length)}`
531
+ } else if (url.startsWith('http://') || url.startsWith('https://')) {
532
+ httpUrl = url
533
+ } else {
534
+ httpUrl = `${/:443(\/|$)/.test(url) ? 'https' : 'http'}://${url}`
535
+ }
536
+ // Strip trailing slashes so callers can append a path. A linear scan rather
537
+ // than `/\/+$/` — that pattern backtracks polynomially on a long slash run
538
+ // (a user-entered fetcher URL is uncontrolled input) and trips ReDoS scanners.
539
+ let end = httpUrl.length
540
+ while (end > 0 && httpUrl.charCodeAt(end - 1) === 47 /* '/' */) {
541
+ end--
542
+ }
543
+ return httpUrl.slice(0, end)
544
+ }
545
+
546
+ /**
547
+ * Fetch instance/room info from a LAPLACE Event Fetcher `/info` endpoint.
548
+ *
549
+ * Returns `null` (never throws) whenever the info cannot be determined: an old
550
+ * fetcher without `/info`, a plain Event Bridge server, an aborted request, or
551
+ * any network/HTTP/parse error. Callers should treat `null` as "not supported"
552
+ * and fall back to manual entry.
553
+ *
554
+ * @example
555
+ * const info = await fetchInfo({ url: 'ws://localhost:9696', token })
556
+ * info?.rooms.forEach(room => console.log(room.roomId, room.username))
557
+ */
558
+ export async function fetchInfo(options: FetchInfoOptions): Promise<FetcherInfo | null> {
559
+ const { url, token, signal } = options
560
+ if (!url) {
561
+ return null
562
+ }
563
+
564
+ try {
565
+ const headers: Record<string, string> = {}
566
+ if (token) {
567
+ headers.authorization = `Bearer ${token}`
568
+ }
569
+
570
+ const response = await fetch(`${wsToHttp(url)}/info`, {
571
+ method: 'GET',
572
+ headers,
573
+ signal,
574
+ })
575
+
576
+ if (!response.ok) {
577
+ return null
578
+ }
579
+
580
+ const json = (await response.json()) as FetcherInfoResponse
581
+ if (!json?.success || !Array.isArray(json?.data?.rooms)) {
582
+ return null
583
+ }
584
+
585
+ return json.data
586
+ } catch {
587
+ // Silent fallback: old fetcher without /info, plain Event Bridge server,
588
+ // aborted request, network failure, or a non-JSON response.
589
+ return null
590
+ }
591
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@laplace.live/event-bridge-sdk",
3
3
  "description": "LAPLACE Event Bridge SDK",
4
- "version": "1.0.24",
4
+ "version": "1.1.0",
5
5
  "module": "index.ts",
6
6
  "types": "index.ts",
7
7
  "license": "MIT",