diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 0c3d5fa..7f5295c 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -66,6 +66,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Typecheck tests + run: yarn typecheck:tests + - name: Build package run: yarn build diff --git a/package.json b/package.json index a394ab4..b5b8c54 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "type": "commonjs", "license": "MIT", "scripts": { - "build": "tsc", + "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "yarn clean && tsc", + "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", "lint": "eslint src/**/*.ts", "prepare": "yarn build", "test": "vitest run", @@ -40,6 +42,9 @@ "ai" ], "dependencies": { + "@types/node": "^22.9.1", + "@types/node-fetch": "^2.6.4", + "@types/ws": "^8.18.1", "form-data": "^4.0.1", "node-fetch": "2.7.0", "ws": "^8.19.0", @@ -47,9 +52,6 @@ "zod-to-json-schema": "^3.25.0" }, "devDependencies": { - "@types/node": "^22.9.1", - "@types/node-fetch": "^2.6.4", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "dotenv": "^17.3.1", diff --git a/src/client.ts b/src/client.ts index 5325859..cfa6330 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,7 +12,7 @@ import { HyperAgentService } from "./services/agents/hyper-agent"; import { TeamService } from "./services/team"; import { ComputerActionService } from "./services/computer-action"; import { GeminiComputerUseService } from "./services/agents/gemini-computer-use"; -import { WebService } from "./services/web"; +import { WebService } from "./services/web/index"; import { SandboxesService } from "./services/sandboxes"; import { VolumesService } from "./services/volumes"; diff --git a/src/sandbox/base.ts b/src/sandbox/base.ts index b1b020a..465e773 100644 --- a/src/sandbox/base.ts +++ b/src/sandbox/base.ts @@ -52,12 +52,13 @@ export class RuntimeTransport { async requestJSON(path: string, init?: RequestInit, params?: RuntimeParams): Promise { const response = await this.fetchWithAuth(path, init, params); - if (response.headers.get("content-length") === "0") { + const responseText = await response.text(); + if (!responseText) { return {} as T; } try { - return (await response.json()) as T; + return JSON.parse(responseText) as T; } catch { throw new HyperbrowserError("Failed to parse JSON response", { statusCode: response.status, diff --git a/src/sandbox/files.ts b/src/sandbox/files.ts index 81858b1..3ec9700 100644 --- a/src/sandbox/files.ts +++ b/src/sandbox/files.ts @@ -1,5 +1,5 @@ import { Blob, Buffer } from "buffer"; -import nodePath from "path"; +import nodePath from "node:path/posix"; import { ReadableStream } from "node:stream/web"; import WebSocket from "ws"; import { HyperbrowserError } from "../client"; @@ -158,7 +158,7 @@ const relativeWatchName = (root: string, absolutePath: string): string => { if (!relative || relative === ".") { return nodePath.basename(absolutePath); } - return relative.split(nodePath.sep).join("/"); + return relative; }; const isReadableStreamLike = (value: SandboxFileWriteData): value is ReadableStream => { @@ -248,9 +248,10 @@ const encodeWriteData = async ( class RuntimeFileWatchHandle { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly status: RawFileWatchStatus, - private readonly runtimeProxyOverride?: string + private readonly runtimeProxyOverride?: string, + private readonly webSocketTimeout?: number ) {} get id(): string { @@ -271,23 +272,40 @@ class RuntimeFileWatchHandle { } async *events(cursor?: number): AsyncGenerator { - const connectionInfo = await this.getConnectionInfo(); - const target = toWebSocketUrl( - connectionInfo.baseUrl, - `/sandbox/files/watch/${this.status.id}/ws?sessionId=${encodeURIComponent( - connectionInfo.sandboxId - )}${cursor !== undefined ? `&cursor=${encodeURIComponent(String(cursor))}` : ""}`, - this.runtimeProxyOverride - ); + const buildTarget = async (forceRefresh: boolean = false) => { + const connectionInfo = await this.getConnectionInfo(forceRefresh); + const target = toWebSocketUrl( + connectionInfo.baseUrl, + `/sandbox/files/watch/${this.status.id}/ws?sessionId=${encodeURIComponent( + connectionInfo.sandboxId + )}${cursor !== undefined ? `&cursor=${encodeURIComponent(String(cursor))}` : ""}`, + this.runtimeProxyOverride + ); + + const headers: Record = { + Authorization: `Bearer ${connectionInfo.token}`, + }; + if (target.hostHeader) { + headers.Host = target.hostHeader; + } - const headers: Record = { - Authorization: `Bearer ${connectionInfo.token}`, + return { target, headers }; }; - if (target.hostHeader) { - headers.Host = target.hostHeader; - } - const ws = await openRuntimeWebSocket(target, headers); + const openSocket = async () => { + const { target, headers } = await buildTarget(); + try { + return await openRuntimeWebSocket(target, headers, this.webSocketTimeout); + } catch (error) { + if (error instanceof HyperbrowserError && error.statusCode === 401) { + const refreshed = await buildTarget(true); + return openRuntimeWebSocket(refreshed.target, refreshed.headers, this.webSocketTimeout); + } + throw error; + } + }; + + const ws = await openSocket(); const queue = new AsyncEventQueue(); ws.on("message", (data) => { @@ -405,9 +423,10 @@ export class SandboxWatchDirHandle { export class SandboxFilesApi { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly runtimeProxyOverride?: string, - private readonly defaultRunAs?: string + private readonly defaultRunAs?: string, + private readonly webSocketTimeout?: number ) {} withRunAs(runAs?: string): SandboxFilesApi { @@ -416,7 +435,8 @@ export class SandboxFilesApi { this.transport, this.getConnectionInfo, this.runtimeProxyOverride, - normalized ? normalized : undefined + normalized ? normalized : undefined, + this.webSocketTimeout ); } @@ -716,7 +736,8 @@ export class SandboxFilesApi { this.transport, this.getConnectionInfo, response.watch, - this.runtimeProxyOverride + this.runtimeProxyOverride, + this.webSocketTimeout ); return new SandboxWatchDirHandle(watch, onEvent, options.onExit, options.timeoutMs); diff --git a/src/sandbox/terminal.ts b/src/sandbox/terminal.ts index 1446679..b6d9f42 100644 --- a/src/sandbox/terminal.ts +++ b/src/sandbox/terminal.ts @@ -1,6 +1,7 @@ import WebSocket from "ws"; import { RuntimeTransport } from "./base"; import { AsyncEventQueue, openRuntimeWebSocket, toWebSocketUrl } from "./ws"; +import { HyperbrowserError } from "../client"; import { SandboxTerminalCreateParams, SandboxTerminalEvent, @@ -180,9 +181,10 @@ export class SandboxTerminalConnection { export class SandboxTerminalHandle { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private status: SandboxTerminalStatus, - private readonly runtimeProxyOverride?: string + private readonly runtimeProxyOverride?: string, + private readonly webSocketTimeout?: number ) {} get id(): string { @@ -268,27 +270,41 @@ export class SandboxTerminalHandle { } async attach(cursor?: number | string): Promise { - const connectionInfo = await this.getConnectionInfo(); - const query = new URLSearchParams({ - sessionId: connectionInfo.sandboxId, - }); - if (cursor !== undefined) { - query.set("cursor", String(cursor)); - } - const target = toWebSocketUrl( - connectionInfo.baseUrl, - `/sandbox/pty/${this.id}/ws?${query.toString()}`, - this.runtimeProxyOverride - ); + const buildTarget = async (forceRefresh: boolean = false) => { + const connectionInfo = await this.getConnectionInfo(forceRefresh); + const query = new URLSearchParams({ + sessionId: connectionInfo.sandboxId, + }); + if (cursor !== undefined) { + query.set("cursor", String(cursor)); + } + const target = toWebSocketUrl( + connectionInfo.baseUrl, + `/sandbox/pty/${this.id}/ws?${query.toString()}`, + this.runtimeProxyOverride + ); + + const headers: Record = { + Authorization: `Bearer ${connectionInfo.token}`, + }; + if (target.hostHeader) { + headers.Host = target.hostHeader; + } - const headers: Record = { - Authorization: `Bearer ${connectionInfo.token}`, + return { target, headers }; }; - if (target.hostHeader) { - headers.Host = target.hostHeader; - } - const ws = await openRuntimeWebSocket(target, headers); + const { target, headers } = await buildTarget(); + let ws: WebSocket; + try { + ws = await openRuntimeWebSocket(target, headers, this.webSocketTimeout); + } catch (error) { + if (!(error instanceof HyperbrowserError) || error.statusCode !== 401) { + throw error; + } + const refreshed = await buildTarget(true); + ws = await openRuntimeWebSocket(refreshed.target, refreshed.headers, this.webSocketTimeout); + } return new SandboxTerminalConnection(ws); } @@ -297,8 +313,9 @@ export class SandboxTerminalHandle { export class SandboxTerminalApi { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, - private readonly runtimeProxyOverride?: string + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, + private readonly runtimeProxyOverride?: string, + private readonly webSocketTimeout?: number ) {} async create(params: SandboxTerminalCreateParams): Promise { @@ -314,7 +331,8 @@ export class SandboxTerminalApi { this.transport, this.getConnectionInfo, normalizeTerminalStatus(response.pty), - this.runtimeProxyOverride + this.runtimeProxyOverride, + this.webSocketTimeout ); } @@ -329,7 +347,8 @@ export class SandboxTerminalApi { this.transport, this.getConnectionInfo, normalizeTerminalStatus(response.pty), - this.runtimeProxyOverride + this.runtimeProxyOverride, + this.webSocketTimeout ); } } diff --git a/src/sandbox/ws.ts b/src/sandbox/ws.ts index 61f83d2..f766d33 100644 --- a/src/sandbox/ws.ts +++ b/src/sandbox/ws.ts @@ -158,7 +158,7 @@ export const resolveRuntimeTransportTarget = ( url.username = override.username; url.password = override.password; url.hostname = override.hostname; - url.port = override.port || url.port; + url.port = override.port; return { url: url.toString(), @@ -252,12 +252,13 @@ const buildHandshakeError = async (response: IncomingMessage): Promise + headers: Record, + timeout: number = 30000 ): Promise => new Promise((resolve, reject) => { let settled = false; - const socket = new WebSocket(target.url, { headers }); + const socket = new WebSocket(target.url, { headers, handshakeTimeout: timeout }); const rejectOnce = (error: unknown) => { if (settled) { diff --git a/src/services/base.ts b/src/services/base.ts index f455424..bac8d66 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -97,12 +97,13 @@ export class BaseService { }); } - if (response.headers.get("content-length") === "0") { + const responseText = await response.text(); + if (!responseText) { return {} as T; } try { - return (await response.json()) as T; + return JSON.parse(responseText) as T; } catch { throw new HyperbrowserError("Failed to parse JSON response", { statusCode: response.status, diff --git a/src/services/crawl.ts b/src/services/crawl.ts index 9990012..d3443ca 100644 --- a/src/services/crawl.ts +++ b/src/services/crawl.ts @@ -83,7 +83,7 @@ export class CrawlService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } diff --git a/src/services/extract.ts b/src/services/extract.ts index 050ed1b..b382c68 100644 --- a/src/services/extract.ts +++ b/src/services/extract.ts @@ -87,7 +87,7 @@ export class ExtractService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { return await this.get(jobId); } failures = 0; diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 826b4c8..15de0bc 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -204,13 +204,16 @@ export class SandboxHandle { this.processes = new SandboxProcessesApi(this.transport); this.files = new SandboxFilesApi( this.transport, - () => this.resolveRuntimeSocketConnectionInfo(), - service.runtimeProxyOverride + (forceRefresh) => this.resolveRuntimeSocketConnectionInfo(forceRefresh), + service.runtimeProxyOverride, + undefined, + service.runtimeTimeout ); this.terminal = new SandboxTerminalApi( this.transport, - () => this.resolveRuntimeSocketConnectionInfo(), - service.runtimeProxyOverride + (forceRefresh) => this.resolveRuntimeSocketConnectionInfo(forceRefresh), + service.runtimeProxyOverride, + service.runtimeTimeout ); this.pty = this.terminal; } @@ -342,12 +345,12 @@ export class SandboxHandle { }; } - private async resolveRuntimeSocketConnectionInfo(): Promise<{ + private async resolveRuntimeSocketConnectionInfo(forceRefresh: boolean = false): Promise<{ sandboxId: string; baseUrl: string; token: string; }> { - const session = await this.ensureRuntimeSession(); + const session = await this.ensureRuntimeSession(forceRefresh); return { sandboxId: this.id, baseUrl: session.runtime.baseUrl, diff --git a/src/services/scrape.ts b/src/services/scrape.ts index 9d50ed7..aba91eb 100644 --- a/src/services/scrape.ts +++ b/src/services/scrape.ts @@ -87,7 +87,7 @@ export class BatchScrapeService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } @@ -236,7 +236,7 @@ export class ScrapeService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { return await this.get(jobId); } failures = 0; diff --git a/src/services/sessions.ts b/src/services/sessions.ts index e8c7fe3..ef88ce0 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -237,13 +237,6 @@ export class SessionsService extends BaseService { const fileStream = createReadStream(fileInput); const fileBaseName = fileName || path.basename(fileInput); - fileStream.on("error", (error) => { - throw new HyperbrowserError( - `Failed to read file ${fileInput}: ${error.message}`, - undefined - ); - }); - formData.append("file", fileStream, { filename: fileBaseName, }); diff --git a/src/services/web/batch-fetch.ts b/src/services/web/batch-fetch.ts index c70f800..8eb7d84 100644 --- a/src/services/web/batch-fetch.ts +++ b/src/services/web/batch-fetch.ts @@ -105,7 +105,7 @@ export class BatchFetchService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } diff --git a/src/services/web/crawl.ts b/src/services/web/crawl.ts index a2c7ecf..a5072b7 100644 --- a/src/services/web/crawl.ts +++ b/src/services/web/crawl.ts @@ -105,7 +105,7 @@ export class WebCrawlService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } diff --git a/src/types/constants.ts b/src/types/constants.ts index c9b0f58..885723b 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -4,9 +4,9 @@ export type SessionEventLogType = | "captcha_error" | "file_downloaded"; export type ScrapeFormat = "markdown" | "html" | "links" | "screenshot"; -export type ScrapeJobStatus = "pending" | "running" | "completed" | "failed"; -export type ExtractJobStatus = "pending" | "running" | "completed" | "failed"; -export type CrawlJobStatus = "pending" | "running" | "completed" | "failed"; +export type ScrapeJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; +export type ExtractJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; +export type CrawlJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export type BrowserUseTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export type CuaTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export type HyperAgentTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"; diff --git a/src/types/web/batch-fetch.ts b/src/types/web/batch-fetch.ts index 28c8ea4..9a4ee06 100644 --- a/src/types/web/batch-fetch.ts +++ b/src/types/web/batch-fetch.ts @@ -7,7 +7,7 @@ import { PageData, } from "./common"; -export type BatchFetchJobStatus = "pending" | "running" | "completed" | "failed"; +export type BatchFetchJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export interface StartBatchFetchJobParams { urls: string[]; diff --git a/src/types/web/crawl.ts b/src/types/web/crawl.ts index 7081da1..97ff4d5 100644 --- a/src/types/web/crawl.ts +++ b/src/types/web/crawl.ts @@ -7,7 +7,7 @@ import { PageData, } from "./common"; -export type WebCrawlJobStatus = "pending" | "running" | "completed" | "failed"; +export type WebCrawlJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export interface WebCrawlOptions { maxPages?: number; diff --git a/tests/sandbox/e2e/process-api.test.ts b/tests/sandbox/e2e/process-api.test.ts index a4ede9f..7b79274 100644 --- a/tests/sandbox/e2e/process-api.test.ts +++ b/tests/sandbox/e2e/process-api.test.ts @@ -143,8 +143,13 @@ describe("sandbox process api", () => { test("sandbox handle exec forwards string options to processes.exec", async () => { const exec = vi.fn().mockResolvedValue(execResponse.result); + const execMethod = SandboxHandle.prototype.exec as unknown as ( + this: { processes: { exec: typeof exec } }, + input: string, + options?: { runAs: string } + ) => Promise; - await SandboxHandle.prototype.exec.call( + await execMethod.call( { processes: { exec }, }, diff --git a/tests/sandbox/e2e/runtime-transport.test.ts b/tests/sandbox/e2e/runtime-transport.test.ts index 94be667..a1c0ecc 100644 --- a/tests/sandbox/e2e/runtime-transport.test.ts +++ b/tests/sandbox/e2e/runtime-transport.test.ts @@ -52,6 +52,19 @@ describe("sandbox runtime transport target", () => { }); }); + test("clears the runtime port when the proxy override has no explicit port", () => { + const target = resolveRuntimeTransportTarget( + "https://session.example.dev:8443", + "/sandbox/exec?foo=bar", + "http://127.0.0.1" + ); + + expect(target).toEqual({ + url: "http://127.0.0.1/sandbox/exec?foo=bar", + hostHeader: "session.example.dev:8443", + }); + }); + test("applies the explicit override to websocket targets", () => { const target = toWebSocketUrl( "https://session.example.dev:8443", diff --git a/tests/sandbox/e2e/sandbox-contract.test.ts b/tests/sandbox/e2e/sandbox-contract.test.ts index c1e6541..823d748 100644 --- a/tests/sandbox/e2e/sandbox-contract.test.ts +++ b/tests/sandbox/e2e/sandbox-contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi, afterEach } from "vitest"; import { SandboxFilesApi } from "../../../src/sandbox/files"; import { SandboxTerminalHandle } from "../../../src/sandbox/terminal"; import * as wsModule from "../../../src/sandbox/ws"; +import { HyperbrowserError } from "../../../src/client"; import { SandboxesService } from "../../../src/services/sandboxes"; import type { SandboxExposeResult } from "../../../src/types"; @@ -79,7 +80,8 @@ describe("sandbox control and runtime contract", () => { method: "POST", }) ); - expect(JSON.parse(requestSpy.mock.calls[0][1].body)).toEqual({ + const createRequest = requestSpy.mock.calls[0][1] as { body: string }; + expect(JSON.parse(createRequest.body)).toEqual({ imageName: "node", exposedPorts: [{ port: 3000, auth: true }], mounts: { @@ -122,7 +124,8 @@ describe("sandbox control and runtime contract", () => { method: "POST", }) ); - expect(JSON.parse(requestSpy.mock.calls[0][1].body)).toEqual({ + const createRequest = requestSpy.mock.calls[0][1] as { body: string }; + expect(JSON.parse(createRequest.body)).toEqual({ snapshotName: "snapshot-1", mounts: { "/workspace/readonly": { @@ -307,4 +310,103 @@ describe("sandbox control and runtime contract", () => { undefined ); }); + + test("terminal attach refreshes runtime auth once after a 401 handshake", async () => { + const openRuntimeWebSocketSpy = vi + .spyOn(wsModule, "openRuntimeWebSocket") + .mockRejectedValueOnce( + new HyperbrowserError("expired runtime token", { + statusCode: 401, + service: "runtime", + }) + ) + .mockResolvedValueOnce({ + on: vi.fn(), + once: vi.fn(), + close: vi.fn(), + send: vi.fn(), + readyState: 1, + } as any); + const getConnectionInfo = vi + .fn() + .mockResolvedValueOnce({ + sandboxId: "sbx_123", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", + token: "old-token", + }) + .mockResolvedValueOnce({ + sandboxId: "sbx_123", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", + token: "new-token", + }); + + const terminal = new SandboxTerminalHandle( + {} as any, + getConnectionInfo, + { + id: "pty_123", + command: "bash", + cwd: "/", + running: true, + rows: 24, + cols: 80, + startedAt: Date.now(), + } + ); + + await terminal.attach(); + + expect(getConnectionInfo).toHaveBeenNthCalledWith(1, false); + expect(getConnectionInfo).toHaveBeenNthCalledWith(2, true); + expect(openRuntimeWebSocketSpy).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + expect.objectContaining({ Authorization: "Bearer old-token" }), + undefined + ); + expect(openRuntimeWebSocketSpy).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ Authorization: "Bearer new-token" }), + undefined + ); + }); + + test("terminal attach forwards the websocket handshake timeout", async () => { + const openRuntimeWebSocketSpy = vi.spyOn(wsModule, "openRuntimeWebSocket").mockResolvedValue({ + on: vi.fn(), + once: vi.fn(), + close: vi.fn(), + send: vi.fn(), + readyState: 1, + } as any); + + const terminal = new SandboxTerminalHandle( + {} as any, + async () => ({ + sandboxId: "sbx_123", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", + token: "runtime-token", + }), + { + id: "pty_123", + command: "bash", + cwd: "/", + running: true, + rows: 24, + cols: 80, + startedAt: Date.now(), + }, + undefined, + 12_345 + ); + + await terminal.attach(); + + expect(openRuntimeWebSocketSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ Authorization: "Bearer runtime-token" }), + 12_345 + ); + }); });