Skip to content
Open
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.4",
"version": "0.90.0",
"description": "Node SDK for Hyperbrowser API",
"author": "",
"repository": {
Expand Down
59 changes: 36 additions & 23 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HyperbrowserConfig } from "./types/config";
import { ControlAuthError, resolveControlPlaneConfig } from "./control-auth";
import { SessionsService } from "./services/sessions";
import { ScrapeService } from "./services/scrape";
import { CrawlService } from "./services/crawl";
Expand Down Expand Up @@ -74,34 +75,46 @@ export class HyperbrowserClient {
public readonly volumes: VolumesService;

constructor(config: HyperbrowserConfig = {}) {
const apiKey = config.apiKey || process.env["HYPERBROWSER_API_KEY"];
const baseUrl = config.baseUrl || "https://api.hyperbrowser.ai";
let authManager: ReturnType<typeof resolveControlPlaneConfig>["authManager"];
let baseUrl = "";
try {
const resolved = resolveControlPlaneConfig(config);
authManager = resolved.authManager;
baseUrl = resolved.baseUrl;
} catch (error) {
if (error instanceof ControlAuthError) {
throw new HyperbrowserError(error.message, {
statusCode: error.statusCode,
code: error.code,
retryable: error.retryable,
service: "control",
details: error.details,
cause: error.cause ?? error,
});
}
throw error;
}
const timeout = config.timeout || 30000;
const runtimeProxyOverride = config.runtimeProxyOverride?.trim() || undefined;
if (!apiKey) {
throw new HyperbrowserError(
"API key is required - either pass it in config or set HYPERBROWSER_API_KEY environment variable"
);
}

this.sessions = new SessionsService(apiKey, baseUrl, timeout);
this.scrape = new ScrapeService(apiKey, baseUrl, timeout);
this.crawl = new CrawlService(apiKey, baseUrl, timeout);
this.extract = new ExtractService(apiKey, baseUrl, timeout);
this.profiles = new ProfilesService(apiKey, baseUrl, timeout);
this.extensions = new ExtensionService(apiKey, baseUrl, timeout);
this.web = new WebService(apiKey, baseUrl, timeout);
this.team = new TeamService(apiKey, baseUrl, timeout);
this.computerAction = new ComputerActionService(apiKey, baseUrl, timeout);
this.sandboxes = new SandboxesService(apiKey, baseUrl, timeout, runtimeProxyOverride);
this.volumes = new VolumesService(apiKey, baseUrl, timeout);
this.sessions = new SessionsService(authManager, baseUrl, timeout);
this.scrape = new ScrapeService(authManager, baseUrl, timeout);
this.crawl = new CrawlService(authManager, baseUrl, timeout);
this.extract = new ExtractService(authManager, baseUrl, timeout);
this.profiles = new ProfilesService(authManager, baseUrl, timeout);
this.extensions = new ExtensionService(authManager, baseUrl, timeout);
this.web = new WebService(authManager, baseUrl, timeout);
this.team = new TeamService(authManager, baseUrl, timeout);
this.computerAction = new ComputerActionService(authManager, baseUrl, timeout);
this.sandboxes = new SandboxesService(authManager, baseUrl, timeout, runtimeProxyOverride);
this.volumes = new VolumesService(authManager, baseUrl, timeout);

this.agents = {
browserUse: new BrowserUseService(apiKey, baseUrl, timeout),
claudeComputerUse: new ClaudeComputerUseService(apiKey, baseUrl, timeout),
cua: new CuaService(apiKey, baseUrl, timeout),
hyperAgent: new HyperAgentService(apiKey, baseUrl, timeout),
geminiComputerUse: new GeminiComputerUseService(apiKey, baseUrl, timeout),
browserUse: new BrowserUseService(authManager, baseUrl, timeout),
claudeComputerUse: new ClaudeComputerUseService(authManager, baseUrl, timeout),
cua: new CuaService(authManager, baseUrl, timeout),
hyperAgent: new HyperAgentService(authManager, baseUrl, timeout),
geminiComputerUse: new GeminiComputerUseService(authManager, baseUrl, timeout),
};
}
}
25 changes: 25 additions & 0 deletions src/control-auth-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface ControlAuthErrorOptions {
statusCode?: number;
code?: string;
retryable?: boolean;
details?: unknown;
cause?: unknown;
}

export class ControlAuthError extends Error {
public readonly statusCode?: number;
public readonly code?: string;
public readonly retryable: boolean;
public readonly details?: unknown;
public readonly cause?: unknown;

constructor(message: string, options: ControlAuthErrorOptions = {}) {
super(message);
this.name = "ControlAuthError";
this.statusCode = options.statusCode;
this.code = options.code;
this.retryable = options.retryable ?? false;
this.details = options.details;
this.cause = options.cause;
}
}
116 changes: 116 additions & 0 deletions src/control-auth-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { homedir } from "os";
import * as path from "path";
import { ControlAuthError } from "./control-auth-errors";

export const DEFAULT_PROFILE = "default";
export const DEFAULT_BASE_URL = "https://api.hyperbrowser.ai";
export const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
export const DEFAULT_LOCK_POLL_INTERVAL_MS = 125;
export const DEFAULT_LOCK_STALE_MS = 120_000;
export const OAUTH_REFRESH_EARLY_EXPIRY_MS = 30_000;
export const ENV_PROFILE = "HYPERBROWSER_PROFILE";
export const ENV_API_KEY = "HYPERBROWSER_API_KEY";
export const ENV_BASE_URL = "HYPERBROWSER_BASE_URL";
export const ENV_LOCK_TIMEOUT_MS = "HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS";
export const ENV_LOCK_POLL_INTERVAL_MS = "HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS";
export const ENV_LOCK_STALE_MS = "HYPERBROWSER_AUTH_LOCK_STALE_MS";

const PROFILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
const REDACTED_VALUE = "[REDACTED]";
const SENSITIVE_DETAIL_KEYS = new Set(["access_token", "refresh_token"]);

export function resolveOAuthSessionPath(profile: string): string {
return path.join(homedir(), ".hx_config", "auth", `${profile}.json`);
}

export function normalizeProfile(value?: string | null): string {
const normalized = normalizeText(value);
if (!normalized) {
return DEFAULT_PROFILE;
}
if (!PROFILE_NAME_PATTERN.test(normalized)) {
throw new ControlAuthError(
"Profile names may contain only letters, numbers, dots, underscores, and hyphens",
{
code: "invalid_profile",
retryable: false,
details: {
profile: normalized,
},
}
);
}
return normalized;
}

export function normalizeControlPlaneBaseUrl(value?: string | null): string {
const normalized = normalizeText(value).replace(/\/+$/, "").replace(/\/api$/, "");
if (!normalized) {
return normalized;
}

let parsed: URL;
try {
parsed = new URL(normalized);
} catch (cause) {
throw new ControlAuthError(`Invalid control-plane base URL: ${normalized}`, {
code: "invalid_base_url",
retryable: false,
cause,
});
}

if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) {
throw new ControlAuthError(
`Control-plane base URL must be an origin (scheme + host [+ port]) with no path, query, or fragment: ${normalized}`,
{
code: "invalid_base_url",
retryable: false,
}
);
}

return normalized;
}

export function normalizeText(value?: string | null): string {
return (value || "").trim();
}

export function normalizePositiveInteger(
explicitValue: number | undefined,
envValue: string | undefined,
fallback: number
): number {
if (typeof explicitValue === "number" && Number.isFinite(explicitValue) && explicitValue > 0) {
return explicitValue;
}
if (envValue) {
const parsed = Number.parseInt(envValue, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return fallback;
}

export function resolveOAuthTokenUrl(baseUrl: string): string {
return new URL("/oauth/token", `${baseUrl}/`).toString();
}

export function redactSensitiveDetails<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => redactSensitiveDetails(item)) as T;
}

if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
key,
SENSITIVE_DETAIL_KEYS.has(key) ? REDACTED_VALUE : redactSensitiveDetails(nestedValue),
])
) as T;
}

return value;
}
89 changes: 89 additions & 0 deletions src/control-auth-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { promises as fs } from "fs";
import * as path from "path";
import { ControlAuthError } from "./control-auth-errors";

export type LockHandle = Awaited<ReturnType<typeof fs.open>>;

export async function tryAcquireRotationLock(lockPath: string): Promise<LockHandle | null> {
await fs.mkdir(path.dirname(lockPath), {
recursive: true,
mode: 0o700,
});
await fs.chmod(path.dirname(lockPath), 0o700).catch(() => undefined);

let handle: LockHandle | undefined;
try {
handle = await fs.open(lockPath, "wx", 0o600);
await handle.writeFile(`pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, "utf8");
await handle.sync();
return handle;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
return null;
}
await handle?.close().catch(() => undefined);
if (handle) {
await fs
.rm(lockPath, {
force: true,
})
.catch(() => undefined);
}
throw new ControlAuthError("Failed to create OAuth rotation lock", {
code: "auth_rotation_lock_failed",
retryable: false,
cause: error,
});
}
}

export async function clearStaleRotationLock(
lockPath: string,
lockStaleMs: number
): Promise<void> {
let info;
try {
info = await fs.stat(lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
throw new ControlAuthError("Failed to inspect OAuth rotation lock", {
code: "auth_rotation_lock_failed",
retryable: false,
cause: error,
});
}

if (Date.now() - info.mtimeMs < lockStaleMs) {
return;
}

await fs.rm(lockPath, {
force: true,
});
}

export async function releaseRotationLock(lockPath: string, handle: LockHandle): Promise<void> {
await handle.close().catch(() => undefined);
await fs
.rm(lockPath, {
force: true,
})
.catch(() => undefined);
}

export async function tryReadSessionMtimeMs(sessionPath: string): Promise<number | null> {
try {
return (await fs.stat(sessionPath)).mtimeMs;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw new ControlAuthError("Failed to inspect saved OAuth session", {
code: "oauth_session_read_failed",
retryable: false,
cause: error,
});
}
}
20 changes: 20 additions & 0 deletions src/control-auth-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { RequestInit } from "node-fetch";

export type RequestInitFactory = () => RequestInit | Promise<RequestInit>;

export async function resolveRequestInit(init: RequestInit | RequestInitFactory): Promise<RequestInit> {
return typeof init === "function" ? await init() : init;
}

export function isReplayableBody(body: RequestInit["body"]): boolean {
if (body == null) {
return true;
}
if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof URLSearchParams) {
return true;
}
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
return true;
}
return false;
}
Loading