Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperbrowser/sdk",
"version": "0.89.2",
"version": "0.89.3",
"description": "Node SDK for Hyperbrowser API",
"author": "",
"repository": {
Expand Down
28 changes: 16 additions & 12 deletions src/sandbox/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,9 @@ export class RuntimeTransport {
init?: RequestInit,
params?: RuntimeParams
): Promise<Response> {
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);
Expand Down Expand Up @@ -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;
}
}
15 changes: 15 additions & 0 deletions src/sandbox/runtime-path.ts
Original file line number Diff line number Diff line change
@@ -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);
};
47 changes: 46 additions & 1 deletion src/sandbox/ws.ts
Original file line number Diff line number Diff line change
@@ -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<T> implements AsyncIterable<T> {
private readonly values: T[] = [];
Expand Down Expand Up @@ -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;
};
Comment thread
cursor[bot] marked this conversation as resolved.

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 {
Expand Down
34 changes: 32 additions & 2 deletions src/services/sandboxes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
};

Expand Down
8 changes: 4 additions & 4 deletions tests/sandbox/e2e/list-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
Expand Down
46 changes: 46 additions & 0 deletions tests/sandbox/e2e/runtime-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
});
});
});
24 changes: 12 additions & 12 deletions tests/sandbox/e2e/sandbox-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ const wireSandboxDetail = (overrides: Record<string, unknown> = {}): Record<stri
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: [],
token: "runtime-token",
Expand All @@ -50,8 +50,8 @@ describe("sandbox control and runtime contract", () => {
{
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",
},
],
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: [],
},
Expand Down Expand Up @@ -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",
}));

Expand Down Expand Up @@ -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",
}),
{
Expand All @@ -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
);
Expand Down