@particle-academy/agent-integrations 0.45.0 → 0.46.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 +10 -3
- package/dist/{chunk-OZBUPO4K.js → chunk-5W34X4EY.js} +263 -58
- package/dist/chunk-5W34X4EY.js.map +1 -0
- package/dist/relay-server/index.d.cts +110 -26
- package/dist/relay-server/index.d.ts +110 -26
- package/dist/relay-server-cli.cjs +374 -89
- package/dist/relay-server-cli.cjs.map +1 -1
- package/dist/relay-server-cli.js +120 -38
- package/dist/relay-server-cli.js.map +1 -1
- package/dist/relay-server.cjs +255 -51
- package/dist/relay-server.cjs.map +1 -1
- package/dist/relay-server.js +1 -1
- package/docs/relay-protocol.md +41 -5
- package/docs/relay-server.md +102 -15
- package/package.json +7 -1
- package/dist/chunk-OZBUPO4K.js.map +0 -1
package/README.md
CHANGED
|
@@ -323,9 +323,16 @@ to you:
|
|
|
323
323
|
`confirm` argument that would apply it (revoking the last passkey is a lockout).
|
|
324
324
|
`passkey_list` re-projects onto the public summary fields, so handing it your
|
|
325
325
|
ORM model cannot leak a public key, a user handle, or a signature counter.
|
|
326
|
-
- **
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
- **Activity reaches every peer; tool results reach the caller only if clients
|
|
327
|
+
label themselves.** Since 0.44.0 a reply goes only to the client that asked
|
|
328
|
+
when clients send a `client` query parameter; unlabelled clients still get
|
|
329
|
+
every frame. Notifications are always broadcast. The terminal bridge redacts
|
|
330
|
+
raw command bytes from broadcast meta, but treat anything you return as
|
|
331
|
+
visible to every holder of the token — don't return secrets.
|
|
332
|
+
- **The relay serves SSE and long-poll.** Behind Cloudflare (whose HTTP/3 edge
|
|
333
|
+
resets long SSE streams) install `@particle-academy/fancy-cf-relay` so the
|
|
334
|
+
browser polls; see `docs/relay-server.md`. Pass `--cors` a list of your
|
|
335
|
+
sites' origins rather than leaving `*` on a public relay.
|
|
329
336
|
|
|
330
337
|
## Status
|
|
331
338
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, createHash, timingSafeEqual } from 'crypto';
|
|
2
|
-
import { URL } from 'url';
|
|
2
|
+
import { URL as URL$1 } from 'url';
|
|
3
3
|
|
|
4
4
|
// src/relay-server/core.ts
|
|
5
5
|
var MemoryStore = class {
|
|
@@ -22,6 +22,7 @@ var MemoryStore = class {
|
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
24
|
var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;
|
|
25
|
+
var SUBSCRIBER_ID_PATTERN = /^[a-f0-9]{16}$/;
|
|
25
26
|
var RelayBroker = class {
|
|
26
27
|
constructor(opts = {}) {
|
|
27
28
|
/** Per-session, per-direction subscriber list. */
|
|
@@ -29,6 +30,7 @@ var RelayBroker = class {
|
|
|
29
30
|
/** Per-session map of JSON-RPC request id -> the client that asked. */
|
|
30
31
|
this.callers = /* @__PURE__ */ new Map();
|
|
31
32
|
this.ttlMs = opts.ttlMs ?? 4 * 60 * 60 * 1e3;
|
|
33
|
+
this.pollIdleMs = opts.pollIdleMs ?? 6e4;
|
|
32
34
|
this.store = opts.store ?? new MemoryStore();
|
|
33
35
|
const tick = opts.reapIntervalMs ?? 6e4;
|
|
34
36
|
if (tick > 0) {
|
|
@@ -40,6 +42,7 @@ var RelayBroker = class {
|
|
|
40
42
|
}
|
|
41
43
|
dispose() {
|
|
42
44
|
if (this.reaper) clearInterval(this.reaper);
|
|
45
|
+
for (const id of [...this.subs.keys()]) this.endSubscribers(id);
|
|
43
46
|
this.subs.clear();
|
|
44
47
|
this.callers.clear();
|
|
45
48
|
}
|
|
@@ -64,6 +67,7 @@ var RelayBroker = class {
|
|
|
64
67
|
unregister(id, token) {
|
|
65
68
|
if (!this.validate(id, token)) return false;
|
|
66
69
|
this.store.deleteSession(id);
|
|
70
|
+
this.endSubscribers(id);
|
|
67
71
|
this.subs.delete(id);
|
|
68
72
|
this.callers.delete(id);
|
|
69
73
|
return true;
|
|
@@ -113,6 +117,7 @@ var RelayBroker = class {
|
|
|
113
117
|
/** Drop a session — the page closed, or a test needs it gone. */
|
|
114
118
|
dropSession(id) {
|
|
115
119
|
this.store.deleteSession(id);
|
|
120
|
+
this.endSubscribers(id);
|
|
116
121
|
this.subs.delete(id);
|
|
117
122
|
this.callers.delete(id);
|
|
118
123
|
}
|
|
@@ -143,7 +148,8 @@ var RelayBroker = class {
|
|
|
143
148
|
* the caller (an HTTP handler) pumps as SSE.
|
|
144
149
|
*/
|
|
145
150
|
subscribe(id, token, direction, opts = {}) {
|
|
146
|
-
|
|
151
|
+
const auth = this.check(id, token);
|
|
152
|
+
if (!auth.ok) return auth;
|
|
147
153
|
const subscriberId = randomBytes(8).toString("hex");
|
|
148
154
|
const subscriber = {
|
|
149
155
|
id: subscriberId,
|
|
@@ -153,31 +159,7 @@ var RelayBroker = class {
|
|
|
153
159
|
client: opts.client
|
|
154
160
|
};
|
|
155
161
|
this.getDirSubs(id, direction).set(subscriberId, subscriber);
|
|
156
|
-
if (direction === "outbound")
|
|
157
|
-
this.fanOut(
|
|
158
|
-
id,
|
|
159
|
-
"inbound",
|
|
160
|
-
JSON.stringify({
|
|
161
|
-
jsonrpc: "2.0",
|
|
162
|
-
method: "notifications/peer_joined",
|
|
163
|
-
params: { subscriberId, ts: Date.now() }
|
|
164
|
-
})
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
const unsubscribe = () => {
|
|
168
|
-
this.getDirSubs(id, direction).delete(subscriberId);
|
|
169
|
-
if (direction === "outbound") {
|
|
170
|
-
this.fanOut(
|
|
171
|
-
id,
|
|
172
|
-
"inbound",
|
|
173
|
-
JSON.stringify({
|
|
174
|
-
jsonrpc: "2.0",
|
|
175
|
-
method: "notifications/peer_left",
|
|
176
|
-
params: { subscriberId, ts: Date.now() }
|
|
177
|
-
})
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
};
|
|
162
|
+
if (direction === "outbound") this.announcePeer(id, "notifications/peer_joined", subscriberId);
|
|
181
163
|
const frames = async function* () {
|
|
182
164
|
while (true) {
|
|
183
165
|
if (this.queue.length > 0) {
|
|
@@ -185,6 +167,7 @@ var RelayBroker = class {
|
|
|
185
167
|
if (next2 !== void 0) yield next2;
|
|
186
168
|
continue;
|
|
187
169
|
}
|
|
170
|
+
if (this.ended) return;
|
|
188
171
|
const next = await new Promise((resolve) => {
|
|
189
172
|
this.resolveNext = resolve;
|
|
190
173
|
});
|
|
@@ -198,8 +181,88 @@ var RelayBroker = class {
|
|
|
198
181
|
subscriberId,
|
|
199
182
|
frames: frames(),
|
|
200
183
|
unsubscribe: () => {
|
|
201
|
-
|
|
202
|
-
|
|
184
|
+
this.endSubscriber(subscriber);
|
|
185
|
+
this.removeSubscriber(id, subscriber);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Attach a LONG-POLL subscriber, or re-attach the one a previous poll created.
|
|
191
|
+
*
|
|
192
|
+
* The CDN-safe receive leg: a series of short requests instead of one open
|
|
193
|
+
* stream, because Cloudflare's HTTP/3 edge resets long-lived SSE. Same
|
|
194
|
+
* contract as the PHP relay in px-ui-sandbox and the one
|
|
195
|
+
* `@particle-academy/fancy-cf-relay` and `mcp-relay-client` speak:
|
|
196
|
+
*
|
|
197
|
+
* - The first poll gets a fresh 16-hex subscriber id; the client echoes it back
|
|
198
|
+
* as `subscriber` on every later poll and reads the same queue.
|
|
199
|
+
* - An id in that shape that this session does not know (pruned, say) is
|
|
200
|
+
* adopted as a NEW subscriber under that id, as the PHP relay does, so a
|
|
201
|
+
* client that went quiet and came back keeps its identity.
|
|
202
|
+
* - An id belonging to a STREAMING subscriber is never adopted: one queue read
|
|
203
|
+
* by two consumers splits its frames between them.
|
|
204
|
+
*
|
|
205
|
+
* Frames queue on the subscriber between polls, so nothing is lost in the gap
|
|
206
|
+
* between one request ending and the next arriving. A poller that stops is
|
|
207
|
+
* pruned after `pollIdleMs`.
|
|
208
|
+
*/
|
|
209
|
+
poll(id, token, direction, opts = {}) {
|
|
210
|
+
const auth = this.check(id, token);
|
|
211
|
+
if (!auth.ok) return auth;
|
|
212
|
+
const dir = this.getDirSubs(id, direction);
|
|
213
|
+
const requested = opts.subscriber && SUBSCRIBER_ID_PATTERN.test(opts.subscriber) ? opts.subscriber : void 0;
|
|
214
|
+
const existing = requested ? dir.get(requested) : void 0;
|
|
215
|
+
let subscriber;
|
|
216
|
+
let created;
|
|
217
|
+
if (existing?.poll) {
|
|
218
|
+
subscriber = existing;
|
|
219
|
+
created = false;
|
|
220
|
+
} else {
|
|
221
|
+
const subscriberId = requested && !existing ? requested : randomBytes(8).toString("hex");
|
|
222
|
+
subscriber = {
|
|
223
|
+
id: subscriberId,
|
|
224
|
+
direction,
|
|
225
|
+
queue: [],
|
|
226
|
+
resolveNext: null,
|
|
227
|
+
client: opts.client,
|
|
228
|
+
poll: { lastSeen: Date.now(), parked: 0, wakers: /* @__PURE__ */ new Set() }
|
|
229
|
+
};
|
|
230
|
+
dir.set(subscriberId, subscriber);
|
|
231
|
+
created = true;
|
|
232
|
+
if (direction === "outbound") this.announcePeer(id, "notifications/peer_joined", subscriberId);
|
|
233
|
+
}
|
|
234
|
+
const state = subscriber.poll;
|
|
235
|
+
state.lastSeen = Date.now();
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
subscriberId: subscriber.id,
|
|
239
|
+
created,
|
|
240
|
+
wait: async (ms, signal) => {
|
|
241
|
+
state.parked++;
|
|
242
|
+
state.lastSeen = Date.now();
|
|
243
|
+
try {
|
|
244
|
+
if (subscriber.queue.length === 0 && ms > 0 && !subscriber.ended && !signal?.aborted) {
|
|
245
|
+
await new Promise((resolve) => {
|
|
246
|
+
const done = () => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
signal?.removeEventListener("abort", done);
|
|
249
|
+
state.wakers.delete(done);
|
|
250
|
+
resolve();
|
|
251
|
+
};
|
|
252
|
+
const timer = setTimeout(done, ms);
|
|
253
|
+
state.wakers.add(done);
|
|
254
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (signal?.aborted) return [];
|
|
258
|
+
return subscriber.queue.splice(0);
|
|
259
|
+
} finally {
|
|
260
|
+
state.parked--;
|
|
261
|
+
state.lastSeen = Date.now();
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
requeue: (frames) => {
|
|
265
|
+
if (frames.length > 0 && !subscriber.ended) subscriber.queue.unshift(...frames);
|
|
203
266
|
}
|
|
204
267
|
};
|
|
205
268
|
}
|
|
@@ -292,16 +355,78 @@ var RelayBroker = class {
|
|
|
292
355
|
if (!dir) return;
|
|
293
356
|
for (const sub of dir.values()) {
|
|
294
357
|
if (sub.client !== client) continue;
|
|
295
|
-
|
|
296
|
-
sub.resolveNext?.(sub.queue.shift() ?? null);
|
|
358
|
+
this.deliver(sub, payload);
|
|
297
359
|
}
|
|
298
360
|
}
|
|
299
361
|
fanOut(sessionId, direction, payload) {
|
|
300
362
|
const dir = this.subs.get(sessionId)?.get(direction);
|
|
301
363
|
if (!dir) return;
|
|
302
|
-
for (const sub of dir.values())
|
|
303
|
-
|
|
304
|
-
|
|
364
|
+
for (const sub of dir.values()) this.deliver(sub, payload);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Hand one frame to one subscriber.
|
|
368
|
+
*
|
|
369
|
+
* The resolver is CLEARED before it is called. It used to stay set until the
|
|
370
|
+
* generator resumed on a later microtask, so a second frame in the same tick
|
|
371
|
+
* called an already-settled resolver — a no-op — after shifting its frame out
|
|
372
|
+
* of the queue, and that frame was lost. Cleared first, the second frame just
|
|
373
|
+
* waits in the queue for the generator to come back for it.
|
|
374
|
+
*/
|
|
375
|
+
deliver(sub, payload) {
|
|
376
|
+
if (sub.ended) return;
|
|
377
|
+
sub.queue.push(payload);
|
|
378
|
+
const resolve = sub.resolveNext;
|
|
379
|
+
if (resolve) {
|
|
380
|
+
sub.resolveNext = null;
|
|
381
|
+
resolve(sub.queue.shift() ?? null);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (sub.poll) for (const wake of [...sub.poll.wakers]) wake();
|
|
385
|
+
}
|
|
386
|
+
/** Tell the page (the inbound side) that an agent arrived or left. */
|
|
387
|
+
announcePeer(sessionId, method, subscriberId) {
|
|
388
|
+
this.fanOut(sessionId, "inbound", JSON.stringify({ jsonrpc: "2.0", method, params: { subscriberId, ts: Date.now() } }));
|
|
389
|
+
}
|
|
390
|
+
/** End one subscriber: close its stream, or release its parked polls. Idempotent. */
|
|
391
|
+
endSubscriber(sub) {
|
|
392
|
+
if (sub.ended) return;
|
|
393
|
+
sub.ended = true;
|
|
394
|
+
const resolve = sub.resolveNext;
|
|
395
|
+
sub.resolveNext = null;
|
|
396
|
+
resolve?.(null);
|
|
397
|
+
if (sub.poll) for (const wake of [...sub.poll.wakers]) wake();
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* End every subscriber of a session whose session is going away.
|
|
401
|
+
*
|
|
402
|
+
* Streams used to be dropped from the map WITHOUT being woken, so an SSE leg
|
|
403
|
+
* stayed open, heartbeating, for a session that no longer existed — and its
|
|
404
|
+
* client could not learn the page had closed. Ended, the stream closes, the
|
|
405
|
+
* client reconnects, and `events` answers 410.
|
|
406
|
+
*/
|
|
407
|
+
endSubscribers(sessionId) {
|
|
408
|
+
const dirs = this.subs.get(sessionId);
|
|
409
|
+
if (!dirs) return;
|
|
410
|
+
for (const dir of dirs.values()) for (const sub of dir.values()) this.endSubscriber(sub);
|
|
411
|
+
}
|
|
412
|
+
/** Remove a subscriber from its session, announcing an agent's departure once. */
|
|
413
|
+
removeSubscriber(sessionId, sub) {
|
|
414
|
+
const dir = this.subs.get(sessionId)?.get(sub.direction);
|
|
415
|
+
if (!dir || dir.get(sub.id) !== sub) return;
|
|
416
|
+
dir.delete(sub.id);
|
|
417
|
+
if (sub.direction === "outbound") this.announcePeer(sessionId, "notifications/peer_left", sub.id);
|
|
418
|
+
}
|
|
419
|
+
/** Drop long-poll subscribers that stopped polling. Never one with a poll parked. */
|
|
420
|
+
prunePollers(now) {
|
|
421
|
+
for (const [sessionId, dirs] of this.subs) {
|
|
422
|
+
for (const dir of dirs.values()) {
|
|
423
|
+
for (const sub of [...dir.values()]) {
|
|
424
|
+
if (!sub.poll || sub.poll.parked > 0) continue;
|
|
425
|
+
if (now - sub.poll.lastSeen <= this.pollIdleMs) continue;
|
|
426
|
+
this.endSubscriber(sub);
|
|
427
|
+
this.removeSubscriber(sessionId, sub);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
305
430
|
}
|
|
306
431
|
}
|
|
307
432
|
/**
|
|
@@ -337,18 +462,15 @@ var RelayBroker = class {
|
|
|
337
462
|
return "id" in o && hasResult !== hasError;
|
|
338
463
|
}
|
|
339
464
|
reap() {
|
|
340
|
-
const
|
|
465
|
+
const now = Date.now();
|
|
466
|
+
const cutoff = now - this.ttlMs;
|
|
341
467
|
for (const id of this.store.expiredSessionIds(cutoff)) {
|
|
342
|
-
|
|
343
|
-
if (dirs) {
|
|
344
|
-
for (const dir of dirs.values()) {
|
|
345
|
-
for (const sub of dir.values()) sub.resolveNext?.(null);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
468
|
+
this.endSubscribers(id);
|
|
348
469
|
this.subs.delete(id);
|
|
349
470
|
this.callers.delete(id);
|
|
350
471
|
this.store.deleteSession(id);
|
|
351
472
|
}
|
|
473
|
+
this.prunePollers(now);
|
|
352
474
|
}
|
|
353
475
|
};
|
|
354
476
|
function sha256Hex(input) {
|
|
@@ -358,19 +480,69 @@ function timingSafeEqualHex(a, b) {
|
|
|
358
480
|
if (a.length !== b.length) return false;
|
|
359
481
|
return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
360
482
|
}
|
|
483
|
+
|
|
484
|
+
// src/relay-server/cors.ts
|
|
485
|
+
function parseCorsOrigins(value) {
|
|
486
|
+
const entries = (typeof value === "string" ? value.split(",") : [...value]).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
487
|
+
if (entries.length === 0) {
|
|
488
|
+
throw new Error("CORS origin list is empty: pass `*` or one or more origins, e.g. https://example.com");
|
|
489
|
+
}
|
|
490
|
+
if (entries.includes("*")) {
|
|
491
|
+
if (entries.length === 1) return { kind: "any" };
|
|
492
|
+
throw new Error(
|
|
493
|
+
"CORS `*` cannot be combined with named origins \u2014 `*` already allows every origin, so the list would mean nothing. Pass either `*` or the origins."
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const origins = [];
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
const origin = normalizeOrigin(entry);
|
|
499
|
+
if (!origins.includes(origin)) origins.push(origin);
|
|
500
|
+
}
|
|
501
|
+
return { kind: "list", origins };
|
|
502
|
+
}
|
|
503
|
+
function normalizeOrigin(raw) {
|
|
504
|
+
if (raw.toLowerCase() === "null") {
|
|
505
|
+
throw new Error("CORS origin `null` is refused: it is what sandboxed iframes and file:// pages send.");
|
|
506
|
+
}
|
|
507
|
+
let url;
|
|
508
|
+
try {
|
|
509
|
+
url = new URL(raw);
|
|
510
|
+
} catch {
|
|
511
|
+
throw new Error(`CORS origin "${raw}" is not an origin \u2014 expected scheme://host[:port], e.g. https://example.com`);
|
|
512
|
+
}
|
|
513
|
+
if (url.pathname !== "/" && url.pathname !== "" || url.search || url.hash || url.username || url.password) {
|
|
514
|
+
throw new Error(`CORS origin "${raw}" has a path, query or credentials \u2014 an origin is only scheme://host[:port]`);
|
|
515
|
+
}
|
|
516
|
+
if (url.origin !== "null") return url.origin;
|
|
517
|
+
if (url.host) return `${url.protocol}//${url.host}`;
|
|
518
|
+
throw new Error(`CORS origin "${raw}" is not an origin \u2014 expected scheme://host[:port], e.g. https://example.com`);
|
|
519
|
+
}
|
|
520
|
+
function allowOriginFor(policy, requestOrigin) {
|
|
521
|
+
if (policy.kind === "any") return "*";
|
|
522
|
+
return requestOrigin !== void 0 && policy.origins.includes(requestOrigin) ? requestOrigin : null;
|
|
523
|
+
}
|
|
524
|
+
function describeCorsPolicy(policy) {
|
|
525
|
+
return policy.kind === "any" ? "*" : policy.origins.join(",");
|
|
526
|
+
}
|
|
527
|
+
var POLL_MAX_WAIT_MS = 25e3;
|
|
528
|
+
var POLL_DEFAULT_WAIT_MS = 2e4;
|
|
529
|
+
function pollWaitMs(raw) {
|
|
530
|
+
if (raw === null || raw === void 0) return POLL_DEFAULT_WAIT_MS;
|
|
531
|
+
const n = Number.parseInt(raw, 10);
|
|
532
|
+
if (!Number.isFinite(n)) return 0;
|
|
533
|
+
return Math.min(Math.max(n, 0), POLL_MAX_WAIT_MS);
|
|
534
|
+
}
|
|
361
535
|
function createNodeRelay(opts = {}) {
|
|
362
536
|
const broker = new RelayBroker(opts);
|
|
363
537
|
const prefix = (opts.pathPrefix ?? "").replace(/\/$/, "");
|
|
364
|
-
const cors = opts.corsAllowOrigin ?? "*";
|
|
365
|
-
const allowedOrigins = opts.allowedOrigins;
|
|
538
|
+
const cors = opts.allowedOrigins && opts.allowedOrigins.length > 0 ? parseCorsOrigins(opts.allowedOrigins) : parseCorsOrigins(opts.corsAllowOrigin ?? "*");
|
|
366
539
|
function setCorsHeaders(res, req) {
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
res.setHeader("access-control-allow-origin", origin && allowedOrigins.includes(origin) ? origin : "null");
|
|
370
|
-
res.setHeader("vary", "origin");
|
|
371
|
-
} else {
|
|
372
|
-
res.setHeader("access-control-allow-origin", cors);
|
|
540
|
+
if (cors.kind === "list") {
|
|
541
|
+
res.setHeader("vary", "Origin");
|
|
373
542
|
}
|
|
543
|
+
const origin = req?.headers.origin;
|
|
544
|
+
const allow = allowOriginFor(cors, typeof origin === "string" ? origin : void 0);
|
|
545
|
+
if (allow !== null) res.setHeader("access-control-allow-origin", allow);
|
|
374
546
|
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
375
547
|
res.setHeader("access-control-allow-headers", "content-type, x-csrf-token, accept, authorization");
|
|
376
548
|
res.setHeader("access-control-max-age", "86400");
|
|
@@ -399,12 +571,12 @@ function createNodeRelay(opts = {}) {
|
|
|
399
571
|
}
|
|
400
572
|
function getQuery(req) {
|
|
401
573
|
const host = req.headers.host || "x";
|
|
402
|
-
const u = new URL(req.url || "/", `http://${host}`);
|
|
574
|
+
const u = new URL$1(req.url || "/", `http://${host}`);
|
|
403
575
|
return u.searchParams;
|
|
404
576
|
}
|
|
405
577
|
function getPathname(req) {
|
|
406
578
|
const host = req.headers.host || "x";
|
|
407
|
-
const u = new URL(req.url || "/", `http://${host}`);
|
|
579
|
+
const u = new URL$1(req.url || "/", `http://${host}`);
|
|
408
580
|
return u.pathname;
|
|
409
581
|
}
|
|
410
582
|
const register = async (req, res) => {
|
|
@@ -487,7 +659,7 @@ function createNodeRelay(opts = {}) {
|
|
|
487
659
|
client: getQuery(req).get("client") ?? void 0
|
|
488
660
|
});
|
|
489
661
|
if (!sub.ok) {
|
|
490
|
-
res.statusCode = 401;
|
|
662
|
+
res.statusCode = sub.reason === "session_gone" ? 410 : 401;
|
|
491
663
|
res.setHeader("content-type", "text/event-stream");
|
|
492
664
|
res.write(`event: error
|
|
493
665
|
data: ${sub.reason}
|
|
@@ -532,6 +704,38 @@ data: ${frame}
|
|
|
532
704
|
res.end();
|
|
533
705
|
}
|
|
534
706
|
};
|
|
707
|
+
const poll = async (req, res) => {
|
|
708
|
+
setCorsHeaders(res, req);
|
|
709
|
+
if (req.method === "OPTIONS") {
|
|
710
|
+
res.statusCode = 204;
|
|
711
|
+
return res.end();
|
|
712
|
+
}
|
|
713
|
+
if (req.method !== "GET") return json(res, 405, { error: "method_not_allowed" });
|
|
714
|
+
const session = extractSession(req, prefix);
|
|
715
|
+
if (!session) return json(res, 400, { error: "missing_session" });
|
|
716
|
+
const q = getQuery(req);
|
|
717
|
+
const direction = q.get("direction") === "outbound" ? "outbound" : "inbound";
|
|
718
|
+
const attached = broker.poll(session, q.get("token") ?? "", direction, {
|
|
719
|
+
subscriber: q.get("subscriber") ?? void 0,
|
|
720
|
+
client: q.get("client") ?? void 0
|
|
721
|
+
});
|
|
722
|
+
if (!attached.ok) {
|
|
723
|
+
return json(res, attached.reason === "session_gone" ? 410 : 401, { error: attached.reason });
|
|
724
|
+
}
|
|
725
|
+
const gone = new AbortController();
|
|
726
|
+
const onClose = () => {
|
|
727
|
+
if (!res.writableEnded) gone.abort();
|
|
728
|
+
};
|
|
729
|
+
res.on("close", onClose);
|
|
730
|
+
const frames = await attached.wait(pollWaitMs(q.get("wait")), gone.signal);
|
|
731
|
+
res.off("close", onClose);
|
|
732
|
+
if (gone.signal.aborted || res.destroyed) {
|
|
733
|
+
attached.requeue(frames);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
res.setHeader("cache-control", "no-store");
|
|
737
|
+
return json(res, 200, { subscriber: attached.subscriberId, frames });
|
|
738
|
+
};
|
|
535
739
|
const handler = async (req, res) => {
|
|
536
740
|
const pathname = getPathname(req);
|
|
537
741
|
if (!pathname.startsWith(prefix + "/")) {
|
|
@@ -539,20 +743,21 @@ data: ${frame}
|
|
|
539
743
|
}
|
|
540
744
|
const rest = pathname.slice(prefix.length);
|
|
541
745
|
if (rest === "/register") return register(req, res);
|
|
542
|
-
const m = /^\/([A-Za-z0-9_-]{4,64})\/(inbox|outbox|events|unregister)$/.exec(rest);
|
|
746
|
+
const m = /^\/([A-Za-z0-9_-]{4,64})\/(inbox|outbox|events|poll|unregister)$/.exec(rest);
|
|
543
747
|
if (!m) return json(res, 404, { error: "not_found" });
|
|
544
748
|
const route = m[2];
|
|
545
749
|
if (route === "inbox") return inbox(req, res);
|
|
546
750
|
if (route === "outbox") return outbox(req, res);
|
|
547
751
|
if (route === "events") return events(req, res);
|
|
752
|
+
if (route === "poll") return poll(req, res);
|
|
548
753
|
if (route === "unregister") return unregister(req, res);
|
|
549
754
|
return json(res, 404, { error: "not_found" });
|
|
550
755
|
};
|
|
551
|
-
return { broker, handler, register, inbox, outbox, events, unregister };
|
|
756
|
+
return { broker, handler, register, inbox, outbox, events, poll, unregister };
|
|
552
757
|
}
|
|
553
758
|
function extractSession(req, prefix) {
|
|
554
759
|
const host = req.headers.host || "x";
|
|
555
|
-
const u = new URL(req.url || "/", `http://${host}`);
|
|
760
|
+
const u = new URL$1(req.url || "/", `http://${host}`);
|
|
556
761
|
const path = u.pathname;
|
|
557
762
|
if (prefix && !path.startsWith(prefix + "/")) return null;
|
|
558
763
|
const rest = prefix ? path.slice(prefix.length) : path;
|
|
@@ -564,6 +769,6 @@ function flush(res) {
|
|
|
564
769
|
r.flush?.();
|
|
565
770
|
}
|
|
566
771
|
|
|
567
|
-
export { RelayBroker, createNodeRelay };
|
|
568
|
-
//# sourceMappingURL=chunk-
|
|
569
|
-
//# sourceMappingURL=chunk-
|
|
772
|
+
export { RelayBroker, createNodeRelay, describeCorsPolicy, parseCorsOrigins };
|
|
773
|
+
//# sourceMappingURL=chunk-5W34X4EY.js.map
|
|
774
|
+
//# sourceMappingURL=chunk-5W34X4EY.js.map
|