@nudge-ui/mcp 0.2.4 → 0.2.5

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/dist/bridge.js CHANGED
@@ -1,16 +1,36 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
- import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAgentActivity, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
3
+ import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentActivity, isAgentStatusUpdate, isCanvasCommand, isCanvasCommandResult, validateSketchAttachments, } from "./internal/agent-protocol.js";
4
+ import { BridgeSessionError, createBridgeSession, } from "./bridgeSession.js";
4
5
  import { AGENT_CONTROL_ENDPOINTS, BRIDGE_ENDPOINTS } from "./protocol.js";
5
6
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
6
7
  const MAX_BODY_BYTES = 256 * 1024;
7
8
  const MAX_PROMPT_BODY_BYTES = 12 * 1024 * 1024;
8
- const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
9
- const AGENT_CLAIM_TIMEOUT_MS = 30_000;
10
- const SYSTEM_CLOCK = {
11
- now: () => Date.now(),
12
- setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
13
- clearTimeout: (handle) => clearTimeout(handle),
9
+ const SESSION_ERROR_STATUS = {
10
+ project_not_found: 404,
11
+ origin_not_allowed: 403,
12
+ origin_mismatch: 403,
13
+ invalid_session: 401,
14
+ invalid_page_url: 400,
15
+ invalid_status: 400,
16
+ invalid_activity: 400,
17
+ invalid_canvas_command: 400,
18
+ invalid_routes: 400,
19
+ already_paired: 409,
20
+ browser_unpaired: 409,
21
+ listener_active: 409,
22
+ listener_unavailable: 409,
23
+ listener_cancelled: 409,
24
+ listener_aborted: 409,
25
+ request_in_flight: 409,
26
+ unknown_request: 409,
27
+ inactive_request: 409,
28
+ command_in_flight: 409,
29
+ no_command: 409,
30
+ unknown_command: 409,
31
+ session_claimed: 409,
32
+ agent_not_owner: 409,
33
+ session_closed: 409,
14
34
  };
15
35
  class BridgeRequestError extends Error {
16
36
  statusCode;
@@ -22,26 +42,18 @@ class BridgeRequestError extends Error {
22
42
  this.code = code;
23
43
  }
24
44
  }
25
- function randomToken() {
26
- return randomBytes(32).toString("base64url");
27
- }
28
- function randomId() {
29
- return randomUUID();
30
- }
31
- function deferred() {
32
- let resolvePromise;
33
- let rejectPromise;
34
- const promise = new Promise((resolve, reject) => {
35
- resolvePromise = resolve;
36
- rejectPromise = reject;
37
- });
38
- return { promise, resolve: resolvePromise, reject: rejectPromise };
45
+ function route(method, path, handler) {
46
+ return [`${method} ${path}`, handler];
39
47
  }
40
48
  function loopbackAddress(address) {
41
49
  if (!address)
42
50
  return false;
43
51
  return LOOPBACK_HOSTS.has(address) || address === "::ffff:127.0.0.1";
44
52
  }
53
+ function requireLoopback(request, code, message) {
54
+ if (!loopbackAddress(request.socket.remoteAddress))
55
+ throw new BridgeRequestError(403, code, message);
56
+ }
45
57
  function validPort(port) {
46
58
  return Number.isInteger(port) && port >= 0 && port <= 65_535;
47
59
  }
@@ -54,38 +66,31 @@ function headerValue(request, name) {
54
66
  function requestOrigin(request) {
55
67
  return canonicalOrigin(headerValue(request, "origin"));
56
68
  }
57
- function parseProjectId(value, expected) {
58
- if (value !== expected) {
59
- throw new BridgeRequestError(404, "project_not_found", "The requested project is not served by this companion.");
60
- }
69
+ function optionalString(value) {
70
+ return typeof value === "string" ? value : undefined;
61
71
  }
62
- function parseToken(value, expected) {
63
- if (!expected || value !== expected) {
64
- throw new BridgeRequestError(401, "invalid_session", "The browser session token is invalid or expired.");
65
- }
72
+ function setCors(response, origin) {
73
+ response.setHeader("Access-Control-Allow-Origin", origin);
74
+ response.setHeader("Access-Control-Allow-Credentials", "true");
75
+ response.setHeader("Vary", "Origin");
66
76
  }
67
- function jsonHeaders(response, origin) {
77
+ function sendJson(response, statusCode, body, origin) {
68
78
  response.setHeader("Content-Type", "application/json; charset=utf-8");
69
79
  response.setHeader("Cache-Control", "no-store");
70
80
  response.setHeader("X-Content-Type-Options", "nosniff");
71
- if (origin) {
72
- response.setHeader("Access-Control-Allow-Origin", origin);
73
- response.setHeader("Access-Control-Allow-Credentials", "true");
74
- response.setHeader("Vary", "Origin");
75
- }
76
- }
77
- function sendJson(response, statusCode, body, origin) {
78
- jsonHeaders(response, origin);
81
+ if (origin)
82
+ setCors(response, origin);
79
83
  response.statusCode = statusCode;
80
84
  response.end(JSON.stringify(body));
81
85
  }
82
- function sendError(response, error, origin) {
86
+ function errorResponse(error) {
83
87
  if (error instanceof BridgeRequestError) {
84
- const body = { error: { code: error.code, message: error.message } };
85
- sendJson(response, error.statusCode, body, origin);
86
- return;
88
+ return { statusCode: error.statusCode, body: { error: { code: error.code, message: error.message } } };
87
89
  }
88
- sendJson(response, 500, { error: { code: "internal_error", message: "The bridge could not complete the request." } }, origin);
90
+ if (error instanceof BridgeSessionError) {
91
+ return { statusCode: SESSION_ERROR_STATUS[error.code], body: { error: { code: error.code, message: error.message } } };
92
+ }
93
+ return { statusCode: 500, body: { error: { code: "internal_error", message: "The bridge could not complete the request." } } };
89
94
  }
90
95
  async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
91
96
  const chunks = [];
@@ -122,7 +127,7 @@ function pngDimensions(bytes) {
122
127
  height: bytes.readUInt32BE(20),
123
128
  };
124
129
  }
125
- function parseSketchAttachments(value) {
130
+ function decodeSketchAttachments(value) {
126
131
  let attachments;
127
132
  try {
128
133
  attachments = validateSketchAttachments(value);
@@ -143,591 +148,291 @@ function parseSketchAttachments(value) {
143
148
  }
144
149
  return attachments;
145
150
  }
146
- function createEnvelope(projectId, sessionToken, event) {
151
+ function decodePromptDispatch(body) {
152
+ const { prompt, changeRevision, clientDispatchId } = body;
153
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
154
+ throw new BridgeRequestError(400, "invalid_prompt", "prompt must be a bounded non-empty string");
155
+ }
156
+ if (changeRevision !== undefined && (typeof changeRevision !== "number" || !Number.isSafeInteger(changeRevision) || changeRevision < 0)) {
157
+ throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
158
+ }
159
+ if (clientDispatchId !== undefined && (typeof clientDispatchId !== "string"
160
+ || clientDispatchId.length === 0 || clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
161
+ throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
162
+ }
147
163
  return {
148
- protocolVersion: AGENT_PROTOCOL_VERSION,
149
- projectId,
150
- sessionToken,
151
- event,
164
+ prompt,
165
+ ...(changeRevision === undefined ? {} : { changeRevision }),
166
+ ...(clientDispatchId === undefined ? {} : { clientDispatchId }),
167
+ attachments: decodeSketchAttachments(body.attachments),
152
168
  };
153
169
  }
170
+ function decodeAgentId(value) {
171
+ if (value === undefined || value === null)
172
+ return null;
173
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) {
174
+ throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
175
+ }
176
+ return value;
177
+ }
154
178
  function writeSse(response, envelope) {
155
179
  if (response.destroyed || response.writableEnded)
156
- return;
157
- response.write(`event: ${envelope.event.type}\ndata: ${JSON.stringify(envelope)}\n\n`);
158
- }
159
- function connectionFor(listenerActive, paired, request) {
160
- if (request?.status === "working")
161
- return "working";
162
- if (paired)
163
- return "paired";
164
- if (listenerActive)
165
- return "listening";
166
- return "offline";
167
- }
168
- function statusWith(projectId, listenerActive, paired, request, pageUrl, agentClientName) {
169
- return {
170
- protocolVersion: AGENT_PROTOCOL_VERSION,
171
- projectId,
172
- connection: connectionFor(listenerActive, paired, request),
173
- listenerActive,
174
- paired,
175
- pageUrl,
176
- ...(agentClientName === undefined ? {} : { agentClientName }),
177
- request,
178
- };
180
+ return false;
181
+ try {
182
+ response.write(`event: ${envelope.event.type}\ndata: ${JSON.stringify(envelope)}\n\n`);
183
+ return true;
184
+ }
185
+ catch {
186
+ response.destroy();
187
+ return false;
188
+ }
179
189
  }
180
190
  /**
181
191
  * Creates the local browser bridge. It binds only to loopback and keeps every
182
192
  * pairing, prompt, command, and acknowledgement in process memory.
183
193
  */
184
194
  export function createLoopbackBridge(options) {
185
- if (typeof options.projectId !== "string" || options.projectId.length === 0
186
- || options.projectId.length > AGENT_PROTOCOL_LIMITS.projectId) {
187
- throw new TypeError("projectId must be a bounded non-empty string");
188
- }
189
- const configuredOrigin = options.origin === undefined ? null : canonicalOrigin(options.origin);
190
- if (options.origin !== undefined && !configuredOrigin)
191
- throw new TypeError("origin must be a canonical HTTP(S) origin");
192
195
  const host = options.host ?? "127.0.0.1";
193
196
  if (!LOOPBACK_HOSTS.has(host))
194
197
  throw new TypeError("The browser bridge must bind to a loopback host");
195
198
  const port = options.port ?? 0;
196
199
  if (!validPort(port))
197
200
  throw new TypeError("port must be between 0 and 65535");
198
- const timeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
199
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
200
- throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
201
- }
202
- const allowedOrigins = [
203
- ...(configuredOrigin ? [configuredOrigin] : []),
204
- ...(options.allowedOrigins ?? []),
205
- ].map((candidate) => canonicalOrigin(candidate));
206
- if (allowedOrigins.some((candidate) => candidate === null)) {
207
- throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
208
- }
209
- const canonicalAllowedOrigins = allowedOrigins;
210
- const clock = options.clock ?? SYSTEM_CLOCK;
211
- const tokenFactory = options.tokenFactory ?? randomToken;
212
- const idFactory = options.idFactory ?? randomId;
213
- const agentControlToken = options.agentControlToken;
201
+ const { agentControlToken } = options;
214
202
  if (agentControlToken !== undefined && (agentControlToken.length < 32 || agentControlToken.length > 256)) {
215
203
  throw new TypeError("agentControlToken must contain between 32 and 256 characters");
216
204
  }
217
- let lifecycle = "created";
218
- let currentAddress = null;
219
- let sessionToken = null;
220
- let pairedOrigin = null;
221
- let lastPageUrl = null;
222
- const sseClients = new Set();
223
- let listener = null;
224
- let currentRequest = null;
225
- let pendingCanvas = null;
226
- let closePromise = null;
227
- let startPromise = null;
228
- let agentOwner = null;
229
- let agentClientName;
230
- let agentOwnerSeenAt = 0;
231
- let agentOwnerExpiry = null;
232
- const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl, agentClientName);
233
- const broadcast = (event) => {
234
- if (!sessionToken)
235
- return;
236
- const envelope = createEnvelope(options.projectId, sessionToken, event);
237
- for (const response of [...sseClients]) {
238
- if (response.destroyed || response.writableEnded) {
239
- sseClients.delete(response);
240
- continue;
241
- }
242
- try {
243
- writeSse(response, envelope);
244
- }
245
- catch {
246
- sseClients.delete(response);
247
- response.destroy();
248
- }
249
- }
250
- };
251
- const broadcastStatus = () => {
252
- const statusEvent = { type: "status", status: getStatus() };
253
- broadcast(statusEvent);
254
- };
255
- const originIsAllowed = (candidate) => {
256
- if (!candidate)
257
- return false;
258
- // Once paired, the explicitly approved browser's exact origin remains the
259
- // only origin accepted. Before pairing, only an origin configured by the
260
- // host (directly or through the allow-list) is eligible. An empty list is
261
- // deliberately closed rather than a trust-on-first-use wildcard.
262
- if (pairedOrigin === candidate)
263
- return true;
264
- return isAllowedOrigin(candidate, canonicalAllowedOrigins);
265
- };
266
- const requireBrowserRequest = (request, url, bodyToken) => {
267
- if (!loopbackAddress(request.socket.remoteAddress)) {
268
- throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
269
- }
270
- const incomingOrigin = requestOrigin(request);
271
- if (!originIsAllowed(incomingOrigin)) {
272
- throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
273
- }
274
- if (pairedOrigin !== incomingOrigin) {
275
- throw new BridgeRequestError(403, "origin_mismatch", "The browser origin does not match the paired project.");
276
- }
277
- const candidate = typeof bodyToken === "string" ? bodyToken : url.searchParams.get("sessionToken");
278
- parseToken(candidate, sessionToken);
279
- return incomingOrigin;
280
- };
281
- const handleOptions = (request, response) => {
282
- const incomingOrigin = requestOrigin(request);
283
- if (incomingOrigin && !originIsAllowed(incomingOrigin)) {
284
- sendError(response, new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project."), null);
285
- return;
286
- }
287
- if (incomingOrigin) {
288
- response.setHeader("Access-Control-Allow-Origin", incomingOrigin);
289
- response.setHeader("Access-Control-Allow-Credentials", "true");
290
- response.setHeader("Vary", "Origin");
291
- }
292
- response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
293
- response.setHeader("Access-Control-Allow-Headers", "Content-Type");
294
- response.statusCode = 204;
295
- response.end();
205
+ const session = createBridgeSession({
206
+ projectId: options.projectId,
207
+ origin: options.origin,
208
+ allowedOrigins: options.allowedOrigins,
209
+ commandTimeoutMs: options.commandTimeoutMs,
210
+ clock: options.clock,
211
+ tokenFactory: options.tokenFactory ?? (() => randomBytes(32).toString("base64url")),
212
+ idFactory: options.idFactory ?? randomUUID,
213
+ });
214
+ const browserOrigin = (request) => {
215
+ requireLoopback(request, "loopback_only", "The browser bridge accepts loopback connections only.");
216
+ return session.authorizeOrigin(requestOrigin(request));
296
217
  };
297
- const handleHealth = (request, response, url) => {
298
- if (!loopbackAddress(request.socket.remoteAddress)) {
299
- throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
300
- }
301
- const incomingOrigin = requestOrigin(request);
302
- if (!originIsAllowed(incomingOrigin)) {
303
- throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
304
- }
305
- parseProjectId(url.searchParams.get("projectId"), options.projectId);
306
- sendJson(response, 200, {
307
- protocolVersion: AGENT_PROTOCOL_VERSION,
308
- projectId: options.projectId,
309
- origin: incomingOrigin,
310
- status: getStatus(),
311
- sessionRequired: true,
312
- pageUrl: lastPageUrl,
313
- }, incomingOrigin);
218
+ const pairedBrowser = (request, sessionToken) => {
219
+ requireLoopback(request, "loopback_only", "The browser bridge accepts loopback connections only.");
220
+ return session.authorizeBrowser(requestOrigin(request), sessionToken);
314
221
  };
315
222
  const readPairRequest = async (request) => {
316
- if (!loopbackAddress(request.socket.remoteAddress)) {
317
- throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
318
- }
319
- const incomingOrigin = requestOrigin(request);
320
- if (!originIsAllowed(incomingOrigin)) {
321
- throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
322
- }
223
+ const origin = browserOrigin(request);
323
224
  const body = await readJsonBody(request);
324
- parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
325
- const requestedOrigin = canonicalOrigin(body.origin);
326
- if (!requestedOrigin || requestedOrigin !== incomingOrigin) {
225
+ session.assertProject(body.projectId);
226
+ if (canonicalOrigin(body.origin) !== origin) {
327
227
  throw new BridgeRequestError(403, "origin_mismatch", "Pairing requires an explicitly configured project origin.");
328
228
  }
329
- let requestedPageUrl;
330
- if (body.pageUrl !== undefined) {
331
- if (typeof body.pageUrl !== "string" || body.pageUrl.length === 0 || body.pageUrl.length > AGENT_PROTOCOL_LIMITS.routeUrl
332
- || !isSameOriginRoute({ url: body.pageUrl }, requestedOrigin)) {
333
- throw new BridgeRequestError(400, "invalid_page_url", "pageUrl must be a bounded same-origin URL.");
334
- }
335
- requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
229
+ if (body.pageUrl !== undefined && typeof body.pageUrl !== "string") {
230
+ throw new BridgeRequestError(400, "invalid_page_url", "pageUrl must be a bounded same-origin URL.");
336
231
  }
337
- return { body, incomingOrigin, requestedPageUrl };
232
+ return { origin, ...(body.pageUrl === undefined ? {} : { pageUrl: body.pageUrl }), body };
338
233
  };
339
- const handlePair = async (request, response) => {
340
- const { body, incomingOrigin, requestedPageUrl } = await readPairRequest(request);
341
- if (sessionToken) {
342
- if (body.sessionToken !== sessionToken) {
343
- throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
344
- }
345
- lastPageUrl = requestedPageUrl ?? lastPageUrl;
346
- const existing = {
347
- protocolVersion: AGENT_PROTOCOL_VERSION,
348
- projectId: options.projectId,
349
- origin: pairedOrigin,
350
- sessionToken,
351
- ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
352
- status: getStatus(),
353
- };
354
- sendJson(response, 200, existing, incomingOrigin);
355
- return;
356
- }
357
- sessionToken = tokenFactory();
358
- if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
359
- throw new Error("tokenFactory returned an invalid session token");
360
- }
361
- pairedOrigin = incomingOrigin;
362
- lastPageUrl = requestedPageUrl ?? null;
363
- const connected = { type: "connected", status: getStatus() };
364
- broadcast(connected);
365
- const result = {
366
- protocolVersion: AGENT_PROTOCOL_VERSION,
367
- projectId: options.projectId,
368
- origin: pairedOrigin,
369
- sessionToken,
370
- ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
371
- status: getStatus(),
372
- };
373
- sendJson(response, 200, result, incomingOrigin);
374
- broadcastStatus();
375
- };
376
- const handleTakeover = async (request, response) => {
377
- const { incomingOrigin, requestedPageUrl } = await readPairRequest(request);
378
- // Takeover is intentionally separate from normal pairing. The caller has
379
- // made an explicit UI choice to revoke the existing browser session.
380
- if (sessionToken)
381
- disconnectBrowser(sessionToken);
382
- const pairing = pairBrowser(options.projectId, incomingOrigin, requestedPageUrl);
383
- sendJson(response, 200, pairing, incomingOrigin);
384
- };
385
- const handleEvents = (request, response, url) => {
386
- parseProjectId(url.searchParams.get("projectId"), options.projectId);
387
- const incomingOrigin = requireBrowserRequest(request, url);
388
- response.statusCode = 200;
389
- response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
390
- response.setHeader("Cache-Control", "no-cache, no-store");
391
- response.setHeader("Connection", "keep-alive");
392
- response.setHeader("X-Accel-Buffering", "no");
393
- response.setHeader("Access-Control-Allow-Origin", incomingOrigin);
394
- response.setHeader("Access-Control-Allow-Credentials", "true");
395
- response.setHeader("Vary", "Origin");
396
- response.flushHeaders();
397
- sseClients.add(response);
398
- const initial = { type: "status", status: getStatus() };
399
- writeSse(response, createEnvelope(options.projectId, sessionToken, initial));
400
- const closeClient = () => {
401
- sseClients.delete(response);
402
- };
403
- request.once("close", closeClient);
404
- response.once("close", closeClient);
405
- };
406
- const handleStatus = (request, response, url) => {
407
- parseProjectId(url.searchParams.get("projectId"), options.projectId);
408
- const incomingOrigin = requireBrowserRequest(request, url);
409
- sendJson(response, 200, getStatus(), incomingOrigin);
410
- };
411
- const handlePrompt = async (request, response) => {
412
- const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
413
- const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
414
- parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
415
- if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
416
- throw new BridgeRequestError(400, "invalid_prompt", "prompt must be a bounded non-empty string");
417
- }
418
- if (body.changeRevision !== undefined
419
- && (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
420
- throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
421
- }
422
- if (body.clientDispatchId !== undefined
423
- && (typeof body.clientDispatchId !== "string"
424
- || body.clientDispatchId.length === 0
425
- || body.clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
426
- throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
427
- }
428
- const attachments = parseSketchAttachments(body.attachments);
429
- if (!listener) {
430
- throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
431
- }
432
- if (currentRequest) {
433
- throw new BridgeRequestError(409, "request_in_flight", "Another Nudge request is still in flight.");
434
- }
435
- const prompt = {
436
- requestId: idFactory(),
437
- projectId: options.projectId,
438
- prompt: body.prompt,
439
- ...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
440
- ...(body.clientDispatchId === undefined ? {} : { clientDispatchId: body.clientDispatchId }),
441
- ...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
442
- };
443
- const delivered = {
444
- ...prompt,
445
- ...(attachments.length === 0 ? {} : { attachments }),
446
- };
447
- const pending = listener;
448
- listener = null;
449
- if (pending.abortHandler && pending.signal)
450
- pending.signal.removeEventListener("abort", pending.abortHandler);
451
- currentRequest = { ...prompt, status: "working" };
452
- pending.resolve(delivered);
453
- const result = { request: prompt, status: "working" };
454
- sendJson(response, 202, result, incomingOrigin);
455
- broadcastStatus();
456
- };
457
- const handleCanvasAcknowledgement = async (request, response) => {
458
- const body = await readJsonBody(request);
459
- const url = new URL("http://bridge.invalid");
460
- const incomingOrigin = requireBrowserRequest(request, url, body.sessionToken);
461
- parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
462
- if (!pendingCanvas)
463
- throw new BridgeRequestError(409, "no_command", "No Canvas command is awaiting an acknowledgement.");
464
- if (typeof body.commandId !== "string" || body.commandId !== pendingCanvas.command.commandId) {
465
- throw new BridgeRequestError(409, "unknown_command", "The Canvas command is no longer awaiting an acknowledgement.");
466
- }
467
- if (typeof body.ok !== "boolean")
468
- throw new BridgeRequestError(400, "invalid_acknowledgement", "ok must be a boolean");
469
- // Acknowledgement payloads flow back to the agent verbatim, so every
470
- // optional field is validated before it is accepted into the result.
471
- if (body.state !== undefined && !isCanvasState(body.state)) {
472
- throw new BridgeRequestError(400, "invalid_acknowledgement", "state must be a valid Canvas state.");
473
- }
474
- if (body.group !== undefined && !isCanvasGroup(body.group)) {
475
- throw new BridgeRequestError(400, "invalid_acknowledgement", "group must be a valid Canvas group.");
476
- }
477
- if (body.error !== undefined) {
478
- const error = body.error;
479
- if (typeof error !== "object" || error === null
480
- || typeof error.code !== "string" || typeof error.message !== "string") {
481
- throw new BridgeRequestError(400, "invalid_acknowledgement", "error must include string code and message fields.");
482
- }
483
- }
484
- const acknowledgement = {
485
- commandId: body.commandId,
486
- ok: body.ok,
487
- ...(body.state === undefined ? {} : { state: body.state }),
488
- ...(body.group === undefined ? {} : { group: body.group }),
489
- ...(body.error === undefined ? {} : { error: body.error }),
490
- };
491
- if (acknowledgement.ok && acknowledgement.error) {
492
- throw new BridgeRequestError(400, "invalid_acknowledgement", "A successful acknowledgement cannot include an error.");
493
- }
494
- const pendingCommand = pendingCanvas;
495
- pendingCanvas = null;
496
- clock.clearTimeout(pendingCommand.timeout);
497
- const result = {
498
- commandId: acknowledgement.commandId,
499
- ok: acknowledgement.ok,
500
- ...(acknowledgement.state === undefined ? {} : { state: acknowledgement.state }),
501
- ...(acknowledgement.group === undefined ? {} : { group: acknowledgement.group }),
502
- ...(acknowledgement.error === undefined ? {} : { error: acknowledgement.error }),
503
- };
504
- pendingCommand.resolve(result);
505
- sendJson(response, 202, { commandId: result.commandId, accepted: true }, incomingOrigin);
506
- };
507
- const handleDisconnect = async (request, response) => {
508
- const body = await readJsonBody(request);
509
- const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
510
- disconnectBrowser(body.sessionToken);
511
- sendJson(response, 200, { disconnected: true }, incomingOrigin);
512
- };
513
- const requireAgentControl = (request, bodyAgentId) => {
514
- if (!agentControlToken || !loopbackAddress(request.socket.remoteAddress)) {
234
+ const requireAgentControl = (request) => {
235
+ if (!agentControlToken) {
515
236
  throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
516
237
  }
238
+ requireLoopback(request, "agent_control_unavailable", "Private agent control is unavailable.");
517
239
  if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
518
240
  throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
519
241
  }
520
- if (bodyAgentId === undefined)
521
- return null;
522
- if (typeof bodyAgentId !== "string" || bodyAgentId.length === 0 || bodyAgentId.length > 256) {
523
- throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
524
- }
525
- return bodyAgentId;
526
242
  };
527
- const requireAgentOwner = (request, body) => {
528
- const agentId = requireAgentControl(request, body.agentId);
529
- if (!agentId || agentOwner !== agentId) {
530
- throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
531
- }
532
- agentOwnerSeenAt = clock.now();
533
- scheduleAgentOwnerExpiry();
534
- return agentId;
535
- };
536
- const releaseAgentOwner = (reason) => {
537
- if (agentOwnerExpiry !== null)
538
- clock.clearTimeout(agentOwnerExpiry);
539
- agentOwnerExpiry = null;
540
- agentOwner = null;
541
- agentClientName = undefined;
542
- agentOwnerSeenAt = 0;
543
- cancelListener(reason);
544
- if (currentRequest?.status === "working") {
545
- currentRequest = { ...currentRequest, status: "interrupted", error: reason };
546
- broadcastStatus();
547
- }
548
- if (pendingCanvas) {
549
- const pendingCommand = pendingCanvas;
550
- pendingCanvas = null;
551
- clock.clearTimeout(pendingCommand.timeout);
552
- pendingCommand.resolve({
553
- commandId: pendingCommand.command.commandId,
554
- ok: false,
555
- error: { code: "agent_released", message: reason },
556
- });
557
- }
558
- broadcastStatus();
559
- };
560
- const scheduleAgentOwnerExpiry = () => {
561
- if (agentOwnerExpiry !== null)
562
- clock.clearTimeout(agentOwnerExpiry);
563
- const expectedOwner = agentOwner;
564
- if (!expectedOwner) {
565
- agentOwnerExpiry = null;
566
- return;
567
- }
568
- const expireIfStale = () => {
569
- agentOwnerExpiry = null;
570
- if (agentOwner !== expectedOwner)
571
- return;
572
- const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
573
- if (remaining > 0) {
574
- agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
575
- return;
576
- }
577
- releaseAgentOwner("The MCP adapter claim expired.");
578
- };
579
- agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
580
- };
581
- const handleAgentClaim = async (request, response) => {
243
+ /** Authenticates the adapter before reading its body, then confirms it owns the session. */
244
+ const readAgentRequest = async (request) => {
245
+ requireAgentControl(request);
582
246
  const body = await readJsonBody(request);
583
- const agentId = requireAgentControl(request, body.agentId);
584
- if (!agentId)
585
- throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
586
- if (agentOwner && agentOwner !== agentId && clock.now() - agentOwnerSeenAt <= AGENT_CLAIM_TIMEOUT_MS) {
587
- throw new BridgeRequestError(409, "session_claimed", "Another MCP adapter owns this project session.");
588
- }
589
- if (agentOwner && agentOwner !== agentId)
590
- releaseAgentOwner("The previous MCP adapter claim expired.");
591
- agentOwner = agentId;
592
- agentOwnerSeenAt = clock.now();
593
- scheduleAgentOwnerExpiry();
594
- sendJson(response, 200, { claimed: true }, null);
595
- };
596
- const handleAgentRelease = async (request, response) => {
597
- const body = await readJsonBody(request);
598
- const agentId = requireAgentOwner(request, body);
599
- releaseAgentOwner(`Agent ${agentId} released the project session.`);
600
- sendJson(response, 200, { released: true }, null);
601
- };
602
- const handleAgentListen = async (request, response) => {
603
- const body = await readJsonBody(request);
604
- requireAgentOwner(request, body);
605
- const abort = new AbortController();
606
- response.once("close", () => abort.abort());
607
- const clientName = typeof body.agentClientName === "string" ? body.agentClientName.trim().slice(0, 120) : undefined;
608
- const prompt = await waitForPrompt(abort.signal, undefined, clientName);
609
- sendJson(response, 200, prompt, null);
610
- };
611
- const handleAgentReportStatus = async (request, response) => {
612
- const body = await readJsonBody(request);
613
- requireAgentOwner(request, body);
614
- if (!isAgentStatusUpdate(body.update)) {
615
- throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
616
- }
617
- updateRequestStatus(body.update);
618
- sendJson(response, 200, getStatus(), null);
619
- };
620
- const handleAgentActivity = async (request, response) => {
621
- const body = await readJsonBody(request);
622
- requireAgentOwner(request, body);
623
- if (!isAgentActivity(body.activity))
624
- throw new BridgeRequestError(400, "invalid_activity", "activity must identify a workspace-relative source file.");
625
- reportActivity(body.activity);
626
- sendJson(response, 200, { accepted: true }, null);
627
- };
628
- const handleAgentCanvas = async (request, response) => {
629
- const body = await readJsonBody(request);
630
- requireAgentOwner(request, body);
631
- if (!isCanvasCommand(body.command)) {
632
- throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
633
- }
634
- sendJson(response, 200, await dispatchCanvasCommand(body.command), null);
635
- };
636
- const httpServer = createServer((request, response) => {
637
- void (async () => {
638
- const url = new URL(request.url ?? "/", `http://${host}:${port}`);
639
- if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.health) {
640
- requireAgentControl(request);
641
- sendJson(response, 200, { projectId: options.projectId, available: true, claimed: agentOwner !== null, status: getStatus() }, null);
642
- return;
643
- }
644
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.claim) {
645
- await handleAgentClaim(request, response);
646
- return;
647
- }
648
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.release) {
649
- await handleAgentRelease(request, response);
650
- return;
247
+ session.confirmAgent(decodeAgentId(body.agentId));
248
+ return body;
249
+ };
250
+ const routes = new Map([
251
+ route("GET", BRIDGE_ENDPOINTS.health, (request, response, url) => {
252
+ const origin = browserOrigin(request);
253
+ session.assertProject(url.searchParams.get("projectId"));
254
+ const status = session.getStatus();
255
+ sendJson(response, 200, {
256
+ protocolVersion: AGENT_PROTOCOL_VERSION,
257
+ projectId: session.projectId,
258
+ origin,
259
+ status,
260
+ sessionRequired: true,
261
+ pageUrl: status.pageUrl ?? null,
262
+ }, origin);
263
+ }),
264
+ route("POST", BRIDGE_ENDPOINTS.pair, async (request, response) => {
265
+ const { origin, pageUrl, body } = await readPairRequest(request);
266
+ sendJson(response, 200, session.pairBrowser(origin, pageUrl, optionalString(body.sessionToken)), origin);
267
+ }),
268
+ route("POST", BRIDGE_ENDPOINTS.takeover, async (request, response) => {
269
+ const { origin, pageUrl } = await readPairRequest(request);
270
+ sendJson(response, 200, session.takeOver(origin, pageUrl), origin);
271
+ }),
272
+ route("GET", BRIDGE_ENDPOINTS.events, (request, response, url) => {
273
+ session.assertProject(url.searchParams.get("projectId"));
274
+ const { origin } = pairedBrowser(request, url.searchParams.get("sessionToken"));
275
+ response.statusCode = 200;
276
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
277
+ response.setHeader("Cache-Control", "no-cache, no-store");
278
+ response.setHeader("Connection", "keep-alive");
279
+ response.setHeader("X-Accel-Buffering", "no");
280
+ setCors(response, origin);
281
+ response.flushHeaders();
282
+ const detach = session.attachStream({
283
+ send: (envelope) => writeSse(response, envelope),
284
+ end: () => response.end(),
285
+ });
286
+ request.once("close", detach);
287
+ response.once("close", detach);
288
+ }),
289
+ route("GET", BRIDGE_ENDPOINTS.status, (request, response, url) => {
290
+ session.assertProject(url.searchParams.get("projectId"));
291
+ const { origin } = pairedBrowser(request, url.searchParams.get("sessionToken"));
292
+ sendJson(response, 200, session.getStatus(), origin);
293
+ }),
294
+ route("POST", BRIDGE_ENDPOINTS.prompt, async (request, response) => {
295
+ const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
296
+ const { origin } = pairedBrowser(request, optionalString(body.sessionToken));
297
+ session.assertProject(body.projectId);
298
+ sendJson(response, 202, session.dispatchPrompt(decodePromptDispatch(body)), origin);
299
+ }),
300
+ route("POST", BRIDGE_ENDPOINTS.canvasAcknowledgement, async (request, response) => {
301
+ const body = await readJsonBody(request);
302
+ const { origin } = pairedBrowser(request, optionalString(body.sessionToken));
303
+ session.assertProject(body.projectId);
304
+ // Acknowledgement payloads flow back to the agent verbatim.
305
+ const { projectId: _projectId, sessionToken: _sessionToken, ...result } = body;
306
+ if (!isCanvasCommandResult(result)) {
307
+ throw new BridgeRequestError(400, "invalid_acknowledgement", "The acknowledgement is not a valid Canvas command result.");
651
308
  }
652
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.heartbeat) {
653
- const body = await readJsonBody(request);
654
- requireAgentOwner(request, body);
655
- sendJson(response, 200, { alive: true }, null);
656
- return;
309
+ session.acknowledgeCanvas(result);
310
+ sendJson(response, 202, { commandId: result.commandId, accepted: true }, origin);
311
+ }),
312
+ route("POST", BRIDGE_ENDPOINTS.disconnect, async (request, response) => {
313
+ const body = await readJsonBody(request);
314
+ const grant = pairedBrowser(request, optionalString(body.sessionToken));
315
+ session.disconnectBrowser(grant.sessionToken);
316
+ sendJson(response, 200, { disconnected: true }, grant.origin);
317
+ }),
318
+ route("GET", AGENT_CONTROL_ENDPOINTS.health, (request, response) => {
319
+ requireAgentControl(request);
320
+ sendJson(response, 200, {
321
+ projectId: session.projectId,
322
+ available: true,
323
+ claimed: session.agentClaimed,
324
+ status: session.getStatus(),
325
+ }, null);
326
+ }),
327
+ route("POST", AGENT_CONTROL_ENDPOINTS.claim, async (request, response) => {
328
+ requireAgentControl(request);
329
+ const agentId = decodeAgentId((await readJsonBody(request)).agentId);
330
+ if (!agentId)
331
+ throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
332
+ session.claimAgent(agentId);
333
+ sendJson(response, 200, { claimed: true }, null);
334
+ }),
335
+ route("POST", AGENT_CONTROL_ENDPOINTS.release, async (request, response) => {
336
+ requireAgentControl(request);
337
+ session.releaseAgent(decodeAgentId((await readJsonBody(request)).agentId));
338
+ sendJson(response, 200, { released: true }, null);
339
+ }),
340
+ route("POST", AGENT_CONTROL_ENDPOINTS.heartbeat, async (request, response) => {
341
+ await readAgentRequest(request);
342
+ sendJson(response, 200, { alive: true }, null);
343
+ }),
344
+ route("POST", AGENT_CONTROL_ENDPOINTS.request, async (request, response) => {
345
+ const body = await readAgentRequest(request);
346
+ if (typeof body.requestId !== "string" || !body.requestId || body.requestId.length > AGENT_PROTOCOL_LIMITS.requestId) {
347
+ throw new BridgeRequestError(400, "invalid_request_id", "requestId must be a bounded non-empty string.");
657
348
  }
658
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.listen) {
659
- await handleAgentListen(request, response);
660
- return;
349
+ sendJson(response, 200, session.getRequest(body.requestId), null);
350
+ }),
351
+ route("POST", AGENT_CONTROL_ENDPOINTS.listen, async (request, response) => {
352
+ const body = await readAgentRequest(request);
353
+ const abort = new AbortController();
354
+ response.once("close", () => abort.abort());
355
+ const prompt = await session.waitForPrompt({
356
+ signal: abort.signal,
357
+ agentClientName: optionalString(body.agentClientName),
358
+ });
359
+ sendJson(response, 200, prompt, null);
360
+ }),
361
+ route("GET", AGENT_CONTROL_ENDPOINTS.status, (request, response, url) => {
362
+ requireAgentControl(request);
363
+ session.confirmAgent(decodeAgentId(url.searchParams.get("agentId")));
364
+ sendJson(response, 200, session.getStatus(), null);
365
+ }),
366
+ route("POST", AGENT_CONTROL_ENDPOINTS.reportStatus, async (request, response) => {
367
+ const { update } = await readAgentRequest(request);
368
+ if (!isAgentStatusUpdate(update)) {
369
+ throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
661
370
  }
662
- if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.status) {
663
- const agentId = url.searchParams.get("agentId");
664
- requireAgentControl(request, agentId);
665
- if (agentOwner !== agentId)
666
- throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
667
- sendJson(response, 200, getStatus(), null);
668
- return;
371
+ session.updateRequestStatus(update);
372
+ sendJson(response, 200, session.getStatus(), null);
373
+ }),
374
+ route("POST", AGENT_CONTROL_ENDPOINTS.activity, async (request, response) => {
375
+ const { activity } = await readAgentRequest(request);
376
+ if (!isAgentActivity(activity)) {
377
+ throw new BridgeRequestError(400, "invalid_activity", "activity must identify a workspace-relative source file.");
669
378
  }
670
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.reportStatus) {
671
- await handleAgentReportStatus(request, response);
672
- return;
379
+ session.reportActivity(activity);
380
+ sendJson(response, 200, { accepted: true }, null);
381
+ }),
382
+ route("POST", AGENT_CONTROL_ENDPOINTS.canvas, async (request, response) => {
383
+ const { command } = await readAgentRequest(request);
384
+ if (!isCanvasCommand(command)) {
385
+ throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
673
386
  }
674
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.activity) {
675
- await handleAgentActivity(request, response);
676
- return;
387
+ sendJson(response, 200, await session.dispatchCanvasCommand(command), null);
388
+ }),
389
+ ]);
390
+ const handleOptions = (request, response) => {
391
+ const origin = requestOrigin(request);
392
+ if (origin) {
393
+ try {
394
+ session.authorizeOrigin(origin);
677
395
  }
678
- if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.canvas) {
679
- await handleAgentCanvas(request, response);
396
+ catch (error) {
397
+ const { statusCode, body } = errorResponse(error);
398
+ sendJson(response, statusCode, body, null);
680
399
  return;
681
400
  }
401
+ setCors(response, origin);
402
+ }
403
+ response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
404
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type");
405
+ response.statusCode = 204;
406
+ response.end();
407
+ };
408
+ const httpServer = createServer((request, response) => {
409
+ void (async () => {
410
+ const url = new URL(request.url ?? "/", `http://${host}:${port}`);
682
411
  if (request.method === "OPTIONS") {
683
412
  handleOptions(request, response);
684
413
  return;
685
414
  }
686
- if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.health) {
687
- handleHealth(request, response, url);
688
- return;
689
- }
690
- if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.pair) {
691
- await handlePair(request, response);
692
- return;
693
- }
694
- if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.takeover) {
695
- await handleTakeover(request, response);
696
- return;
697
- }
698
- if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
699
- handleEvents(request, response, url);
700
- return;
701
- }
702
- if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.status) {
703
- handleStatus(request, response, url);
415
+ const handler = routes.get(`${request.method ?? ""} ${url.pathname}`);
416
+ if (!handler) {
417
+ sendJson(response, 404, { error: { code: "not_found", message: "The bridge endpoint does not exist." } }, requestOrigin(request));
704
418
  return;
705
419
  }
706
- if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.prompt) {
707
- await handlePrompt(request, response);
708
- return;
709
- }
710
- if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.canvasAcknowledgement) {
711
- await handleCanvasAcknowledgement(request, response);
712
- return;
713
- }
714
- if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.disconnect) {
715
- await handleDisconnect(request, response);
716
- return;
717
- }
718
- sendJson(response, 404, { error: { code: "not_found", message: "The bridge endpoint does not exist." } }, requestOrigin(request));
420
+ await handler(request, response, url);
719
421
  })().catch((error) => {
720
- sendError(response, error, requestOrigin(request));
422
+ const { statusCode, body } = errorResponse(error);
423
+ sendJson(response, statusCode, body, requestOrigin(request));
721
424
  });
722
425
  });
426
+ let closed = false;
427
+ let currentAddress = null;
428
+ let startPromise = null;
429
+ let closePromise = null;
723
430
  const start = () => {
724
- if (lifecycle === "closed")
431
+ if (closed)
725
432
  return Promise.reject(new Error("The browser bridge is closed."));
726
- if (lifecycle === "listening" && currentAddress)
433
+ if (currentAddress)
727
434
  return Promise.resolve(currentAddress);
728
- if (startPromise)
729
- return startPromise;
730
- startPromise = new Promise((resolveStart, rejectStart) => {
435
+ startPromise ??= new Promise((resolveStart, rejectStart) => {
731
436
  const onError = (error) => {
732
437
  httpServer.off("listening", onListening);
733
438
  startPromise = null;
@@ -741,7 +446,6 @@ export function createLoopbackBridge(options) {
741
446
  rejectStart(new Error("The browser bridge did not expose a TCP address."));
742
447
  return;
743
448
  }
744
- lifecycle = "listening";
745
449
  currentAddress = {
746
450
  host,
747
451
  port: address.port,
@@ -755,217 +459,10 @@ export function createLoopbackBridge(options) {
755
459
  });
756
460
  return startPromise;
757
461
  };
758
- const cancelListener = (reason = "listener_cancelled") => {
759
- if (!listener)
760
- return;
761
- const pending = listener;
762
- listener = null;
763
- if (pending.abortHandler && pending.signal)
764
- pending.signal.removeEventListener("abort", pending.abortHandler);
765
- pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
766
- broadcastStatus();
767
- };
768
- const waitForPrompt = (signal, _sessionId, clientName) => {
769
- if (lifecycle === "closed")
770
- return Promise.reject(new Error("The browser bridge is closed."));
771
- if (listener)
772
- return Promise.reject(new Error("A listening call is already active for this project."));
773
- if (currentRequest?.status === "working")
774
- return Promise.reject(new Error("A request is already in flight for this project."));
775
- agentClientName = clientName?.trim().slice(0, 120) || undefined;
776
- // Keep the terminal outcome visible until the agent explicitly rearms its
777
- // listening call. Rearming is the lifecycle boundary for the old request.
778
- if (currentRequest)
779
- currentRequest = null;
780
- const pendingDeferred = deferred();
781
- const pending = {
782
- resolve: pendingDeferred.resolve,
783
- reject: pendingDeferred.reject,
784
- signal,
785
- };
786
- if (signal) {
787
- if (signal.aborted)
788
- return Promise.reject(new Error("The listening call was aborted."));
789
- pending.abortHandler = () => {
790
- if (listener !== pending)
791
- return;
792
- listener = null;
793
- pending.reject(new Error("The listening call was aborted."));
794
- broadcastStatus();
795
- };
796
- signal.addEventListener("abort", pending.abortHandler, { once: true });
797
- }
798
- listener = pending;
799
- broadcastStatus();
800
- if (sessionToken && lastPageUrl && sseClients.size === 0 && options.onControllerUnavailable) {
801
- void Promise.resolve(options.onControllerUnavailable(lastPageUrl)).catch(() => undefined);
802
- }
803
- return pendingDeferred.promise;
804
- };
805
- const updateRequestStatus = (update) => {
806
- if (!isAgentStatusUpdate(update))
807
- throw new TypeError("Invalid agent status update");
808
- if (!currentRequest || currentRequest.requestId !== update.requestId) {
809
- throw new BridgeRequestError(409, "unknown_request", "The request is not in flight for this project.");
810
- }
811
- if (update.status === "working") {
812
- currentRequest = { ...currentRequest, status: "working" };
813
- }
814
- else {
815
- currentRequest = {
816
- ...currentRequest,
817
- status: update.status,
818
- ...(update.summary === undefined ? {} : { summary: update.summary }),
819
- ...(update.error === undefined ? {} : { error: update.error }),
820
- };
821
- }
822
- broadcastStatus();
823
- };
824
- const reportActivity = (activity) => {
825
- if (!isAgentActivity(activity))
826
- throw new TypeError("Invalid agent activity");
827
- if (!currentRequest || currentRequest.requestId !== activity.requestId || currentRequest.status !== "working") {
828
- throw new BridgeRequestError(409, "inactive_request", "Activity requires the currently working request.");
829
- }
830
- broadcast({ type: "activity", activity });
831
- };
832
- const dispatchCanvasCommand = (input) => {
833
- if (lifecycle === "closed")
834
- return Promise.reject(new Error("The browser bridge is closed."));
835
- if (!sessionToken)
836
- return Promise.reject(new Error("No browser is paired with this project."));
837
- if (pendingCanvas)
838
- return Promise.reject(new Error("A Canvas command is already in flight."));
839
- const command = { ...input, commandId: input.commandId ?? idFactory() };
840
- if (!isCanvasCommand(command))
841
- return Promise.reject(new TypeError("Invalid Canvas command"));
842
- // The browser performs the final route check too, but rejecting here keeps
843
- // the agent side from dispatching a cross-origin URL into the project.
844
- const routeOrigin = pairedOrigin ?? configuredOrigin;
845
- if (command.type === "present-routes" && !routeOrigin) {
846
- return Promise.reject(new Error("Canvas routes require a paired browser origin"));
847
- }
848
- if (command.type === "present-routes" && command.routes.some((route) => !isSameOriginRoute(route, routeOrigin))) {
849
- return Promise.reject(new TypeError("Canvas routes must belong to the paired project origin"));
850
- }
851
- if (command.type === "present-routes") {
852
- try {
853
- validateRoutes(command.routes, routeOrigin);
854
- }
855
- catch (error) {
856
- return Promise.reject(error);
857
- }
858
- }
859
- const pendingDeferred = deferred();
860
- const timeout = clock.setTimeout(() => {
861
- if (!pendingCanvas || pendingCanvas.command.commandId !== command.commandId)
862
- return;
863
- pendingCanvas = null;
864
- pendingDeferred.resolve({
865
- commandId: command.commandId,
866
- ok: false,
867
- error: { code: "ack_timeout", message: "The browser did not acknowledge the Canvas command in time." },
868
- });
869
- }, timeoutMs);
870
- pendingCanvas = { command, resolve: pendingDeferred.resolve, timeout };
871
- const event = { type: "canvas-command", command };
872
- broadcast(event);
873
- return pendingDeferred.promise;
874
- };
875
- const pairBrowser = (projectId, requestOriginValue, pageUrl) => {
876
- if (projectId !== options.projectId)
877
- throw new Error("Project identity does not match this bridge");
878
- const requestCanonicalOrigin = canonicalOrigin(requestOriginValue);
879
- if (!requestCanonicalOrigin)
880
- throw new Error("Browser origin does not match this bridge");
881
- // This method is the explicit host-side approval path. A bridge without
882
- // an HTTP allow-list can still be paired by trusted host code, while all
883
- // browser-originated requests remain closed until that approval exists.
884
- if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
885
- throw new Error("Browser origin is not in the configured allow-list");
886
- }
887
- let requestedPageUrl;
888
- if (pageUrl !== undefined) {
889
- if (!isSameOriginRoute({ url: pageUrl }, requestCanonicalOrigin)) {
890
- throw new Error("Browser page URL does not match this bridge");
891
- }
892
- requestedPageUrl = new URL(pageUrl, requestCanonicalOrigin).href;
893
- }
894
- if (sessionToken)
895
- throw new Error("A browser is already paired with this project");
896
- const nextSessionToken = tokenFactory();
897
- if (!nextSessionToken || nextSessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
898
- throw new Error("tokenFactory returned an invalid session token");
899
- }
900
- sessionToken = nextSessionToken;
901
- pairedOrigin = requestCanonicalOrigin;
902
- lastPageUrl = requestedPageUrl ?? null;
903
- const result = {
904
- protocolVersion: AGENT_PROTOCOL_VERSION,
905
- projectId: options.projectId,
906
- origin: requestCanonicalOrigin,
907
- sessionToken,
908
- ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
909
- status: getStatus(),
910
- };
911
- broadcast({ type: "connected", status: getStatus() });
912
- broadcastStatus();
913
- return result;
914
- };
915
- const disconnectBrowser = (token) => {
916
- parseToken(token, sessionToken);
917
- if (currentRequest?.status === "working") {
918
- currentRequest = {
919
- ...currentRequest,
920
- status: "interrupted",
921
- error: "The paired browser disconnected while the request was in flight.",
922
- };
923
- broadcastStatus();
924
- }
925
- broadcast({ type: "disconnected", reason: "browser_disconnected" });
926
- sessionToken = null;
927
- pairedOrigin = null;
928
- for (const response of [...sseClients])
929
- response.end();
930
- sseClients.clear();
931
- if (pendingCanvas) {
932
- clock.clearTimeout(pendingCanvas.timeout);
933
- pendingCanvas.resolve({
934
- commandId: pendingCanvas.command.commandId,
935
- ok: false,
936
- error: { code: "browser_disconnected", message: "The paired browser disconnected before acknowledging the command." },
937
- });
938
- pendingCanvas = null;
939
- }
940
- lastPageUrl = null;
941
- };
942
462
  const close = () => {
943
- if (closePromise)
944
- return closePromise;
945
- lifecycle = "closed";
946
- if (agentOwnerExpiry !== null)
947
- clock.clearTimeout(agentOwnerExpiry);
948
- agentOwnerExpiry = null;
949
- agentOwner = null;
950
- agentClientName = undefined;
951
- agentOwnerSeenAt = 0;
952
- cancelListener("bridge_closed");
953
- if (pendingCanvas) {
954
- clock.clearTimeout(pendingCanvas.timeout);
955
- pendingCanvas.resolve({
956
- commandId: pendingCanvas.command.commandId,
957
- ok: false,
958
- error: { code: "bridge_closed", message: "The browser bridge closed before acknowledging the command." },
959
- });
960
- pendingCanvas = null;
961
- }
962
- for (const response of [...sseClients])
963
- response.end();
964
- sseClients.clear();
965
- sessionToken = null;
966
- pairedOrigin = null;
967
- lastPageUrl = null;
968
- closePromise = new Promise((resolveClose) => {
463
+ closed = true;
464
+ session.close();
465
+ closePromise ??= new Promise((resolveClose) => {
969
466
  if (!httpServer.listening) {
970
467
  resolveClose();
971
468
  return;
@@ -974,25 +471,18 @@ export function createLoopbackBridge(options) {
974
471
  });
975
472
  return closePromise;
976
473
  };
977
- const bridge = {
978
- httpServer,
979
- projectId: options.projectId,
980
- origin: configuredOrigin,
981
- get effectiveOrigin() { return pairedOrigin ?? configuredOrigin; },
982
- get lifecycle() { return lifecycle; },
474
+ return {
475
+ projectId: session.projectId,
983
476
  get address() { return currentAddress; },
984
- get sessionToken() { return sessionToken; },
985
- get lastPageUrl() { return lastPageUrl; },
986
- getStatus,
987
477
  start,
988
478
  close,
989
- waitForPrompt,
990
- cancelListener,
991
- updateRequestStatus,
992
- reportActivity,
993
- dispatchCanvasCommand,
994
- pairBrowser,
995
- disconnectBrowser,
479
+ getStatus: session.getStatus,
480
+ getRequest: session.getRequest,
481
+ pairBrowser: (origin, pageUrl) => session.pairBrowser(origin, pageUrl),
482
+ disconnectBrowser: session.disconnectBrowser,
483
+ waitForPrompt: session.waitForPrompt,
484
+ updateRequestStatus: session.updateRequestStatus,
485
+ reportActivity: session.reportActivity,
486
+ dispatchCanvasCommand: session.dispatchCanvasCommand,
996
487
  };
997
- return bridge;
998
488
  }