@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 +37 -0
- package/dist/index.js +1 -1
- package/index.ts +122 -0
- package/package.json +1 -1
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
|
|
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
|
+
}
|