@particle-academy/agent-integrations 0.44.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.
Files changed (182) hide show
  1. package/README.md +42 -3
  2. package/dist/bridges/artboard.d.cts +3 -3
  3. package/dist/bridges/artboard.d.ts +3 -3
  4. package/dist/bridges/catalog.d.cts +3 -3
  5. package/dist/bridges/catalog.d.ts +3 -3
  6. package/dist/bridges/charts.d.cts +3 -3
  7. package/dist/bridges/charts.d.ts +3 -3
  8. package/dist/bridges/cms.d.cts +3 -3
  9. package/dist/bridges/cms.d.ts +3 -3
  10. package/dist/bridges/code.d.cts +3 -3
  11. package/dist/bridges/code.d.ts +3 -3
  12. package/dist/bridges/doc.d.cts +3 -3
  13. package/dist/bridges/doc.d.ts +3 -3
  14. package/dist/bridges/features.d.cts +3 -3
  15. package/dist/bridges/features.d.ts +3 -3
  16. package/dist/bridges/files.d.cts +3 -3
  17. package/dist/bridges/files.d.ts +3 -3
  18. package/dist/bridges/flow.d.cts +3 -3
  19. package/dist/bridges/flow.d.ts +3 -3
  20. package/dist/bridges/forms.d.cts +3 -3
  21. package/dist/bridges/forms.d.ts +3 -3
  22. package/dist/bridges/git.d.cts +3 -3
  23. package/dist/bridges/git.d.ts +3 -3
  24. package/dist/bridges/grid.d.cts +3 -3
  25. package/dist/bridges/grid.d.ts +3 -3
  26. package/dist/bridges/map.d.cts +3 -3
  27. package/dist/bridges/map.d.ts +3 -3
  28. package/dist/bridges/passkeys.d.cts +3 -3
  29. package/dist/bridges/passkeys.d.ts +3 -3
  30. package/dist/bridges/scene.d.cts +3 -3
  31. package/dist/bridges/scene.d.ts +3 -3
  32. package/dist/bridges/screens.d.cts +3 -3
  33. package/dist/bridges/screens.d.ts +3 -3
  34. package/dist/bridges/sheets.d.cts +3 -3
  35. package/dist/bridges/sheets.d.ts +3 -3
  36. package/dist/bridges/slides.d.cts +3 -3
  37. package/dist/bridges/slides.d.ts +3 -3
  38. package/dist/bridges/terminal.d.cts +3 -3
  39. package/dist/bridges/terminal.d.ts +3 -3
  40. package/dist/bridges/tui.d.cts +2 -2
  41. package/dist/bridges/tui.d.ts +2 -2
  42. package/dist/bridges/whiteboard.d.cts +3 -3
  43. package/dist/bridges/whiteboard.d.ts +3 -3
  44. package/dist/bridges-artboard.cjs.map +1 -1
  45. package/dist/bridges-artboard.js +2 -2
  46. package/dist/bridges-catalog.cjs.map +1 -1
  47. package/dist/bridges-catalog.js +3 -3
  48. package/dist/bridges-charts.cjs.map +1 -1
  49. package/dist/bridges-charts.js +3 -3
  50. package/dist/bridges-cms.cjs.map +1 -1
  51. package/dist/bridges-cms.js +4 -4
  52. package/dist/bridges-code.cjs.map +1 -1
  53. package/dist/bridges-code.js +3 -3
  54. package/dist/bridges-doc.cjs.map +1 -1
  55. package/dist/bridges-doc.js +3 -3
  56. package/dist/bridges-features.cjs.map +1 -1
  57. package/dist/bridges-features.js +3 -3
  58. package/dist/bridges-files.cjs.map +1 -1
  59. package/dist/bridges-files.js +3 -3
  60. package/dist/bridges-flow.cjs.map +1 -1
  61. package/dist/bridges-flow.js +2 -2
  62. package/dist/bridges-forms.cjs.map +1 -1
  63. package/dist/bridges-forms.js +3 -3
  64. package/dist/bridges-git.cjs.map +1 -1
  65. package/dist/bridges-git.js +3 -3
  66. package/dist/bridges-grid.cjs.map +1 -1
  67. package/dist/bridges-grid.js +3 -3
  68. package/dist/bridges-map.cjs.map +1 -1
  69. package/dist/bridges-map.js +3 -3
  70. package/dist/bridges-passkeys.cjs.map +1 -1
  71. package/dist/bridges-passkeys.js +3 -3
  72. package/dist/bridges-scene.cjs.map +1 -1
  73. package/dist/bridges-scene.js +3 -3
  74. package/dist/bridges-screens.cjs.map +1 -1
  75. package/dist/bridges-screens.js +3 -3
  76. package/dist/bridges-sheets.cjs.map +1 -1
  77. package/dist/bridges-sheets.js +3 -3
  78. package/dist/bridges-slides.cjs.map +1 -1
  79. package/dist/bridges-slides.js +3 -3
  80. package/dist/bridges-terminal.cjs.map +1 -1
  81. package/dist/bridges-terminal.js +3 -3
  82. package/dist/bridges-tui.cjs.map +1 -1
  83. package/dist/bridges-tui.js +2 -2
  84. package/dist/bridges-whiteboard.cjs.map +1 -1
  85. package/dist/bridges-whiteboard.js +3 -3
  86. package/dist/{chunk-5W6YOXLT.js → chunk-36MKPTVD.js} +4 -4
  87. package/dist/{chunk-5W6YOXLT.js.map → chunk-36MKPTVD.js.map} +1 -1
  88. package/dist/{chunk-SXVRIIXW.js → chunk-3HVYUMQF.js} +4 -4
  89. package/dist/{chunk-SXVRIIXW.js.map → chunk-3HVYUMQF.js.map} +1 -1
  90. package/dist/{chunk-MZ74LLDS.js → chunk-3UUF5KKZ.js} +4 -4
  91. package/dist/{chunk-MZ74LLDS.js.map → chunk-3UUF5KKZ.js.map} +1 -1
  92. package/dist/{chunk-WEXMZMJH.js → chunk-3WVXAB3D.js} +3 -3
  93. package/dist/{chunk-WEXMZMJH.js.map → chunk-3WVXAB3D.js.map} +1 -1
  94. package/dist/{chunk-OZBUPO4K.js → chunk-5W34X4EY.js} +263 -58
  95. package/dist/chunk-5W34X4EY.js.map +1 -0
  96. package/dist/{chunk-Z4L2GOAK.js → chunk-5ZWNCHZS.js} +4 -4
  97. package/dist/{chunk-Z4L2GOAK.js.map → chunk-5ZWNCHZS.js.map} +1 -1
  98. package/dist/{chunk-TEB5U6Y5.js → chunk-7NVATRLE.js} +4 -4
  99. package/dist/{chunk-TEB5U6Y5.js.map → chunk-7NVATRLE.js.map} +1 -1
  100. package/dist/{chunk-PUMQDV67.js → chunk-AXWMWYH5.js} +4 -4
  101. package/dist/{chunk-PUMQDV67.js.map → chunk-AXWMWYH5.js.map} +1 -1
  102. package/dist/{chunk-NKAH67JK.js → chunk-CS3CHGYC.js} +4 -4
  103. package/dist/{chunk-NKAH67JK.js.map → chunk-CS3CHGYC.js.map} +1 -1
  104. package/dist/{chunk-QJYRWEXD.js → chunk-CZV3WUW5.js} +35 -8
  105. package/dist/chunk-CZV3WUW5.js.map +1 -0
  106. package/dist/{chunk-PCFRBVKS.js → chunk-FCXSR7I5.js} +4 -4
  107. package/dist/{chunk-PCFRBVKS.js.map → chunk-FCXSR7I5.js.map} +1 -1
  108. package/dist/{chunk-NIFPRC7P.js → chunk-GVTBMJU6.js} +4 -4
  109. package/dist/{chunk-NIFPRC7P.js.map → chunk-GVTBMJU6.js.map} +1 -1
  110. package/dist/{chunk-E2I3YVKC.js → chunk-JLHVQWOV.js} +3 -3
  111. package/dist/{chunk-E2I3YVKC.js.map → chunk-JLHVQWOV.js.map} +1 -1
  112. package/dist/{chunk-2LSU3NFO.js → chunk-LQB42OSA.js} +4 -4
  113. package/dist/{chunk-2LSU3NFO.js.map → chunk-LQB42OSA.js.map} +1 -1
  114. package/dist/{chunk-VJD3Y7E5.js → chunk-MIDZWKIF.js} +4 -4
  115. package/dist/{chunk-VJD3Y7E5.js.map → chunk-MIDZWKIF.js.map} +1 -1
  116. package/dist/{chunk-LOD77NCD.js → chunk-PWYEW6NL.js} +4 -4
  117. package/dist/{chunk-LOD77NCD.js.map → chunk-PWYEW6NL.js.map} +1 -1
  118. package/dist/{chunk-HVDHT7DM.js → chunk-R4XXOGQH.js} +3 -3
  119. package/dist/{chunk-HVDHT7DM.js.map → chunk-R4XXOGQH.js.map} +1 -1
  120. package/dist/{chunk-7LLMILTM.js → chunk-RVMM4BHK.js} +4 -4
  121. package/dist/{chunk-7LLMILTM.js.map → chunk-RVMM4BHK.js.map} +1 -1
  122. package/dist/{chunk-NIXOY473.js → chunk-SWWJUOCY.js} +3 -3
  123. package/dist/{chunk-NIXOY473.js.map → chunk-SWWJUOCY.js.map} +1 -1
  124. package/dist/{chunk-EIBV76IB.js → chunk-VFWFT3OK.js} +4 -4
  125. package/dist/{chunk-EIBV76IB.js.map → chunk-VFWFT3OK.js.map} +1 -1
  126. package/dist/{chunk-2Q4EGZHJ.js → chunk-VMRUW4B4.js} +4 -4
  127. package/dist/{chunk-2Q4EGZHJ.js.map → chunk-VMRUW4B4.js.map} +1 -1
  128. package/dist/{chunk-GT63LSPJ.js → chunk-VOCUGTZB.js} +4 -4
  129. package/dist/{chunk-GT63LSPJ.js.map → chunk-VOCUGTZB.js.map} +1 -1
  130. package/dist/{chunk-N5VOPV64.js → chunk-Z5NL5D4J.js} +4 -4
  131. package/dist/{chunk-N5VOPV64.js.map → chunk-Z5NL5D4J.js.map} +1 -1
  132. package/dist/components-shared-whiteboard.cjs +33 -6
  133. package/dist/components-shared-whiteboard.cjs.map +1 -1
  134. package/dist/components-shared-whiteboard.js +3 -3
  135. package/dist/index.cjs +33 -6
  136. package/dist/index.cjs.map +1 -1
  137. package/dist/index.d.cts +7 -7
  138. package/dist/index.d.ts +7 -7
  139. package/dist/index.js +27 -27
  140. package/dist/mcp/index.d.cts +5 -5
  141. package/dist/mcp/index.d.ts +5 -5
  142. package/dist/mcp/stdio.d.cts +94 -0
  143. package/dist/mcp/stdio.d.ts +94 -0
  144. package/dist/mcp-stdio.cjs +120 -0
  145. package/dist/mcp-stdio.cjs.map +1 -0
  146. package/dist/mcp-stdio.js +117 -0
  147. package/dist/mcp-stdio.js.map +1 -0
  148. package/dist/mcp.cjs +33 -6
  149. package/dist/mcp.cjs.map +1 -1
  150. package/dist/mcp.js +1 -1
  151. package/dist/presence/index.d.cts +1 -1
  152. package/dist/presence/index.d.ts +1 -1
  153. package/dist/relay-server/index.d.cts +110 -26
  154. package/dist/relay-server/index.d.ts +110 -26
  155. package/dist/relay-server-cli.cjs +374 -89
  156. package/dist/relay-server-cli.cjs.map +1 -1
  157. package/dist/relay-server-cli.js +120 -38
  158. package/dist/relay-server-cli.js.map +1 -1
  159. package/dist/relay-server.cjs +255 -51
  160. package/dist/relay-server.cjs.map +1 -1
  161. package/dist/relay-server.js +1 -1
  162. package/dist/{server-BJNF7pQe.d.cts → server-BIcA3ZtK.d.cts} +32 -3
  163. package/dist/{server-Dt97UxuD.d.ts → server-DnUyeavY.d.ts} +32 -3
  164. package/dist/sharing/index.d.cts +3 -3
  165. package/dist/sharing/index.d.ts +3 -3
  166. package/dist/sheets-adapter.d.cts +3 -3
  167. package/dist/sheets-adapter.d.ts +3 -3
  168. package/dist/{tool-host-BpEBqf5u.d.cts → tool-host-LVBgCN5m.d.cts} +1 -1
  169. package/dist/{tool-host-xY5F-eqG.d.ts → tool-host-Wo0pkY83.d.ts} +1 -1
  170. package/dist/{types-DSwsNTBG.d.ts → types-BxNRKSVd.d.ts} +1 -1
  171. package/dist/{types-C58ZXs4K.d.cts → types-C2DtnHIn.d.cts} +25 -1
  172. package/dist/{types-C58ZXs4K.d.ts → types-C2DtnHIn.d.ts} +25 -1
  173. package/dist/{types-C4NaIW6B.d.cts → types-EcElKOlk.d.cts} +1 -1
  174. package/dist/undo/index.d.cts +2 -2
  175. package/dist/undo/index.d.ts +2 -2
  176. package/dist/undo.cjs.map +1 -1
  177. package/dist/undo.js +3 -3
  178. package/docs/relay-protocol.md +41 -5
  179. package/docs/relay-server.md +102 -15
  180. package/package.json +19 -2
  181. package/dist/chunk-OZBUPO4K.js.map +0 -1
  182. package/dist/chunk-QJYRWEXD.js.map +0 -1
@@ -5,6 +5,136 @@ var http = require('http');
5
5
  var url = require('url');
6
6
  var crypto = require('crypto');
7
7
 
8
+ // src/relay-server/cors.ts
9
+ function parseCorsOrigins(value) {
10
+ const entries = (typeof value === "string" ? value.split(",") : [...value]).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
11
+ if (entries.length === 0) {
12
+ throw new Error("CORS origin list is empty: pass `*` or one or more origins, e.g. https://example.com");
13
+ }
14
+ if (entries.includes("*")) {
15
+ if (entries.length === 1) return { kind: "any" };
16
+ throw new Error(
17
+ "CORS `*` cannot be combined with named origins \u2014 `*` already allows every origin, so the list would mean nothing. Pass either `*` or the origins."
18
+ );
19
+ }
20
+ const origins = [];
21
+ for (const entry of entries) {
22
+ const origin = normalizeOrigin(entry);
23
+ if (!origins.includes(origin)) origins.push(origin);
24
+ }
25
+ return { kind: "list", origins };
26
+ }
27
+ function normalizeOrigin(raw) {
28
+ if (raw.toLowerCase() === "null") {
29
+ throw new Error("CORS origin `null` is refused: it is what sandboxed iframes and file:// pages send.");
30
+ }
31
+ let url;
32
+ try {
33
+ url = new URL(raw);
34
+ } catch {
35
+ throw new Error(`CORS origin "${raw}" is not an origin \u2014 expected scheme://host[:port], e.g. https://example.com`);
36
+ }
37
+ if (url.pathname !== "/" && url.pathname !== "" || url.search || url.hash || url.username || url.password) {
38
+ throw new Error(`CORS origin "${raw}" has a path, query or credentials \u2014 an origin is only scheme://host[:port]`);
39
+ }
40
+ if (url.origin !== "null") return url.origin;
41
+ if (url.host) return `${url.protocol}//${url.host}`;
42
+ throw new Error(`CORS origin "${raw}" is not an origin \u2014 expected scheme://host[:port], e.g. https://example.com`);
43
+ }
44
+ function allowOriginFor(policy, requestOrigin) {
45
+ if (policy.kind === "any") return "*";
46
+ return requestOrigin !== void 0 && policy.origins.includes(requestOrigin) ? requestOrigin : null;
47
+ }
48
+ function describeCorsPolicy(policy) {
49
+ return policy.kind === "any" ? "*" : policy.origins.join(",");
50
+ }
51
+
52
+ // src/relay-server/args.ts
53
+ var SETTINGS = {
54
+ port: { flag: "--port", env: "PORT", fallback: "8787" },
55
+ // Loopback by default: this relay fronts terminal_run (RCE) and register is
56
+ // unauthenticated, so it must not listen on all interfaces unless the
57
+ // operator explicitly opts in with --host 0.0.0.0.
58
+ host: { flag: "--host", env: "HOST", fallback: "127.0.0.1" },
59
+ prefix: { flag: "--prefix", env: "PREFIX", fallback: "" },
60
+ ttlMs: { flag: "--ttl-ms", env: "TTL_MS", fallback: String(4 * 60 * 60 * 1e3) },
61
+ cors: { flag: "--cors", env: "CORS_ALLOW_ORIGIN", fallback: "*" }
62
+ };
63
+ function parseRelayArgs(argv, env) {
64
+ const flags = {};
65
+ let help = false;
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const arg = argv[i];
68
+ if (arg === "-h" || arg === "--help") {
69
+ help = true;
70
+ continue;
71
+ }
72
+ const key = Object.keys(SETTINGS).find((k) => SETTINGS[k].flag === arg);
73
+ if (!key) return { ok: false, error: `unknown flag: ${arg}` };
74
+ const value = argv[i + 1];
75
+ if (value === void 0 || value.startsWith("--")) return { ok: false, error: `${arg} needs a value` };
76
+ i++;
77
+ flags[key] = key === "cors" && flags.cors !== void 0 ? `${flags.cors},${value}` : value;
78
+ }
79
+ const warnings = [];
80
+ const raw = {};
81
+ const sources = {};
82
+ for (const key of Object.keys(SETTINGS)) {
83
+ const { flag, env: envName, fallback } = SETTINGS[key];
84
+ const fromEnv = env[envName] === "" ? void 0 : env[envName];
85
+ const fromFlag = flags[key];
86
+ if (fromFlag !== void 0) {
87
+ raw[key] = fromFlag;
88
+ sources[key] = "flag";
89
+ if (fromEnv !== void 0 && fromEnv !== fromFlag) {
90
+ warnings.push(`${envName}=${fromEnv} is ignored: ${flag} ${fromFlag} was passed, and a flag wins over its env var.`);
91
+ }
92
+ } else if (fromEnv !== void 0) {
93
+ raw[key] = fromEnv;
94
+ sources[key] = "env";
95
+ } else {
96
+ raw[key] = fallback;
97
+ sources[key] = "default";
98
+ }
99
+ }
100
+ const named = (key) => sources[key] === "env" ? SETTINGS[key].env : SETTINGS[key].flag;
101
+ const port = Number(raw.port);
102
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
103
+ return { ok: false, error: `invalid ${named("port")}: ${raw.port}` };
104
+ }
105
+ const ttlMs = Number(raw.ttlMs);
106
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
107
+ return { ok: false, error: `invalid ${named("ttlMs")}: ${raw.ttlMs}` };
108
+ }
109
+ let cors;
110
+ try {
111
+ cors = parseCorsOrigins(raw.cors);
112
+ } catch (e) {
113
+ return { ok: false, error: `invalid ${named("cors")}: ${e instanceof Error ? e.message : String(e)}` };
114
+ }
115
+ if (sources.cors === "flag" && env.CORS_ALLOW_ORIGIN) {
116
+ try {
117
+ if (describeCorsPolicy(parseCorsOrigins(env.CORS_ALLOW_ORIGIN)) === describeCorsPolicy(cors)) {
118
+ const i = warnings.findIndex((w) => w.startsWith("CORS_ALLOW_ORIGIN="));
119
+ if (i !== -1) warnings.splice(i, 1);
120
+ }
121
+ } catch {
122
+ }
123
+ }
124
+ return {
125
+ ok: true,
126
+ config: {
127
+ port,
128
+ host: raw.host,
129
+ prefix: raw.prefix,
130
+ ttlMs,
131
+ cors,
132
+ sources,
133
+ warnings,
134
+ help
135
+ }
136
+ };
137
+ }
8
138
  var MemoryStore = class {
9
139
  constructor() {
10
140
  this.sessions = /* @__PURE__ */ new Map();
@@ -25,6 +155,7 @@ var MemoryStore = class {
25
155
  }
26
156
  };
27
157
  var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;
158
+ var SUBSCRIBER_ID_PATTERN = /^[a-f0-9]{16}$/;
28
159
  var RelayBroker = class {
29
160
  constructor(opts = {}) {
30
161
  /** Per-session, per-direction subscriber list. */
@@ -32,6 +163,7 @@ var RelayBroker = class {
32
163
  /** Per-session map of JSON-RPC request id -> the client that asked. */
33
164
  this.callers = /* @__PURE__ */ new Map();
34
165
  this.ttlMs = opts.ttlMs ?? 4 * 60 * 60 * 1e3;
166
+ this.pollIdleMs = opts.pollIdleMs ?? 6e4;
35
167
  this.store = opts.store ?? new MemoryStore();
36
168
  const tick = opts.reapIntervalMs ?? 6e4;
37
169
  if (tick > 0) {
@@ -43,6 +175,7 @@ var RelayBroker = class {
43
175
  }
44
176
  dispose() {
45
177
  if (this.reaper) clearInterval(this.reaper);
178
+ for (const id of [...this.subs.keys()]) this.endSubscribers(id);
46
179
  this.subs.clear();
47
180
  this.callers.clear();
48
181
  }
@@ -67,6 +200,7 @@ var RelayBroker = class {
67
200
  unregister(id, token) {
68
201
  if (!this.validate(id, token)) return false;
69
202
  this.store.deleteSession(id);
203
+ this.endSubscribers(id);
70
204
  this.subs.delete(id);
71
205
  this.callers.delete(id);
72
206
  return true;
@@ -116,6 +250,7 @@ var RelayBroker = class {
116
250
  /** Drop a session — the page closed, or a test needs it gone. */
117
251
  dropSession(id) {
118
252
  this.store.deleteSession(id);
253
+ this.endSubscribers(id);
119
254
  this.subs.delete(id);
120
255
  this.callers.delete(id);
121
256
  }
@@ -146,7 +281,8 @@ var RelayBroker = class {
146
281
  * the caller (an HTTP handler) pumps as SSE.
147
282
  */
148
283
  subscribe(id, token, direction, opts = {}) {
149
- if (!this.validate(id, token)) return { ok: false, reason: "invalid_token" };
284
+ const auth = this.check(id, token);
285
+ if (!auth.ok) return auth;
150
286
  const subscriberId = crypto.randomBytes(8).toString("hex");
151
287
  const subscriber = {
152
288
  id: subscriberId,
@@ -156,31 +292,7 @@ var RelayBroker = class {
156
292
  client: opts.client
157
293
  };
158
294
  this.getDirSubs(id, direction).set(subscriberId, subscriber);
159
- if (direction === "outbound") {
160
- this.fanOut(
161
- id,
162
- "inbound",
163
- JSON.stringify({
164
- jsonrpc: "2.0",
165
- method: "notifications/peer_joined",
166
- params: { subscriberId, ts: Date.now() }
167
- })
168
- );
169
- }
170
- const unsubscribe = () => {
171
- this.getDirSubs(id, direction).delete(subscriberId);
172
- if (direction === "outbound") {
173
- this.fanOut(
174
- id,
175
- "inbound",
176
- JSON.stringify({
177
- jsonrpc: "2.0",
178
- method: "notifications/peer_left",
179
- params: { subscriberId, ts: Date.now() }
180
- })
181
- );
182
- }
183
- };
295
+ if (direction === "outbound") this.announcePeer(id, "notifications/peer_joined", subscriberId);
184
296
  const frames = async function* () {
185
297
  while (true) {
186
298
  if (this.queue.length > 0) {
@@ -188,6 +300,7 @@ var RelayBroker = class {
188
300
  if (next2 !== void 0) yield next2;
189
301
  continue;
190
302
  }
303
+ if (this.ended) return;
191
304
  const next = await new Promise((resolve) => {
192
305
  this.resolveNext = resolve;
193
306
  });
@@ -201,8 +314,88 @@ var RelayBroker = class {
201
314
  subscriberId,
202
315
  frames: frames(),
203
316
  unsubscribe: () => {
204
- subscriber.resolveNext?.(null);
205
- unsubscribe();
317
+ this.endSubscriber(subscriber);
318
+ this.removeSubscriber(id, subscriber);
319
+ }
320
+ };
321
+ }
322
+ /**
323
+ * Attach a LONG-POLL subscriber, or re-attach the one a previous poll created.
324
+ *
325
+ * The CDN-safe receive leg: a series of short requests instead of one open
326
+ * stream, because Cloudflare's HTTP/3 edge resets long-lived SSE. Same
327
+ * contract as the PHP relay in px-ui-sandbox and the one
328
+ * `@particle-academy/fancy-cf-relay` and `mcp-relay-client` speak:
329
+ *
330
+ * - The first poll gets a fresh 16-hex subscriber id; the client echoes it back
331
+ * as `subscriber` on every later poll and reads the same queue.
332
+ * - An id in that shape that this session does not know (pruned, say) is
333
+ * adopted as a NEW subscriber under that id, as the PHP relay does, so a
334
+ * client that went quiet and came back keeps its identity.
335
+ * - An id belonging to a STREAMING subscriber is never adopted: one queue read
336
+ * by two consumers splits its frames between them.
337
+ *
338
+ * Frames queue on the subscriber between polls, so nothing is lost in the gap
339
+ * between one request ending and the next arriving. A poller that stops is
340
+ * pruned after `pollIdleMs`.
341
+ */
342
+ poll(id, token, direction, opts = {}) {
343
+ const auth = this.check(id, token);
344
+ if (!auth.ok) return auth;
345
+ const dir = this.getDirSubs(id, direction);
346
+ const requested = opts.subscriber && SUBSCRIBER_ID_PATTERN.test(opts.subscriber) ? opts.subscriber : void 0;
347
+ const existing = requested ? dir.get(requested) : void 0;
348
+ let subscriber;
349
+ let created;
350
+ if (existing?.poll) {
351
+ subscriber = existing;
352
+ created = false;
353
+ } else {
354
+ const subscriberId = requested && !existing ? requested : crypto.randomBytes(8).toString("hex");
355
+ subscriber = {
356
+ id: subscriberId,
357
+ direction,
358
+ queue: [],
359
+ resolveNext: null,
360
+ client: opts.client,
361
+ poll: { lastSeen: Date.now(), parked: 0, wakers: /* @__PURE__ */ new Set() }
362
+ };
363
+ dir.set(subscriberId, subscriber);
364
+ created = true;
365
+ if (direction === "outbound") this.announcePeer(id, "notifications/peer_joined", subscriberId);
366
+ }
367
+ const state = subscriber.poll;
368
+ state.lastSeen = Date.now();
369
+ return {
370
+ ok: true,
371
+ subscriberId: subscriber.id,
372
+ created,
373
+ wait: async (ms, signal) => {
374
+ state.parked++;
375
+ state.lastSeen = Date.now();
376
+ try {
377
+ if (subscriber.queue.length === 0 && ms > 0 && !subscriber.ended && !signal?.aborted) {
378
+ await new Promise((resolve) => {
379
+ const done = () => {
380
+ clearTimeout(timer);
381
+ signal?.removeEventListener("abort", done);
382
+ state.wakers.delete(done);
383
+ resolve();
384
+ };
385
+ const timer = setTimeout(done, ms);
386
+ state.wakers.add(done);
387
+ signal?.addEventListener("abort", done, { once: true });
388
+ });
389
+ }
390
+ if (signal?.aborted) return [];
391
+ return subscriber.queue.splice(0);
392
+ } finally {
393
+ state.parked--;
394
+ state.lastSeen = Date.now();
395
+ }
396
+ },
397
+ requeue: (frames) => {
398
+ if (frames.length > 0 && !subscriber.ended) subscriber.queue.unshift(...frames);
206
399
  }
207
400
  };
208
401
  }
@@ -295,16 +488,78 @@ var RelayBroker = class {
295
488
  if (!dir) return;
296
489
  for (const sub of dir.values()) {
297
490
  if (sub.client !== client) continue;
298
- sub.queue.push(payload);
299
- sub.resolveNext?.(sub.queue.shift() ?? null);
491
+ this.deliver(sub, payload);
300
492
  }
301
493
  }
302
494
  fanOut(sessionId, direction, payload) {
303
495
  const dir = this.subs.get(sessionId)?.get(direction);
304
496
  if (!dir) return;
305
- for (const sub of dir.values()) {
306
- sub.queue.push(payload);
307
- sub.resolveNext?.(sub.queue.shift() ?? null);
497
+ for (const sub of dir.values()) this.deliver(sub, payload);
498
+ }
499
+ /**
500
+ * Hand one frame to one subscriber.
501
+ *
502
+ * The resolver is CLEARED before it is called. It used to stay set until the
503
+ * generator resumed on a later microtask, so a second frame in the same tick
504
+ * called an already-settled resolver — a no-op — after shifting its frame out
505
+ * of the queue, and that frame was lost. Cleared first, the second frame just
506
+ * waits in the queue for the generator to come back for it.
507
+ */
508
+ deliver(sub, payload) {
509
+ if (sub.ended) return;
510
+ sub.queue.push(payload);
511
+ const resolve = sub.resolveNext;
512
+ if (resolve) {
513
+ sub.resolveNext = null;
514
+ resolve(sub.queue.shift() ?? null);
515
+ return;
516
+ }
517
+ if (sub.poll) for (const wake of [...sub.poll.wakers]) wake();
518
+ }
519
+ /** Tell the page (the inbound side) that an agent arrived or left. */
520
+ announcePeer(sessionId, method, subscriberId) {
521
+ this.fanOut(sessionId, "inbound", JSON.stringify({ jsonrpc: "2.0", method, params: { subscriberId, ts: Date.now() } }));
522
+ }
523
+ /** End one subscriber: close its stream, or release its parked polls. Idempotent. */
524
+ endSubscriber(sub) {
525
+ if (sub.ended) return;
526
+ sub.ended = true;
527
+ const resolve = sub.resolveNext;
528
+ sub.resolveNext = null;
529
+ resolve?.(null);
530
+ if (sub.poll) for (const wake of [...sub.poll.wakers]) wake();
531
+ }
532
+ /**
533
+ * End every subscriber of a session whose session is going away.
534
+ *
535
+ * Streams used to be dropped from the map WITHOUT being woken, so an SSE leg
536
+ * stayed open, heartbeating, for a session that no longer existed — and its
537
+ * client could not learn the page had closed. Ended, the stream closes, the
538
+ * client reconnects, and `events` answers 410.
539
+ */
540
+ endSubscribers(sessionId) {
541
+ const dirs = this.subs.get(sessionId);
542
+ if (!dirs) return;
543
+ for (const dir of dirs.values()) for (const sub of dir.values()) this.endSubscriber(sub);
544
+ }
545
+ /** Remove a subscriber from its session, announcing an agent's departure once. */
546
+ removeSubscriber(sessionId, sub) {
547
+ const dir = this.subs.get(sessionId)?.get(sub.direction);
548
+ if (!dir || dir.get(sub.id) !== sub) return;
549
+ dir.delete(sub.id);
550
+ if (sub.direction === "outbound") this.announcePeer(sessionId, "notifications/peer_left", sub.id);
551
+ }
552
+ /** Drop long-poll subscribers that stopped polling. Never one with a poll parked. */
553
+ prunePollers(now) {
554
+ for (const [sessionId, dirs] of this.subs) {
555
+ for (const dir of dirs.values()) {
556
+ for (const sub of [...dir.values()]) {
557
+ if (!sub.poll || sub.poll.parked > 0) continue;
558
+ if (now - sub.poll.lastSeen <= this.pollIdleMs) continue;
559
+ this.endSubscriber(sub);
560
+ this.removeSubscriber(sessionId, sub);
561
+ }
562
+ }
308
563
  }
309
564
  }
310
565
  /**
@@ -340,18 +595,15 @@ var RelayBroker = class {
340
595
  return "id" in o && hasResult !== hasError;
341
596
  }
342
597
  reap() {
343
- const cutoff = Date.now() - this.ttlMs;
598
+ const now = Date.now();
599
+ const cutoff = now - this.ttlMs;
344
600
  for (const id of this.store.expiredSessionIds(cutoff)) {
345
- const dirs = this.subs.get(id);
346
- if (dirs) {
347
- for (const dir of dirs.values()) {
348
- for (const sub of dir.values()) sub.resolveNext?.(null);
349
- }
350
- }
601
+ this.endSubscribers(id);
351
602
  this.subs.delete(id);
352
603
  this.callers.delete(id);
353
604
  this.store.deleteSession(id);
354
605
  }
606
+ this.prunePollers(now);
355
607
  }
356
608
  };
357
609
  function sha256Hex(input) {
@@ -363,19 +615,25 @@ function timingSafeEqualHex(a, b) {
363
615
  }
364
616
 
365
617
  // src/relay-server/node.ts
618
+ var POLL_MAX_WAIT_MS = 25e3;
619
+ var POLL_DEFAULT_WAIT_MS = 2e4;
620
+ function pollWaitMs(raw) {
621
+ if (raw === null || raw === void 0) return POLL_DEFAULT_WAIT_MS;
622
+ const n = Number.parseInt(raw, 10);
623
+ if (!Number.isFinite(n)) return 0;
624
+ return Math.min(Math.max(n, 0), POLL_MAX_WAIT_MS);
625
+ }
366
626
  function createNodeRelay(opts = {}) {
367
627
  const broker = new RelayBroker(opts);
368
628
  const prefix = (opts.pathPrefix ?? "").replace(/\/$/, "");
369
- const cors = opts.corsAllowOrigin ?? "*";
370
- const allowedOrigins = opts.allowedOrigins;
629
+ const cors = opts.allowedOrigins && opts.allowedOrigins.length > 0 ? parseCorsOrigins(opts.allowedOrigins) : parseCorsOrigins(opts.corsAllowOrigin ?? "*");
371
630
  function setCorsHeaders(res, req) {
372
- if (allowedOrigins && allowedOrigins.length) {
373
- const origin = req?.headers.origin;
374
- res.setHeader("access-control-allow-origin", origin && allowedOrigins.includes(origin) ? origin : "null");
375
- res.setHeader("vary", "origin");
376
- } else {
377
- res.setHeader("access-control-allow-origin", cors);
631
+ if (cors.kind === "list") {
632
+ res.setHeader("vary", "Origin");
378
633
  }
634
+ const origin = req?.headers.origin;
635
+ const allow = allowOriginFor(cors, typeof origin === "string" ? origin : void 0);
636
+ if (allow !== null) res.setHeader("access-control-allow-origin", allow);
379
637
  res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
380
638
  res.setHeader("access-control-allow-headers", "content-type, x-csrf-token, accept, authorization");
381
639
  res.setHeader("access-control-max-age", "86400");
@@ -492,7 +750,7 @@ function createNodeRelay(opts = {}) {
492
750
  client: getQuery(req).get("client") ?? void 0
493
751
  });
494
752
  if (!sub.ok) {
495
- res.statusCode = 401;
753
+ res.statusCode = sub.reason === "session_gone" ? 410 : 401;
496
754
  res.setHeader("content-type", "text/event-stream");
497
755
  res.write(`event: error
498
756
  data: ${sub.reason}
@@ -537,6 +795,38 @@ data: ${frame}
537
795
  res.end();
538
796
  }
539
797
  };
798
+ const poll = async (req, res) => {
799
+ setCorsHeaders(res, req);
800
+ if (req.method === "OPTIONS") {
801
+ res.statusCode = 204;
802
+ return res.end();
803
+ }
804
+ if (req.method !== "GET") return json(res, 405, { error: "method_not_allowed" });
805
+ const session = extractSession(req, prefix);
806
+ if (!session) return json(res, 400, { error: "missing_session" });
807
+ const q = getQuery(req);
808
+ const direction = q.get("direction") === "outbound" ? "outbound" : "inbound";
809
+ const attached = broker.poll(session, q.get("token") ?? "", direction, {
810
+ subscriber: q.get("subscriber") ?? void 0,
811
+ client: q.get("client") ?? void 0
812
+ });
813
+ if (!attached.ok) {
814
+ return json(res, attached.reason === "session_gone" ? 410 : 401, { error: attached.reason });
815
+ }
816
+ const gone = new AbortController();
817
+ const onClose = () => {
818
+ if (!res.writableEnded) gone.abort();
819
+ };
820
+ res.on("close", onClose);
821
+ const frames = await attached.wait(pollWaitMs(q.get("wait")), gone.signal);
822
+ res.off("close", onClose);
823
+ if (gone.signal.aborted || res.destroyed) {
824
+ attached.requeue(frames);
825
+ return;
826
+ }
827
+ res.setHeader("cache-control", "no-store");
828
+ return json(res, 200, { subscriber: attached.subscriberId, frames });
829
+ };
540
830
  const handler = async (req, res) => {
541
831
  const pathname = getPathname(req);
542
832
  if (!pathname.startsWith(prefix + "/")) {
@@ -544,16 +834,17 @@ data: ${frame}
544
834
  }
545
835
  const rest = pathname.slice(prefix.length);
546
836
  if (rest === "/register") return register(req, res);
547
- const m = /^\/([A-Za-z0-9_-]{4,64})\/(inbox|outbox|events|unregister)$/.exec(rest);
837
+ const m = /^\/([A-Za-z0-9_-]{4,64})\/(inbox|outbox|events|poll|unregister)$/.exec(rest);
548
838
  if (!m) return json(res, 404, { error: "not_found" });
549
839
  const route = m[2];
550
840
  if (route === "inbox") return inbox(req, res);
551
841
  if (route === "outbox") return outbox(req, res);
552
842
  if (route === "events") return events(req, res);
843
+ if (route === "poll") return poll(req, res);
553
844
  if (route === "unregister") return unregister(req, res);
554
845
  return json(res, 404, { error: "not_found" });
555
846
  };
556
- return { broker, handler, register, inbox, outbox, events, unregister };
847
+ return { broker, handler, register, inbox, outbox, events, poll, unregister };
557
848
  }
558
849
  function extractSession(req, prefix) {
559
850
  const host = req.headers.host || "x";
@@ -571,46 +862,27 @@ function flush(res) {
571
862
 
572
863
  // src/relay-server/cli.ts
573
864
  async function main() {
574
- const argv = process.argv.slice(2);
575
- let port = Number(process.env.PORT ?? 8787);
576
- let host = process.env.HOST ?? "127.0.0.1";
577
- let prefix = process.env.PREFIX ?? "";
578
- let ttlMs = Number(process.env.TTL_MS ?? 4 * 60 * 60 * 1e3);
579
- let cors = process.env.CORS_ALLOW_ORIGIN ?? "*";
580
- for (let i = 0; i < argv.length; i++) {
581
- const a = argv[i];
582
- switch (a) {
583
- case "--port":
584
- port = Number(argv[++i]);
585
- break;
586
- case "--host":
587
- host = argv[++i];
588
- break;
589
- case "--prefix":
590
- prefix = argv[++i];
591
- break;
592
- case "--ttl-ms":
593
- ttlMs = Number(argv[++i]);
594
- break;
595
- case "--cors":
596
- cors = argv[++i];
597
- break;
598
- case "-h":
599
- case "--help":
600
- process.stdout.write(HELP);
601
- process.exit(0);
602
- default:
603
- process.stderr.write(`[relay] unknown flag: ${a}
865
+ const parsed = parseRelayArgs(process.argv.slice(2), process.env);
866
+ if (!parsed.ok) {
867
+ process.stderr.write(`[relay] ${parsed.error}
604
868
  `);
605
- process.exit(2);
606
- }
869
+ process.exit(2);
607
870
  }
608
- if (!Number.isFinite(port) || port <= 0) {
609
- process.stderr.write(`[relay] invalid --port: ${port}
871
+ const { config } = parsed;
872
+ if (config.help) {
873
+ process.stdout.write(HELP);
874
+ process.exit(0);
875
+ }
876
+ for (const warning of config.warnings) {
877
+ process.stderr.write(`[relay] WARNING: ${warning}
610
878
  `);
611
- process.exit(2);
612
879
  }
613
- const relay = createNodeRelay({ pathPrefix: prefix, ttlMs, corsAllowOrigin: cors });
880
+ const { port, host, prefix, ttlMs, cors } = config;
881
+ const relay = createNodeRelay({
882
+ pathPrefix: prefix,
883
+ ttlMs,
884
+ corsAllowOrigin: cors.kind === "any" ? "*" : cors.origins.join(",")
885
+ });
614
886
  const server = http.createServer((req, res) => {
615
887
  const url = req.url || "/";
616
888
  if ((url === "/" || url === "/healthz" || url === prefix + "/") && req.method === "GET") {
@@ -628,9 +900,10 @@ async function main() {
628
900
  `
629
901
  );
630
902
  }
903
+ const corsSource = { flag: "--cors", env: "CORS_ALLOW_ORIGIN", default: "default" }[config.sources.cors];
631
904
  server.listen(port, host, () => {
632
905
  process.stdout.write(
633
- `[relay] listening on http://${host}:${port}${prefix || ""} (ttl=${Math.round(ttlMs / 1e3)}s, cors=${cors})
906
+ `[relay] listening on http://${host}:${port}${prefix || ""} (ttl=${Math.round(ttlMs / 1e3)}s, cors=${describeCorsPolicy(cors)} from ${corsSource})
634
907
  `
635
908
  );
636
909
  });
@@ -653,17 +926,29 @@ Options:
653
926
  Pass 0.0.0.0 to expose on all interfaces (prints a warning).
654
927
  --prefix <path> URL path prefix (env: PREFIX). Default "".
655
928
  --ttl-ms <n> Session TTL ms (env: TTL_MS). Default 14_400_000.
656
- --cors <origin> CORS Access-Control-Allow-Origin (env: CORS_ALLOW_ORIGIN). Default "*".
929
+ --cors <origins> Allowed browser origins (env: CORS_ALLOW_ORIGIN). Default "*".
930
+ "*", or a comma-separated list such as
931
+ "https://example.com,https://www.example.com". May be
932
+ repeated. A listed Origin is echoed back with Vary: Origin;
933
+ an unlisted one gets no Access-Control-Allow-Origin.
657
934
  -h, --help Show this help.
658
935
 
936
+ Precedence: a flag wins over its env var, which wins over the default. An env
937
+ var that a flag overrides is reported at startup. An empty env var is unset.
938
+
659
939
  Endpoints (under --prefix):
660
940
  POST /register { session, token } \u2192 { ok: true }
661
941
  POST /<session>/inbox?token=... body: JSON-RPC frame
662
942
  POST /<session>/outbox?token=... body: JSON-RPC frame
663
943
  GET /<session>/events?token=...&direction=inbound|outbound
664
944
  Server-sent events stream
945
+ GET /<session>/poll?token=...&direction=inbound|outbound&wait=<ms>&subscriber=<id>
946
+ Long-poll \u2192 { subscriber, frames }
665
947
  POST /<session>/unregister?token=... Tear down session
666
948
  GET / Healthcheck \u2192 200
949
+
950
+ A session that has ended answers 410 {"error":"session_gone"} on every session
951
+ route; a wrong or missing token answers 401.
667
952
  `;
668
953
  main().catch((e) => {
669
954
  process.stderr.write(`[relay] fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}