agent-browser-loop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/agent-browser-loop/REFERENCE.md +374 -0
- package/.claude/skills/agent-browser-loop/SKILL.md +211 -0
- package/LICENSE +9 -0
- package/README.md +168 -0
- package/package.json +73 -0
- package/src/actions.ts +267 -0
- package/src/browser.ts +564 -0
- package/src/chrome.ts +45 -0
- package/src/cli.ts +795 -0
- package/src/commands.ts +455 -0
- package/src/config.ts +59 -0
- package/src/context.ts +20 -0
- package/src/daemon-entry.ts +4 -0
- package/src/daemon.ts +626 -0
- package/src/id.ts +109 -0
- package/src/index.ts +58 -0
- package/src/log.ts +42 -0
- package/src/server.ts +927 -0
- package/src/state.ts +602 -0
- package/src/types.ts +229 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import { HTTPException } from "hono/http-exception";
|
|
5
|
+
import type { AgentBrowserOptions } from "./browser";
|
|
6
|
+
import { createBrowser } from "./browser";
|
|
7
|
+
import {
|
|
8
|
+
type Command,
|
|
9
|
+
commandSchema,
|
|
10
|
+
executeActions,
|
|
11
|
+
executeCommand,
|
|
12
|
+
formatStepText,
|
|
13
|
+
formatWaitText,
|
|
14
|
+
getStateOptionsSchema,
|
|
15
|
+
type StepAction,
|
|
16
|
+
stepActionSchema,
|
|
17
|
+
type WaitCondition,
|
|
18
|
+
waitConditionSchema,
|
|
19
|
+
} from "./commands";
|
|
20
|
+
import { createIdGenerator } from "./id";
|
|
21
|
+
import { log } from "./log";
|
|
22
|
+
import { formatStateText } from "./state";
|
|
23
|
+
|
|
24
|
+
export interface BrowserServerConfig {
|
|
25
|
+
host?: string;
|
|
26
|
+
port?: number;
|
|
27
|
+
sessionTtlMs?: number;
|
|
28
|
+
browserOptions: AgentBrowserOptions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ServerSession = {
|
|
32
|
+
id: string;
|
|
33
|
+
browser: ReturnType<typeof createBrowser>;
|
|
34
|
+
lastUsed: number;
|
|
35
|
+
busy: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// Utility functions
|
|
41
|
+
function getErrorMessage(error: unknown): string {
|
|
42
|
+
return error instanceof Error ? error.message : String(error);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function wantsJsonResponse(c: Context): boolean {
|
|
46
|
+
const format = c.req.query("format");
|
|
47
|
+
if (format === "json") return true;
|
|
48
|
+
if (format === "text") return false;
|
|
49
|
+
const accept = c.req.header("accept") ?? "";
|
|
50
|
+
// Default to text/plain unless explicitly requesting JSON
|
|
51
|
+
return accept
|
|
52
|
+
.split(",")
|
|
53
|
+
.some((v) => v.toLowerCase().includes("application/json"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createErrorResponse(message: string, status: number): Response {
|
|
57
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
58
|
+
status,
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function throwNotFound(message: string): never {
|
|
64
|
+
throw new HTTPException(404, { res: createErrorResponse(message, 404) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function throwBusy(): never {
|
|
68
|
+
throw new HTTPException(409, {
|
|
69
|
+
res: createErrorResponse("Session is busy", 409),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _throwBadRequest(message: string): never {
|
|
74
|
+
throw new HTTPException(400, { res: createErrorResponse(message, 400) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function throwServerError(error: unknown): never {
|
|
78
|
+
const message = getErrorMessage(error);
|
|
79
|
+
throw new HTTPException(500, { res: createErrorResponse(message, 500) });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function throwAborted(message: string): never {
|
|
83
|
+
throw new HTTPException(408, { res: createErrorResponse(message, 408) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getSessionOrThrow(
|
|
87
|
+
sessions: Map<string, ServerSession>,
|
|
88
|
+
sessionId: string,
|
|
89
|
+
): ServerSession {
|
|
90
|
+
const session = sessions.get(sessionId);
|
|
91
|
+
if (!session) {
|
|
92
|
+
throwNotFound(`Session not found: ${sessionId}`);
|
|
93
|
+
}
|
|
94
|
+
if (session.busy) {
|
|
95
|
+
throwBusy();
|
|
96
|
+
}
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function withSession<T>(
|
|
101
|
+
session: ServerSession,
|
|
102
|
+
fn: () => Promise<T>,
|
|
103
|
+
): Promise<T> {
|
|
104
|
+
session.busy = true;
|
|
105
|
+
session.lastUsed = Date.now();
|
|
106
|
+
try {
|
|
107
|
+
return await fn();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof HTTPException) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
throwServerError(error);
|
|
113
|
+
} finally {
|
|
114
|
+
session.busy = false;
|
|
115
|
+
session.lastUsed = Date.now();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Command and step action schemas are imported from ./commands
|
|
120
|
+
|
|
121
|
+
const stepRequestSchema = z.object({
|
|
122
|
+
actions: z.array(stepActionSchema).default([]),
|
|
123
|
+
state: getStateOptionsSchema.optional(),
|
|
124
|
+
includeState: z.boolean().default(false),
|
|
125
|
+
includeStateText: z.boolean().default(true),
|
|
126
|
+
haltOnError: z.boolean().default(true),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// WaitCondition type and waitConditionSchema are imported from ./commands
|
|
130
|
+
|
|
131
|
+
// Wait request uses discriminated union: either has "expect" wrapper or inline conditions
|
|
132
|
+
const waitWithExpectSchema = z.object({
|
|
133
|
+
kind: z.literal("expect").default("expect"),
|
|
134
|
+
expect: waitConditionSchema,
|
|
135
|
+
timeoutMs: z.number().int().optional(),
|
|
136
|
+
includeState: z.boolean().default(false),
|
|
137
|
+
includeStateText: z.boolean().default(true),
|
|
138
|
+
state: getStateOptionsSchema.optional(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const waitInlineSchema = z
|
|
142
|
+
.object({
|
|
143
|
+
kind: z.literal("inline").default("inline"),
|
|
144
|
+
timeoutMs: z.number().int().optional(),
|
|
145
|
+
includeState: z.boolean().default(false),
|
|
146
|
+
includeStateText: z.boolean().default(true),
|
|
147
|
+
state: getStateOptionsSchema.optional(),
|
|
148
|
+
})
|
|
149
|
+
.extend(waitConditionSchema.shape);
|
|
150
|
+
|
|
151
|
+
// Transform incoming request to normalized form
|
|
152
|
+
const waitRequestSchema = z
|
|
153
|
+
.union([
|
|
154
|
+
z.object({ expect: waitConditionSchema }).passthrough(),
|
|
155
|
+
waitConditionSchema.passthrough(),
|
|
156
|
+
])
|
|
157
|
+
.transform(
|
|
158
|
+
(
|
|
159
|
+
data,
|
|
160
|
+
):
|
|
161
|
+
| z.infer<typeof waitWithExpectSchema>
|
|
162
|
+
| z.infer<typeof waitInlineSchema> => {
|
|
163
|
+
if ("expect" in data && data.expect) {
|
|
164
|
+
return {
|
|
165
|
+
kind: "expect",
|
|
166
|
+
expect: data.expect,
|
|
167
|
+
timeoutMs:
|
|
168
|
+
"timeoutMs" in data
|
|
169
|
+
? (data.timeoutMs as number | undefined)
|
|
170
|
+
: undefined,
|
|
171
|
+
includeState:
|
|
172
|
+
"includeState" in data ? (data.includeState as boolean) : false,
|
|
173
|
+
includeStateText:
|
|
174
|
+
"includeStateText" in data
|
|
175
|
+
? (data.includeStateText as boolean)
|
|
176
|
+
: true,
|
|
177
|
+
state:
|
|
178
|
+
"state" in data
|
|
179
|
+
? (data.state as
|
|
180
|
+
| z.infer<typeof getStateOptionsSchema>
|
|
181
|
+
| undefined)
|
|
182
|
+
: undefined,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
kind: "inline",
|
|
187
|
+
selector:
|
|
188
|
+
"selector" in data
|
|
189
|
+
? (data.selector as string | undefined)
|
|
190
|
+
: undefined,
|
|
191
|
+
text: "text" in data ? (data.text as string | undefined) : undefined,
|
|
192
|
+
url: "url" in data ? (data.url as string | undefined) : undefined,
|
|
193
|
+
notSelector:
|
|
194
|
+
"notSelector" in data
|
|
195
|
+
? (data.notSelector as string | undefined)
|
|
196
|
+
: undefined,
|
|
197
|
+
notText:
|
|
198
|
+
"notText" in data ? (data.notText as string | undefined) : undefined,
|
|
199
|
+
timeoutMs:
|
|
200
|
+
"timeoutMs" in data
|
|
201
|
+
? (data.timeoutMs as number | undefined)
|
|
202
|
+
: undefined,
|
|
203
|
+
includeState:
|
|
204
|
+
"includeState" in data ? (data.includeState as boolean) : false,
|
|
205
|
+
includeStateText:
|
|
206
|
+
"includeStateText" in data
|
|
207
|
+
? (data.includeStateText as boolean)
|
|
208
|
+
: true,
|
|
209
|
+
state:
|
|
210
|
+
"state" in data
|
|
211
|
+
? (data.state as z.infer<typeof getStateOptionsSchema> | undefined)
|
|
212
|
+
: undefined,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
type WaitRequest = z.infer<typeof waitRequestSchema>;
|
|
218
|
+
|
|
219
|
+
function getWaitCondition(data: WaitRequest): WaitCondition {
|
|
220
|
+
if (data.kind === "expect") {
|
|
221
|
+
return data.expect;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
selector: data.selector,
|
|
225
|
+
text: data.text,
|
|
226
|
+
url: data.url,
|
|
227
|
+
notSelector: data.notSelector,
|
|
228
|
+
notText: data.notText,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const createSessionBodySchema = z.object({
|
|
233
|
+
headless: z.boolean().optional(),
|
|
234
|
+
userDataDir: z.string().optional(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const sessionParamsSchema = z.object({
|
|
238
|
+
sessionId: z.string(),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Response schemas
|
|
242
|
+
const errorResponseSchema = z.object({
|
|
243
|
+
error: z.string(),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const sessionInfoSchema = z.object({
|
|
247
|
+
id: z.string(),
|
|
248
|
+
url: z.string(),
|
|
249
|
+
title: z.string(),
|
|
250
|
+
busy: z.boolean(),
|
|
251
|
+
lastUsed: z.number().int(),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const listSessionsResponseSchema = z.array(sessionInfoSchema);
|
|
255
|
+
|
|
256
|
+
const createSessionResponseSchema = z.object({
|
|
257
|
+
sessionId: z.string(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const commandResponseSchema = z.unknown();
|
|
261
|
+
|
|
262
|
+
const stepResultSchema = z.object({
|
|
263
|
+
action: stepActionSchema,
|
|
264
|
+
result: z.unknown().optional(),
|
|
265
|
+
error: z.string().optional(),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const stepResponseSchema = z.object({
|
|
269
|
+
results: z.array(stepResultSchema),
|
|
270
|
+
state: z.unknown().optional(),
|
|
271
|
+
stateText: z.string().optional(),
|
|
272
|
+
error: z.string().optional(),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const waitResponseSchema = z.object({
|
|
276
|
+
state: z.unknown().optional(),
|
|
277
|
+
stateText: z.string().optional(),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// runCommand, runStepActions, formatStepText, formatWaitText are imported from ./commands
|
|
281
|
+
// Wrapper to use executeCommand with session
|
|
282
|
+
async function runCommand(session: ServerSession, command: Command) {
|
|
283
|
+
return executeCommand(session.browser, command);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runStepActions(
|
|
287
|
+
session: ServerSession,
|
|
288
|
+
actions: StepAction[],
|
|
289
|
+
haltOnError: boolean,
|
|
290
|
+
) {
|
|
291
|
+
return executeActions(session.browser, actions, { haltOnError });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Route definitions
|
|
295
|
+
const listSessionsRoute = createRoute({
|
|
296
|
+
method: "get",
|
|
297
|
+
path: "/sessions",
|
|
298
|
+
responses: {
|
|
299
|
+
200: {
|
|
300
|
+
description: "List all sessions with url and title",
|
|
301
|
+
content: {
|
|
302
|
+
"application/json": {
|
|
303
|
+
schema: listSessionsResponseSchema,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const createSessionRoute = createRoute({
|
|
311
|
+
method: "post",
|
|
312
|
+
path: "/session",
|
|
313
|
+
request: {
|
|
314
|
+
body: {
|
|
315
|
+
required: false,
|
|
316
|
+
content: {
|
|
317
|
+
"application/json": {
|
|
318
|
+
schema: createSessionBodySchema,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
responses: {
|
|
324
|
+
200: {
|
|
325
|
+
description: "Created session",
|
|
326
|
+
content: {
|
|
327
|
+
"application/json": {
|
|
328
|
+
schema: createSessionResponseSchema,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
400: {
|
|
333
|
+
description: "Invalid request",
|
|
334
|
+
content: {
|
|
335
|
+
"application/json": {
|
|
336
|
+
schema: errorResponseSchema,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const commandRoute = createRoute({
|
|
344
|
+
method: "post",
|
|
345
|
+
path: "/session/{sessionId}/command",
|
|
346
|
+
request: {
|
|
347
|
+
params: sessionParamsSchema,
|
|
348
|
+
body: {
|
|
349
|
+
required: true,
|
|
350
|
+
content: {
|
|
351
|
+
"application/json": {
|
|
352
|
+
schema: commandSchema,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
responses: {
|
|
358
|
+
200: {
|
|
359
|
+
description: "Command result",
|
|
360
|
+
content: {
|
|
361
|
+
"application/json": {
|
|
362
|
+
schema: commandResponseSchema,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
400: {
|
|
367
|
+
description: "Invalid request",
|
|
368
|
+
content: {
|
|
369
|
+
"application/json": {
|
|
370
|
+
schema: errorResponseSchema,
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
404: {
|
|
375
|
+
description: "Session not found",
|
|
376
|
+
content: {
|
|
377
|
+
"application/json": {
|
|
378
|
+
schema: errorResponseSchema,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
409: {
|
|
383
|
+
description: "Session busy",
|
|
384
|
+
content: {
|
|
385
|
+
"application/json": {
|
|
386
|
+
schema: errorResponseSchema,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
500: {
|
|
391
|
+
description: "Command failed",
|
|
392
|
+
content: {
|
|
393
|
+
"application/json": {
|
|
394
|
+
schema: errorResponseSchema,
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const stepRoute = createRoute({
|
|
402
|
+
method: "post",
|
|
403
|
+
path: "/session/{sessionId}/step",
|
|
404
|
+
request: {
|
|
405
|
+
params: sessionParamsSchema,
|
|
406
|
+
body: {
|
|
407
|
+
required: true,
|
|
408
|
+
content: {
|
|
409
|
+
"application/json": {
|
|
410
|
+
schema: stepRequestSchema,
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
responses: {
|
|
416
|
+
200: {
|
|
417
|
+
description: "Step results",
|
|
418
|
+
content: {
|
|
419
|
+
"application/json": {
|
|
420
|
+
schema: stepResponseSchema,
|
|
421
|
+
},
|
|
422
|
+
"text/plain": {
|
|
423
|
+
schema: z.string(),
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
400: {
|
|
428
|
+
description: "Invalid request",
|
|
429
|
+
content: {
|
|
430
|
+
"application/json": {
|
|
431
|
+
schema: errorResponseSchema,
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
404: {
|
|
436
|
+
description: "Session not found",
|
|
437
|
+
content: {
|
|
438
|
+
"application/json": {
|
|
439
|
+
schema: errorResponseSchema,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
409: {
|
|
444
|
+
description: "Session busy",
|
|
445
|
+
content: {
|
|
446
|
+
"application/json": {
|
|
447
|
+
schema: errorResponseSchema,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
500: {
|
|
452
|
+
description: "Step failed",
|
|
453
|
+
content: {
|
|
454
|
+
"application/json": {
|
|
455
|
+
schema: errorResponseSchema,
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const waitRoute = createRoute({
|
|
463
|
+
method: "post",
|
|
464
|
+
path: "/session/{sessionId}/wait",
|
|
465
|
+
request: {
|
|
466
|
+
params: sessionParamsSchema,
|
|
467
|
+
body: {
|
|
468
|
+
required: true,
|
|
469
|
+
content: {
|
|
470
|
+
"application/json": {
|
|
471
|
+
schema: waitRequestSchema,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
responses: {
|
|
477
|
+
200: {
|
|
478
|
+
description: "Wait result",
|
|
479
|
+
content: {
|
|
480
|
+
"application/json": {
|
|
481
|
+
schema: waitResponseSchema,
|
|
482
|
+
},
|
|
483
|
+
"text/plain": {
|
|
484
|
+
schema: z.string(),
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
400: {
|
|
489
|
+
description: "Invalid request",
|
|
490
|
+
content: {
|
|
491
|
+
"application/json": {
|
|
492
|
+
schema: errorResponseSchema,
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
404: {
|
|
497
|
+
description: "Session not found",
|
|
498
|
+
content: {
|
|
499
|
+
"application/json": {
|
|
500
|
+
schema: errorResponseSchema,
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
409: {
|
|
505
|
+
description: "Session busy",
|
|
506
|
+
content: {
|
|
507
|
+
"application/json": {
|
|
508
|
+
schema: errorResponseSchema,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
408: {
|
|
513
|
+
description: "Client closed request",
|
|
514
|
+
content: {
|
|
515
|
+
"application/json": {
|
|
516
|
+
schema: errorResponseSchema,
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
500: {
|
|
521
|
+
description: "Wait failed",
|
|
522
|
+
content: {
|
|
523
|
+
"application/json": {
|
|
524
|
+
schema: errorResponseSchema,
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const closeRoute = createRoute({
|
|
532
|
+
method: "post",
|
|
533
|
+
path: "/session/{sessionId}/close",
|
|
534
|
+
request: {
|
|
535
|
+
params: sessionParamsSchema,
|
|
536
|
+
},
|
|
537
|
+
responses: {
|
|
538
|
+
204: {
|
|
539
|
+
description: "Session closed",
|
|
540
|
+
},
|
|
541
|
+
404: {
|
|
542
|
+
description: "Session not found",
|
|
543
|
+
content: {
|
|
544
|
+
"application/json": {
|
|
545
|
+
schema: errorResponseSchema,
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
409: {
|
|
550
|
+
description: "Session busy",
|
|
551
|
+
content: {
|
|
552
|
+
"application/json": {
|
|
553
|
+
schema: errorResponseSchema,
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
500: {
|
|
558
|
+
description: "Close failed",
|
|
559
|
+
content: {
|
|
560
|
+
"application/json": {
|
|
561
|
+
schema: errorResponseSchema,
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const stateRoute = createRoute({
|
|
569
|
+
method: "get",
|
|
570
|
+
path: "/session/{sessionId}/state",
|
|
571
|
+
request: {
|
|
572
|
+
params: sessionParamsSchema,
|
|
573
|
+
},
|
|
574
|
+
responses: {
|
|
575
|
+
200: {
|
|
576
|
+
description: "Session state as plain text",
|
|
577
|
+
content: {
|
|
578
|
+
"text/plain": {
|
|
579
|
+
schema: z.string(),
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
404: {
|
|
584
|
+
description: "Session not found",
|
|
585
|
+
content: {
|
|
586
|
+
"application/json": {
|
|
587
|
+
schema: errorResponseSchema,
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
409: {
|
|
592
|
+
description: "Session busy",
|
|
593
|
+
content: {
|
|
594
|
+
"application/json": {
|
|
595
|
+
schema: errorResponseSchema,
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
500: {
|
|
600
|
+
description: "State retrieval failed",
|
|
601
|
+
content: {
|
|
602
|
+
"application/json": {
|
|
603
|
+
schema: errorResponseSchema,
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
export function startBrowserServer(config: BrowserServerConfig) {
|
|
611
|
+
const sessions = new Map<string, ServerSession>();
|
|
612
|
+
const idGenerator = createIdGenerator();
|
|
613
|
+
const host = config.host ?? "localhost";
|
|
614
|
+
const port = config.port ?? 3790;
|
|
615
|
+
const ttl = config.sessionTtlMs ?? DEFAULT_TTL_MS;
|
|
616
|
+
|
|
617
|
+
async function createNewSession(overrides?: Partial<AgentBrowserOptions>) {
|
|
618
|
+
const id = idGenerator.next();
|
|
619
|
+
const browser = createBrowser({
|
|
620
|
+
...config.browserOptions,
|
|
621
|
+
...overrides,
|
|
622
|
+
});
|
|
623
|
+
await browser.start();
|
|
624
|
+
sessions.set(id, {
|
|
625
|
+
id,
|
|
626
|
+
browser,
|
|
627
|
+
lastUsed: Date.now(),
|
|
628
|
+
busy: false,
|
|
629
|
+
});
|
|
630
|
+
return id;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const timer = setInterval(
|
|
634
|
+
async () => {
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
for (const [id, session] of sessions) {
|
|
637
|
+
if (now - session.lastUsed > ttl && !session.busy) {
|
|
638
|
+
await session.browser.stop();
|
|
639
|
+
sessions.delete(id);
|
|
640
|
+
idGenerator.release(id);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
Math.max(10_000, Math.floor(ttl / 2)),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const app = new OpenAPIHono();
|
|
648
|
+
|
|
649
|
+
app.use("*", async (c, next) => {
|
|
650
|
+
const start = Date.now();
|
|
651
|
+
try {
|
|
652
|
+
await next();
|
|
653
|
+
} finally {
|
|
654
|
+
const durationMs = Date.now() - start;
|
|
655
|
+
log
|
|
656
|
+
.withMetadata({
|
|
657
|
+
method: c.req.method,
|
|
658
|
+
path: c.req.path,
|
|
659
|
+
status: c.res.status,
|
|
660
|
+
durationMs,
|
|
661
|
+
})
|
|
662
|
+
.info("HTTP");
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Root route - plain text description
|
|
667
|
+
app.get("/", (c) => {
|
|
668
|
+
const sessionCount = sessions.size;
|
|
669
|
+
const sessionList = Array.from(sessions.values());
|
|
670
|
+
const lines = [
|
|
671
|
+
"Agent Browser Loop Server",
|
|
672
|
+
"=========================",
|
|
673
|
+
"",
|
|
674
|
+
`Sessions: ${sessionCount}`,
|
|
675
|
+
];
|
|
676
|
+
|
|
677
|
+
if (sessionCount > 0) {
|
|
678
|
+
lines.push("");
|
|
679
|
+
for (const s of sessionList) {
|
|
680
|
+
const state = s.browser.getLastState();
|
|
681
|
+
const url = state?.url ?? "about:blank";
|
|
682
|
+
const title = state?.title ?? "(no title)";
|
|
683
|
+
const status = s.busy ? "[busy]" : "[idle]";
|
|
684
|
+
lines.push(` ${s.id} ${status}`);
|
|
685
|
+
lines.push(` ${title}`);
|
|
686
|
+
lines.push(` ${url}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
lines.push("");
|
|
691
|
+
lines.push("Endpoints:");
|
|
692
|
+
lines.push(" GET / - This help");
|
|
693
|
+
lines.push(" GET /openapi.json - OpenAPI spec");
|
|
694
|
+
lines.push(" GET /sessions - List sessions");
|
|
695
|
+
lines.push(" POST /session - Create session");
|
|
696
|
+
lines.push(" POST /session/:id/command - Run command");
|
|
697
|
+
lines.push(" POST /session/:id/step - Run actions + get state");
|
|
698
|
+
lines.push(" POST /session/:id/wait - Wait for condition");
|
|
699
|
+
lines.push(" GET /session/:id/state - Get session state");
|
|
700
|
+
lines.push(" POST /session/:id/close - Close session");
|
|
701
|
+
|
|
702
|
+
return c.text(lines.join("\n"), 200);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
app.get("/openapi.json", (c) => {
|
|
706
|
+
const spec = app.getOpenAPIDocument({
|
|
707
|
+
openapi: "3.0.0",
|
|
708
|
+
info: {
|
|
709
|
+
title: "Agent Browser Loop Server",
|
|
710
|
+
version: "0.1.0",
|
|
711
|
+
},
|
|
712
|
+
servers: [{ url: `http://${host}:${port}` }],
|
|
713
|
+
});
|
|
714
|
+
return c.text(JSON.stringify(spec, null, 2), 200, {
|
|
715
|
+
"Content-Type": "application/json",
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
app.openapi(listSessionsRoute, async (c) => {
|
|
720
|
+
const sessionList = await Promise.all(
|
|
721
|
+
Array.from(sessions.values()).map(async (s) => {
|
|
722
|
+
const state = s.browser.getLastState();
|
|
723
|
+
return {
|
|
724
|
+
id: s.id,
|
|
725
|
+
url: state?.url ?? "about:blank",
|
|
726
|
+
title: state?.title ?? "",
|
|
727
|
+
busy: s.busy,
|
|
728
|
+
lastUsed: s.lastUsed,
|
|
729
|
+
};
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
return c.json(sessionList);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
app.openapi(
|
|
736
|
+
createSessionRoute,
|
|
737
|
+
async (c) => {
|
|
738
|
+
const body = c.req.valid("json");
|
|
739
|
+
|
|
740
|
+
const overrides: Partial<AgentBrowserOptions> = {};
|
|
741
|
+
if (body?.headless != null) {
|
|
742
|
+
overrides.headless = body.headless;
|
|
743
|
+
}
|
|
744
|
+
if (body?.userDataDir) {
|
|
745
|
+
overrides.userDataDir = body.userDataDir;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const id = await createNewSession(overrides);
|
|
749
|
+
return c.json({ sessionId: id }, 200);
|
|
750
|
+
},
|
|
751
|
+
(result, c) => {
|
|
752
|
+
if (!result.success) {
|
|
753
|
+
return c.json({ error: result.error.message }, 400);
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
app.openapi(
|
|
759
|
+
commandRoute,
|
|
760
|
+
async (c) => {
|
|
761
|
+
const { sessionId } = c.req.valid("param");
|
|
762
|
+
const session = getSessionOrThrow(sessions, sessionId);
|
|
763
|
+
const command = c.req.valid("json");
|
|
764
|
+
|
|
765
|
+
return withSession(session, async () => {
|
|
766
|
+
const result = await runCommand(session, command);
|
|
767
|
+
if (command.type === "close") {
|
|
768
|
+
sessions.delete(sessionId);
|
|
769
|
+
idGenerator.release(sessionId);
|
|
770
|
+
}
|
|
771
|
+
return c.json(result, 200);
|
|
772
|
+
});
|
|
773
|
+
},
|
|
774
|
+
(result, c) => {
|
|
775
|
+
if (!result.success) {
|
|
776
|
+
return c.json({ error: result.error.message }, 400);
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
app.openapi(
|
|
782
|
+
stepRoute,
|
|
783
|
+
async (c) => {
|
|
784
|
+
const { sessionId } = c.req.valid("param");
|
|
785
|
+
const session = getSessionOrThrow(sessions, sessionId);
|
|
786
|
+
const { actions, state, includeState, includeStateText, haltOnError } =
|
|
787
|
+
c.req.valid("json");
|
|
788
|
+
|
|
789
|
+
return withSession(session, async () => {
|
|
790
|
+
const results = await runStepActions(session, actions, haltOnError);
|
|
791
|
+
const hasError = results.some((r) => r.error != null);
|
|
792
|
+
|
|
793
|
+
let stateResult: unknown;
|
|
794
|
+
let stateTextResult: string | undefined;
|
|
795
|
+
if (includeState || includeStateText) {
|
|
796
|
+
const currentState = await session.browser.getState(state);
|
|
797
|
+
if (includeState) {
|
|
798
|
+
stateResult = currentState;
|
|
799
|
+
}
|
|
800
|
+
if (includeStateText) {
|
|
801
|
+
stateTextResult = formatStateText(currentState);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (wantsJsonResponse(c)) {
|
|
806
|
+
return c.json(
|
|
807
|
+
{
|
|
808
|
+
results,
|
|
809
|
+
state: stateResult,
|
|
810
|
+
stateText: stateTextResult,
|
|
811
|
+
error: hasError ? "One or more actions failed" : undefined,
|
|
812
|
+
},
|
|
813
|
+
200,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return c.text(
|
|
818
|
+
formatStepText({ results, stateText: stateTextResult }),
|
|
819
|
+
200,
|
|
820
|
+
);
|
|
821
|
+
});
|
|
822
|
+
},
|
|
823
|
+
(result, c) => {
|
|
824
|
+
if (!result.success) {
|
|
825
|
+
return c.json({ error: result.error.message }, 400);
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
app.openapi(
|
|
831
|
+
waitRoute,
|
|
832
|
+
async (c) => {
|
|
833
|
+
const { sessionId } = c.req.valid("param");
|
|
834
|
+
const session = getSessionOrThrow(sessions, sessionId);
|
|
835
|
+
const data = c.req.valid("json");
|
|
836
|
+
const condition = getWaitCondition(data);
|
|
837
|
+
const { timeoutMs, includeState, includeStateText, state } = data;
|
|
838
|
+
|
|
839
|
+
return withSession(session, async () => {
|
|
840
|
+
try {
|
|
841
|
+
await session.browser.waitFor({
|
|
842
|
+
...condition,
|
|
843
|
+
timeoutMs,
|
|
844
|
+
signal: c.req.raw.signal,
|
|
845
|
+
});
|
|
846
|
+
} catch (error) {
|
|
847
|
+
const message = getErrorMessage(error);
|
|
848
|
+
if (message === "Request aborted") {
|
|
849
|
+
throwAborted(message);
|
|
850
|
+
}
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let stateResult: unknown;
|
|
855
|
+
let stateTextResult: string | undefined;
|
|
856
|
+
if (includeState || includeStateText) {
|
|
857
|
+
const currentState = await session.browser.getState(state);
|
|
858
|
+
if (includeState) {
|
|
859
|
+
stateResult = currentState;
|
|
860
|
+
}
|
|
861
|
+
if (includeStateText) {
|
|
862
|
+
stateTextResult = formatStateText(currentState);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (wantsJsonResponse(c)) {
|
|
867
|
+
return c.json(
|
|
868
|
+
{ state: stateResult, stateText: stateTextResult },
|
|
869
|
+
200,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return c.text(
|
|
874
|
+
formatWaitText({ condition, stateText: stateTextResult }),
|
|
875
|
+
200,
|
|
876
|
+
);
|
|
877
|
+
});
|
|
878
|
+
},
|
|
879
|
+
(result, c) => {
|
|
880
|
+
if (!result.success) {
|
|
881
|
+
return c.json({ error: result.error.message }, 400);
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
app.openapi(closeRoute, async (c) => {
|
|
887
|
+
const { sessionId } = c.req.valid("param");
|
|
888
|
+
const session = getSessionOrThrow(sessions, sessionId);
|
|
889
|
+
|
|
890
|
+
return withSession(session, async () => {
|
|
891
|
+
await session.browser.stop();
|
|
892
|
+
sessions.delete(sessionId);
|
|
893
|
+
idGenerator.release(sessionId);
|
|
894
|
+
return c.body(null, 204);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
app.openapi(stateRoute, async (c) => {
|
|
899
|
+
const { sessionId } = c.req.valid("param");
|
|
900
|
+
const session = getSessionOrThrow(sessions, sessionId);
|
|
901
|
+
|
|
902
|
+
return withSession(session, async () => {
|
|
903
|
+
const currentState = await session.browser.getState();
|
|
904
|
+
return c.text(formatStateText(currentState), 200);
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
const server = Bun.serve({
|
|
909
|
+
hostname: host,
|
|
910
|
+
port,
|
|
911
|
+
fetch: app.fetch,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
host,
|
|
916
|
+
port,
|
|
917
|
+
server,
|
|
918
|
+
close: async () => {
|
|
919
|
+
clearInterval(timer);
|
|
920
|
+
for (const session of sessions.values()) {
|
|
921
|
+
await session.browser.stop();
|
|
922
|
+
}
|
|
923
|
+
sessions.clear();
|
|
924
|
+
server.stop(true);
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|