diff --git a/package.json b/package.json index 7ecfc25..0ed69a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "0.89.2", + "version": "0.89.3", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": { diff --git a/src/sandbox/base.ts b/src/sandbox/base.ts index 1be43a7..b1b020a 100644 --- a/src/sandbox/base.ts +++ b/src/sandbox/base.ts @@ -199,18 +199,9 @@ export class RuntimeTransport { init?: RequestInit, params?: RuntimeParams ): Promise { - const url = new URL(path, this.normalizeBaseUrl(connection.baseUrl)); - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url.searchParams.append(key, String(value)); - } - } - } - const target = resolveRuntimeTransportTarget( connection.baseUrl, - `${url.pathname}${url.search}`, + this.buildRequestPath(path, params), this.runtimeProxyOverride ); const headers = this.buildHeaders(connection, init?.headers, target.hostHeader); @@ -307,7 +298,20 @@ export class RuntimeTransport { return headers; } - private normalizeBaseUrl(baseUrl: string): string { - return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + private buildRequestPath(path: string, params?: RuntimeParams): string { + const trimmed = path.trim(); + const [rawPath, rawQuery = ""] = trimmed.split("?", 2); + const queryParams = new URLSearchParams(rawQuery); + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + queryParams.append(key, String(value)); + } + } + } + + const query = queryParams.toString(); + return query ? `${rawPath}?${query}` : rawPath; } } diff --git a/src/sandbox/runtime-path.ts b/src/sandbox/runtime-path.ts new file mode 100644 index 0000000..ddd4f16 --- /dev/null +++ b/src/sandbox/runtime-path.ts @@ -0,0 +1,15 @@ +export const runtimeSessionIdFromPath = (rawPath: string): string | null => { + const segments = rawPath + .trim() + .replace(/^\/+|\/+$/g, "") + .split("/") + .filter(Boolean); + if (segments.length < 2 || segments[0] !== "sandbox" || !segments[1]?.trim()) { + return null; + } + return segments[1].trim(); +}; + +export const runtimeBaseUrlSessionId = (runtimeBaseUrl: string): string | null => { + return runtimeSessionIdFromPath(new URL(runtimeBaseUrl).pathname); +}; diff --git a/src/sandbox/ws.ts b/src/sandbox/ws.ts index 0ac09b3..61f83d2 100644 --- a/src/sandbox/ws.ts +++ b/src/sandbox/ws.ts @@ -1,6 +1,7 @@ import type { IncomingMessage } from "http"; import WebSocket from "ws"; import { HyperbrowserError } from "../client"; +import { runtimeBaseUrlSessionId } from "./runtime-path"; export class AsyncEventQueue implements AsyncIterable { private readonly values: T[] = []; @@ -90,12 +91,56 @@ const RETRYABLE_NETWORK_CODES = new Set([ const hasScheme = (value: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//i.test(value); +const shouldPrependSandboxToRuntimeAPI = (runtimeBaseUrl: string): boolean => { + return runtimeBaseUrlSessionId(runtimeBaseUrl) === null; +}; + +const normalizeRuntimeAPIPath = (pathname: string, prependSandbox: boolean): string => { + const trimmed = pathname.trim(); + if (!trimmed) { + return prependSandbox ? "/sandbox" : "/"; + } + + const absolute = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (prependSandbox) { + if (absolute === "/sandbox" || absolute.startsWith("/sandbox/")) { + return absolute; + } + return `/sandbox${absolute}`; + } + + if (absolute === "/sandbox") { + return "/"; + } + if (absolute.startsWith("/sandbox/")) { + return `/${absolute.slice("/sandbox/".length)}`; + } + return absolute; +}; + +const normalizeRuntimeRelativePath = (baseUrl: string, path: string): string => { + const trimmed = path.trim(); + if (!trimmed) { + return ""; + } + + const parsedPath = new URL(trimmed, "http://runtime.local"); + const prependSandbox = shouldPrependSandboxToRuntimeAPI(baseUrl); + const normalizedPath = normalizeRuntimeAPIPath(parsedPath.pathname, prependSandbox); + + const relativePath = normalizedPath.replace(/^\/+/, ""); + return `${relativePath}${parsedPath.search}${parsedPath.hash}`; +}; + export const resolveRuntimeTransportTarget = ( baseUrl: string, path: string, runtimeProxyOverride?: string ): RuntimeTransportTarget => { - const url = new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); + const url = new URL( + normalizeRuntimeRelativePath(baseUrl, path), + baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/` + ); if (!runtimeProxyOverride) { return { diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 7d6b485..826b4c8 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -1,6 +1,7 @@ import { HyperbrowserError } from "../client"; import { SandboxFilesApi } from "../sandbox/files"; import { RuntimeConnection, RuntimeTransport } from "../sandbox/base"; +import { runtimeSessionIdFromPath } from "../sandbox/runtime-path"; import { SandboxProcessHandle, SandboxProcessesApi } from "../sandbox/process"; import { SandboxTerminalApi } from "../sandbox/terminal"; import { BasicResponse } from "../types/session"; @@ -135,11 +136,40 @@ type SandboxRuntimeState = { runtime: SandboxDetail["runtime"]; }; +const resolveSandboxRuntimeSessionHost = ( + runtime: SandboxDetail["runtime"], + baseUrl: URL +): string => { + const sessionIdFromBasePath = runtimeSessionIdFromPath(baseUrl.pathname); + if (sessionIdFromBasePath && baseUrl.hostname) { + return `${sessionIdFromBasePath}.${baseUrl.hostname}`; + } + + const runtimeHost = runtime.host?.trim() || ""; + if (runtimeHost) { + try { + const parsedHost = new URL(runtimeHost); + const sessionIdFromHostPath = runtimeSessionIdFromPath(parsedHost.pathname); + if (sessionIdFromHostPath && parsedHost.hostname) { + return `${sessionIdFromHostPath}.${parsedHost.hostname}`; + } + if (parsedHost.hostname) { + return parsedHost.hostname; + } + } catch { + return runtimeHost; + } + } + + return baseUrl.hostname; +}; + const buildSandboxExposedUrl = (runtime: SandboxDetail["runtime"], port: number): string => { const baseUrl = new URL(runtime.baseUrl); + const sessionHost = resolveSandboxRuntimeSessionHost(runtime, baseUrl); const authority = baseUrl.port - ? `${port}-${runtime.host}:${baseUrl.port}` - : `${port}-${runtime.host}`; + ? `${port}-${sessionHost}:${baseUrl.port}` + : `${port}-${sessionHost}`; return new URL("/", `${baseUrl.protocol}//${authority}`).toString(); }; diff --git a/tests/sandbox/e2e/list-contract.test.ts b/tests/sandbox/e2e/list-contract.test.ts index bf65ed7..a4edb82 100644 --- a/tests/sandbox/e2e/list-contract.test.ts +++ b/tests/sandbox/e2e/list-contract.test.ts @@ -27,15 +27,15 @@ describe("sandbox control list contract", () => { proxyBytesUsed: 3, runtime: { transport: "regional_proxy" as const, - host: "runtime.example.com", - baseUrl: "https://runtime.example.com", + host: "https://runtime.example.com", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", }, exposedPorts: [ { port: 3000, auth: true, - url: "https://3000-runtime.example.com/", - browserUrl: "https://3000-runtime.example.com/_hb/auth?grant=token&next=%2F", + url: "https://3000-sbx_123.runtime.example.com/", + browserUrl: "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=token&next=%2F", browserUrlExpiresAt: "2026-03-12T00:00:01Z", }, ], diff --git a/tests/sandbox/e2e/runtime-transport.test.ts b/tests/sandbox/e2e/runtime-transport.test.ts index 7f11022..94be667 100644 --- a/tests/sandbox/e2e/runtime-transport.test.ts +++ b/tests/sandbox/e2e/runtime-transport.test.ts @@ -28,6 +28,17 @@ describe("sandbox runtime transport target", () => { }); }); + test("prepends /sandbox for session-host runtimes when callers pass relative runtime paths", () => { + const target = resolveRuntimeTransportTarget( + "https://session.example.dev:8443", + "/exec?foo=bar" + ); + + expect(target).toEqual({ + url: "https://session.example.dev:8443/sandbox/exec?foo=bar", + }); + }); + test("applies an explicit runtime proxy override and preserves the original host header", () => { const target = resolveRuntimeTransportTarget( "https://session.example.dev:8443", @@ -53,4 +64,39 @@ describe("sandbox runtime transport target", () => { hostHeader: "session.example.dev:8443", }); }); + + test("preserves runtime base path prefixes", () => { + const target = resolveRuntimeTransportTarget( + "https://region.example.dev/sandbox/sbx_123", + "/sandbox/exec?foo=bar" + ); + + expect(target).toEqual({ + url: "https://region.example.dev/sandbox/sbx_123/exec?foo=bar", + }); + }); + + test("does not double-prefix /sandbox for region-path runtimes when callers pass relative runtime paths", () => { + const target = resolveRuntimeTransportTarget( + "https://region.example.dev/sandbox/sbx_123", + "/exec?foo=bar" + ); + + expect(target).toEqual({ + url: "https://region.example.dev/sandbox/sbx_123/exec?foo=bar", + }); + }); + + test("preserves runtime base path prefixes for websocket targets with overrides", () => { + const target = toWebSocketUrl( + "https://region.example.dev/sandbox/sbx_123", + "/sandbox/pty/pty_123/ws?sessionId=sandbox_123", + "http://127.0.0.1:8090" + ); + + expect(target).toEqual({ + url: "ws://127.0.0.1:8090/sandbox/sbx_123/pty/pty_123/ws?sessionId=sandbox_123", + hostHeader: "region.example.dev", + }); + }); }); diff --git a/tests/sandbox/e2e/sandbox-contract.test.ts b/tests/sandbox/e2e/sandbox-contract.test.ts index aa9d695..c1e6541 100644 --- a/tests/sandbox/e2e/sandbox-contract.test.ts +++ b/tests/sandbox/e2e/sandbox-contract.test.ts @@ -29,8 +29,8 @@ const wireSandboxDetail = (overrides: Record = {}): Record { { port: 3000, auth: true, - url: "https://3000-runtime.example.com/", - browserUrl: "https://3000-runtime.example.com/_hb/auth?grant=token&next=%2F", + url: "https://3000-sbx_123.runtime.example.com/", + browserUrl: "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=token&next=%2F", browserUrlExpiresAt: "2026-03-12T13:00:00Z", }, ], @@ -99,7 +99,7 @@ describe("sandbox control and runtime contract", () => { memoryMiB: 2048, diskMiB: 8192, }); - expect(sandbox.getExposedUrl(3000)).toBe("https://3000-runtime.example.com/"); + expect(sandbox.getExposedUrl(3000)).toBe("https://3000-sbx_123.runtime.example.com/"); }); test("create forwards mounts for snapshot launches", async () => { @@ -141,8 +141,8 @@ describe("sandbox control and runtime contract", () => { .mockResolvedValueOnce({ port: 3000, auth: true, - url: "https://3000-runtime.example.com/", - browserUrl: "https://3000-runtime.example.com/_hb/auth?grant=token&next=%2F", + url: "https://3000-sbx_123.runtime.example.com/", + browserUrl: "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=token&next=%2F", browserUrlExpiresAt: "2026-03-12T13:00:00Z", }) .mockResolvedValueOnce({ @@ -199,8 +199,8 @@ describe("sandbox control and runtime contract", () => { diskSizeMiB: 8192, runtime: { transport: "regional_proxy", - host: "runtime.example.com", - baseUrl: "https://runtime.example.com", + host: "https://runtime.example.com", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", }, exposedPorts: [], }, @@ -236,7 +236,7 @@ describe("sandbox control and runtime contract", () => { }); const files = new SandboxFilesApi({ requestJSON } as any, async () => ({ sandboxId: "sbx_123", - baseUrl: "https://runtime.example.com", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", token: "runtime-token", })); @@ -285,7 +285,7 @@ describe("sandbox control and runtime contract", () => { {} as any, async () => ({ sandboxId: "sbx_123", - baseUrl: "https://runtime.example.com", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", token: "runtime-token", }), { @@ -302,7 +302,7 @@ describe("sandbox control and runtime contract", () => { await terminal.attach(10); expect(toWebSocketUrlSpy).toHaveBeenCalledWith( - "https://runtime.example.com", + "https://runtime.example.com/sandbox/sbx_123", "/sandbox/pty/pty_123/ws?sessionId=sbx_123&cursor=10", undefined );