@giselles-ai/browser-tool 0.1.4 → 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/relay/index.d.ts +25 -0
- package/dist/relay/index.js +758 -0
- package/package.json +7 -1
|
@@ -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
|
+
"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": {
|