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
16 changes: 0 additions & 16 deletions packages/sandbox/src/preview-proxy-protocol.ts

This file was deleted.

63 changes: 63 additions & 0 deletions packages/sandbox/src/preview/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { validatePort } from '../security';

/** @internal */
export const PREVIEW_PROXY_HEADER = 'x-sandbox-preview-proxy';
/** @internal */
export const PREVIEW_PROXY_PORT_HEADER = 'x-sandbox-preview-port';
/** @internal */
export const PREVIEW_PROXY_TOKEN_HEADER = 'x-sandbox-preview-token';
/** @internal */
export const PREVIEW_PROXY_SANDBOX_ID_HEADER = 'x-sandbox-preview-sandbox-id';

/** @internal */
export const PREVIEW_PROXY_HEADERS = [
PREVIEW_PROXY_HEADER,
PREVIEW_PROXY_PORT_HEADER,
PREVIEW_PROXY_TOKEN_HEADER,
PREVIEW_PROXY_SANDBOX_ID_HEADER
] as const;

export interface PreviewProxyMetadata {
port: number;
token: string;
sandboxId: string;
}

export function isPreviewProxyRequest(request: Request): boolean {
return request.headers.get(PREVIEW_PROXY_HEADER) === '1';
}

export function readPreviewProxyMetadata(
request: Request
): PreviewProxyMetadata | null {
if (!isPreviewProxyRequest(request)) {
return null;
}

const portValue = request.headers.get(PREVIEW_PROXY_PORT_HEADER);
const token = request.headers.get(PREVIEW_PROXY_TOKEN_HEADER);
const sandboxId = request.headers.get(PREVIEW_PROXY_SANDBOX_ID_HEADER);
const port = portValue === null ? Number.NaN : Number.parseInt(portValue, 10);

if (!Number.isFinite(port) || !validatePort(port) || !token || !sandboxId) {
return null;
}

return { port, token, sandboxId };
}

export function withPreviewProxyMetadata(
request: Request,
{ port, token, sandboxId }: PreviewProxyMetadata
): Request {
const headers = new Headers(request.headers);
for (const header of PREVIEW_PROXY_HEADERS) {
headers.delete(header);
}
headers.set(PREVIEW_PROXY_HEADER, '1');
headers.set(PREVIEW_PROXY_PORT_HEADER, port.toString());
headers.set(PREVIEW_PROXY_TOKEN_HEADER, token);
headers.set(PREVIEW_PROXY_SANDBOX_ID_HEADER, sandboxId);

return new Request(request, { headers });
}
46 changes: 46 additions & 0 deletions packages/sandbox/src/preview/proxy-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { PREVIEW_PROXY_HEADERS } from './protocol';

export interface BuildPreviewProxyRequestOptions {
port: number;
sandboxId: string;
sandboxName: string | null;
}

export function buildPreviewProxyRequest(
request: Request,
{ port, sandboxId, sandboxName }: BuildPreviewProxyRequestOptions
): Request {
const url = new URL(request.url);
const proxyURL = `http://localhost:${port}${url.pathname}${url.search}`;
const headers = stripPreviewProxyHeaders(request.headers);

headers.set('X-Original-URL', request.url);
headers.set('X-Forwarded-Host', url.hostname);
headers.set('X-Forwarded-Proto', url.protocol.replace(':', ''));
headers.set('X-Sandbox-Name', sandboxName ?? sandboxId);

const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader?.toLowerCase() === 'websocket') {
return new Request(request, {
headers,
redirect: 'manual'
});
}

return new Request(proxyURL, {
method: request.method,
headers,
body: request.body,
// @ts-expect-error - duplex required for body streaming in modern runtimes
duplex: 'half',
redirect: 'manual'
});
}

function stripPreviewProxyHeaders(source: Headers): Headers {
const headers = new Headers(source);
for (const header of PREVIEW_PROXY_HEADERS) {
headers.delete(header);
}
return headers;
}
140 changes: 140 additions & 0 deletions packages/sandbox/src/preview/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
SandboxSecurityError,
sanitizeSandboxId,
validatePort
} from '../security';
import { isLocalhostPattern } from './url';

export interface PreviewRouteInfo {
port: number;
sandboxId: string;
token: string;
}

export interface ConstructPreviewURLOptions {
port: number;
sandboxId: string;
effectiveId: string;
hostname: string;
token: string;
normalizeId: boolean;
}

export function parsePreviewRoute(url: URL): PreviewRouteInfo | null {
// URL format: {port}-{sandboxId}-{token}.{domain}
// Tokens are [a-z0-9_]+, so split at the last hyphen to handle sandbox IDs
// with hyphens such as UUIDs.
const dotIndex = url.hostname.indexOf('.');
if (dotIndex === -1) {
return null;
}

const subdomain = url.hostname.slice(0, dotIndex);

const firstHyphen = subdomain.indexOf('-');
if (firstHyphen === -1) {
return null;
}

const portStr = subdomain.slice(0, firstHyphen);
if (!/^\d{4,5}$/.test(portStr)) {
return null;
}

const port = Number.parseInt(portStr, 10);
if (!validatePort(port)) {
return null;
}

const rest = subdomain.slice(firstHyphen + 1);
const lastHyphen = rest.lastIndexOf('-');
if (lastHyphen === -1) {
return null;
}

const sandboxId = rest.slice(0, lastHyphen);
const token = rest.slice(lastHyphen + 1);

if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) {
return null;
}

if (sandboxId.length === 0 || sandboxId.length > 63) {
return null;
}

let sanitizedSandboxId: string;
try {
sanitizedSandboxId = sanitizeSandboxId(sandboxId);
} catch {
return null;
}

return {
port,
sandboxId: sanitizedSandboxId,
token
};
}

export function constructPreviewURL({
port,
sandboxId,
effectiveId,
hostname,
token,
normalizeId
}: ConstructPreviewURLOptions): string {
if (!validatePort(port)) {
throw new SandboxSecurityError(
`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`
);
}

const hasUppercase = /[A-Z]/.test(effectiveId);
if (!normalizeId && hasUppercase) {
throw new SandboxSecurityError(
`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\n` +
`To fix this:\n` +
`1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n` +
`2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\n` +
`Note: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`
);
}

const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
const isLocalhost = isLocalhostPattern(hostname);

if (isLocalhost) {
const [host, portStr] = hostname.split(':');
const mainPort = portStr || '80';

try {
const baseURL = new URL(`http://${host}:${mainPort}`);
const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${host}`;
baseURL.hostname = subdomainHost;

return baseURL.toString();
} catch (error) {
throw new SandboxSecurityError(
`Failed to construct preview URL: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}

try {
const baseURL = new URL(`https://${hostname}`);
const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${hostname}`;
baseURL.hostname = subdomainHost;

return baseURL.toString();
} catch (error) {
throw new SandboxSecurityError(
`Failed to construct preview URL: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
Loading
Loading