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/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
+ }