@nudge-ui/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js ADDED
@@ -0,0 +1,731 @@
1
+ import { createServer, } from "node:http";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateRoutes, } from "@nudge-ui/agent-protocol";
4
+ import { BRIDGE_ENDPOINTS } from "./protocol.js";
5
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
6
+ const MAX_BODY_BYTES = 256 * 1024;
7
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
8
+ const SYSTEM_CLOCK = {
9
+ now: () => Date.now(),
10
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
11
+ clearTimeout: (handle) => clearTimeout(handle),
12
+ };
13
+ class BridgeRequestError extends Error {
14
+ statusCode;
15
+ code;
16
+ constructor(statusCode, code, message) {
17
+ super(message);
18
+ this.name = "BridgeRequestError";
19
+ this.statusCode = statusCode;
20
+ this.code = code;
21
+ }
22
+ }
23
+ function randomToken() {
24
+ return randomBytes(32).toString("base64url");
25
+ }
26
+ function randomId() {
27
+ return randomUUID();
28
+ }
29
+ function deferred() {
30
+ let resolvePromise;
31
+ let rejectPromise;
32
+ const promise = new Promise((resolve, reject) => {
33
+ resolvePromise = resolve;
34
+ rejectPromise = reject;
35
+ });
36
+ return { promise, resolve: resolvePromise, reject: rejectPromise };
37
+ }
38
+ function loopbackAddress(address) {
39
+ if (!address)
40
+ return false;
41
+ return LOOPBACK_HOSTS.has(address) || address === "::ffff:127.0.0.1";
42
+ }
43
+ function validPort(port) {
44
+ return Number.isInteger(port) && port >= 0 && port <= 65_535;
45
+ }
46
+ function headerValue(request, name) {
47
+ const value = request.headers[name];
48
+ if (Array.isArray(value))
49
+ return undefined;
50
+ return value;
51
+ }
52
+ function requestOrigin(request) {
53
+ return canonicalOrigin(headerValue(request, "origin"));
54
+ }
55
+ function parseProjectId(value, expected) {
56
+ if (value !== expected) {
57
+ throw new BridgeRequestError(404, "project_not_found", "The requested project is not served by this companion.");
58
+ }
59
+ }
60
+ function parseToken(value, expected) {
61
+ if (!expected || value !== expected) {
62
+ throw new BridgeRequestError(401, "invalid_session", "The browser session token is invalid or expired.");
63
+ }
64
+ }
65
+ function jsonHeaders(response, origin) {
66
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
67
+ response.setHeader("Cache-Control", "no-store");
68
+ response.setHeader("X-Content-Type-Options", "nosniff");
69
+ if (origin) {
70
+ response.setHeader("Access-Control-Allow-Origin", origin);
71
+ response.setHeader("Access-Control-Allow-Credentials", "true");
72
+ response.setHeader("Vary", "Origin");
73
+ }
74
+ }
75
+ function sendJson(response, statusCode, body, origin) {
76
+ jsonHeaders(response, origin);
77
+ response.statusCode = statusCode;
78
+ response.end(JSON.stringify(body));
79
+ }
80
+ function sendError(response, error, origin) {
81
+ if (error instanceof BridgeRequestError) {
82
+ const body = { error: { code: error.code, message: error.message } };
83
+ sendJson(response, error.statusCode, body, origin);
84
+ return;
85
+ }
86
+ sendJson(response, 500, { error: { code: "internal_error", message: "The bridge could not complete the request." } }, origin);
87
+ }
88
+ async function readJsonBody(request) {
89
+ const chunks = [];
90
+ let total = 0;
91
+ for await (const chunk of request) {
92
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
93
+ total += buffer.length;
94
+ if (total > MAX_BODY_BYTES) {
95
+ throw new BridgeRequestError(413, "body_too_large", "The bridge request body is too large.");
96
+ }
97
+ chunks.push(buffer);
98
+ }
99
+ if (chunks.length === 0)
100
+ return {};
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
104
+ }
105
+ catch {
106
+ throw new BridgeRequestError(400, "invalid_json", "The bridge request body must be valid JSON.");
107
+ }
108
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
109
+ throw new BridgeRequestError(400, "invalid_body", "The bridge request body must be a JSON object.");
110
+ }
111
+ return parsed;
112
+ }
113
+ function createEnvelope(projectId, sessionToken, event) {
114
+ return {
115
+ protocolVersion: AGENT_PROTOCOL_VERSION,
116
+ projectId,
117
+ sessionToken,
118
+ event,
119
+ };
120
+ }
121
+ function writeSse(response, envelope) {
122
+ if (response.destroyed || response.writableEnded)
123
+ return;
124
+ response.write(`event: ${envelope.event.type}\ndata: ${JSON.stringify(envelope)}\n\n`);
125
+ }
126
+ function connectionFor(listenerActive, paired, request) {
127
+ if (request?.status === "working")
128
+ return "working";
129
+ if (paired)
130
+ return "paired";
131
+ if (listenerActive)
132
+ return "listening";
133
+ return "offline";
134
+ }
135
+ function statusWith(projectId, listenerActive, paired, request, pageUrl) {
136
+ return {
137
+ protocolVersion: AGENT_PROTOCOL_VERSION,
138
+ projectId,
139
+ connection: connectionFor(listenerActive, paired, request),
140
+ listenerActive,
141
+ paired,
142
+ pageUrl,
143
+ request,
144
+ };
145
+ }
146
+ /**
147
+ * Creates the local browser bridge. It binds only to loopback and keeps every
148
+ * pairing, prompt, command, and acknowledgement in process memory.
149
+ */
150
+ export function createLoopbackBridge(options) {
151
+ if (typeof options.projectId !== "string" || options.projectId.length === 0
152
+ || options.projectId.length > AGENT_PROTOCOL_LIMITS.projectId) {
153
+ throw new TypeError("projectId must be a bounded non-empty string");
154
+ }
155
+ const configuredOrigin = options.origin === undefined ? null : canonicalOrigin(options.origin);
156
+ if (options.origin !== undefined && !configuredOrigin)
157
+ throw new TypeError("origin must be a canonical HTTP(S) origin");
158
+ const host = options.host ?? "127.0.0.1";
159
+ if (!LOOPBACK_HOSTS.has(host))
160
+ throw new TypeError("The browser bridge must bind to a loopback host");
161
+ const port = options.port ?? 0;
162
+ if (!validPort(port))
163
+ throw new TypeError("port must be between 0 and 65535");
164
+ const timeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
165
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
166
+ throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
167
+ }
168
+ const allowedOrigins = (options.allowedOrigins ?? (configuredOrigin ? [configuredOrigin] : [])).map((candidate) => canonicalOrigin(candidate));
169
+ if (allowedOrigins.some((candidate) => candidate === null)) {
170
+ throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
171
+ }
172
+ const canonicalAllowedOrigins = allowedOrigins;
173
+ const clock = options.clock ?? SYSTEM_CLOCK;
174
+ const tokenFactory = options.tokenFactory ?? randomToken;
175
+ const idFactory = options.idFactory ?? randomId;
176
+ let lifecycle = "created";
177
+ let currentAddress = null;
178
+ let sessionToken = null;
179
+ let pairedOrigin = null;
180
+ let lastPageUrl = null;
181
+ const sseClients = new Set();
182
+ let listener = null;
183
+ let currentRequest = null;
184
+ let pendingCanvas = null;
185
+ let closePromise = null;
186
+ let startPromise = null;
187
+ const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl);
188
+ const broadcast = (event) => {
189
+ if (!sessionToken)
190
+ return;
191
+ const envelope = createEnvelope(options.projectId, sessionToken, event);
192
+ for (const response of [...sseClients]) {
193
+ if (response.destroyed || response.writableEnded) {
194
+ sseClients.delete(response);
195
+ continue;
196
+ }
197
+ try {
198
+ writeSse(response, envelope);
199
+ }
200
+ catch {
201
+ sseClients.delete(response);
202
+ response.destroy();
203
+ }
204
+ }
205
+ };
206
+ const broadcastStatus = () => {
207
+ const statusEvent = { type: "status", status: getStatus() };
208
+ broadcast(statusEvent);
209
+ };
210
+ const originIsAllowed = (candidate) => {
211
+ if (!candidate)
212
+ return false;
213
+ // Once paired, the explicitly approved browser's exact origin remains the
214
+ // only origin accepted. Before pairing, only an origin configured by the
215
+ // host (directly or through the allow-list) is eligible. An empty list is
216
+ // deliberately closed rather than a trust-on-first-use wildcard.
217
+ if (pairedOrigin === candidate)
218
+ return true;
219
+ return isAllowedOrigin(candidate, canonicalAllowedOrigins);
220
+ };
221
+ const requireBrowserRequest = (request, url, bodyToken) => {
222
+ if (!loopbackAddress(request.socket.remoteAddress)) {
223
+ throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
224
+ }
225
+ const incomingOrigin = requestOrigin(request);
226
+ if (!originIsAllowed(incomingOrigin)) {
227
+ throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
228
+ }
229
+ if (pairedOrigin !== incomingOrigin) {
230
+ throw new BridgeRequestError(403, "origin_mismatch", "The browser origin does not match the paired project.");
231
+ }
232
+ const candidate = typeof bodyToken === "string" ? bodyToken : url.searchParams.get("sessionToken");
233
+ parseToken(candidate, sessionToken);
234
+ return incomingOrigin;
235
+ };
236
+ const handleOptions = (request, response) => {
237
+ const incomingOrigin = requestOrigin(request);
238
+ if (incomingOrigin && !originIsAllowed(incomingOrigin)) {
239
+ sendError(response, new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project."), null);
240
+ return;
241
+ }
242
+ if (incomingOrigin) {
243
+ response.setHeader("Access-Control-Allow-Origin", incomingOrigin);
244
+ response.setHeader("Access-Control-Allow-Credentials", "true");
245
+ response.setHeader("Vary", "Origin");
246
+ }
247
+ response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
248
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type");
249
+ response.statusCode = 204;
250
+ response.end();
251
+ };
252
+ const handleHealth = (request, response, url) => {
253
+ if (!loopbackAddress(request.socket.remoteAddress)) {
254
+ throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
255
+ }
256
+ const incomingOrigin = requestOrigin(request);
257
+ if (!originIsAllowed(incomingOrigin)) {
258
+ throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
259
+ }
260
+ parseProjectId(url.searchParams.get("projectId"), options.projectId);
261
+ sendJson(response, 200, {
262
+ protocolVersion: AGENT_PROTOCOL_VERSION,
263
+ projectId: options.projectId,
264
+ origin: configuredOrigin ?? pairedOrigin,
265
+ status: getStatus(),
266
+ sessionRequired: true,
267
+ pageUrl: lastPageUrl,
268
+ }, incomingOrigin);
269
+ };
270
+ const handlePair = async (request, response) => {
271
+ if (!loopbackAddress(request.socket.remoteAddress)) {
272
+ throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
273
+ }
274
+ const incomingOrigin = requestOrigin(request);
275
+ if (!originIsAllowed(incomingOrigin)) {
276
+ throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
277
+ }
278
+ const body = await readJsonBody(request);
279
+ parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
280
+ const requestedOrigin = canonicalOrigin(body.origin);
281
+ if (!requestedOrigin || requestedOrigin !== incomingOrigin || (configuredOrigin !== null && requestedOrigin !== configuredOrigin)) {
282
+ throw new BridgeRequestError(403, "origin_mismatch", "Pairing requires an explicitly configured project origin.");
283
+ }
284
+ let requestedPageUrl;
285
+ if (body.pageUrl !== undefined) {
286
+ if (typeof body.pageUrl !== "string" || body.pageUrl.length === 0 || body.pageUrl.length > AGENT_PROTOCOL_LIMITS.routeUrl
287
+ || !isSameOriginRoute({ url: body.pageUrl }, requestedOrigin)) {
288
+ throw new BridgeRequestError(400, "invalid_page_url", "pageUrl must be a bounded same-origin URL.");
289
+ }
290
+ requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
291
+ }
292
+ if (sessionToken) {
293
+ throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
294
+ }
295
+ sessionToken = tokenFactory();
296
+ if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
297
+ throw new Error("tokenFactory returned an invalid session token");
298
+ }
299
+ pairedOrigin = incomingOrigin;
300
+ lastPageUrl = requestedPageUrl ?? null;
301
+ const connected = { type: "connected", status: getStatus() };
302
+ broadcast(connected);
303
+ const result = {
304
+ protocolVersion: AGENT_PROTOCOL_VERSION,
305
+ projectId: options.projectId,
306
+ origin: configuredOrigin ?? pairedOrigin,
307
+ sessionToken,
308
+ ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
309
+ status: getStatus(),
310
+ };
311
+ sendJson(response, 200, result, incomingOrigin);
312
+ broadcastStatus();
313
+ };
314
+ const handleEvents = (request, response, url) => {
315
+ parseProjectId(url.searchParams.get("projectId"), options.projectId);
316
+ const incomingOrigin = requireBrowserRequest(request, url);
317
+ response.statusCode = 200;
318
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
319
+ response.setHeader("Cache-Control", "no-cache, no-store");
320
+ response.setHeader("Connection", "keep-alive");
321
+ response.setHeader("X-Accel-Buffering", "no");
322
+ response.setHeader("Access-Control-Allow-Origin", incomingOrigin);
323
+ response.setHeader("Access-Control-Allow-Credentials", "true");
324
+ response.setHeader("Vary", "Origin");
325
+ response.flushHeaders();
326
+ sseClients.add(response);
327
+ const initial = { type: "status", status: getStatus() };
328
+ writeSse(response, createEnvelope(options.projectId, sessionToken, initial));
329
+ const closeClient = () => {
330
+ sseClients.delete(response);
331
+ };
332
+ request.once("close", closeClient);
333
+ response.once("close", closeClient);
334
+ };
335
+ const handleStatus = (request, response, url) => {
336
+ parseProjectId(url.searchParams.get("projectId"), options.projectId);
337
+ const incomingOrigin = requireBrowserRequest(request, url);
338
+ sendJson(response, 200, getStatus(), incomingOrigin);
339
+ };
340
+ const handlePrompt = async (request, response) => {
341
+ const body = await readJsonBody(request);
342
+ const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
343
+ parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
344
+ if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
345
+ throw new BridgeRequestError(400, "invalid_prompt", "prompt must be a bounded non-empty string");
346
+ }
347
+ if (body.changeRevision !== undefined
348
+ && (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
349
+ throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
350
+ }
351
+ if (!listener) {
352
+ throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
353
+ }
354
+ if (currentRequest) {
355
+ throw new BridgeRequestError(409, "request_in_flight", "Another Nudge request is still in flight.");
356
+ }
357
+ const prompt = {
358
+ requestId: idFactory(),
359
+ projectId: options.projectId,
360
+ prompt: body.prompt,
361
+ ...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
362
+ };
363
+ const pending = listener;
364
+ listener = null;
365
+ if (pending.abortHandler && pending.signal)
366
+ pending.signal.removeEventListener("abort", pending.abortHandler);
367
+ currentRequest = { ...prompt, status: "working" };
368
+ pending.resolve(prompt);
369
+ const result = { request: prompt, status: "working" };
370
+ sendJson(response, 202, result, incomingOrigin);
371
+ broadcastStatus();
372
+ };
373
+ const handleCanvasAcknowledgement = async (request, response) => {
374
+ const body = await readJsonBody(request);
375
+ const url = new URL("http://bridge.invalid");
376
+ const incomingOrigin = requireBrowserRequest(request, url, body.sessionToken);
377
+ parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
378
+ if (!pendingCanvas)
379
+ throw new BridgeRequestError(409, "no_command", "No Canvas command is awaiting an acknowledgement.");
380
+ if (typeof body.commandId !== "string" || body.commandId !== pendingCanvas.command.commandId) {
381
+ throw new BridgeRequestError(409, "unknown_command", "The Canvas command is no longer awaiting an acknowledgement.");
382
+ }
383
+ if (typeof body.ok !== "boolean")
384
+ throw new BridgeRequestError(400, "invalid_acknowledgement", "ok must be a boolean");
385
+ // Acknowledgement payloads flow back to the agent verbatim, so every
386
+ // optional field is validated before it is accepted into the result.
387
+ if (body.state !== undefined && !isCanvasState(body.state)) {
388
+ throw new BridgeRequestError(400, "invalid_acknowledgement", "state must be a valid Canvas state.");
389
+ }
390
+ if (body.group !== undefined && !isCanvasGroup(body.group)) {
391
+ throw new BridgeRequestError(400, "invalid_acknowledgement", "group must be a valid Canvas group.");
392
+ }
393
+ if (body.error !== undefined) {
394
+ const error = body.error;
395
+ if (typeof error !== "object" || error === null
396
+ || typeof error.code !== "string" || typeof error.message !== "string") {
397
+ throw new BridgeRequestError(400, "invalid_acknowledgement", "error must include string code and message fields.");
398
+ }
399
+ }
400
+ const acknowledgement = {
401
+ commandId: body.commandId,
402
+ ok: body.ok,
403
+ ...(body.state === undefined ? {} : { state: body.state }),
404
+ ...(body.group === undefined ? {} : { group: body.group }),
405
+ ...(body.error === undefined ? {} : { error: body.error }),
406
+ };
407
+ if (acknowledgement.ok && acknowledgement.error) {
408
+ throw new BridgeRequestError(400, "invalid_acknowledgement", "A successful acknowledgement cannot include an error.");
409
+ }
410
+ const pendingCommand = pendingCanvas;
411
+ pendingCanvas = null;
412
+ clock.clearTimeout(pendingCommand.timeout);
413
+ const result = {
414
+ commandId: acknowledgement.commandId,
415
+ ok: acknowledgement.ok,
416
+ ...(acknowledgement.state === undefined ? {} : { state: acknowledgement.state }),
417
+ ...(acknowledgement.group === undefined ? {} : { group: acknowledgement.group }),
418
+ ...(acknowledgement.error === undefined ? {} : { error: acknowledgement.error }),
419
+ };
420
+ pendingCommand.resolve(result);
421
+ sendJson(response, 202, { commandId: result.commandId, accepted: true }, incomingOrigin);
422
+ };
423
+ const handleDisconnect = async (request, response) => {
424
+ const body = await readJsonBody(request);
425
+ const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
426
+ disconnectBrowser(body.sessionToken);
427
+ sendJson(response, 200, { disconnected: true }, incomingOrigin);
428
+ };
429
+ const httpServer = createServer((request, response) => {
430
+ void (async () => {
431
+ const url = new URL(request.url ?? "/", `http://${host}:${port}`);
432
+ if (request.method === "OPTIONS") {
433
+ handleOptions(request, response);
434
+ return;
435
+ }
436
+ if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.health) {
437
+ handleHealth(request, response, url);
438
+ return;
439
+ }
440
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.pair) {
441
+ await handlePair(request, response);
442
+ return;
443
+ }
444
+ if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
445
+ handleEvents(request, response, url);
446
+ return;
447
+ }
448
+ if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.status) {
449
+ handleStatus(request, response, url);
450
+ return;
451
+ }
452
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.prompt) {
453
+ await handlePrompt(request, response);
454
+ return;
455
+ }
456
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.canvasAcknowledgement) {
457
+ await handleCanvasAcknowledgement(request, response);
458
+ return;
459
+ }
460
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.disconnect) {
461
+ await handleDisconnect(request, response);
462
+ return;
463
+ }
464
+ sendJson(response, 404, { error: { code: "not_found", message: "The bridge endpoint does not exist." } }, requestOrigin(request));
465
+ })().catch((error) => {
466
+ sendError(response, error, requestOrigin(request));
467
+ });
468
+ });
469
+ const start = () => {
470
+ if (lifecycle === "closed")
471
+ return Promise.reject(new Error("The browser bridge is closed."));
472
+ if (lifecycle === "listening" && currentAddress)
473
+ return Promise.resolve(currentAddress);
474
+ if (startPromise)
475
+ return startPromise;
476
+ startPromise = new Promise((resolveStart, rejectStart) => {
477
+ const onError = (error) => {
478
+ httpServer.off("listening", onListening);
479
+ startPromise = null;
480
+ rejectStart(error);
481
+ };
482
+ const onListening = () => {
483
+ httpServer.off("error", onError);
484
+ const address = httpServer.address();
485
+ if (!address || typeof address === "string") {
486
+ startPromise = null;
487
+ rejectStart(new Error("The browser bridge did not expose a TCP address."));
488
+ return;
489
+ }
490
+ lifecycle = "listening";
491
+ currentAddress = {
492
+ host,
493
+ port: address.port,
494
+ url: `http://${host === "::1" ? `[${host}]` : host}:${address.port}`,
495
+ };
496
+ resolveStart(currentAddress);
497
+ };
498
+ httpServer.once("error", onError);
499
+ httpServer.once("listening", onListening);
500
+ httpServer.listen(port, host);
501
+ });
502
+ return startPromise;
503
+ };
504
+ const cancelListener = (reason = "listener_cancelled") => {
505
+ if (!listener)
506
+ return;
507
+ const pending = listener;
508
+ listener = null;
509
+ if (pending.abortHandler && pending.signal)
510
+ pending.signal.removeEventListener("abort", pending.abortHandler);
511
+ pending.reject(new Error(reason));
512
+ broadcastStatus();
513
+ };
514
+ const waitForPrompt = (signal) => {
515
+ if (lifecycle === "closed")
516
+ return Promise.reject(new Error("The browser bridge is closed."));
517
+ if (listener)
518
+ return Promise.reject(new Error("A listening call is already active for this project."));
519
+ if (currentRequest?.status === "working")
520
+ return Promise.reject(new Error("A request is already in flight for this project."));
521
+ // Keep the terminal outcome visible until the agent explicitly rearms its
522
+ // listening call. Rearming is the lifecycle boundary for the old request.
523
+ if (currentRequest)
524
+ currentRequest = null;
525
+ const pendingDeferred = deferred();
526
+ const pending = {
527
+ resolve: pendingDeferred.resolve,
528
+ reject: pendingDeferred.reject,
529
+ signal,
530
+ };
531
+ if (signal) {
532
+ if (signal.aborted)
533
+ return Promise.reject(new Error("The listening call was aborted."));
534
+ pending.abortHandler = () => {
535
+ if (listener !== pending)
536
+ return;
537
+ listener = null;
538
+ pending.reject(new Error("The listening call was aborted."));
539
+ broadcastStatus();
540
+ };
541
+ signal.addEventListener("abort", pending.abortHandler, { once: true });
542
+ }
543
+ listener = pending;
544
+ broadcastStatus();
545
+ if (sessionToken && lastPageUrl && sseClients.size === 0 && options.onControllerUnavailable) {
546
+ void Promise.resolve(options.onControllerUnavailable(lastPageUrl)).catch(() => undefined);
547
+ }
548
+ return pendingDeferred.promise;
549
+ };
550
+ const updateRequestStatus = (update) => {
551
+ if (!isAgentStatusUpdate(update))
552
+ throw new TypeError("Invalid agent status update");
553
+ if (!currentRequest || currentRequest.requestId !== update.requestId) {
554
+ throw new BridgeRequestError(409, "unknown_request", "The request is not in flight for this project.");
555
+ }
556
+ if (update.status === "working") {
557
+ currentRequest = { ...currentRequest, status: "working" };
558
+ }
559
+ else {
560
+ currentRequest = {
561
+ ...currentRequest,
562
+ status: update.status,
563
+ ...(update.summary === undefined ? {} : { summary: update.summary }),
564
+ ...(update.error === undefined ? {} : { error: update.error }),
565
+ };
566
+ }
567
+ broadcastStatus();
568
+ };
569
+ const dispatchCanvasCommand = (input) => {
570
+ if (lifecycle === "closed")
571
+ return Promise.reject(new Error("The browser bridge is closed."));
572
+ if (!sessionToken)
573
+ return Promise.reject(new Error("No browser is paired with this project."));
574
+ if (pendingCanvas)
575
+ return Promise.reject(new Error("A Canvas command is already in flight."));
576
+ const command = { ...input, commandId: input.commandId ?? idFactory() };
577
+ if (!isCanvasCommand(command))
578
+ return Promise.reject(new TypeError("Invalid Canvas command"));
579
+ // The browser performs the final route check too, but rejecting here keeps
580
+ // the agent side from dispatching a cross-origin URL into the project.
581
+ const routeOrigin = pairedOrigin ?? configuredOrigin;
582
+ if (command.type === "present-routes" && !routeOrigin) {
583
+ return Promise.reject(new Error("Canvas routes require a paired browser origin"));
584
+ }
585
+ if (command.type === "present-routes" && command.routes.some((route) => !isSameOriginRoute(route, routeOrigin))) {
586
+ return Promise.reject(new TypeError("Canvas routes must belong to the paired project origin"));
587
+ }
588
+ if (command.type === "present-routes") {
589
+ try {
590
+ validateRoutes(command.routes, routeOrigin);
591
+ }
592
+ catch (error) {
593
+ return Promise.reject(error);
594
+ }
595
+ }
596
+ const pendingDeferred = deferred();
597
+ const timeout = clock.setTimeout(() => {
598
+ if (!pendingCanvas || pendingCanvas.command.commandId !== command.commandId)
599
+ return;
600
+ pendingCanvas = null;
601
+ pendingDeferred.resolve({
602
+ commandId: command.commandId,
603
+ ok: false,
604
+ error: { code: "ack_timeout", message: "The browser did not acknowledge the Canvas command in time." },
605
+ });
606
+ }, timeoutMs);
607
+ pendingCanvas = { command, resolve: pendingDeferred.resolve, timeout };
608
+ const event = { type: "canvas-command", command };
609
+ broadcast(event);
610
+ return pendingDeferred.promise;
611
+ };
612
+ const pairBrowser = (projectId, requestOriginValue, pageUrl) => {
613
+ if (projectId !== options.projectId)
614
+ throw new Error("Project identity does not match this bridge");
615
+ const requestCanonicalOrigin = canonicalOrigin(requestOriginValue);
616
+ if (!requestCanonicalOrigin)
617
+ throw new Error("Browser origin does not match this bridge");
618
+ // This method is the explicit host-side approval path. A bridge without
619
+ // an HTTP allow-list can still be paired by trusted host code, while all
620
+ // browser-originated requests remain closed until that approval exists.
621
+ if (configuredOrigin !== null && requestCanonicalOrigin !== configuredOrigin) {
622
+ throw new Error("Browser origin does not match this bridge");
623
+ }
624
+ if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
625
+ throw new Error("Browser origin is not in the configured allow-list");
626
+ }
627
+ let requestedPageUrl;
628
+ if (pageUrl !== undefined) {
629
+ if (!isSameOriginRoute({ url: pageUrl }, requestCanonicalOrigin)) {
630
+ throw new Error("Browser page URL does not match this bridge");
631
+ }
632
+ requestedPageUrl = new URL(pageUrl, requestCanonicalOrigin).href;
633
+ }
634
+ if (sessionToken)
635
+ throw new Error("A browser is already paired with this project");
636
+ const nextSessionToken = tokenFactory();
637
+ if (!nextSessionToken || nextSessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
638
+ throw new Error("tokenFactory returned an invalid session token");
639
+ }
640
+ sessionToken = nextSessionToken;
641
+ pairedOrigin = requestCanonicalOrigin;
642
+ lastPageUrl = requestedPageUrl ?? null;
643
+ const result = {
644
+ protocolVersion: AGENT_PROTOCOL_VERSION,
645
+ projectId: options.projectId,
646
+ origin: requestCanonicalOrigin,
647
+ sessionToken,
648
+ ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
649
+ status: getStatus(),
650
+ };
651
+ broadcast({ type: "connected", status: getStatus() });
652
+ broadcastStatus();
653
+ return result;
654
+ };
655
+ const disconnectBrowser = (token) => {
656
+ parseToken(token, sessionToken);
657
+ if (currentRequest?.status === "working") {
658
+ currentRequest = {
659
+ ...currentRequest,
660
+ status: "interrupted",
661
+ error: "The paired browser disconnected while the request was in flight.",
662
+ };
663
+ broadcastStatus();
664
+ }
665
+ broadcast({ type: "disconnected", reason: "browser_disconnected" });
666
+ sessionToken = null;
667
+ pairedOrigin = null;
668
+ for (const response of [...sseClients])
669
+ response.end();
670
+ sseClients.clear();
671
+ if (pendingCanvas) {
672
+ clock.clearTimeout(pendingCanvas.timeout);
673
+ pendingCanvas.resolve({
674
+ commandId: pendingCanvas.command.commandId,
675
+ ok: false,
676
+ error: { code: "browser_disconnected", message: "The paired browser disconnected before acknowledging the command." },
677
+ });
678
+ pendingCanvas = null;
679
+ }
680
+ lastPageUrl = null;
681
+ };
682
+ const close = () => {
683
+ if (closePromise)
684
+ return closePromise;
685
+ lifecycle = "closed";
686
+ cancelListener("bridge_closed");
687
+ if (pendingCanvas) {
688
+ clock.clearTimeout(pendingCanvas.timeout);
689
+ pendingCanvas.resolve({
690
+ commandId: pendingCanvas.command.commandId,
691
+ ok: false,
692
+ error: { code: "bridge_closed", message: "The browser bridge closed before acknowledging the command." },
693
+ });
694
+ pendingCanvas = null;
695
+ }
696
+ for (const response of [...sseClients])
697
+ response.end();
698
+ sseClients.clear();
699
+ sessionToken = null;
700
+ pairedOrigin = null;
701
+ lastPageUrl = null;
702
+ closePromise = new Promise((resolveClose) => {
703
+ if (!httpServer.listening) {
704
+ resolveClose();
705
+ return;
706
+ }
707
+ httpServer.close(() => resolveClose());
708
+ });
709
+ return closePromise;
710
+ };
711
+ const bridge = {
712
+ httpServer,
713
+ projectId: options.projectId,
714
+ origin: configuredOrigin,
715
+ get effectiveOrigin() { return pairedOrigin ?? configuredOrigin; },
716
+ get lifecycle() { return lifecycle; },
717
+ get address() { return currentAddress; },
718
+ get sessionToken() { return sessionToken; },
719
+ get lastPageUrl() { return lastPageUrl; },
720
+ getStatus,
721
+ start,
722
+ close,
723
+ waitForPrompt,
724
+ cancelListener,
725
+ updateRequestStatus,
726
+ dispatchCanvasCommand,
727
+ pairBrowser,
728
+ disconnectBrowser,
729
+ };
730
+ return bridge;
731
+ }