@giselles-ai/browser-tool 0.1.3 → 0.1.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/index.d.ts CHANGED
@@ -33,7 +33,7 @@ type ExecutionReport = {
33
33
  warnings: string[];
34
34
  };
35
35
  type BrowserToolStatus = "idle" | "snapshotting" | "planning" | "ready" | "applying" | "error";
36
- type BridgeErrorCode = "UNAUTHORIZED" | "NO_BROWSER" | "TIMEOUT" | "INVALID_RESPONSE" | "NOT_FOUND" | "INTERNAL";
36
+ type RelayErrorCode = "UNAUTHORIZED" | "NO_BROWSER" | "TIMEOUT" | "INVALID_RESPONSE" | "NOT_FOUND" | "INTERNAL";
37
37
  declare const fieldKindSchema: z.ZodEnum<{
38
38
  text: "text";
39
39
  textarea: "textarea";
@@ -128,7 +128,7 @@ declare const executeRequestSchema: z.ZodObject<{
128
128
  options: z.ZodOptional<z.ZodArray<z.ZodString>>;
129
129
  }, z.core.$strip>>;
130
130
  }, z.core.$strip>;
131
- declare const bridgeRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
131
+ declare const relayRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
132
132
  type: z.ZodLiteral<"snapshot_request">;
133
133
  requestId: z.ZodString;
134
134
  instruction: z.ZodString;
@@ -201,7 +201,7 @@ declare const errorResponseSchema: z.ZodObject<{
201
201
  requestId: z.ZodString;
202
202
  message: z.ZodString;
203
203
  }, z.core.$strip>;
204
- declare const bridgeResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
204
+ declare const relayResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
205
205
  type: z.ZodLiteral<"snapshot_response">;
206
206
  requestId: z.ZodString;
207
207
  fields: z.ZodArray<z.ZodObject<{
@@ -275,7 +275,7 @@ declare const dispatchErrorSchema: z.ZodObject<{
275
275
  errorCode: z.ZodUnion<readonly [z.ZodLiteral<"UNAUTHORIZED">, z.ZodLiteral<"NO_BROWSER">, z.ZodLiteral<"TIMEOUT">, z.ZodLiteral<"INVALID_RESPONSE">, z.ZodLiteral<"NOT_FOUND">, z.ZodLiteral<"INTERNAL">]>;
276
276
  message: z.ZodString;
277
277
  }, z.core.$strip>;
278
- type BridgeRequest = z.infer<typeof bridgeRequestSchema>;
279
- type BridgeResponse = z.infer<typeof bridgeResponseSchema>;
278
+ type RelayRequest = z.infer<typeof relayRequestSchema>;
279
+ type RelayResponse = z.infer<typeof relayResponseSchema>;
280
280
 
281
- export { type BridgeErrorCode, type BridgeRequest, type BridgeResponse, type BrowserToolAction, type BrowserToolStatus, type ClickAction, type ExecutionReport, type FieldKind, type FillAction, type SelectAction, type SnapshotField, bridgeRequestSchema, bridgeResponseSchema, browserToolActionSchema, clickActionSchema, dispatchErrorSchema, dispatchSuccessSchema, errorResponseSchema, executeRequestSchema, executeResponseSchema, executionReportSchema, fieldKindSchema, fillActionSchema, selectActionSchema, snapshotFieldSchema, snapshotRequestSchema, snapshotResponseSchema };
281
+ export { type BrowserToolAction, type BrowserToolStatus, type ClickAction, type ExecutionReport, type FieldKind, type FillAction, type RelayErrorCode, type RelayRequest, type RelayResponse, type SelectAction, type SnapshotField, browserToolActionSchema, clickActionSchema, dispatchErrorSchema, dispatchSuccessSchema, errorResponseSchema, executeRequestSchema, executeResponseSchema, executionReportSchema, fieldKindSchema, fillActionSchema, relayRequestSchema, relayResponseSchema, selectActionSchema, snapshotFieldSchema, snapshotRequestSchema, snapshotResponseSchema };
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ var executeRequestSchema = z.object({
54
54
  actions: z.array(browserToolActionSchema),
55
55
  fields: z.array(snapshotFieldSchema)
56
56
  });
57
- var bridgeRequestSchema = z.discriminatedUnion("type", [
57
+ var relayRequestSchema = z.discriminatedUnion("type", [
58
58
  snapshotRequestSchema,
59
59
  executeRequestSchema
60
60
  ]);
@@ -73,14 +73,14 @@ var errorResponseSchema = z.object({
73
73
  requestId: z.string().min(1),
74
74
  message: z.string().min(1)
75
75
  });
76
- var bridgeResponseSchema = z.discriminatedUnion("type", [
76
+ var relayResponseSchema = z.discriminatedUnion("type", [
77
77
  snapshotResponseSchema,
78
78
  executeResponseSchema,
79
79
  errorResponseSchema
80
80
  ]);
81
81
  var dispatchSuccessSchema = z.object({
82
82
  ok: z.literal(true),
83
- response: bridgeResponseSchema
83
+ response: relayResponseSchema
84
84
  });
85
85
  var dispatchErrorSchema = z.object({
86
86
  ok: z.literal(false),
@@ -95,8 +95,6 @@ var dispatchErrorSchema = z.object({
95
95
  message: z.string()
96
96
  });
97
97
  export {
98
- bridgeRequestSchema,
99
- bridgeResponseSchema,
100
98
  browserToolActionSchema,
101
99
  clickActionSchema,
102
100
  dispatchErrorSchema,
@@ -107,6 +105,8 @@ export {
107
105
  executionReportSchema,
108
106
  fieldKindSchema,
109
107
  fillActionSchema,
108
+ relayRequestSchema,
109
+ relayResponseSchema,
110
110
  selectActionSchema,
111
111
  snapshotFieldSchema,
112
112
  snapshotRequestSchema,
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
 
5
- // src/mcp-server/bridge-client.ts
5
+ // src/mcp-server/relay-client.ts
6
6
  import { randomUUID } from "crypto";
7
7
 
8
8
  // src/types.ts
@@ -61,7 +61,7 @@ var executeRequestSchema = z.object({
61
61
  actions: z.array(browserToolActionSchema),
62
62
  fields: z.array(snapshotFieldSchema)
63
63
  });
64
- var bridgeRequestSchema = z.discriminatedUnion("type", [
64
+ var relayRequestSchema = z.discriminatedUnion("type", [
65
65
  snapshotRequestSchema,
66
66
  executeRequestSchema
67
67
  ]);
@@ -80,14 +80,14 @@ var errorResponseSchema = z.object({
80
80
  requestId: z.string().min(1),
81
81
  message: z.string().min(1)
82
82
  });
83
- var bridgeResponseSchema = z.discriminatedUnion("type", [
83
+ var relayResponseSchema = z.discriminatedUnion("type", [
84
84
  snapshotResponseSchema,
85
85
  executeResponseSchema,
86
86
  errorResponseSchema
87
87
  ]);
88
88
  var dispatchSuccessSchema = z.object({
89
89
  ok: z.literal(true),
90
- response: bridgeResponseSchema
90
+ response: relayResponseSchema
91
91
  });
92
92
  var dispatchErrorSchema = z.object({
93
93
  ok: z.literal(false),
@@ -102,7 +102,7 @@ var dispatchErrorSchema = z.object({
102
102
  message: z.string()
103
103
  });
104
104
 
105
- // src/mcp-server/bridge-client.ts
105
+ // src/mcp-server/relay-client.ts
106
106
  function requiredEnv(name) {
107
107
  const value = process.env[name]?.trim();
108
108
  if (!value) {
@@ -113,15 +113,15 @@ function requiredEnv(name) {
113
113
  function trimTrailingSlash(input) {
114
114
  return input.replace(/\/+$/, "");
115
115
  }
116
- var BridgeClient = class {
117
- baseUrl;
116
+ var RelayClient = class {
117
+ url;
118
118
  sessionId;
119
119
  token;
120
120
  timeoutMs;
121
121
  vercelProtectionBypass;
122
122
  giselleProtectionBypass;
123
123
  constructor(input) {
124
- this.baseUrl = trimTrailingSlash(input.baseUrl);
124
+ this.url = trimTrailingSlash(input.url);
125
125
  this.sessionId = input.sessionId;
126
126
  this.token = input.token;
127
127
  this.timeoutMs = input.timeoutMs ?? 2e4;
@@ -136,7 +136,7 @@ var BridgeClient = class {
136
136
  document: input.document
137
137
  });
138
138
  if (response.type !== "snapshot_response") {
139
- throw new Error(`Unexpected bridge response type: ${response.type}`);
139
+ throw new Error(`Unexpected relay response type: ${response.type}`);
140
140
  }
141
141
  return response.fields;
142
142
  }
@@ -148,12 +148,12 @@ var BridgeClient = class {
148
148
  fields: input.fields
149
149
  });
150
150
  if (response.type !== "execute_response") {
151
- throw new Error(`Unexpected bridge response type: ${response.type}`);
151
+ throw new Error(`Unexpected relay response type: ${response.type}`);
152
152
  }
153
153
  return response.report;
154
154
  }
155
155
  async dispatch(request) {
156
- const payload = bridgeRequestSchema.parse(request);
156
+ const payload = relayRequestSchema.parse(request);
157
157
  let response;
158
158
  try {
159
159
  const headers = {
@@ -165,11 +165,11 @@ var BridgeClient = class {
165
165
  if (this.giselleProtectionBypass) {
166
166
  headers["x-giselle-protection-bypass"] = this.giselleProtectionBypass;
167
167
  }
168
- response = await fetch(`${this.baseUrl}`, {
168
+ response = await fetch(`${this.url}`, {
169
169
  method: "POST",
170
170
  headers,
171
171
  body: JSON.stringify({
172
- type: "bridge.dispatch",
172
+ type: "relay.dispatch",
173
173
  sessionId: this.sessionId,
174
174
  token: this.token,
175
175
  timeoutMs: this.timeoutMs,
@@ -180,9 +180,9 @@ var BridgeClient = class {
180
180
  const message = error instanceof Error ? error.message : String(error);
181
181
  throw new Error(
182
182
  [
183
- "Bridge dispatch network request failed.",
184
- `baseUrl=${this.baseUrl}`,
185
- "Ensure BROWSER_TOOL_BRIDGE_BASE_URL is reachable from the sandbox runtime.",
183
+ "Relay dispatch network request failed.",
184
+ `url=${this.url}`,
185
+ "Ensure BROWSER_TOOL_RELAY_URL is reachable from the sandbox runtime.",
186
186
  `cause=${message}`
187
187
  ].join(" ")
188
188
  );
@@ -194,28 +194,28 @@ var BridgeClient = class {
194
194
  }
195
195
  const success = dispatchSuccessSchema.safeParse(body);
196
196
  if (!success.success) {
197
- throw new Error("Bridge dispatch returned an unexpected payload.");
197
+ throw new Error("Relay dispatch returned an unexpected payload.");
198
198
  }
199
199
  if (!response.ok) {
200
- throw new Error(`Bridge dispatch failed with HTTP ${response.status}.`);
200
+ throw new Error(`Relay dispatch failed with HTTP ${response.status}.`);
201
201
  }
202
- const parsedResponse = bridgeResponseSchema.parse(success.data.response);
202
+ const parsedResponse = relayResponseSchema.parse(success.data.response);
203
203
  return parsedResponse;
204
204
  }
205
205
  };
206
- function createBridgeClientFromEnv() {
206
+ function createRelayClientFromEnv() {
207
207
  const vercelProtectionBypass = process.env.VERCEL_PROTECTION_BYPASS;
208
208
  const giselleProtectionBypass = process.env.GISELLE_PROTECTION_BYPASS;
209
209
  console.error(
210
- `[bridge-client] VERCEL_PROTECTION_BYPASS=${vercelProtectionBypass?.trim() ? "(set)" : "(unset)"}`
210
+ `[relay-client] VERCEL_PROTECTION_BYPASS=${vercelProtectionBypass?.trim() ? "(set)" : "(unset)"}`
211
211
  );
212
212
  console.error(
213
- `[bridge-client] GISELLE_PROTECTION_BYPASS=${giselleProtectionBypass?.trim() ? "(set)" : "(unset)"}`
213
+ `[relay-client] GISELLE_PROTECTION_BYPASS=${giselleProtectionBypass?.trim() ? "(set)" : "(unset)"}`
214
214
  );
215
- return new BridgeClient({
216
- baseUrl: requiredEnv("BROWSER_TOOL_BRIDGE_BASE_URL"),
217
- sessionId: requiredEnv("BROWSER_TOOL_BRIDGE_SESSION_ID"),
218
- token: requiredEnv("BROWSER_TOOL_BRIDGE_TOKEN"),
215
+ return new RelayClient({
216
+ url: requiredEnv("BROWSER_TOOL_RELAY_URL"),
217
+ sessionId: requiredEnv("BROWSER_TOOL_RELAY_SESSION_ID"),
218
+ token: requiredEnv("BROWSER_TOOL_RELAY_TOKEN"),
219
219
  vercelProtectionBypass,
220
220
  giselleProtectionBypass
221
221
  });
@@ -228,17 +228,17 @@ var executeFormActionsInputShape = {
228
228
  fields: z2.array(snapshotFieldSchema)
229
229
  };
230
230
  var executeFormActionsInputSchema = z2.object(executeFormActionsInputShape);
231
- async function runExecuteFormActions(input, bridgeClient) {
231
+ async function runExecuteFormActions(input, relayClient) {
232
232
  const parsed = executeFormActionsInputSchema.parse(input);
233
- return await bridgeClient.requestExecute({
233
+ return await relayClient.requestExecute({
234
234
  actions: parsed.actions,
235
235
  fields: parsed.fields
236
236
  });
237
237
  }
238
238
 
239
239
  // src/mcp-server/tools/get-form-snapshot.ts
240
- async function runGetFormSnapshot(bridgeClient) {
241
- const fields = await bridgeClient.requestSnapshot({
240
+ async function runGetFormSnapshot(relayClient) {
241
+ const fields = await relayClient.requestSnapshot({
242
242
  instruction: "snapshot"
243
243
  });
244
244
  return { fields };
@@ -274,8 +274,8 @@ var server = new McpServer(
274
274
  );
275
275
  server.registerTool("getFormSnapshot", {}, async () => {
276
276
  try {
277
- const bridgeClient = createBridgeClientFromEnv();
278
- const output = await runGetFormSnapshot(bridgeClient);
277
+ const relayClient = createRelayClientFromEnv();
278
+ const output = await runGetFormSnapshot(relayClient);
279
279
  return {
280
280
  content: [
281
281
  {
@@ -308,8 +308,8 @@ server.registerTool(
308
308
  { inputSchema: executeFormActionsInputShape },
309
309
  async (input) => {
310
310
  try {
311
- const bridgeClient = createBridgeClientFromEnv();
312
- const output = await runExecuteFormActions(input, bridgeClient);
311
+ const relayClient = createRelayClientFromEnv();
312
+ const output = await runExecuteFormActions(input, relayClient);
313
313
  return {
314
314
  content: [
315
315
  {
@@ -0,0 +1,25 @@
1
+ import Redis from 'ioredis';
2
+
3
+ declare function createRelayHandler(): {
4
+ GET: (request: Request) => Promise<Response>;
5
+ POST: (request: Request) => Promise<Response>;
6
+ };
7
+
8
+ type RelayErrorCode = "UNAUTHORIZED" | "NO_BROWSER" | "TIMEOUT" | "INVALID_RESPONSE" | "NOT_FOUND" | "INTERNAL";
9
+
10
+ declare global {
11
+ var __browserToolRelayRedis: Redis | undefined;
12
+ }
13
+ declare class RelayStoreError extends Error {
14
+ readonly code: RelayErrorCode;
15
+ readonly status: number;
16
+ constructor(code: RelayErrorCode, message: string, status: number);
17
+ }
18
+ declare function createRelaySession(): Promise<{
19
+ sessionId: string;
20
+ token: string;
21
+ expiresAt: number;
22
+ }>;
23
+ declare function toRelayError(error: unknown): RelayStoreError;
24
+
25
+ export { createRelayHandler, createRelaySession, toRelayError };
@@ -0,0 +1,758 @@
1
+ // src/relay/relay-handler.ts
2
+ import { z as z2 } from "zod";
3
+
4
+ // src/types.ts
5
+ import { z } from "zod";
6
+ var fieldKindSchema = z.enum([
7
+ "text",
8
+ "textarea",
9
+ "select",
10
+ "checkbox",
11
+ "radio"
12
+ ]);
13
+ var snapshotFieldSchema = z.object({
14
+ fieldId: z.string().min(1),
15
+ selector: z.string().min(1),
16
+ kind: fieldKindSchema,
17
+ label: z.string().min(1),
18
+ name: z.string().optional(),
19
+ required: z.boolean(),
20
+ placeholder: z.string().optional(),
21
+ currentValue: z.union([z.string(), z.boolean()]),
22
+ options: z.array(z.string()).optional()
23
+ });
24
+ var fillActionSchema = z.object({
25
+ action: z.literal("fill"),
26
+ fieldId: z.string().min(1),
27
+ value: z.string()
28
+ });
29
+ var clickActionSchema = z.object({
30
+ action: z.literal("click"),
31
+ fieldId: z.string().min(1)
32
+ });
33
+ var selectActionSchema = z.object({
34
+ action: z.literal("select"),
35
+ fieldId: z.string().min(1),
36
+ value: z.string()
37
+ });
38
+ var browserToolActionSchema = z.discriminatedUnion("action", [
39
+ fillActionSchema,
40
+ clickActionSchema,
41
+ selectActionSchema
42
+ ]);
43
+ var executionReportSchema = z.object({
44
+ applied: z.number().int().nonnegative(),
45
+ skipped: z.number().int().nonnegative(),
46
+ warnings: z.array(z.string())
47
+ });
48
+ var snapshotRequestSchema = z.object({
49
+ type: z.literal("snapshot_request"),
50
+ requestId: z.string().min(1),
51
+ instruction: z.string().min(1),
52
+ document: z.string().optional()
53
+ });
54
+ var executeRequestSchema = z.object({
55
+ type: z.literal("execute_request"),
56
+ requestId: z.string().min(1),
57
+ actions: z.array(browserToolActionSchema),
58
+ fields: z.array(snapshotFieldSchema)
59
+ });
60
+ var relayRequestSchema = z.discriminatedUnion("type", [
61
+ snapshotRequestSchema,
62
+ executeRequestSchema
63
+ ]);
64
+ var snapshotResponseSchema = z.object({
65
+ type: z.literal("snapshot_response"),
66
+ requestId: z.string().min(1),
67
+ fields: z.array(snapshotFieldSchema)
68
+ });
69
+ var executeResponseSchema = z.object({
70
+ type: z.literal("execute_response"),
71
+ requestId: z.string().min(1),
72
+ report: executionReportSchema
73
+ });
74
+ var errorResponseSchema = z.object({
75
+ type: z.literal("error_response"),
76
+ requestId: z.string().min(1),
77
+ message: z.string().min(1)
78
+ });
79
+ var relayResponseSchema = z.discriminatedUnion("type", [
80
+ snapshotResponseSchema,
81
+ executeResponseSchema,
82
+ errorResponseSchema
83
+ ]);
84
+ var dispatchSuccessSchema = z.object({
85
+ ok: z.literal(true),
86
+ response: relayResponseSchema
87
+ });
88
+ var dispatchErrorSchema = z.object({
89
+ ok: z.literal(false),
90
+ errorCode: z.union([
91
+ z.literal("UNAUTHORIZED"),
92
+ z.literal("NO_BROWSER"),
93
+ z.literal("TIMEOUT"),
94
+ z.literal("INVALID_RESPONSE"),
95
+ z.literal("NOT_FOUND"),
96
+ z.literal("INTERNAL")
97
+ ]),
98
+ message: z.string()
99
+ });
100
+
101
+ // src/relay/relay-store.ts
102
+ import { randomUUID } from "crypto";
103
+ import Redis from "ioredis";
104
+ var DEFAULT_SESSION_TTL_MS = 10 * 60 * 1e3;
105
+ var DEFAULT_REQUEST_TTL_SEC = 60;
106
+ var DEFAULT_DISPATCH_TIMEOUT_MS = 20 * 1e3;
107
+ var BROWSER_PRESENCE_TTL_SEC = 90;
108
+ var RELAY_SSE_KEEPALIVE_INTERVAL_MS = 20 * 1e3;
109
+ var REDIS_URL_ENV_CANDIDATES = [
110
+ "REDIS_URL",
111
+ "REDIS_TLS_URL",
112
+ "KV_URL",
113
+ "UPSTASH_REDIS_TLS_URL",
114
+ "UPSTASH_REDIS_URL"
115
+ ];
116
+ var RELAY_SUBSCRIBER_REDIS_OPTIONS = {
117
+ enableReadyCheck: false,
118
+ autoResubscribe: false,
119
+ autoResendUnfulfilledCommands: false,
120
+ maxRetriesPerRequest: 2
121
+ };
122
+ var RelayStoreError = class extends Error {
123
+ code;
124
+ status;
125
+ constructor(code, message, status) {
126
+ super(message);
127
+ this.code = code;
128
+ this.status = status;
129
+ }
130
+ };
131
+ function createRelayError(code, message) {
132
+ switch (code) {
133
+ case "UNAUTHORIZED":
134
+ return new RelayStoreError(code, message, 401);
135
+ case "NO_BROWSER":
136
+ return new RelayStoreError(code, message, 409);
137
+ case "TIMEOUT":
138
+ return new RelayStoreError(code, message, 408);
139
+ case "INVALID_RESPONSE":
140
+ return new RelayStoreError(code, message, 422);
141
+ case "NOT_FOUND":
142
+ return new RelayStoreError(code, message, 404);
143
+ case "INTERNAL":
144
+ return new RelayStoreError(code, message, 500);
145
+ default:
146
+ return new RelayStoreError("INTERNAL", message, 500);
147
+ }
148
+ }
149
+ function resolveRedisUrl() {
150
+ for (const name of REDIS_URL_ENV_CANDIDATES) {
151
+ const value = process.env[name]?.trim();
152
+ if (value) {
153
+ return value;
154
+ }
155
+ }
156
+ throw createRelayError(
157
+ "INTERNAL",
158
+ `Missing Redis URL. Set one of: ${REDIS_URL_ENV_CANDIDATES.join(", ")}`
159
+ );
160
+ }
161
+ function getRedisClient() {
162
+ if (!globalThis.__browserToolRelayRedis) {
163
+ globalThis.__browserToolRelayRedis = new Redis(resolveRedisUrl(), {
164
+ maxRetriesPerRequest: 2
165
+ });
166
+ }
167
+ return globalThis.__browserToolRelayRedis;
168
+ }
169
+ function createRelaySubscriber() {
170
+ return getRedisClient().duplicate(RELAY_SUBSCRIBER_REDIS_OPTIONS);
171
+ }
172
+ function sessionKey(sessionId) {
173
+ return `relay:session:${sessionId}`;
174
+ }
175
+ function browserPresenceKey(sessionId) {
176
+ return `relay:browser:${sessionId}`;
177
+ }
178
+ function requestTypeKey(sessionId, requestId) {
179
+ return `relay:req:${sessionId}:${requestId}:type`;
180
+ }
181
+ function responseKey(sessionId, requestId) {
182
+ return `relay:resp:${sessionId}:${requestId}`;
183
+ }
184
+ function relayRequestChannel(sessionId) {
185
+ return `relay:${sessionId}:request`;
186
+ }
187
+ function relayResponseChannel(sessionId, requestId) {
188
+ return `relay:${sessionId}:response:${requestId}`;
189
+ }
190
+ function sessionExpiryTimestamp() {
191
+ return Date.now() + DEFAULT_SESSION_TTL_MS;
192
+ }
193
+ function parseSessionRecord(raw) {
194
+ let parsed = null;
195
+ try {
196
+ parsed = JSON.parse(raw);
197
+ } catch {
198
+ throw createRelayError(
199
+ "INTERNAL",
200
+ "Relay session payload in Redis is malformed."
201
+ );
202
+ }
203
+ if (!parsed || typeof parsed !== "object" || !("token" in parsed) || !("expiresAt" in parsed) || typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") {
204
+ throw createRelayError(
205
+ "INTERNAL",
206
+ "Relay session payload in Redis is invalid."
207
+ );
208
+ }
209
+ return {
210
+ token: parsed.token,
211
+ expiresAt: parsed.expiresAt
212
+ };
213
+ }
214
+ function toRequestType(value) {
215
+ if (value === "snapshot_request" || value === "execute_request") {
216
+ return value;
217
+ }
218
+ throw createRelayError(
219
+ "INTERNAL",
220
+ `Stored request type is invalid: ${value}`
221
+ );
222
+ }
223
+ function parseStoredRelayResponse(raw) {
224
+ let decoded = null;
225
+ try {
226
+ decoded = JSON.parse(raw);
227
+ } catch {
228
+ throw createRelayError(
229
+ "INVALID_RESPONSE",
230
+ "Relay response payload in Redis is malformed."
231
+ );
232
+ }
233
+ const parsed = relayResponseSchema.safeParse(decoded);
234
+ if (!parsed.success) {
235
+ throw createRelayError(
236
+ "INVALID_RESPONSE",
237
+ "Relay response payload in Redis is invalid."
238
+ );
239
+ }
240
+ return parsed.data;
241
+ }
242
+ async function touchSession(sessionId, token) {
243
+ const expiresAt = sessionExpiryTimestamp();
244
+ const redis = getRedisClient();
245
+ await redis.set(
246
+ sessionKey(sessionId),
247
+ JSON.stringify({ token, expiresAt }),
248
+ "EX",
249
+ Math.ceil(DEFAULT_SESSION_TTL_MS / 1e3)
250
+ );
251
+ return expiresAt;
252
+ }
253
+ async function getAuthorizedSession(sessionId, token) {
254
+ const redis = getRedisClient();
255
+ const raw = await redis.get(sessionKey(sessionId));
256
+ if (!raw) {
257
+ throw createRelayError(
258
+ "UNAUTHORIZED",
259
+ "Invalid relay session credentials."
260
+ );
261
+ }
262
+ const session = parseSessionRecord(raw);
263
+ if (session.token !== token) {
264
+ throw createRelayError(
265
+ "UNAUTHORIZED",
266
+ "Invalid relay session credentials."
267
+ );
268
+ }
269
+ const expiresAt = await touchSession(sessionId, token);
270
+ return {
271
+ token,
272
+ expiresAt
273
+ };
274
+ }
275
+ function validateResponseType(requestType, responseType) {
276
+ if (requestType === "snapshot_request" && responseType !== "snapshot_response") {
277
+ throw createRelayError(
278
+ "INVALID_RESPONSE",
279
+ `Expected snapshot_response, but received ${responseType}.`
280
+ );
281
+ }
282
+ if (requestType === "execute_request" && responseType !== "execute_response") {
283
+ throw createRelayError(
284
+ "INVALID_RESPONSE",
285
+ `Expected execute_response, but received ${responseType}.`
286
+ );
287
+ }
288
+ }
289
+ async function ensureBrowserConnected(sessionId) {
290
+ const redis = getRedisClient();
291
+ const isConnected = await redis.exists(browserPresenceKey(sessionId));
292
+ if (isConnected === 0) {
293
+ throw createRelayError(
294
+ "NO_BROWSER",
295
+ "No browser client is connected to this relay session."
296
+ );
297
+ }
298
+ }
299
+ async function waitForRelayResponseSignal(input) {
300
+ await new Promise((resolve, reject) => {
301
+ let settled = false;
302
+ const onMessage = (channel) => {
303
+ if (settled || channel !== input.channel) {
304
+ return;
305
+ }
306
+ settled = true;
307
+ cleanup();
308
+ resolve();
309
+ };
310
+ const onError = (error) => {
311
+ if (settled) {
312
+ return;
313
+ }
314
+ settled = true;
315
+ cleanup();
316
+ const message = error instanceof Error ? error.message : "Unknown Redis subscriber error.";
317
+ reject(
318
+ createRelayError(
319
+ "INTERNAL",
320
+ `Redis subscriber failed while waiting for response. ${message}`
321
+ )
322
+ );
323
+ };
324
+ const onTimeout = () => {
325
+ if (settled) {
326
+ return;
327
+ }
328
+ settled = true;
329
+ cleanup();
330
+ reject(
331
+ createRelayError(
332
+ "TIMEOUT",
333
+ "Timed out waiting for browser relay response."
334
+ )
335
+ );
336
+ };
337
+ const timeoutId = setTimeout(onTimeout, input.timeoutMs);
338
+ const cleanup = () => {
339
+ clearTimeout(timeoutId);
340
+ input.subscriber.off("message", onMessage);
341
+ input.subscriber.off("error", onError);
342
+ };
343
+ input.subscriber.on("message", onMessage);
344
+ input.subscriber.on("error", onError);
345
+ void input.trigger().catch((error) => {
346
+ if (settled) {
347
+ return;
348
+ }
349
+ settled = true;
350
+ cleanup();
351
+ if (error instanceof RelayStoreError) {
352
+ reject(error);
353
+ return;
354
+ }
355
+ const message = error instanceof Error ? error.message : "Unknown Redis publish error.";
356
+ reject(
357
+ createRelayError(
358
+ "INTERNAL",
359
+ `Failed to publish relay request. ${message}`
360
+ )
361
+ );
362
+ });
363
+ });
364
+ }
365
+ async function storeRelayResponse(input) {
366
+ const redis = getRedisClient();
367
+ await redis.multi().set(
368
+ responseKey(input.sessionId, input.requestId),
369
+ JSON.stringify(input.response),
370
+ "EX",
371
+ DEFAULT_REQUEST_TTL_SEC
372
+ ).del(requestTypeKey(input.sessionId, input.requestId)).publish(
373
+ relayResponseChannel(input.sessionId, input.requestId),
374
+ input.requestId
375
+ ).exec();
376
+ }
377
+ async function createRelaySession() {
378
+ const sessionId = randomUUID();
379
+ const token = `${randomUUID()}-${randomUUID()}`;
380
+ const expiresAt = sessionExpiryTimestamp();
381
+ const redis = getRedisClient();
382
+ await redis.set(
383
+ sessionKey(sessionId),
384
+ JSON.stringify({
385
+ token,
386
+ expiresAt
387
+ }),
388
+ "EX",
389
+ Math.ceil(DEFAULT_SESSION_TTL_MS / 1e3)
390
+ );
391
+ return {
392
+ sessionId,
393
+ token,
394
+ expiresAt
395
+ };
396
+ }
397
+ async function assertRelaySession(sessionId, token) {
398
+ await getAuthorizedSession(sessionId, token);
399
+ }
400
+ async function markBrowserConnected(sessionId, token) {
401
+ await getAuthorizedSession(sessionId, token);
402
+ const redis = getRedisClient();
403
+ await redis.set(
404
+ browserPresenceKey(sessionId),
405
+ "1",
406
+ "EX",
407
+ BROWSER_PRESENCE_TTL_SEC
408
+ );
409
+ }
410
+ async function touchBrowserConnected(sessionId) {
411
+ const redis = getRedisClient();
412
+ await redis.set(
413
+ browserPresenceKey(sessionId),
414
+ "1",
415
+ "EX",
416
+ BROWSER_PRESENCE_TTL_SEC
417
+ );
418
+ }
419
+ async function dispatchRelayRequest(input) {
420
+ await getAuthorizedSession(input.sessionId, input.token);
421
+ await ensureBrowserConnected(input.sessionId);
422
+ const redis = getRedisClient();
423
+ const timeoutMs = input.timeoutMs ?? DEFAULT_DISPATCH_TIMEOUT_MS;
424
+ const requestId = input.request.requestId;
425
+ const requestTypeStateKey = requestTypeKey(input.sessionId, requestId);
426
+ const storedResponseKey = responseKey(input.sessionId, requestId);
427
+ const responseEventChannel = relayResponseChannel(input.sessionId, requestId);
428
+ const subscriber = createRelaySubscriber();
429
+ const setPending = await redis.set(
430
+ requestTypeStateKey,
431
+ input.request.type,
432
+ "EX",
433
+ DEFAULT_REQUEST_TTL_SEC,
434
+ "NX"
435
+ );
436
+ if (setPending !== "OK") {
437
+ throw createRelayError(
438
+ "INVALID_RESPONSE",
439
+ `Request id is already pending: ${requestId}`
440
+ );
441
+ }
442
+ try {
443
+ await redis.del(storedResponseKey);
444
+ await subscriber.subscribe(responseEventChannel);
445
+ await waitForRelayResponseSignal({
446
+ subscriber,
447
+ channel: responseEventChannel,
448
+ timeoutMs,
449
+ trigger: async () => {
450
+ await redis.publish(
451
+ relayRequestChannel(input.sessionId),
452
+ JSON.stringify(input.request)
453
+ );
454
+ }
455
+ });
456
+ const storedResponse = await redis.get(storedResponseKey);
457
+ if (!storedResponse) {
458
+ throw createRelayError(
459
+ "TIMEOUT",
460
+ "Relay response notification was received without payload."
461
+ );
462
+ }
463
+ const parsedResponse = parseStoredRelayResponse(storedResponse);
464
+ if (parsedResponse.type === "error_response") {
465
+ throw createRelayError("INVALID_RESPONSE", parsedResponse.message);
466
+ }
467
+ validateResponseType(input.request.type, parsedResponse.type);
468
+ return parsedResponse;
469
+ } finally {
470
+ await Promise.allSettled([
471
+ redis.del(requestTypeStateKey),
472
+ redis.del(storedResponseKey)
473
+ ]);
474
+ await subscriber.unsubscribe(responseEventChannel).catch(() => void 0);
475
+ await subscriber.quit().catch(() => {
476
+ subscriber.disconnect();
477
+ });
478
+ }
479
+ }
480
+ async function resolveRelayResponse(input) {
481
+ await getAuthorizedSession(input.sessionId, input.token);
482
+ const redis = getRedisClient();
483
+ const requestId = input.response.requestId;
484
+ const requestTypeStateKey = requestTypeKey(input.sessionId, requestId);
485
+ const expectedRequestTypeRaw = await redis.get(requestTypeStateKey);
486
+ if (!expectedRequestTypeRaw) {
487
+ throw createRelayError(
488
+ "NOT_FOUND",
489
+ `Pending request was not found: ${requestId}`
490
+ );
491
+ }
492
+ const expectedRequestType = toRequestType(expectedRequestTypeRaw);
493
+ if (input.response.type === "error_response") {
494
+ await storeRelayResponse({
495
+ sessionId: input.sessionId,
496
+ requestId,
497
+ response: input.response
498
+ });
499
+ return;
500
+ }
501
+ try {
502
+ validateResponseType(expectedRequestType, input.response.type);
503
+ } catch (error) {
504
+ const relayError = error instanceof RelayStoreError ? error : createRelayError(
505
+ "INVALID_RESPONSE",
506
+ "Unexpected relay response type."
507
+ );
508
+ await storeRelayResponse({
509
+ sessionId: input.sessionId,
510
+ requestId,
511
+ response: {
512
+ type: "error_response",
513
+ requestId,
514
+ message: relayError.message
515
+ }
516
+ });
517
+ throw relayError;
518
+ }
519
+ await storeRelayResponse({
520
+ sessionId: input.sessionId,
521
+ requestId,
522
+ response: input.response
523
+ });
524
+ }
525
+ function toRelayError(error) {
526
+ if (error instanceof RelayStoreError) {
527
+ return error;
528
+ }
529
+ const message = error instanceof Error ? error.message : "Unexpected relay failure.";
530
+ return createRelayError("INTERNAL", message);
531
+ }
532
+
533
+ // src/relay/relay-handler.ts
534
+ var LOG_PREFIX = "[relay-handler]";
535
+ var dispatchSchema = z2.object({
536
+ type: z2.literal("relay.dispatch"),
537
+ sessionId: z2.string().min(1),
538
+ token: z2.string().min(1),
539
+ request: relayRequestSchema,
540
+ timeoutMs: z2.number().int().positive().max(55e3).optional()
541
+ });
542
+ var respondSchema = z2.object({
543
+ type: z2.literal("relay.respond"),
544
+ sessionId: z2.string().min(1),
545
+ token: z2.string().min(1),
546
+ response: relayResponseSchema
547
+ });
548
+ var postBodySchema = z2.discriminatedUnion("type", [
549
+ dispatchSchema,
550
+ respondSchema
551
+ ]);
552
+ function createSafeError(code, message, status) {
553
+ return Response.json({ ok: false, errorCode: code, message }, { status });
554
+ }
555
+ function createRelayEventsRoute(request) {
556
+ const url = new URL(request.url);
557
+ const sessionId = url.searchParams.get("sessionId") ?? "";
558
+ const token = url.searchParams.get("token") ?? "";
559
+ if (!sessionId || !token) {
560
+ return Promise.resolve(
561
+ Response.json(
562
+ {
563
+ errorCode: "UNAUTHORIZED",
564
+ message: "sessionId and token are required."
565
+ },
566
+ { status: 401 }
567
+ )
568
+ );
569
+ }
570
+ let cleanup = null;
571
+ const encoder = new TextEncoder();
572
+ return assertRelaySession(sessionId, token).then(() => {
573
+ console.info(`${LOG_PREFIX} sse.connect`, { sessionId });
574
+ const requestChannel = relayRequestChannel(sessionId);
575
+ const stream = new ReadableStream({
576
+ start(controller) {
577
+ const subscriber = createRelaySubscriber();
578
+ let keepaliveId = null;
579
+ let closed = false;
580
+ let nextEventId = 0;
581
+ const sendSseData = (payload) => {
582
+ nextEventId += 1;
583
+ controller.enqueue(
584
+ encoder.encode(
585
+ `id: ${nextEventId}
586
+ data: ${JSON.stringify(payload)}
587
+
588
+ `
589
+ )
590
+ );
591
+ };
592
+ const sendSseRawJson = (rawJson) => {
593
+ nextEventId += 1;
594
+ controller.enqueue(
595
+ encoder.encode(`id: ${nextEventId}
596
+ data: ${rawJson}
597
+
598
+ `)
599
+ );
600
+ };
601
+ const sendSseComment = (comment) => {
602
+ controller.enqueue(encoder.encode(`: ${comment}
603
+
604
+ `));
605
+ };
606
+ const closeController = () => {
607
+ try {
608
+ controller.close();
609
+ } catch {
610
+ }
611
+ };
612
+ const onAbort = () => {
613
+ void cleanup?.();
614
+ closeController();
615
+ };
616
+ cleanup = async () => {
617
+ if (closed) {
618
+ return;
619
+ }
620
+ closed = true;
621
+ request.signal.removeEventListener("abort", onAbort);
622
+ if (keepaliveId) {
623
+ clearInterval(keepaliveId);
624
+ }
625
+ await subscriber.unsubscribe(requestChannel).catch(() => void 0);
626
+ await subscriber.quit().catch(() => {
627
+ subscriber.disconnect();
628
+ });
629
+ };
630
+ request.signal.addEventListener("abort", onAbort);
631
+ sendSseComment("connected");
632
+ subscriber.on("message", (channel, message) => {
633
+ if (closed || channel !== requestChannel) {
634
+ return;
635
+ }
636
+ try {
637
+ JSON.parse(message);
638
+ } catch {
639
+ return;
640
+ }
641
+ try {
642
+ console.info(`${LOG_PREFIX} sse.push`, { sessionId, message });
643
+ sendSseRawJson(message);
644
+ } catch {
645
+ void cleanup?.();
646
+ closeController();
647
+ }
648
+ });
649
+ subscriber.on("error", (error) => {
650
+ if (closed) {
651
+ return;
652
+ }
653
+ controller.error(error);
654
+ void cleanup?.();
655
+ });
656
+ void (async () => {
657
+ try {
658
+ await subscriber.subscribe(requestChannel);
659
+ await markBrowserConnected(sessionId, token);
660
+ console.info(`${LOG_PREFIX} sse.ready`, { sessionId });
661
+ sendSseData({ type: "ready", sessionId });
662
+ keepaliveId = setInterval(() => {
663
+ void touchBrowserConnected(sessionId).catch(() => void 0);
664
+ try {
665
+ sendSseComment("keepalive");
666
+ } catch {
667
+ void cleanup?.();
668
+ closeController();
669
+ }
670
+ }, RELAY_SSE_KEEPALIVE_INTERVAL_MS);
671
+ } catch (error) {
672
+ if (closed) {
673
+ return;
674
+ }
675
+ controller.error(error);
676
+ await cleanup?.();
677
+ }
678
+ })();
679
+ },
680
+ cancel() {
681
+ void cleanup?.();
682
+ }
683
+ });
684
+ return new Response(stream, {
685
+ headers: {
686
+ "Content-Type": "text/event-stream; charset=utf-8",
687
+ "Cache-Control": "no-cache, no-transform",
688
+ Connection: "keep-alive",
689
+ "X-Accel-Buffering": "no",
690
+ "Content-Encoding": "none"
691
+ }
692
+ });
693
+ }).catch((error) => {
694
+ console.error(`${LOG_PREFIX} sse.error`, {
695
+ sessionId,
696
+ error: error instanceof Error ? error.message : String(error)
697
+ });
698
+ const relayError = toRelayError(error);
699
+ return Response.json(
700
+ {
701
+ errorCode: relayError.code,
702
+ message: relayError.message
703
+ },
704
+ { status: relayError.status }
705
+ );
706
+ });
707
+ }
708
+ async function createRelayPostRoute(request) {
709
+ const payload = await request.json().catch(() => null);
710
+ const parsed = postBodySchema.safeParse(payload);
711
+ if (!parsed.success) {
712
+ return createSafeError("INVALID_RESPONSE", "Invalid request payload.", 400);
713
+ }
714
+ if (parsed.data.type === "relay.dispatch") {
715
+ try {
716
+ const response = await dispatchRelayRequest({
717
+ sessionId: parsed.data.sessionId,
718
+ token: parsed.data.token,
719
+ request: parsed.data.request,
720
+ timeoutMs: parsed.data.timeoutMs
721
+ });
722
+ return Response.json({ ok: true, response });
723
+ } catch (error) {
724
+ const relayError = toRelayError(error);
725
+ return createSafeError(
726
+ relayError.code,
727
+ relayError.message,
728
+ relayError.status
729
+ );
730
+ }
731
+ }
732
+ try {
733
+ await resolveRelayResponse({
734
+ sessionId: parsed.data.sessionId,
735
+ token: parsed.data.token,
736
+ response: parsed.data.response
737
+ });
738
+ return Response.json({ ok: true });
739
+ } catch (error) {
740
+ const relayError = toRelayError(error);
741
+ return createSafeError(
742
+ relayError.code,
743
+ relayError.message,
744
+ relayError.status
745
+ );
746
+ }
747
+ }
748
+ function createRelayHandler() {
749
+ return {
750
+ GET: async (request) => createRelayEventsRoute(request),
751
+ POST: createRelayPostRoute
752
+ };
753
+ }
754
+ export {
755
+ createRelayHandler,
756
+ createRelaySession,
757
+ toRelayError
758
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giselles-ai/browser-tool",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "Apache-2.0",
@@ -27,6 +27,11 @@
27
27
  "types": "./dist/mcp-server/index.d.ts",
28
28
  "import": "./dist/mcp-server/index.js",
29
29
  "default": "./dist/mcp-server/index.js"
30
+ },
31
+ "./relay": {
32
+ "types": "./dist/relay/index.d.ts",
33
+ "import": "./dist/relay/index.js",
34
+ "default": "./dist/relay/index.js"
30
35
  }
31
36
  },
32
37
  "scripts": {
@@ -37,6 +42,7 @@
37
42
  },
38
43
  "dependencies": {
39
44
  "@modelcontextprotocol/sdk": "^1.0.0",
45
+ "ioredis": "^5.9.2",
40
46
  "zod": "4.3.6"
41
47
  },
42
48
  "devDependencies": {