Skip to content

Commit 5f05cd2

Browse files
committed
add support for session release
1 parent eeca4d6 commit 5f05cd2

3 files changed

Lines changed: 128 additions & 14 deletions

File tree

‎packages/sdk/src/modules/browser.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@ interface BrowserSessionResponse {
1717
* Creates the Browser module for the Base44 SDK.
1818
*
1919
* @param axios - Axios instance (should be service role client)
20-
* @returns Browser module with methods to create remote browser sessions
20+
* @returns Browser module with methods to create and end remote browser sessions
2121
* @internal
2222
*/
2323
export function createBrowserModule(axios: AxiosInstance): BrowserModule {
24+
const endSession = async (sessionId: string): Promise<void> => {
25+
if (!sessionId || typeof sessionId !== "string") {
26+
throw new Error("Session ID is required and must be a string");
27+
}
28+
29+
await axios.delete(`/v1/browser-sessions/${encodeURIComponent(sessionId)}`);
30+
};
31+
2432
return {
2533
async getSession(
2634
options: BrowserSessionOptions = {}
@@ -42,7 +50,10 @@ export function createBrowserModule(axios: AxiosInstance): BrowserModule {
4250
...(data.session_expires_at && {
4351
sessionExpiresAt: data.session_expires_at,
4452
}),
53+
end: () => endSession(data.session_id),
4554
};
4655
},
56+
57+
endSession,
4758
};
4859
}

‎packages/sdk/src/modules/browser.types.ts‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export interface BrowserSessionOptions {
1414
* A remote browser session returned by {@linkcode BrowserModule.getSession | getSession()}.
1515
*/
1616
export interface BrowserSession {
17-
/** Opaque session identifier issued by the remote browser provider. */
17+
/**
18+
* Opaque session identifier issued to your app.
19+
*
20+
* Pass it unchanged to {@linkcode BrowserModule.endSession | endSession()} to end the session from a different request.
21+
*/
1822
sessionId: string;
1923
/**
2024
* Signed WebSocket URL for connecting to the browser over the Chrome DevTools Protocol (CDP).
@@ -33,12 +37,16 @@ export interface BrowserSession {
3337
connectionExpiresAt?: string;
3438
/** ISO 8601 timestamp when the browser session expires. Omitted when the provider doesn't expose it. */
3539
sessionExpiresAt?: string;
40+
/**
41+
* Ends the session. Equivalent to calling {@linkcode BrowserModule.endSession | endSession()} with this session's ID.
42+
*/
43+
end: () => Promise<void>;
3644
}
3745

3846
/**
3947
* Browser module for running browser automation from your backend.
4048
*
41-
* This module creates remote browser sessions that you can control with a CDP client library such as Playwright or Puppeteer. Base44 doesn't store browser state, so each session is independent.
49+
* This module creates remote browser sessions that you can control with a CDP client library such as Playwright or Puppeteer. Base44 doesn't store browser state, so each session is independent. End each session when you're done with it. Otherwise it keeps running until the remote browser provider times it out.
4250
*
4351
* This module is only available to use with a client in service role authentication mode, which means it can only be used in backend environments.
4452
*/
@@ -59,15 +67,19 @@ export interface BrowserModule {
5967
*
6068
* Deno.serve(async (req) => {
6169
* const base44 = createClientFromRequest(req);
62-
* const { connectionUrl } = await base44.asServiceRole.browser.getSession();
70+
* const session = await base44.asServiceRole.browser.getSession();
6371
*
64-
* const browser = await chromium.connectOverCDP(connectionUrl);
65-
* const page = browser.contexts()[0].pages()[0] ?? (await browser.newPage());
66-
* await page.goto('https://example.com');
67-
* const title = await page.title();
68-
* await browser.close();
72+
* try {
73+
* const browser = await chromium.connectOverCDP(session.connectionUrl);
74+
* const page = browser.contexts()[0].pages()[0] ?? (await browser.newPage());
75+
* await page.goto('https://example.com');
76+
* const title = await page.title();
77+
* await browser.close();
6978
*
70-
* return Response.json({ title });
79+
* return Response.json({ title });
80+
* } finally {
81+
* await session.end();
82+
* }
7183
* });
7284
* ```
7385
*
@@ -80,4 +92,28 @@ export interface BrowserModule {
8092
* ```
8193
*/
8294
getSession(options?: BrowserSessionOptions): Promise<BrowserSession>;
95+
96+
/**
97+
* Ends a browser session created by your app.
98+
*
99+
* Use this when the session was created in a different request. Otherwise call `end()` on the session returned by {@linkcode getSession | getSession()}. Ending a session that has already ended succeeds, so it's safe to call more than once. The call fails with the `browser_session_not_found` error code if the ID wasn't issued to your app.
100+
*
101+
* @param sessionId - The `sessionId` returned by {@linkcode getSession | getSession()}, unchanged.
102+
* @returns Promise that resolves when the session has ended.
103+
*
104+
* @example
105+
* ```typescript
106+
* // End a session whose ID was saved by an earlier request
107+
* import { createClientFromRequest } from 'npm:@base44/sdk';
108+
*
109+
* Deno.serve(async (req) => {
110+
* const base44 = createClientFromRequest(req);
111+
* const { sessionId } = await req.json();
112+
* await base44.asServiceRole.browser.endSession(sessionId);
113+
*
114+
* return new Response(null, { status: 204 });
115+
* });
116+
* ```
117+
*/
118+
endSession(sessionId: string): Promise<void>;
83119
}

‎packages/sdk/tests/unit/browser.test.ts‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ describe("Browser module", () => {
77
const serverUrl = "https://base44.app";
88
const serviceToken = "service-token-123";
99
const connectionUrl = "wss://connect.example.com/?signingKey=session-credential";
10+
const sessionId =
11+
"BB1909cef7-23e8-4394-bc31-27404bf4348f.kX9tQ2mVb7LwR4sZ0aYcEg";
1012
let base44: ReturnType<typeof createClient>;
1113
let scope: nock.Scope;
1214

@@ -24,7 +26,7 @@ describe("Browser module", () => {
2426
.post("/api/v1/browser-sessions", { capture_debug_artifacts: true })
2527
.matchHeader("Authorization", `Bearer ${serviceToken}`)
2628
.reply(201, {
27-
session_id: "session-1",
29+
session_id: sessionId,
2830
connection_url: connectionUrl,
2931
connection_scope: "browser",
3032
connection_expires_at: "2030-01-01T12:00:00Z",
@@ -34,11 +36,12 @@ describe("Browser module", () => {
3436
const session = await base44.asServiceRole.browser.getSession();
3537

3638
expect(session).toEqual({
37-
sessionId: "session-1",
39+
sessionId,
3840
connectionUrl,
3941
connectionScope: "browser",
4042
connectionExpiresAt: "2030-01-01T12:00:00Z",
4143
sessionExpiresAt: "2030-01-01T13:00:00Z",
44+
end: expect.any(Function),
4245
});
4346
expect(scope.isDone()).toBe(true);
4447
});
@@ -47,7 +50,7 @@ describe("Browser module", () => {
4750
scope
4851
.post("/api/v1/browser-sessions", { capture_debug_artifacts: false })
4952
.reply(201, {
50-
session_id: "session-1",
53+
session_id: sessionId,
5154
connection_url: connectionUrl,
5255
connection_scope: "page",
5356
});
@@ -57,9 +60,10 @@ describe("Browser module", () => {
5760
});
5861

5962
expect(session).toEqual({
60-
sessionId: "session-1",
63+
sessionId,
6164
connectionUrl,
6265
connectionScope: "page",
66+
end: expect.any(Function),
6367
});
6468
expect(scope.isDone()).toBe(true);
6569
});
@@ -82,6 +86,69 @@ describe("Browser module", () => {
8286
expect(scope.isDone()).toBe(true);
8387
});
8488

89+
test("endSession deletes the session with the service token", async () => {
90+
scope
91+
.delete(`/api/v1/browser-sessions/${sessionId}`)
92+
.matchHeader("Authorization", `Bearer ${serviceToken}`)
93+
.reply(204);
94+
95+
await expect(
96+
base44.asServiceRole.browser.endSession(sessionId)
97+
).resolves.toBeUndefined();
98+
expect(scope.isDone()).toBe(true);
99+
});
100+
101+
test("endSession encodes the session ID in the path", async () => {
102+
scope.delete("/api/v1/browser-sessions/a%2Fb%3Fc").reply(204);
103+
104+
await base44.asServiceRole.browser.endSession("a/b?c");
105+
expect(scope.isDone()).toBe(true);
106+
});
107+
108+
test("session.end() ends the session it was returned with", async () => {
109+
scope
110+
.post("/api/v1/browser-sessions")
111+
.reply(201, {
112+
session_id: sessionId,
113+
connection_url: connectionUrl,
114+
connection_scope: "browser",
115+
})
116+
.delete(`/api/v1/browser-sessions/${sessionId}`)
117+
.reply(204);
118+
119+
const session = await base44.asServiceRole.browser.getSession();
120+
await session.end();
121+
122+
expect(scope.isDone()).toBe(true);
123+
});
124+
125+
test("endSession surfaces browser_session_not_found", async () => {
126+
scope.delete(`/api/v1/browser-sessions/${sessionId}`).reply(404, {
127+
error: {
128+
code: "browser_session_not_found",
129+
message: "Browser session not found.",
130+
},
131+
});
132+
133+
const error = await base44.asServiceRole.browser
134+
.endSession(sessionId)
135+
.catch((e) => e);
136+
137+
expect(error).toBeInstanceOf(Base44Error);
138+
expect(error.status).toBe(404);
139+
expect(error.code).toBe("browser_session_not_found");
140+
});
141+
142+
test.each([undefined, "", 123])(
143+
"endSession rejects %j without sending a request",
144+
async (invalid) => {
145+
await expect(
146+
base44.asServiceRole.browser.endSession(invalid as unknown as string)
147+
).rejects.toThrow("Session ID is required and must be a string");
148+
expect(nock.pendingMocks()).toEqual([]);
149+
}
150+
);
151+
85152
test("browser is not available without a service token", () => {
86153
const userClient = createClient({ serverUrl, appId, token: "user-token" });
87154
expect(() => userClient.asServiceRole.browser).toThrow(

0 commit comments

Comments
 (0)