@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 +6 -6
- package/dist/index.js +5 -5
- package/dist/mcp-server/index.js +34 -34
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +758 -0
- package/package.json +7 -1
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
|
|
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
|
|
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
|
|
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
|
|
279
|
-
type
|
|
278
|
+
type RelayRequest = z.infer<typeof relayRequestSchema>;
|
|
279
|
+
type RelayResponse = z.infer<typeof relayResponseSchema>;
|
|
280
280
|
|
|
281
|
-
export { type
|
|
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
|
|
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
|
|
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:
|
|
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,
|
package/dist/mcp-server/index.js
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
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:
|
|
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/
|
|
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
|
|
117
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
168
|
+
response = await fetch(`${this.url}`, {
|
|
169
169
|
method: "POST",
|
|
170
170
|
headers,
|
|
171
171
|
body: JSON.stringify({
|
|
172
|
-
type: "
|
|
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
|
-
"
|
|
184
|
-
`
|
|
185
|
-
"Ensure
|
|
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("
|
|
197
|
+
throw new Error("Relay dispatch returned an unexpected payload.");
|
|
198
198
|
}
|
|
199
199
|
if (!response.ok) {
|
|
200
|
-
throw new Error(`
|
|
200
|
+
throw new Error(`Relay dispatch failed with HTTP ${response.status}.`);
|
|
201
201
|
}
|
|
202
|
-
const parsedResponse =
|
|
202
|
+
const parsedResponse = relayResponseSchema.parse(success.data.response);
|
|
203
203
|
return parsedResponse;
|
|
204
204
|
}
|
|
205
205
|
};
|
|
206
|
-
function
|
|
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
|
-
`[
|
|
210
|
+
`[relay-client] VERCEL_PROTECTION_BYPASS=${vercelProtectionBypass?.trim() ? "(set)" : "(unset)"}`
|
|
211
211
|
);
|
|
212
212
|
console.error(
|
|
213
|
-
`[
|
|
213
|
+
`[relay-client] GISELLE_PROTECTION_BYPASS=${giselleProtectionBypass?.trim() ? "(set)" : "(unset)"}`
|
|
214
214
|
);
|
|
215
|
-
return new
|
|
216
|
-
|
|
217
|
-
sessionId: requiredEnv("
|
|
218
|
-
token: requiredEnv("
|
|
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,
|
|
231
|
+
async function runExecuteFormActions(input, relayClient) {
|
|
232
232
|
const parsed = executeFormActionsInputSchema.parse(input);
|
|
233
|
-
return await
|
|
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(
|
|
241
|
-
const fields = await
|
|
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
|
|
278
|
-
const output = await runGetFormSnapshot(
|
|
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
|
|
312
|
-
const output = await runExecuteFormActions(input,
|
|
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
|
+
"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": {
|