@johnhenry/browsermesh-transport 0.0.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/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +31 -0
- package/src/channel-relay.mjs +225 -0
- package/src/cross-origin.mjs +543 -0
- package/src/gateway.mjs +627 -0
- package/src/index.mjs +12 -0
- package/src/relay.mjs +653 -0
- package/src/silent-catch.mjs +55 -0
- package/src/streams.mjs +627 -0
- package/src/transport.mjs +357 -0
- package/src/webrtc.mjs +773 -0
- package/src/websocket.mjs +1082 -0
- package/src/webtransport.mjs +216 -0
- package/src/wisp-client.mjs +747 -0
- package/src/wisp.mjs +348 -0
- package/src/wsh-bridge.mjs +242 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
// STATUS: INTEGRATED — wired into ClawserPod lifecycle, proven via E2E testing
|
|
3
|
+
* clawser-mesh-cross-origin.js -- Cross-origin communication bridge.
|
|
4
|
+
*
|
|
5
|
+
* Enables mesh pods running in different browser contexts (iframes,
|
|
6
|
+
* popups, different-origin tabs) to communicate securely using
|
|
7
|
+
* postMessage with origin validation and method allowlisting.
|
|
8
|
+
*
|
|
9
|
+
* No browser-only imports at module level.
|
|
10
|
+
*
|
|
11
|
+
* Run tests:
|
|
12
|
+
* node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-cross-origin.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Trust levels
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const TRUST_LEVELS = Object.freeze({
|
|
20
|
+
ISOLATED: 'isolated', // No communication allowed
|
|
21
|
+
VERIFIED: 'verified', // Origin verified, limited methods
|
|
22
|
+
TRUSTED: 'trusted', // Full method access
|
|
23
|
+
LINKED: 'linked', // Bidirectional trust
|
|
24
|
+
PINNED: 'pinned', // Pinned trust (like HSTS)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Wire message types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export const XO_REQUEST = 'mesh-xo-request'
|
|
32
|
+
export const XO_RESPONSE = 'mesh-xo-response'
|
|
33
|
+
export const XO_HANDSHAKE = 'mesh-xo-handshake'
|
|
34
|
+
export const XO_HANDSHAKE_ACK = 'mesh-xo-handshake-ack'
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// RateLimiter
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rate limiter for cross-origin messages per peer.
|
|
42
|
+
*
|
|
43
|
+
* Tracks message counts in a sliding time window per peerId.
|
|
44
|
+
* Once a peer exceeds `maxPerWindow` messages within `windowMs`,
|
|
45
|
+
* further messages are rejected until the window resets.
|
|
46
|
+
*/
|
|
47
|
+
export class RateLimiter {
|
|
48
|
+
#maxPerWindow
|
|
49
|
+
#windowMs
|
|
50
|
+
#counters = new Map() // peerId -> { count, resetAt }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {number} [opts.maxPerWindow=100] - Max messages per window.
|
|
55
|
+
* @param {number} [opts.windowMs=60000] - Window duration in ms.
|
|
56
|
+
*/
|
|
57
|
+
constructor({ maxPerWindow = 100, windowMs = 60000 } = {}) {
|
|
58
|
+
this.#maxPerWindow = maxPerWindow
|
|
59
|
+
this.#windowMs = windowMs
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @returns {number} Configured max per window. */
|
|
63
|
+
get maxPerWindow() { return this.#maxPerWindow }
|
|
64
|
+
|
|
65
|
+
/** @returns {number} Configured window duration in ms. */
|
|
66
|
+
get windowMs() { return this.#windowMs }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a peer is within rate limits.
|
|
70
|
+
* Does NOT consume a slot -- use `record()` after a successful check.
|
|
71
|
+
* @param {string} peerId
|
|
72
|
+
* @returns {boolean} true if the peer may send another message.
|
|
73
|
+
*/
|
|
74
|
+
check(peerId) {
|
|
75
|
+
const now = Date.now()
|
|
76
|
+
const entry = this.#counters.get(peerId)
|
|
77
|
+
if (!entry) return true
|
|
78
|
+
if (now >= entry.resetAt) return true
|
|
79
|
+
return entry.count < this.#maxPerWindow
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Record one message from a peer.
|
|
84
|
+
* Creates a fresh window if the peer has no active window.
|
|
85
|
+
* @param {string} peerId
|
|
86
|
+
*/
|
|
87
|
+
record(peerId) {
|
|
88
|
+
const now = Date.now()
|
|
89
|
+
let entry = this.#counters.get(peerId)
|
|
90
|
+
if (!entry || now >= entry.resetAt) {
|
|
91
|
+
entry = { count: 0, resetAt: now + this.#windowMs }
|
|
92
|
+
this.#counters.set(peerId, entry)
|
|
93
|
+
}
|
|
94
|
+
entry.count++
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reset all counters for every peer. */
|
|
98
|
+
reset() {
|
|
99
|
+
this.#counters.clear()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reset the counter for a single peer.
|
|
104
|
+
* @param {string} peerId
|
|
105
|
+
* @returns {boolean} true if the peer had an entry.
|
|
106
|
+
*/
|
|
107
|
+
resetPeer(peerId) {
|
|
108
|
+
return this.#counters.delete(peerId)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// CrossOriginBridge
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Cross-origin communication bridge.
|
|
118
|
+
*
|
|
119
|
+
* Manages peer registration, origin validation, method allowlisting,
|
|
120
|
+
* rate limiting and message dispatch.
|
|
121
|
+
*
|
|
122
|
+
* Usage (browser):
|
|
123
|
+
* const bridge = new CrossOriginBridge({ localPodId: 'pod-1' })
|
|
124
|
+
* bridge.registerPeer('pod-2', { origin: 'https://other.example' })
|
|
125
|
+
* bridge.setMethodHandler('ping', () => 'pong')
|
|
126
|
+
* window.addEventListener('message', (e) => bridge.handleMessage(e))
|
|
127
|
+
*/
|
|
128
|
+
export class CrossOriginBridge {
|
|
129
|
+
#localPodId
|
|
130
|
+
#peers = new Map() // peerId -> PeerEntry
|
|
131
|
+
#handlers = new Map() // method -> handler(params, fromPodId)
|
|
132
|
+
#rateLimiter
|
|
133
|
+
#onLog
|
|
134
|
+
#pendingRequests = new Map() // requestId -> { resolve, reject, timer }
|
|
135
|
+
#nextId = 1
|
|
136
|
+
#defaultTimeout
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} opts
|
|
140
|
+
* @param {string} opts.localPodId - This pod's identifier.
|
|
141
|
+
* @param {Function} [opts.onLog] - Logging callback.
|
|
142
|
+
* @param {RateLimiter} [opts.rateLimiter] - Custom rate limiter.
|
|
143
|
+
* @param {number} [opts.defaultTimeout=10000] - Default send timeout ms.
|
|
144
|
+
*/
|
|
145
|
+
constructor({ localPodId, onLog, rateLimiter, defaultTimeout = 10000 } = {}) {
|
|
146
|
+
if (!localPodId) throw new Error('localPodId is required')
|
|
147
|
+
this.#localPodId = localPodId
|
|
148
|
+
this.#onLog = onLog || null
|
|
149
|
+
this.#rateLimiter = rateLimiter || new RateLimiter()
|
|
150
|
+
this.#defaultTimeout = defaultTimeout
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @returns {string} The local pod identifier. */
|
|
154
|
+
get localPodId() { return this.#localPodId }
|
|
155
|
+
|
|
156
|
+
/** @returns {number} Number of registered peers. */
|
|
157
|
+
get peerCount() { return this.#peers.size }
|
|
158
|
+
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
// Peer management
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Register a remote peer for cross-origin communication.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} peerId
|
|
167
|
+
* @param {object} opts
|
|
168
|
+
* @param {string} opts.origin - Expected origin (e.g. 'https://example.com').
|
|
169
|
+
* @param {string} [opts.trust] - Trust level from TRUST_LEVELS. Default: VERIFIED.
|
|
170
|
+
* @param {string[]} [opts.allowedMethods] - Methods this peer may call (VERIFIED only).
|
|
171
|
+
*/
|
|
172
|
+
registerPeer(peerId, { origin, trust = TRUST_LEVELS.VERIFIED, allowedMethods = [] } = {}) {
|
|
173
|
+
if (!peerId) throw new Error('peerId is required')
|
|
174
|
+
if (!origin) throw new Error('origin is required')
|
|
175
|
+
if (trust && !Object.values(TRUST_LEVELS).includes(trust)) {
|
|
176
|
+
throw new Error(`Unknown trust level: ${trust}`)
|
|
177
|
+
}
|
|
178
|
+
this.#peers.set(peerId, {
|
|
179
|
+
origin,
|
|
180
|
+
trust,
|
|
181
|
+
allowedMethods: new Set(allowedMethods),
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Update the trust level for an existing peer.
|
|
187
|
+
* @param {string} peerId
|
|
188
|
+
* @param {string} trust - New trust level.
|
|
189
|
+
*/
|
|
190
|
+
setTrust(peerId, trust) {
|
|
191
|
+
const peer = this.#peers.get(peerId)
|
|
192
|
+
if (!peer) throw new Error(`Peer "${peerId}" not registered`)
|
|
193
|
+
if (!Object.values(TRUST_LEVELS).includes(trust)) {
|
|
194
|
+
throw new Error(`Unknown trust level: ${trust}`)
|
|
195
|
+
}
|
|
196
|
+
peer.trust = trust
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Remove a registered peer and reject any pending requests to it.
|
|
201
|
+
* @param {string} peerId
|
|
202
|
+
* @returns {boolean} true if the peer existed.
|
|
203
|
+
*/
|
|
204
|
+
removePeer(peerId) {
|
|
205
|
+
const existed = this.#peers.delete(peerId)
|
|
206
|
+
// Cancel pending requests to this peer
|
|
207
|
+
for (const [reqId, entry] of this.#pendingRequests) {
|
|
208
|
+
if (entry.peerId === peerId) {
|
|
209
|
+
clearTimeout(entry.timer)
|
|
210
|
+
entry.reject(new Error(`Peer "${peerId}" removed`))
|
|
211
|
+
this.#pendingRequests.delete(reqId)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return existed
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* List all registered peers.
|
|
219
|
+
* @returns {Array<{ peerId, origin, trust, allowedMethods }>}
|
|
220
|
+
*/
|
|
221
|
+
listPeers() {
|
|
222
|
+
return [...this.#peers.entries()].map(([peerId, info]) => ({
|
|
223
|
+
peerId,
|
|
224
|
+
origin: info.origin,
|
|
225
|
+
trust: info.trust,
|
|
226
|
+
allowedMethods: [...info.allowedMethods],
|
|
227
|
+
}))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get info for a single peer.
|
|
232
|
+
* @param {string} peerId
|
|
233
|
+
* @returns {object|null}
|
|
234
|
+
*/
|
|
235
|
+
getPeer(peerId) {
|
|
236
|
+
const info = this.#peers.get(peerId)
|
|
237
|
+
if (!info) return null
|
|
238
|
+
return {
|
|
239
|
+
peerId,
|
|
240
|
+
origin: info.origin,
|
|
241
|
+
trust: info.trust,
|
|
242
|
+
allowedMethods: [...info.allowedMethods],
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
// Method handlers
|
|
248
|
+
// -------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Register a handler for an incoming method call.
|
|
252
|
+
* @param {string} method
|
|
253
|
+
* @param {Function} handler - (params, fromPodId) => result
|
|
254
|
+
*/
|
|
255
|
+
setMethodHandler(method, handler) {
|
|
256
|
+
if (typeof handler !== 'function') throw new Error('handler must be a function')
|
|
257
|
+
this.#handlers.set(method, handler)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Remove a method handler.
|
|
262
|
+
* @param {string} method
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
removeMethodHandler(method) {
|
|
266
|
+
return this.#handlers.delete(method)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* List registered method names.
|
|
271
|
+
* @returns {string[]}
|
|
272
|
+
*/
|
|
273
|
+
listMethods() {
|
|
274
|
+
return [...this.#handlers.keys()]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// Sending
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Send a request to a peer. Returns a promise that resolves with the result.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} peerId - Target peer.
|
|
285
|
+
* @param {string} method - Method to invoke.
|
|
286
|
+
* @param {object} [params={}] - Method parameters.
|
|
287
|
+
* @param {object} target - postMessage target (Window, MessagePort, etc.).
|
|
288
|
+
* @param {object} [opts]
|
|
289
|
+
* @param {number} [opts.timeout] - Override default timeout.
|
|
290
|
+
* @returns {Promise<*>}
|
|
291
|
+
*/
|
|
292
|
+
async send(peerId, method, params, target, opts = {}) {
|
|
293
|
+
const peer = this.#peers.get(peerId)
|
|
294
|
+
if (!peer) throw new Error(`Peer "${peerId}" not registered`)
|
|
295
|
+
if (peer.trust === TRUST_LEVELS.ISOLATED) {
|
|
296
|
+
throw new Error(`Peer "${peerId}" is isolated`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const requestId = `xo_${this.#nextId++}`
|
|
300
|
+
const message = {
|
|
301
|
+
type: XO_REQUEST,
|
|
302
|
+
requestId,
|
|
303
|
+
fromPodId: this.#localPodId,
|
|
304
|
+
method,
|
|
305
|
+
params: params || {},
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const timeout = opts.timeout ?? this.#defaultTimeout
|
|
309
|
+
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
const timer = timeout > 0
|
|
312
|
+
? setTimeout(() => {
|
|
313
|
+
this.#pendingRequests.delete(requestId)
|
|
314
|
+
reject(new Error(`Request ${requestId} to "${peerId}" timed out`))
|
|
315
|
+
}, timeout)
|
|
316
|
+
: null
|
|
317
|
+
|
|
318
|
+
this.#pendingRequests.set(requestId, { resolve, reject, timer, peerId })
|
|
319
|
+
|
|
320
|
+
if (target && typeof target.postMessage === 'function') {
|
|
321
|
+
target.postMessage(message, peer.origin)
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
// Receiving
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Handle an incoming MessageEvent. Validates origin, enforces trust/allowlist,
|
|
332
|
+
* dispatches to handlers, and sends responses.
|
|
333
|
+
*
|
|
334
|
+
* Attach this to `window.addEventListener('message', ...)`.
|
|
335
|
+
*
|
|
336
|
+
* @param {MessageEvent} event
|
|
337
|
+
*/
|
|
338
|
+
handleMessage(event) {
|
|
339
|
+
const data = event.data
|
|
340
|
+
if (!data || typeof data !== 'object') return
|
|
341
|
+
if (typeof data.type !== 'string') return
|
|
342
|
+
if (!data.type.startsWith('mesh-xo-')) return
|
|
343
|
+
|
|
344
|
+
const fromPeerId = data.fromPodId
|
|
345
|
+
const peer = fromPeerId ? this.#peers.get(fromPeerId) : null
|
|
346
|
+
|
|
347
|
+
// Origin validation -- reject if the event origin doesn't match the registered origin
|
|
348
|
+
if (peer && event.origin && event.origin !== peer.origin) {
|
|
349
|
+
this.#log(`Origin mismatch for ${fromPeerId}: expected ${peer.origin}, got ${event.origin}`)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Rate limiting
|
|
354
|
+
if (fromPeerId && !this.#rateLimiter.check(fromPeerId)) {
|
|
355
|
+
this.#log(`Rate limit exceeded for ${fromPeerId}`)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (fromPeerId) this.#rateLimiter.record(fromPeerId)
|
|
359
|
+
|
|
360
|
+
if (data.type === XO_REQUEST) {
|
|
361
|
+
this.#handleRequest(data, event.source, peer)
|
|
362
|
+
} else if (data.type === XO_RESPONSE) {
|
|
363
|
+
this.#handleResponse(data)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// -------------------------------------------------------------------------
|
|
368
|
+
// Internal request/response
|
|
369
|
+
// -------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
#handleRequest(data, source, peer) {
|
|
372
|
+
const { requestId, method, params, fromPodId } = data
|
|
373
|
+
|
|
374
|
+
// ISOLATED peers cannot invoke anything
|
|
375
|
+
if (peer && peer.trust === TRUST_LEVELS.ISOLATED) {
|
|
376
|
+
this.#log(`Blocked request from isolated peer ${fromPodId}`)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// For VERIFIED peers, enforce the method allowlist
|
|
381
|
+
if (peer && peer.trust === TRUST_LEVELS.VERIFIED && peer.allowedMethods.size > 0) {
|
|
382
|
+
if (!peer.allowedMethods.has(method)) {
|
|
383
|
+
this.#sendResponse(source, peer, requestId, null, `Method "${method}" not allowed`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Look up handler
|
|
389
|
+
const handler = this.#handlers.get(method)
|
|
390
|
+
if (!handler) {
|
|
391
|
+
this.#sendResponse(source, peer, requestId, null, `Method "${method}" not found`)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Execute handler (sync or async)
|
|
396
|
+
try {
|
|
397
|
+
const result = handler(params, fromPodId)
|
|
398
|
+
if (result && typeof result.then === 'function') {
|
|
399
|
+
result.then(
|
|
400
|
+
(val) => this.#sendResponse(source, peer, requestId, val, null),
|
|
401
|
+
(err) => this.#sendResponse(source, peer, requestId, null, err.message),
|
|
402
|
+
).catch(() => {})
|
|
403
|
+
} else {
|
|
404
|
+
this.#sendResponse(source, peer, requestId, result, null)
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
this.#sendResponse(source, peer, requestId, null, err.message)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#handleResponse(data) {
|
|
412
|
+
const pending = this.#pendingRequests.get(data.requestId)
|
|
413
|
+
if (!pending) return
|
|
414
|
+
this.#pendingRequests.delete(data.requestId)
|
|
415
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
416
|
+
if (data.error) {
|
|
417
|
+
pending.reject(new Error(data.error))
|
|
418
|
+
} else {
|
|
419
|
+
pending.resolve(data.result)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#sendResponse(source, peer, requestId, result, error) {
|
|
424
|
+
if (!source || typeof source.postMessage !== 'function') return
|
|
425
|
+
const msg = {
|
|
426
|
+
type: XO_RESPONSE,
|
|
427
|
+
requestId,
|
|
428
|
+
fromPodId: this.#localPodId,
|
|
429
|
+
result: result ?? null,
|
|
430
|
+
error: error ?? null,
|
|
431
|
+
}
|
|
432
|
+
const origin = peer ? peer.origin : '*'
|
|
433
|
+
source.postMessage(msg, origin)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
// Lifecycle
|
|
438
|
+
// -------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Reject all pending requests and clear internal state.
|
|
442
|
+
*/
|
|
443
|
+
destroy() {
|
|
444
|
+
for (const [, entry] of this.#pendingRequests) {
|
|
445
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
446
|
+
entry.reject(new Error('Bridge destroyed'))
|
|
447
|
+
}
|
|
448
|
+
this.#pendingRequests.clear()
|
|
449
|
+
this.#peers.clear()
|
|
450
|
+
this.#handlers.clear()
|
|
451
|
+
this.#rateLimiter.reset()
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#log(msg) {
|
|
455
|
+
if (this.#onLog) this.#onLog(msg)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// CrossOriginHandshake
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Cross-origin handshake protocol.
|
|
465
|
+
*
|
|
466
|
+
* Establishes initial trust between two browser contexts using a
|
|
467
|
+
* simple challenge-acknowledge exchange over postMessage.
|
|
468
|
+
*
|
|
469
|
+
* Flow:
|
|
470
|
+
* 1. Initiator calls `initiate(targetWindow, origin)`.
|
|
471
|
+
* 2. Target listens for `mesh-xo-handshake` and calls `accept(event)`.
|
|
472
|
+
* 3. Target sends back `mesh-xo-handshake-ack`.
|
|
473
|
+
* 4. Initiator resolves with { peerId, port }.
|
|
474
|
+
*/
|
|
475
|
+
export class CrossOriginHandshake {
|
|
476
|
+
/**
|
|
477
|
+
* Initiate a handshake with a target window/iframe.
|
|
478
|
+
*
|
|
479
|
+
* @param {Window} targetWindow - The target context.
|
|
480
|
+
* @param {string} origin - Expected origin of the target.
|
|
481
|
+
* @param {object} [opts]
|
|
482
|
+
* @param {string} [opts.peerId] - Suggested peer ID.
|
|
483
|
+
* @param {number} [opts.timeout] - Timeout in ms (default 5000).
|
|
484
|
+
* @returns {Promise<{ peerId: string, port: MessagePort|null }>}
|
|
485
|
+
*/
|
|
486
|
+
static async initiate(targetWindow, origin, opts = {}) {
|
|
487
|
+
const peerId = opts.peerId || `peer_${Date.now().toString(36)}`
|
|
488
|
+
const timeout = opts.timeout ?? 5000
|
|
489
|
+
|
|
490
|
+
return new Promise((resolve, reject) => {
|
|
491
|
+
const timer = setTimeout(() => {
|
|
492
|
+
if (typeof globalThis.removeEventListener === 'function') {
|
|
493
|
+
globalThis.removeEventListener('message', handler)
|
|
494
|
+
}
|
|
495
|
+
reject(new Error('Handshake timeout'))
|
|
496
|
+
}, timeout)
|
|
497
|
+
|
|
498
|
+
const handler = (event) => {
|
|
499
|
+
if (event.origin !== origin) return
|
|
500
|
+
if (!event.data || event.data.type !== XO_HANDSHAKE_ACK) return
|
|
501
|
+
clearTimeout(timer)
|
|
502
|
+
if (typeof globalThis.removeEventListener === 'function') {
|
|
503
|
+
globalThis.removeEventListener('message', handler)
|
|
504
|
+
}
|
|
505
|
+
resolve({
|
|
506
|
+
peerId: event.data.peerId || peerId,
|
|
507
|
+
port: event.data.port || null,
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (typeof globalThis.addEventListener === 'function') {
|
|
512
|
+
globalThis.addEventListener('message', handler)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
targetWindow.postMessage({ type: XO_HANDSHAKE, peerId }, origin)
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Accept a handshake from an incoming message event.
|
|
521
|
+
*
|
|
522
|
+
* @param {MessageEvent} event
|
|
523
|
+
* @param {object} [opts]
|
|
524
|
+
* @param {string} [opts.localPodId] - This side's pod identifier.
|
|
525
|
+
* @returns {Promise<{ peerId: string, port: MessagePort|null }|null>}
|
|
526
|
+
* null if the event is not a handshake request.
|
|
527
|
+
*/
|
|
528
|
+
static async accept(event, opts = {}) {
|
|
529
|
+
if (!event.data || event.data.type !== XO_HANDSHAKE) return null
|
|
530
|
+
|
|
531
|
+
const peerId = event.data.peerId || `peer_${Date.now().toString(36)}`
|
|
532
|
+
const ackPeerId = opts.localPodId || `local_${Date.now().toString(36)}`
|
|
533
|
+
|
|
534
|
+
if (event.source && typeof event.source.postMessage === 'function') {
|
|
535
|
+
event.source.postMessage(
|
|
536
|
+
{ type: XO_HANDSHAKE_ACK, peerId: ackPeerId },
|
|
537
|
+
event.origin,
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return { peerId, port: null }
|
|
542
|
+
}
|
|
543
|
+
}
|