diff --git a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts index 96d1f8b08..e6e2e5b77 100644 --- a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts +++ b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts @@ -8,7 +8,7 @@ import { build } from "vite"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod/v4"; import { connectRPCClient, type RPCClient } from "../../../sdk-ts/src/rpcClient.js"; -import { loadStagehandExtension } from "../../../sdk-ts/src/localBrowserLauncher.js"; +import { loadStagehandExtension } from "../../../sdk-ts/src/runtime/node/localBrowserLauncher.js"; import { StagehandMethods } from "../../schema-registry.js"; import type { StagehandRpcNotification } from "../../types.js"; diff --git a/packages/sdk-ts/package.json b/packages/sdk-ts/package.json index 34ebe6e7d..5aa7b358c 100644 --- a/packages/sdk-ts/package.json +++ b/packages/sdk-ts/package.json @@ -5,15 +5,37 @@ "dist" ], "type": "module", + "sideEffects": false, "exports": { ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "workerd": { + "types": "./dist/web/index.d.ts", + "default": "./dist/web/index.js" + }, + "deno": { + "types": "./dist/web/index.d.ts", + "default": "./dist/web/index.js" + }, + "bun": { + "types": "./dist/web/index.d.ts", + "default": "./dist/web/index.js" + }, + "browser": { + "types": "./dist/web/index.d.ts", + "default": "./dist/web/index.js" + }, + "node": { + "types": "./dist/node/index.d.ts", + "default": "./dist/node/index.js" + }, + "types": "./dist/web/index.d.ts", + "default": "./dist/web/index.js" } }, "scripts": { + "build": "pnpm --dir ../server run build && pnpm run build:extension-archive && tsdown", "build:extension-archive": "node scripts/build-extension-archive.ts", - "build": "tsdown", + "test:package-resolution": "pnpm run build && node scripts/test-package-resolution.mjs", "test": "vitest run --root ../.. packages/sdk-ts/tests" }, "dependencies": { diff --git a/packages/sdk-ts/scripts/test-package-resolution.mjs b/packages/sdk-ts/scripts/test-package-resolution.mjs new file mode 100644 index 000000000..bfa0e40fd --- /dev/null +++ b/packages/sdk-ts/scripts/test-package-resolution.mjs @@ -0,0 +1,37 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const packageName = "@browserbasehq/stagehand-v4-spike-sdk-ts"; +const detectVariant = ` + const sdk = await import(${JSON.stringify(packageName)}); + try { + new sdk.Stagehand({ browser: { type: "local" } }); + process.stdout.write("node"); + } catch { + process.stdout.write("web"); + } +`; + +for (const [condition, expectedVariant] of [ + [undefined, "node"], + ["workerd", "web"], + ["deno", "web"], + ["bun", "web"], + ["browser", "web"], +]) { + const arguments_ = [ + ...(condition === undefined ? [] : [`--conditions=${condition}`]), + "--input-type=module", + "--eval", + detectVariant, + ]; + const { stdout } = await execFileAsync(process.execPath, arguments_, { + cwd: new URL("..", import.meta.url), + }); + if (stdout !== expectedVariant) { + throw new Error( + `Expected ${condition ?? "default Node"} to resolve ${expectedVariant}, received ${stdout}`, + ); + } +} diff --git a/packages/sdk-ts/src/browserSource.shared.ts b/packages/sdk-ts/src/browserSource.shared.ts new file mode 100644 index 000000000..f75f84d82 --- /dev/null +++ b/packages/sdk-ts/src/browserSource.shared.ts @@ -0,0 +1,67 @@ +import { BrowserbaseSessionCreateParamsSchema } from "../../protocol/schemas.js"; +import { + createBrowserbaseSessionClient, + type BrowserbaseSessionClient, + type BrowserbaseSessionClientFactory, +} from "./browserbaseSession.js"; + +export type { BrowserbaseSessionClient, BrowserbaseSessionClientFactory }; + +export type ResolvedBrowserSource = { + cdpUrl: string; + cdpHeaders?: Record; + browserbaseSessionId?: string; + keepAlive: boolean; + close?: () => Promise | void; +}; + +export type RemoteBrowserSourceResolverDependencies = { + browserbase?: BrowserbaseSessionClient; + createBrowserbaseSessionClient?: BrowserbaseSessionClientFactory; +}; + +type RemoteBrowserSourceInitParams = { + apiKey?: string; + browser: + | { + type: "browserbase"; + keepAlive?: boolean; + [key: string]: unknown; + } + | { + type: "cdp"; + cdpUrl: string; + headers?: Record; + }; +}; + +export async function resolveRemoteBrowserSource( + initParams: RemoteBrowserSourceInitParams, + dependencies: RemoteBrowserSourceResolverDependencies = {}, +): Promise { + const browser = initParams.browser; + + if (browser.type === "browserbase") { + const apiKey = initParams.apiKey; + if (apiKey === undefined) { + throw new Error("A Browserbase API key is required for the Browserbase browser source"); + } + const sessionCreateParams = BrowserbaseSessionCreateParamsSchema.strip().parse(browser); + const browserbase = + dependencies.browserbase ?? + (dependencies.createBrowserbaseSessionClient ?? createBrowserbaseSessionClient)(apiKey); + const session = await browserbase.createSession(sessionCreateParams); + return { + cdpUrl: session.cdpUrl, + browserbaseSessionId: session.sessionId, + keepAlive: browser.keepAlive ?? false, + close: session.close, + }; + } + + return { + cdpUrl: browser.cdpUrl, + ...(browser.headers === undefined ? {} : { cdpHeaders: browser.headers }), + keepAlive: true, + }; +} diff --git a/packages/sdk-ts/src/browserSource.ts b/packages/sdk-ts/src/browserSource.ts index 12b57aff1..3dabc99c8 100644 --- a/packages/sdk-ts/src/browserSource.ts +++ b/packages/sdk-ts/src/browserSource.ts @@ -1,71 +1 @@ -import { StagehandClientInitParamsSchema } from "./clientSchemas.js"; -import { BrowserbaseSessionCreateParamsSchema } from "../../protocol/schemas.js"; -import { LocalBrowserLaunchOptionsSchema } from "../../protocol/pending-schemas.js"; -import { - createBrowserbaseSessionClient, - type BrowserbaseSessionClient, - type BrowserbaseSessionClientFactory, -} from "./browserbaseSession.js"; -import { launchLocalBrowser, type LocalBrowserLaunchOptions } from "./localBrowserLauncher.js"; - -export type { BrowserbaseSessionClient, BrowserbaseSessionClientFactory }; - -export type ResolvedBrowserSource = { - cdpUrl: string; - cdpHeaders?: Record; - browserbaseSessionId?: string; - keepAlive: boolean; - close?: () => Promise | void; -}; - -export type LocalBrowserLauncher = ( - options: LocalBrowserLaunchOptions, -) => Promise<{ cdpUrl: string; close: () => Promise | void }>; - -export type BrowserSourceResolverDependencies = { - launchLocalBrowser?: LocalBrowserLauncher; - browserbase?: BrowserbaseSessionClient; - createBrowserbaseSessionClient?: BrowserbaseSessionClientFactory; -}; - -export async function resolveBrowserSource( - input: unknown, - dependencies: BrowserSourceResolverDependencies = {}, -): Promise { - const initParams = StagehandClientInitParamsSchema.parse(input); - const browser = initParams.browser; - - if (browser.type === "browserbase") { - const apiKey = initParams.apiKey; - if (apiKey === undefined) { - throw new Error("A Browserbase API key is required for the Browserbase browser source"); - } - const sessionCreateParams = BrowserbaseSessionCreateParamsSchema.strip().parse(browser); - const browserbase = - dependencies.browserbase ?? - (dependencies.createBrowserbaseSessionClient ?? createBrowserbaseSessionClient)(apiKey); - const session = await browserbase.createSession(sessionCreateParams); - return { - cdpUrl: session.cdpUrl, - browserbaseSessionId: session.sessionId, - keepAlive: browser.keepAlive ?? false, - close: session.close, - }; - } - - if (browser.type === "local") { - const launchOptions = LocalBrowserLaunchOptionsSchema.strip().parse(browser); - const launched = await (dependencies.launchLocalBrowser ?? launchLocalBrowser)(launchOptions); - return { - cdpUrl: launched.cdpUrl, - keepAlive: launchOptions.keepAlive ?? false, - close: launched.close, - }; - } - - return { - cdpUrl: browser.cdpUrl, - ...(browser.headers === undefined ? {} : { cdpHeaders: browser.headers }), - keepAlive: true, - }; -} +export * from "./runtime/node/browserSource.js"; diff --git a/packages/sdk-ts/src/clientSchemas.shared.ts b/packages/sdk-ts/src/clientSchemas.shared.ts new file mode 100644 index 000000000..f4714c453 --- /dev/null +++ b/packages/sdk-ts/src/clientSchemas.shared.ts @@ -0,0 +1,152 @@ +/** + * TypeScript SDK-owned schemas. They extend protocol schemas with SDK-only values such as local/CDP + * connection options, JavaScript callbacks, and Page instances. Those values are consumed by the + * SDK and never cross the RPC boundary. Other language SDKs should follow the same pattern around + * the shared wire params. + */ + +import { z } from "zod/v4"; +import { + ActOptionsSchema, + BrowserbaseBrowserSettingsSchema, + BrowserbaseSessionCreateParamsSchema, + ExtractOptionsSchema, + LLMGenerateParamsSchema, + LLMGenerateResultSchema, + ModelConfigSchema, + ObserveOptionsSchema, + StagehandInitParamsSchema, + StagehandLogLevelSchema, + StagehandLogSchema, +} from "../../protocol/schemas.js"; +import { LocalBrowserLaunchOptionsSchema } from "../../protocol/pending-schemas.js"; +import { Page } from "./page.js"; + +const BrowserbaseClientBrowserSettingsSchema = BrowserbaseBrowserSettingsSchema.omit({ + extensionId: true, +}).strict(); + +/** Browserbase source fields exposed by the TS SDK. Stagehand provisions its own extension. */ +export const BrowserbaseBrowserSourceSchema = BrowserbaseSessionCreateParamsSchema.omit({ + browserSettings: true, + extensionId: true, +}) + .extend({ + type: z.literal("browserbase"), + browserSettings: BrowserbaseClientBrowserSettingsSchema.optional(), + }) + .strict() + .meta({ id: "BrowserbaseClientBrowserSource" }); + +export const LocalBrowserSourceSchema = LocalBrowserLaunchOptionsSchema.extend({ + type: z.literal("local"), +}) + .strict() + .meta({ id: "LocalBrowserSource" }); + +export const CdpBrowserSourceSchema = z + .object({ + type: z.literal("cdp"), + cdpUrl: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + }) + .strict() + .meta({ id: "CdpBrowserSource" }); + +/** An LLM callback implemented locally by the SDK consumer. It never crosses the wire. */ +export const ClientLLMSchema = z + .object({ + generate: z.function({ + input: [LLMGenerateParamsSchema], + output: z.promise(LLMGenerateResultSchema), + }), + }) + .strict() + .meta({ id: "ClientLLM" }); + +export const StagehandClientLogLevelSchema = z + .union([StagehandLogLevelSchema, z.literal("off")]) + .meta({ id: "StagehandClientLogLevel" }); + +export const StagehandClientLogFormatSchema = z + .enum(["pretty", "json"]) + .meta({ id: "StagehandClientLogFormat" }); + +export const StagehandClientOnLogSchema = z + .function({ + input: [StagehandLogSchema], + output: z.union([z.void(), z.promise(z.void())]), + }) + .meta({ id: "StagehandClientOnLog" }); + +export const StagehandClientLoggingConfigSchema = z + .strictObject({ + level: StagehandClientLogLevelSchema.default("info"), + format: StagehandClientLogFormatSchema.default("pretty"), + onLog: StagehandClientOnLogSchema.optional(), + }) + .meta({ id: "StagehandClientLoggingConfig" }); + +export const StagehandClientActOptionsSchema = ActOptionsSchema.unwrap() + .extend({ + page: z.instanceof(Page).optional(), + }) + .strict() + .meta({ id: "StagehandClientActOptions" }); + +export const StagehandClientObserveOptionsSchema = ObserveOptionsSchema.unwrap() + .extend({ + page: z.instanceof(Page).optional(), + }) + .strict() + .meta({ id: "StagehandClientObserveOptions" }); + +export const StagehandClientExtractOptionsSchema = ExtractOptionsSchema.unwrap() + .extend({ + page: z.instanceof(Page).optional(), + }) + .strict() + .meta({ id: "StagehandClientExtractOptions" }); + +export function createStagehandClientInitParamsSchema< + BrowserSourceSchema extends z.ZodType< + | z.output + | z.output + | z.output + >, +>(browserSourceSchema: BrowserSourceSchema) { + return StagehandInitParamsSchema.extend({ + browser: browserSourceSchema.optional().transform( + (browser) => + browser ?? + browserSourceSchema.parse({ + type: "browserbase", + }), + ), + model: z.union([ModelConfigSchema, ClientLLMSchema]).optional(), + logging: StagehandClientLoggingConfigSchema.default({ + level: "info", + format: "pretty", + }), + }) + .strict() + .superRefine((params, context) => { + if (params.browser.type === "browserbase" && params.apiKey === undefined) { + context.addIssue({ + code: "custom", + path: ["apiKey"], + message: "A Browserbase API key is required for the Browserbase browser source", + }); + } + }) + .meta({ id: "StagehandClientInitParams" }); +} + +export type ClientLLM = z.infer; +export type StagehandClientLoggingConfig = z.input; +export type ResolvedStagehandClientLoggingConfig = z.output< + typeof StagehandClientLoggingConfigSchema +>; +export type StagehandClientActOptions = z.input; +export type StagehandClientObserveOptions = z.input; +export type StagehandClientExtractOptions = z.input; diff --git a/packages/sdk-ts/src/clientSchemas.ts b/packages/sdk-ts/src/clientSchemas.ts index 228217974..07c2637ee 100644 --- a/packages/sdk-ts/src/clientSchemas.ts +++ b/packages/sdk-ts/src/clientSchemas.ts @@ -1,149 +1,2 @@ -/** - * TypeScript SDK-owned schemas. They extend protocol schemas with SDK-only values such as local/CDP - * connection options, JavaScript callbacks, and Page instances. Those values are consumed by the - * SDK and never cross the RPC boundary. Other language SDKs should follow the same pattern around - * the shared wire params. - */ - -import { z } from "zod/v4"; -import { - ActOptionsSchema, - BrowserbaseBrowserSettingsSchema, - BrowserbaseSessionCreateParamsSchema, - ExtractOptionsSchema, - LLMGenerateParamsSchema, - LLMGenerateResultSchema, - ModelConfigSchema, - ObserveOptionsSchema, - StagehandInitParamsSchema, - StagehandLogLevelSchema, - StagehandLogSchema, -} from "../../protocol/schemas.js"; -import { LocalBrowserLaunchOptionsSchema } from "../../protocol/pending-schemas.js"; -import { Page } from "./page.js"; - -const BrowserbaseClientBrowserSettingsSchema = BrowserbaseBrowserSettingsSchema.omit({ - extensionId: true, -}).strict(); - -/** Browserbase source fields exposed by the TS SDK. Stagehand provisions its own extension. */ -export const BrowserbaseBrowserSourceSchema = BrowserbaseSessionCreateParamsSchema.omit({ - browserSettings: true, - extensionId: true, -}) - .extend({ - type: z.literal("browserbase"), - browserSettings: BrowserbaseClientBrowserSettingsSchema.optional(), - }) - .strict() - .meta({ id: "BrowserbaseClientBrowserSource" }); - -export const LocalBrowserSourceSchema = LocalBrowserLaunchOptionsSchema.extend({ - type: z.literal("local"), -}) - .strict() - .meta({ id: "LocalBrowserSource" }); - -export const CdpBrowserSourceSchema = z - .object({ - type: z.literal("cdp"), - cdpUrl: z.string().min(1), - headers: z.record(z.string(), z.string()).optional(), - }) - .strict() - .meta({ id: "CdpBrowserSource" }); - -export const BrowserSourceSchema = z - .discriminatedUnion("type", [ - BrowserbaseBrowserSourceSchema, - LocalBrowserSourceSchema, - CdpBrowserSourceSchema, - ]) - .meta({ id: "BrowserSource" }); - -/** An LLM callback implemented locally by the SDK consumer. It never crosses the wire. */ -export const ClientLLMSchema = z - .object({ - generate: z.function({ - input: [LLMGenerateParamsSchema], - output: z.promise(LLMGenerateResultSchema), - }), - }) - .strict() - .meta({ id: "ClientLLM" }); - -export const StagehandClientLogLevelSchema = z - .union([StagehandLogLevelSchema, z.literal("off")]) - .meta({ id: "StagehandClientLogLevel" }); - -export const StagehandClientLogFormatSchema = z - .enum(["pretty", "json"]) - .meta({ id: "StagehandClientLogFormat" }); - -export const StagehandClientOnLogSchema = z - .function({ - input: [StagehandLogSchema], - output: z.union([z.void(), z.promise(z.void())]), - }) - .meta({ id: "StagehandClientOnLog" }); - -export const StagehandClientLoggingConfigSchema = z - .strictObject({ - level: StagehandClientLogLevelSchema.default("info"), - format: StagehandClientLogFormatSchema.default("pretty"), - onLog: StagehandClientOnLogSchema.optional(), - }) - .meta({ id: "StagehandClientLoggingConfig" }); - -export const StagehandClientActOptionsSchema = ActOptionsSchema.unwrap() - .extend({ - page: z.instanceof(Page).optional(), - }) - .strict() - .meta({ id: "StagehandClientActOptions" }); - -export const StagehandClientObserveOptionsSchema = ObserveOptionsSchema.unwrap() - .extend({ - page: z.instanceof(Page).optional(), - }) - .strict() - .meta({ id: "StagehandClientObserveOptions" }); - -export const StagehandClientExtractOptionsSchema = ExtractOptionsSchema.unwrap() - .extend({ - page: z.instanceof(Page).optional(), - }) - .strict() - .meta({ id: "StagehandClientExtractOptions" }); - -export const StagehandClientInitParamsSchema = StagehandInitParamsSchema.extend({ - browser: BrowserSourceSchema.default({ type: "browserbase" }), - model: z.union([ModelConfigSchema, ClientLLMSchema]).optional(), - logging: StagehandClientLoggingConfigSchema.default({ - level: "info", - format: "pretty", - }), -}) - .strict() - .superRefine((params, context) => { - if (params.browser.type === "browserbase" && params.apiKey === undefined) { - context.addIssue({ - code: "custom", - path: ["apiKey"], - message: "A Browserbase API key is required for the Browserbase browser source", - }); - } - }) - .meta({ id: "StagehandClientInitParams" }); - -export type ClientLLM = z.infer; -export type StagehandClientLoggingConfig = z.input; -export type ResolvedStagehandClientLoggingConfig = z.output< - typeof StagehandClientLoggingConfigSchema ->; -export type StagehandClientActOptions = z.input; -export type StagehandClientObserveOptions = z.input; -export type StagehandClientExtractOptions = z.input; -export type BrowserSource = z.infer; -export type StagehandClientInitParams = z.input; -export type ResolvedStagehandClientInitParams = z.output; +export * from "./clientSchemas.shared.js"; +export * from "./runtime/node/clientSchemas.js"; diff --git a/packages/sdk-ts/src/index.shared.ts b/packages/sdk-ts/src/index.shared.ts new file mode 100644 index 000000000..a09f54210 --- /dev/null +++ b/packages/sdk-ts/src/index.shared.ts @@ -0,0 +1,21 @@ +export { + BrowserContext, + type ClearCookieOptions, + type Cookie, + type CookieParam, + type DomainPolicy, +} from "./browserContext.js"; +export { + BrowserClipboard, + type ClipboardOptions, + type ClipboardPasteOptions, +} from "./browserClipboard.js"; +export { Locator } from "./locator.js"; +export { Page, type ScreenshotOptions } from "./page.js"; +export type { InitScriptSource } from "./pageScripts.js"; +export type { + BrowserGetVersionResult, + RuntimeLoopbackStatusResult, + StagehandMetrics, + StagehandPingResult, +} from "../../protocol/types.js"; diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index e80fcd7a9..58e7cd922 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -1,39 +1,3 @@ -export { - BrowserContext, - type ClearCookieOptions, - type Cookie, - type CookieParam, - type DomainPolicy, -} from "./browserContext.js"; -export { - BrowserClipboard, - type ClipboardOptions, - type ClipboardPasteOptions, -} from "./browserClipboard.js"; -export { Locator } from "./locator.js"; -export { Page, type ScreenshotOptions } from "./page.js"; -export type { InitScriptSource } from "./pageScripts.js"; -export { Stagehand } from "./stagehand.js"; -export type { - BrowserGetVersionResult, - RuntimeLoopbackStatusResult, - StagehandMetrics, - StagehandPingResult, -} from "../../protocol/types.js"; -export { - BrowserSourceSchema, - BrowserbaseBrowserSourceSchema, - CdpBrowserSourceSchema, - ClientLLMSchema, - LocalBrowserSourceSchema, - StagehandClientLogFormatSchema, - StagehandClientLoggingConfigSchema, - StagehandClientLogLevelSchema, - StagehandClientInitParamsSchema, - type BrowserSource, - type ClientLLM, - type ResolvedStagehandClientLoggingConfig, - type ResolvedStagehandClientInitParams, - type StagehandClientLoggingConfig, - type StagehandClientInitParams, -} from "./clientSchemas.js"; +// Source imports and repository tests use the Node-capable variant. Published consumers are routed +// to the Node or web artifact by the package export conditions. +export * from "./runtime/node/index.js"; diff --git a/packages/sdk-ts/src/runtime/node/browserSource.ts b/packages/sdk-ts/src/runtime/node/browserSource.ts new file mode 100644 index 000000000..261d37636 --- /dev/null +++ b/packages/sdk-ts/src/runtime/node/browserSource.ts @@ -0,0 +1,49 @@ +import { LocalBrowserLaunchOptionsSchema } from "../../../../protocol/pending-schemas.js"; +import type { LocalBrowserLaunchOptions } from "../../../../protocol/types.js"; +import { + resolveRemoteBrowserSource, + type RemoteBrowserSourceResolverDependencies, + type ResolvedBrowserSource, +} from "../../browserSource.shared.js"; +import { StagehandClientInitParamsSchema } from "./clientSchemas.js"; +import { launchLocalBrowser } from "./localBrowserLauncher.js"; + +export type { + BrowserbaseSessionClient, + BrowserbaseSessionClientFactory, + ResolvedBrowserSource, +} from "../../browserSource.shared.js"; + +export type LocalBrowserLauncher = ( + options: LocalBrowserLaunchOptions, +) => Promise<{ cdpUrl: string; close: () => Promise | void }>; + +export type BrowserSourceResolverDependencies = RemoteBrowserSourceResolverDependencies & { + launchLocalBrowser?: LocalBrowserLauncher; +}; + +export async function resolveBrowserSource( + input: unknown, + dependencies: BrowserSourceResolverDependencies = {}, +): Promise { + const initParams = StagehandClientInitParamsSchema.parse(input); + const browser = initParams.browser; + + if (browser.type === "local") { + const launchOptions = LocalBrowserLaunchOptionsSchema.strip().parse(browser); + const launched = await (dependencies.launchLocalBrowser ?? launchLocalBrowser)(launchOptions); + return { + cdpUrl: launched.cdpUrl, + keepAlive: launchOptions.keepAlive ?? false, + close: launched.close, + }; + } + + return resolveRemoteBrowserSource( + { + ...(initParams.apiKey === undefined ? {} : { apiKey: initParams.apiKey }), + browser, + }, + dependencies, + ); +} diff --git a/packages/sdk-ts/src/runtime/node/clientSchemas.ts b/packages/sdk-ts/src/runtime/node/clientSchemas.ts new file mode 100644 index 000000000..f3667a19e --- /dev/null +++ b/packages/sdk-ts/src/runtime/node/clientSchemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v4"; +import { + BrowserbaseBrowserSourceSchema, + CdpBrowserSourceSchema, + createStagehandClientInitParamsSchema, + LocalBrowserSourceSchema, +} from "../../clientSchemas.shared.js"; + +export const BrowserSourceSchema = z + .discriminatedUnion("type", [ + BrowserbaseBrowserSourceSchema, + LocalBrowserSourceSchema, + CdpBrowserSourceSchema, + ]) + .meta({ id: "BrowserSource" }); + +export const StagehandClientInitParamsSchema = + createStagehandClientInitParamsSchema(BrowserSourceSchema); + +export type BrowserSource = z.infer; +export type StagehandClientInitParams = z.input; +export type ResolvedStagehandClientInitParams = z.output; diff --git a/packages/sdk-ts/src/runtime/node/index.ts b/packages/sdk-ts/src/runtime/node/index.ts new file mode 100644 index 000000000..0a627b5b5 --- /dev/null +++ b/packages/sdk-ts/src/runtime/node/index.ts @@ -0,0 +1,21 @@ +export * from "../../index.shared.js"; +export { Stagehand } from "./stagehand.js"; +export { + BrowserSourceSchema, + StagehandClientInitParamsSchema, + type BrowserSource, + type ResolvedStagehandClientInitParams, + type StagehandClientInitParams, +} from "./clientSchemas.js"; +export { + BrowserbaseBrowserSourceSchema, + CdpBrowserSourceSchema, + ClientLLMSchema, + LocalBrowserSourceSchema, + StagehandClientLogFormatSchema, + StagehandClientLoggingConfigSchema, + StagehandClientLogLevelSchema, + type ClientLLM, + type ResolvedStagehandClientLoggingConfig, + type StagehandClientLoggingConfig, +} from "../../clientSchemas.shared.js"; diff --git a/packages/sdk-ts/src/localBrowserLauncher.ts b/packages/sdk-ts/src/runtime/node/localBrowserLauncher.ts similarity index 98% rename from packages/sdk-ts/src/localBrowserLauncher.ts rename to packages/sdk-ts/src/runtime/node/localBrowserLauncher.ts index f018f5381..a65e71f18 100644 --- a/packages/sdk-ts/src/localBrowserLauncher.ts +++ b/packages/sdk-ts/src/runtime/node/localBrowserLauncher.ts @@ -62,7 +62,10 @@ const DEFAULT_CHROME_FLAGS = [ "--disable-domain-reliability", "--propagate-iph-for-testing", ] as const; -const STAGEHAND_EXTENSION_PATH = new URL("../../server/dist", import.meta.url).pathname; +const STAGEHAND_EXTENSION_PATH = new URL( + import.meta.url.includes("/dist/node/") ? "./extension" : "../../../../server/dist", + import.meta.url, +).pathname; export async function launchLocalBrowser( options: LocalBrowserLaunchOptions, diff --git a/packages/sdk-ts/src/runtime/node/stagehand.ts b/packages/sdk-ts/src/runtime/node/stagehand.ts new file mode 100644 index 000000000..b9acead76 --- /dev/null +++ b/packages/sdk-ts/src/runtime/node/stagehand.ts @@ -0,0 +1,14 @@ +import { StagehandCore } from "../../stagehand.js"; +import { resolveBrowserSource } from "./browserSource.js"; +import { + StagehandClientInitParamsSchema, + type StagehandClientInitParams, +} from "./clientSchemas.js"; + +export class Stagehand extends StagehandCore { + constructor(initParams: StagehandClientInitParams) { + super(initParams, StagehandClientInitParamsSchema.parse(initParams), { + resolveBrowserSource, + }); + } +} diff --git a/packages/sdk-ts/src/runtime/web/browserSource.ts b/packages/sdk-ts/src/runtime/web/browserSource.ts new file mode 100644 index 000000000..f9a3c9824 --- /dev/null +++ b/packages/sdk-ts/src/runtime/web/browserSource.ts @@ -0,0 +1,14 @@ +import { + resolveRemoteBrowserSource, + type RemoteBrowserSourceResolverDependencies, + type ResolvedBrowserSource, +} from "../../browserSource.shared.js"; +import { StagehandClientInitParamsSchema } from "./clientSchemas.js"; + +export async function resolveBrowserSource( + input: unknown, + dependencies: RemoteBrowserSourceResolverDependencies = {}, +): Promise { + const initParams = StagehandClientInitParamsSchema.parse(input); + return resolveRemoteBrowserSource(initParams, dependencies); +} diff --git a/packages/sdk-ts/src/runtime/web/clientSchemas.ts b/packages/sdk-ts/src/runtime/web/clientSchemas.ts new file mode 100644 index 000000000..c67e415a8 --- /dev/null +++ b/packages/sdk-ts/src/runtime/web/clientSchemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; +import { + BrowserbaseBrowserSourceSchema, + CdpBrowserSourceSchema, + createStagehandClientInitParamsSchema, +} from "../../clientSchemas.shared.js"; + +export const BrowserSourceSchema = z + .discriminatedUnion("type", [BrowserbaseBrowserSourceSchema, CdpBrowserSourceSchema]) + .meta({ id: "BrowserSource" }); + +export const StagehandClientInitParamsSchema = + createStagehandClientInitParamsSchema(BrowserSourceSchema); + +export type BrowserSource = z.infer; +export type StagehandClientInitParams = z.input; +export type ResolvedStagehandClientInitParams = z.output; diff --git a/packages/sdk-ts/src/runtime/web/index.ts b/packages/sdk-ts/src/runtime/web/index.ts new file mode 100644 index 000000000..0a627b5b5 --- /dev/null +++ b/packages/sdk-ts/src/runtime/web/index.ts @@ -0,0 +1,21 @@ +export * from "../../index.shared.js"; +export { Stagehand } from "./stagehand.js"; +export { + BrowserSourceSchema, + StagehandClientInitParamsSchema, + type BrowserSource, + type ResolvedStagehandClientInitParams, + type StagehandClientInitParams, +} from "./clientSchemas.js"; +export { + BrowserbaseBrowserSourceSchema, + CdpBrowserSourceSchema, + ClientLLMSchema, + LocalBrowserSourceSchema, + StagehandClientLogFormatSchema, + StagehandClientLoggingConfigSchema, + StagehandClientLogLevelSchema, + type ClientLLM, + type ResolvedStagehandClientLoggingConfig, + type StagehandClientLoggingConfig, +} from "../../clientSchemas.shared.js"; diff --git a/packages/sdk-ts/src/runtime/web/stagehand.ts b/packages/sdk-ts/src/runtime/web/stagehand.ts new file mode 100644 index 000000000..b9acead76 --- /dev/null +++ b/packages/sdk-ts/src/runtime/web/stagehand.ts @@ -0,0 +1,14 @@ +import { StagehandCore } from "../../stagehand.js"; +import { resolveBrowserSource } from "./browserSource.js"; +import { + StagehandClientInitParamsSchema, + type StagehandClientInitParams, +} from "./clientSchemas.js"; + +export class Stagehand extends StagehandCore { + constructor(initParams: StagehandClientInitParams) { + super(initParams, StagehandClientInitParamsSchema.parse(initParams), { + resolveBrowserSource, + }); + } +} diff --git a/packages/sdk-ts/src/stagehand.ts b/packages/sdk-ts/src/stagehand.ts index 654f1c7ef..464d0e7fe 100644 --- a/packages/sdk-ts/src/stagehand.ts +++ b/packages/sdk-ts/src/stagehand.ts @@ -12,7 +12,7 @@ import type { } from "../../protocol/types.js"; import { z } from "zod/v4"; import { BrowserContext } from "./browserContext.js"; -import { resolveBrowserSource, type ResolvedBrowserSource } from "./browserSource.js"; +import type { ResolvedBrowserSource } from "./browserSource.shared.js"; import { StagehandClientActOptionsSchema, StagehandClientExtractOptionsSchema, @@ -28,13 +28,13 @@ import { import { CDPConnectionClosedError } from "./cdpClient.js"; type StagehandAdapters = { - resolveBrowserSource?: (initParams: StagehandClientInitParams) => Promise; + resolveBrowserSource: ( + initParams: ResolvedStagehandClientInitParams, + ) => Promise; connectRpcClient?: (options: RPCClientOptions) => Promise; }; -const stagehandAdapters = new WeakMap(); - -export class Stagehand { +export class Stagehand { browserContext: BrowserContext | undefined; isInitialized = false; rpcClient: RPCClient | undefined; @@ -43,7 +43,11 @@ export class Stagehand { private resolvedBrowser: ResolvedBrowserSource | undefined; closePromise: Promise | undefined; - constructor(readonly initParams: StagehandClientInitParams) {} + constructor( + readonly initParams: InitParams, + private readonly resolvedInitParams: ResolvedStagehandClientInitParams, + private readonly adapters: StagehandAdapters, + ) {} get context(): BrowserContext { if (!this.browserContext) { @@ -84,13 +88,12 @@ export class Stagehand { return; } - const clientInitParams = StagehandClientInitParamsSchema.parse(this.initParams); - const adapters = stagehandAdapters.get(this) ?? {}; - const browser = await (adapters.resolveBrowserSource ?? resolveBrowserSource)(clientInitParams); + const clientInitParams = this.resolvedInitParams; + const browser = await this.adapters.resolveBrowserSource(clientInitParams); this.resolvedBrowser = browser; try { - const rpcClient = await (adapters.connectRpcClient ?? connectRPCClient)({ + const rpcClient = await (this.adapters.connectRpcClient ?? connectRPCClient)({ cdpUrl: browser.cdpUrl, // TODO: Thread browser.cdpHeaders through CDP discovery and the WebSocket handshake. serviceWorkerUrlIncludes: "service-worker.js", @@ -224,6 +227,8 @@ export class Stagehand { } } +export { Stagehand as StagehandCore }; + function stagehandInitParamsForWorker( initParams: ResolvedStagehandClientInitParams, resolvedBrowser: ResolvedBrowserSource, @@ -249,7 +254,9 @@ function stagehandInitParamsForWorker( }); } -export function createStagehandWithClientForTest(client: RPCClient): Stagehand { +export function createStagehandWithClientForTest( + client: RPCClient, +): Stagehand { return createStagehandWithDependenciesForTest( { browser: { @@ -313,8 +320,6 @@ function reportOnLogError(error: unknown): void { export function createStagehandWithDependenciesForTest( initParams: StagehandClientInitParams, adapters: StagehandAdapters, -): Stagehand { - const stagehand = new Stagehand(initParams); - stagehandAdapters.set(stagehand, adapters); - return stagehand; +): Stagehand { + return new Stagehand(initParams, StagehandClientInitParamsSchema.parse(initParams), adapters); } diff --git a/packages/sdk-ts/tests/browserBundle.test.ts b/packages/sdk-ts/tests/browserBundle.test.ts index 11c3ea0c5..5b82e50db 100644 --- a/packages/sdk-ts/tests/browserBundle.test.ts +++ b/packages/sdk-ts/tests/browserBundle.test.ts @@ -9,7 +9,7 @@ describe("TypeScript SDK browser bundle", () => { build: { write: false, lib: { - entry: new URL("../src/index.ts", import.meta.url).pathname, + entry: new URL("../src/runtime/web/index.ts", import.meta.url).pathname, formats: ["es"], }, }, @@ -28,5 +28,6 @@ describe("TypeScript SDK browser bundle", () => { expect(code).not.toMatch(/(?:from\s*|import\()\s*["']node:/); expect(code).not.toContain("@browserbasehq/sdk"); expect(code).not.toMatch(/\bBuffer\b/); + expect(code).not.toContain("Local browser sources are unavailable in this runtime"); }); }); diff --git a/packages/sdk-ts/tests/localBrowserLauncher.test.ts b/packages/sdk-ts/tests/localBrowserLauncher.test.ts index 01f852dae..c415d0f60 100644 --- a/packages/sdk-ts/tests/localBrowserLauncher.test.ts +++ b/packages/sdk-ts/tests/localBrowserLauncher.test.ts @@ -3,7 +3,7 @@ import { launchLocalBrowser, type LocalBrowserNodeRuntime, type LocalBrowserProcess, -} from "../src/localBrowserLauncher.js"; +} from "../src/runtime/node/localBrowserLauncher.js"; describe("local browser launcher", () => { it("launches Chrome with portable options and reads its assigned debugger port", async () => { diff --git a/packages/sdk-ts/tests/package-exports.test.ts b/packages/sdk-ts/tests/package-exports.test.ts new file mode 100644 index 000000000..766070b9f --- /dev/null +++ b/packages/sdk-ts/tests/package-exports.test.ts @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("TypeScript SDK package conditions", () => { + it("routes Node to the local-capable build and every other runtime to web", async () => { + const packageJson = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { + exports: { + ".": Record; + }; + }; + const rootExport = packageJson.exports["."]; + + expect(Object.keys(rootExport)).toStrictEqual([ + "workerd", + "deno", + "bun", + "browser", + "node", + "types", + "default", + ]); + for (const condition of ["workerd", "deno", "bun", "browser"]) { + expect(rootExport[condition]).toStrictEqual({ + types: "./dist/web/index.d.ts", + default: "./dist/web/index.js", + }); + } + expect(rootExport.node).toStrictEqual({ + types: "./dist/node/index.d.ts", + default: "./dist/node/index.js", + }); + expect(rootExport.types).toBe("./dist/web/index.d.ts"); + expect(rootExport.default).toBe("./dist/web/index.js"); + }); +}); diff --git a/packages/sdk-ts/tests/runtime-portability.test.ts b/packages/sdk-ts/tests/runtime-portability.test.ts index c2dee3760..2d593ade7 100644 --- a/packages/sdk-ts/tests/runtime-portability.test.ts +++ b/packages/sdk-ts/tests/runtime-portability.test.ts @@ -30,11 +30,11 @@ describe("TypeScript SDK runtime portability", () => { } expect(references.sort()).toStrictEqual([ - 'localBrowserLauncher.ts: Node module: "node:child_process"', - 'localBrowserLauncher.ts: Node module: "node:fs/promises"', - 'localBrowserLauncher.ts: Node module: "node:os"', - 'localBrowserLauncher.ts: Node module: "node:path"', - 'localBrowserLauncher.ts: Node module: "node:process"', + 'runtime/node/localBrowserLauncher.ts: Node module: "node:child_process"', + 'runtime/node/localBrowserLauncher.ts: Node module: "node:fs/promises"', + 'runtime/node/localBrowserLauncher.ts: Node module: "node:os"', + 'runtime/node/localBrowserLauncher.ts: Node module: "node:path"', + 'runtime/node/localBrowserLauncher.ts: Node module: "node:process"', ]); }); diff --git a/packages/sdk-ts/tests/runtime-variants.test-d.ts b/packages/sdk-ts/tests/runtime-variants.test-d.ts new file mode 100644 index 000000000..53482a868 --- /dev/null +++ b/packages/sdk-ts/tests/runtime-variants.test-d.ts @@ -0,0 +1,12 @@ +import { expectTypeOf } from "vitest"; +import * as NodeVariant from "../src/runtime/node/index.js"; +import * as WebVariant from "../src/runtime/web/index.js"; + +expectTypeOf>().not.toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf< + ConstructorParameters[0] +>().toEqualTypeOf(); +expectTypeOf< + ConstructorParameters[0] +>().toEqualTypeOf(); diff --git a/packages/sdk-ts/tests/runtime-variants.test.ts b/packages/sdk-ts/tests/runtime-variants.test.ts new file mode 100644 index 000000000..edeb9eff5 --- /dev/null +++ b/packages/sdk-ts/tests/runtime-variants.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import * as NodeVariant from "../src/runtime/node/index.js"; +import * as WebVariant from "../src/runtime/web/index.js"; + +describe("TypeScript SDK runtime variants", () => { + it("keeps local browser support in the Node variant", () => { + expect(NodeVariant.BrowserSourceSchema.parse({ type: "local", headless: true })).toStrictEqual({ + type: "local", + headless: true, + }); + expect( + new NodeVariant.Stagehand({ + browser: { type: "local", headless: true }, + }).initParams, + ).toStrictEqual({ + browser: { type: "local", headless: true }, + }); + }); + + it("rejects local browser input immediately in the web variant", () => { + expect(() => WebVariant.BrowserSourceSchema.parse({ type: "local" })).toThrow(); + expect( + () => + new WebVariant.Stagehand({ + // @ts-expect-error -- The web declaration rejects local sources before runtime validation. + browser: { type: "local" }, + }), + ).toThrow(); + }); + + it("keeps the same public constructor for remote usage in both variants", () => { + const initParams = { + browser: { + type: "cdp" as const, + cdpUrl: "wss://browser.example/devtools/browser/session", + }, + }; + + expect(new NodeVariant.Stagehand(initParams).initParams).toStrictEqual(initParams); + expect(new WebVariant.Stagehand(initParams).initParams).toStrictEqual(initParams); + }); +}); diff --git a/packages/sdk-ts/tsdown.config.ts b/packages/sdk-ts/tsdown.config.ts index a29a89de7..29c47cf92 100644 --- a/packages/sdk-ts/tsdown.config.ts +++ b/packages/sdk-ts/tsdown.config.ts @@ -1,10 +1,13 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: "src/index.ts", + entry: { + "node/index": "src/runtime/node/index.ts", + "web/index": "src/runtime/web/index.ts", + }, format: ["esm"], - platform: "node", - target: "node22", + platform: "neutral", + target: "es2022", dts: { sourcemap: true, }, @@ -23,11 +26,11 @@ export default defineConfig({ copy: [ { from: "../server/artifacts/stagehand-extension.zip", - to: "dist/assets", + to: "dist/node/assets", }, { from: "../server/dist", - to: "dist", + to: "dist/node", rename: "extension", }, ],