kimi-code / packages /remote-control /src /remote-control.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw
History Blame Contribute Delete
37.6 kB
import { createHash } from 'node:crypto';
import { hostname, platform } from 'node:os';
import { join } from 'node:path';
import { request as httpRequest, validateHeaderName, validateHeaderValue } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
import { promisify } from 'node:util';
import { gzip } from 'node:zlib';
import {
createKimiDeviceId,
FileTokenStorage,
KIMI_CODE_PROVIDER_NAME,
resolveKimiTokenStorageName,
} from '@moonshot-ai/kimi-code-oauth';
import { WebSocket, type RawData } from 'ws';
import { acquireRemoteControlLock } from './lock';
export const REMOTE_CONTROL_RELAY_ORIGIN = 'https://code-rc.kimi.com';
export const REMOTE_CONTROL_RELAY_URL_ENV = 'KIMI_CODE_REMOTE_CONTROL_RELAY_URL';
export function resolveRemoteControlRelayOrigin(
env: Readonly<Record<string, string | undefined>> = process.env,
): string {
const value = env[REMOTE_CONTROL_RELAY_URL_ENV]?.trim();
return value === undefined || value.length === 0 ? REMOTE_CONTROL_RELAY_ORIGIN : value;
}
const MAX_HTTP_HEADER_BYTES = 64 * 1024;
const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024;
const HTTP_REQUEST_TIMEOUT_MS = 30_000;
const REGISTER_TIMEOUT_MS = 10_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
const RELAY_PING_INTERVAL_MS = 30_000;
const RELAY_SILENCE_TIMEOUT_MS = 300_000;
const BLOCKED_REQUEST_HEADERS = new Set([
'authorization',
'cookie',
'host',
'origin',
'proxy-authorization',
'proxy-authenticate',
'accept-encoding',
'connection',
'keep-alive',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const BLOCKED_RESPONSE_HEADERS = new Set([
'connection',
'content-length',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const GZIP_MIN_BODY_BYTES = 1024;
const GZIP_COMPRESSIBLE_TYPES = new Set([
'application/javascript',
'application/json',
'application/xml',
'image/svg+xml',
]);
const gzipAsync = promisify(gzip);
interface RelayMessage {
readonly type: string;
readonly payload?: Record<string, unknown>;
}
interface PendingHttpRequest {
readonly chunks: Buffer[];
size: number;
}
export interface ParsedRawHttpRequest {
readonly method: string;
readonly path: string;
readonly headers: readonly [string, string][];
readonly body: Buffer;
}
export type RemoteControlStatus =
| 'relay_connected'
| 'relay_disconnected'
| 'device_connected'
| 'device_disconnected';
export interface RemoteControlOptions {
readonly homeDir: string;
readonly localOrigin: string;
readonly localServerToken: string | (() => string);
readonly clientVersion: string;
readonly relayOrigin?: string;
readonly stderr?: Pick<NodeJS.WriteStream, 'write'>;
readonly onStatus?: (status: RemoteControlStatus) => void;
readonly pingIntervalMs?: number;
readonly silenceTimeoutMs?: number;
}
export interface RemoteControlHandle {
readonly deviceId: string;
readonly deviceName: string;
readonly url: string;
readonly closed: Promise<void>;
close(): Promise<void>;
}
interface ActiveStream {
readonly local: WebSocket;
readonly tunnel: WebSocket;
}
class RegistrationError extends Error {}
export function buildRemoteControlUrl(
deviceId: string,
sessionId?: string,
relayOrigin = resolveRemoteControlRelayOrigin(),
): string {
const url = new URL(relayOrigin);
const relayPath = url.pathname.replace(/\/+$/, '');
const devicePath = `${relayPath}/devices/${encodeURIComponent(deviceId)}`;
url.pathname =
sessionId === undefined
? `${devicePath}/`
: `${devicePath}/sessions/${encodeURIComponent(sessionId)}`;
url.search = new URLSearchParams({ rc: '1', from: 'kimi_code_cli' }).toString();
url.hash = '';
return url.toString();
}
export function parseRawHttpRequest(raw: Buffer): ParsedRawHttpRequest {
const separator = raw.indexOf('\r\n\r\n');
if (separator < 0 || separator > MAX_HTTP_HEADER_BYTES) {
throw new SyntaxError('invalid HTTP request headers');
}
const head = raw.subarray(0, separator).toString('latin1');
const lines = head.split('\r\n');
const requestLine = lines.shift();
const match = requestLine?.match(
/^([!#$%&'*+.^_`|~0-9A-Za-z-]+) (\/[^\u0000-\u0020]*) HTTP\/1\.[01]$/,
);
if (match === null || match === undefined || match[2]!.startsWith('//')) {
throw new SyntaxError('invalid HTTP request line');
}
const headers: [string, string][] = [];
for (const line of lines) {
const colon = line.indexOf(':');
if (colon <= 0) throw new SyntaxError('invalid HTTP request header');
const name = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
try {
validateHeaderName(name);
validateHeaderValue(name, value);
} catch {
throw new SyntaxError('invalid HTTP request header');
}
headers.push([name, value]);
}
return {
method: match[1]!,
path: match[2]!,
headers,
body: raw.subarray(separator + 4),
};
}
export function filterForwardRequestHeaders(
headers: readonly [string, string][],
serverToken: string,
): string[] {
const connectionHeaders = new Set<string>();
for (const [name, value] of headers) {
if (name.toLowerCase() === 'connection') {
for (const token of value.split(',')) connectionHeaders.add(token.trim().toLowerCase());
}
}
const result: string[] = [];
for (const [name, value] of headers) {
const lower = name.toLowerCase();
if (BLOCKED_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower)) continue;
result.push(name, value);
}
result.push('Authorization', `Bearer ${serverToken}`);
return result;
}
export function rewriteRemoteControlResponse(
contentType: string,
body: Buffer,
publicPrefix: string,
): Buffer {
const normalizedPrefix = publicPrefix.replace(/\/+$/, '');
if (contentType.toLowerCase().includes('text/html')) {
const prefixLiteral = JSON.stringify(normalizedPrefix);
const injected = `<script>(function(){var p=${prefixLiteral};try{sessionStorage.setItem('kimi-desktop-server-origin',location.origin+p)}catch(e){}var w=function(f){return function(s,t,u){if(typeof u==='string'&&u.charAt(0)==='/'&&u.indexOf(p)!==0)u=p+u;return f.apply(this,[s,t,u])}};history.pushState=w(history.pushState);history.replaceState=w(history.replaceState)})();</script>`;
let text = body.toString('utf8');
const headMatch = /<head(?:\s[^>]*)?>/i.exec(text);
text =
headMatch === null
? injected + text
: text.slice(0, headMatch.index + headMatch[0].length) +
injected +
text.slice(headMatch.index + headMatch[0].length);
text = text.replaceAll(/\bsrc="\//g, `src="${normalizedPrefix}/`);
text = text.replaceAll(/\bhref="\//g, `href="${normalizedPrefix}/`);
return Buffer.from(text);
}
const lower = contentType.toLowerCase();
if (lower.includes('javascript') || lower.includes('text/css')) {
let text = body.toString('utf8');
text = text.replaceAll('"/assets/', `"${normalizedPrefix}/assets/`);
text = text.replaceAll("'/assets/", `'${normalizedPrefix}/assets/`);
text = text.replaceAll('(/assets/', `(${normalizedPrefix}/assets/`);
text = text.replaceAll('"/sessions/"', `"${normalizedPrefix}/sessions/"`);
text = text.replaceAll('return"/"+', `return"${normalizedPrefix}/"+`);
return Buffer.from(text);
}
return body;
}
function acceptsGzipEncoding(headers: readonly [string, string][]): boolean {
let wildcard = false;
for (const [name, value] of headers) {
if (name.toLowerCase() !== 'accept-encoding') continue;
for (const token of value.split(',')) {
const [encoding, ...params] = token.trim().toLowerCase().split(';');
if (encoding !== 'gzip' && encoding !== '*') continue;
const quality = params.map((param) => param.trim()).find((param) => param.startsWith('q='));
const acceptable = quality === undefined || Number(quality.slice(2)) > 0;
if (encoding === 'gzip') return acceptable;
wildcard = wildcard || acceptable;
}
}
return wildcard;
}
function isGzipCompressibleType(contentType: string): boolean {
const mime = contentType.split(';', 1)[0]!.trim().toLowerCase();
return mime.startsWith('text/') || GZIP_COMPRESSIBLE_TYPES.has(mime);
}
function rewrittenResponseETag(body: Buffer): string {
return `W/"${createHash('sha256').update(body).digest('hex')}"`;
}
function requestMatchesETag(
headers: readonly [string, string][],
etag: string,
): boolean {
const candidates = [etag, etag.replace(/^W\//, '')];
for (const [name, value] of headers) {
if (name.toLowerCase() !== 'if-none-match') continue;
for (const token of value.split(',')) {
const candidate = token.trim();
if (candidate === '*') return true;
if (candidates.includes(candidate)) return true;
}
}
return false;
}
export async function startRemoteControl(
options: RemoteControlOptions,
): Promise<RemoteControlHandle> {
const localServerToken =
typeof options.localServerToken === 'function'
? options.localServerToken
: () => options.localServerToken as string;
if (localServerToken().length === 0) {
throw new Error('Remote Control requires local server authentication.');
}
const storage = new FileTokenStorage(join(options.homeDir, 'credentials'));
const token = await storage.load(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
);
if (token?.refreshToken === undefined || token.refreshToken.length === 0) {
throw new Error('Remote Control requires a Kimi login. Run `kimi login` first.');
}
const relayOrigin = options.relayOrigin ?? resolveRemoteControlRelayOrigin();
const deviceId = createKimiDeviceId(options.homeDir);
const deviceName = hostname();
const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin);
const lock = await acquireRemoteControlLock(options.homeDir, {
localOrigin: options.localOrigin.replace(/\/+$/, ''),
deviceId,
url,
});
const client = new RemoteControlClient({
...options,
localServerToken,
relayOrigin,
deviceId,
refreshToken: token.refreshToken,
});
try {
await client.start();
} catch (error) {
await lock.release();
throw error;
}
const closed = client.closed.then(async () => {
await lock.release();
});
return {
deviceId,
deviceName,
url,
closed,
close: async () => {
await client.close();
await closed;
},
};
}
class RemoteControlClient {
private readonly localOrigin: string;
private readonly localServerToken: () => string;
private readonly clientVersion: string;
private readonly relayOrigin: string;
private readonly deviceId: string;
private readonly refreshToken: string;
private readonly stderr: Pick<NodeJS.WriteStream, 'write'>;
private readonly onStatus: (status: RemoteControlStatus) => void;
private readonly streams = new Map<string, ActiveStream>();
private readonly pendingHttpRequests = new Map<string, PendingHttpRequest>();
private management: WebSocket | undefined;
private reconnectAbort: AbortController | undefined;
private http: WebSocket | undefined;
private pendingHttpBytes = 0;
private reconnectAttempt = 0;
private reconnectImmediately = false;
private readonly pingIntervalMs: number;
private readonly silenceTimeoutMs: number;
private stopped = false;
private connected = false;
private relayOnline = false;
private runPromise: Promise<void> | undefined;
private initialResolve: (() => void) | undefined;
private initialReject: ((error: unknown) => void) | undefined;
constructor(
options: Omit<RemoteControlOptions, 'localServerToken'> & {
readonly localServerToken: () => string;
readonly relayOrigin: string;
readonly deviceId: string;
readonly refreshToken: string;
},
) {
this.localOrigin = options.localOrigin.replace(/\/+$/, '');
this.localServerToken = options.localServerToken;
this.clientVersion = options.clientVersion;
this.relayOrigin = options.relayOrigin;
this.deviceId = options.deviceId;
this.refreshToken = options.refreshToken;
this.stderr = options.stderr ?? process.stderr;
this.onStatus = options.onStatus ?? (() => {});
this.pingIntervalMs = options.pingIntervalMs ?? RELAY_PING_INTERVAL_MS;
this.silenceTimeoutMs = options.silenceTimeoutMs ?? RELAY_SILENCE_TIMEOUT_MS;
}
async start(): Promise<void> {
const initial = new Promise<void>((resolve, reject) => {
this.initialResolve = resolve;
this.initialReject = reject;
});
this.runPromise = this.run();
await initial;
}
get closed(): Promise<void> {
return this.runPromise ?? Promise.resolve();
}
async close(): Promise<void> {
if (this.stopped) {
await this.runPromise;
return;
}
this.stopped = true;
if (!this.connected) this.rejectInitial(new Error('Remote Control closed before ready.'));
if (this.management?.readyState === WebSocket.OPEN) {
this.management.send(
JSON.stringify({ type: 'disconnect', payload: { reason: 'local_server_stopped' } }),
);
}
this.closeCycle();
this.reconnectAbort?.abort();
await this.runPromise;
}
private async run(): Promise<void> {
while (!this.stopped) {
try {
await this.serveCycle();
} catch (error) {
if (error instanceof RegistrationError) {
if (!this.connected) {
this.rejectInitial(error);
this.stopped = true;
return;
}
this.stderr.write(`${error.message}\n`);
} else if (!this.stopped && !this.reconnectImmediately) {
this.stderr.write(`Remote Control disconnected: ${errorMessage(error)}\n`);
}
} finally {
this.closeCycle();
}
if (this.stopped) {
if (!this.connected) this.rejectInitial(new Error('Remote Control stopped before ready.'));
return;
}
if (this.reconnectImmediately) {
this.reconnectImmediately = false;
continue;
}
this.reconnectAttempt += 1;
const delay = Math.min(
MAX_RECONNECT_DELAY_MS,
1000 * 2 ** Math.min(this.reconnectAttempt - 1, 5),
);
await this.waitForReconnect(delay);
}
}
private async serveCycle(): Promise<void> {
const management = await this.connectRelay('/v1/remote/create');
this.management = management;
this.watchSocket(management, 'management');
management.send(
JSON.stringify({
type: 'register',
payload: {
device_id: this.deviceId,
alias: hostname(),
platform: platform(),
client_version: this.clientVersion,
local_base_url: this.localOrigin,
},
}),
);
const registration = await waitForRelayMessage(management, REGISTER_TIMEOUT_MS);
if (registration.type === 'register_nak') {
const code = stringField(registration.payload, 'error_code') ?? 'REGISTRATION_REJECTED';
const message = stringField(registration.payload, 'error_message') ?? 'registration rejected';
throw new RegistrationError(`Remote Control registration failed (${code}): ${message}`);
}
if (registration.type !== 'register_ack') {
throw new Error(`Remote Control expected register_ack, received ${registration.type}`);
}
const managementEnd = waitForSocketEnd(management);
const http = await this.connectRelay(
`/v1/remote/http?device_id=${encodeURIComponent(this.deviceId)}`,
);
this.http = http;
this.watchSocket(http, 'http');
if (management.readyState !== WebSocket.OPEN) {
throw new Error('management connection closed');
}
management.on('message', (data) => this.handleManagementMessage(data));
http.on('message', (data) => this.handleHttpMessage(data));
this.reconnectAttempt = 0;
this.relayOnline = true;
this.onStatus('relay_connected');
if (!this.connected) {
this.connected = true;
this.initialResolve?.();
this.initialResolve = undefined;
this.initialReject = undefined;
}
await Promise.race([managementEnd, waitForSocketEnd(http)]);
if (!this.stopped) throw new Error('relay connection closed');
}
private connectRelay(path: string): Promise<WebSocket> {
return connectWebSocket(relayWebSocketUrl(this.relayOrigin, path), this.refreshToken);
}
private watchSocket(socket: WebSocket, label: string): void {
const pingTimer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.ping();
}, this.pingIntervalMs);
pingTimer.unref();
let silenceTimer: NodeJS.Timeout | undefined;
const armSilenceTimer = (): void => {
if (silenceTimer !== undefined) clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
this.stderr.write(
`Remote Control ${label} connection silent for ${Math.round(this.silenceTimeoutMs / 1000)}s; reconnecting…\n`,
);
socket.terminate();
}, this.silenceTimeoutMs);
silenceTimer.unref();
};
armSilenceTimer();
socket.on('message', armSilenceTimer);
socket.on('ping', armSilenceTimer);
socket.on('pong', armSilenceTimer);
socket.once('close', () => {
clearInterval(pingTimer);
if (silenceTimer !== undefined) clearTimeout(silenceTimer);
});
}
private rejectInitial(error: Error): void {
this.initialReject?.(error);
this.initialReject = undefined;
this.initialResolve = undefined;
}
private handleManagementMessage(data: RawData): void {
let message: RelayMessage;
try {
message = parseRelayMessage(data);
} catch (error) {
this.stderr.write(`Remote Control message error: ${errorMessage(error)}\n`);
return;
}
if (message.type === 'open_ws') {
void this.openStream(message.payload ?? {});
return;
}
if (message.type === 'close_ws') {
const streamId = stringField(message.payload, 'stream_id');
if (streamId !== undefined) this.closeStream(streamId);
return;
}
if (message.type === 'disconnect') {
const reason = stringField(message.payload, 'reason');
if (reason === 'user_requested') this.stopped = true;
if (reason === 'server_shutting_down') this.reconnectImmediately = true;
this.closeCycle();
}
}
private handleHttpMessage(data: RawData): void {
const text = rawDataText(data).trim();
if (text.length === 0) return;
let requestId: string | undefined;
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
if (parsed['type'] !== 'request') return;
requestId = typeof parsed['request_id'] === 'string' ? parsed['request_id'] : undefined;
if (
requestId === undefined ||
typeof parsed['body_base64'] !== 'string' ||
typeof parsed['is_last'] !== 'boolean'
) {
throw new SyntaxError('invalid HTTP tunnel request message');
}
const bodyBase64 = parsed['body_base64'];
const minDecodedBytes = Math.floor(bodyBase64.length / 4) * 3 - 2;
if (this.pendingHttpBytes + minDecodedBytes > MAX_HTTP_REQUEST_BYTES) {
throw new SyntaxError('HTTP tunnel request exceeds 10 MiB');
}
const chunk = decodeBase64(bodyBase64);
const pending = this.pendingHttpRequests.get(requestId) ?? { chunks: [], size: 0 };
if (this.pendingHttpBytes + chunk.length > MAX_HTTP_REQUEST_BYTES) {
throw new SyntaxError('HTTP tunnel request exceeds 10 MiB');
}
pending.chunks.push(chunk);
pending.size += chunk.length;
this.pendingHttpBytes += chunk.length;
this.pendingHttpRequests.set(requestId, pending);
if (!parsed['is_last']) return;
const rawRequest = Buffer.concat(pending.chunks, pending.size);
this.clearPendingHttpRequest(requestId);
void this.forwardHttpRequest(requestId, rawRequest);
} catch (error) {
if (requestId !== undefined) {
this.clearPendingHttpRequest(requestId);
const status = error instanceof SyntaxError ? 400 : 502;
this.sendHttpResponse(requestId, buildErrorResponse(status));
}
this.stderr.write(`Remote Control HTTP message error: ${errorMessage(error)}\n`);
}
}
private async forwardHttpRequest(requestId: string, rawRequest: Buffer): Promise<void> {
try {
const parsed = parseRawHttpRequest(rawRequest);
const response = await requestLocalHttp(
this.localOrigin,
parsed,
this.localServerToken(),
this.publicPrefix(),
);
this.sendHttpResponse(requestId, response);
} catch (error) {
const status = error instanceof SyntaxError ? 400 : 502;
this.sendHttpResponse(requestId, buildErrorResponse(status));
this.stderr.write(`Remote Control HTTP forwarding failed: ${errorMessage(error)}\n`);
}
}
private sendHttpResponse(requestId: string, response: Buffer): void {
if (this.http?.readyState !== WebSocket.OPEN) return;
this.http.send(
JSON.stringify({
request_id: requestId,
type: 'response',
is_last: true,
body_base64: response.toString('base64'),
}),
);
}
private async openStream(payload: Record<string, unknown>): Promise<void> {
const streamId = stringField(payload, 'stream_id');
const path = stringField(payload, 'path');
if (streamId === undefined || path === undefined || !path.startsWith('/') || path.startsWith('//')) {
if (streamId !== undefined) {
this.sendOpenStreamResult(streamId, false, 'LOCAL_WS_FAILED', 'invalid local WebSocket path');
}
return;
}
let local: WebSocket | undefined;
let tunnel: WebSocket | undefined;
const earlyLocalFrames: [RawData, boolean][] = [];
try {
local = await connectWebSocket(
localWebSocketUrl(this.localOrigin, path),
this.localServerToken(),
relayHeaders(payload['headers']),
earlyLocalFrames,
);
tunnel = await this.connectRelay(`/v1/remote/stream/${encodeURIComponent(streamId)}`);
if (this.stopped || this.management?.readyState !== WebSocket.OPEN) {
throw new Error('management connection closed');
}
this.streams.set(streamId, { local, tunnel });
this.onStatus('device_connected');
bridgeSockets(
local,
tunnel,
() => {
if (this.streams.get(streamId)?.local === local) {
this.streams.delete(streamId);
this.onStatus('device_disconnected');
}
},
earlyLocalFrames,
);
this.sendOpenStreamResult(streamId, true);
} catch (error) {
local?.close();
tunnel?.close();
this.sendOpenStreamResult(
streamId,
false,
local === undefined ? 'LOCAL_WS_FAILED' : 'TUNNEL_STREAM_FAILED',
errorMessage(error),
);
}
}
private sendOpenStreamResult(
streamId: string,
success: boolean,
errorCode?: string,
error?: string,
): void {
if (this.management?.readyState !== WebSocket.OPEN) return;
this.management.send(
JSON.stringify({
type: 'open_ws_result',
payload: {
stream_id: streamId,
success,
error_code: errorCode,
error_message: error,
},
}),
);
}
private closeStream(streamId: string): void {
const stream = this.streams.get(streamId);
if (stream === undefined) return;
this.streams.delete(streamId);
this.onStatus('device_disconnected');
stream.local.close();
stream.tunnel.close();
}
private clearPendingHttpRequest(requestId: string): void {
const pending = this.pendingHttpRequests.get(requestId);
if (pending === undefined) return;
this.pendingHttpRequests.delete(requestId);
this.pendingHttpBytes -= pending.size;
}
private closeCycle(): void {
for (const streamId of this.streams.keys()) this.closeStream(streamId);
this.pendingHttpRequests.clear();
this.pendingHttpBytes = 0;
this.management?.close();
this.http?.close();
if (this.relayOnline) {
this.relayOnline = false;
this.onStatus('relay_disconnected');
}
this.management = undefined;
this.http = undefined;
}
private publicPrefix(): string {
const relayPath = new URL(this.relayOrigin).pathname.replace(/\/+$/, '');
return `${relayPath}/devices/${encodeURIComponent(this.deviceId)}`;
}
private async waitForReconnect(ms: number): Promise<void> {
if (this.stopped) return;
const controller = new AbortController();
this.reconnectAbort = controller;
try {
await sleep(ms, undefined, { signal: controller.signal });
} catch (error) {
if (!(error instanceof Error) || error.name !== 'AbortError') throw error;
} finally {
if (this.reconnectAbort === controller) this.reconnectAbort = undefined;
}
}
}
async function connectWebSocket(
url: string,
token: string,
headers: Record<string, string> = {},
earlyFrames?: [RawData, boolean][],
): Promise<WebSocket> {
const protocol = `kimi-code.bearer.${token}`;
if (isWebSocketProtocolToken(protocol)) {
try {
return await connectWebSocketAttempt(url, [protocol], headers, earlyFrames);
} catch {}
}
return connectWebSocketAttempt(
url,
undefined,
{
...headers,
Authorization: `Bearer ${token}`,
},
earlyFrames,
);
}
function connectWebSocketAttempt(
url: string,
protocols: string[] | undefined,
headers: Record<string, string>,
earlyFrames?: [RawData, boolean][],
): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url, protocols, {
headers,
handshakeTimeout: REGISTER_TIMEOUT_MS,
});
if (earlyFrames !== undefined) {
socket.on('message', (data, isBinary) => {
earlyFrames.push([data, isBinary]);
});
}
let settled = false;
const cleanup = (): void => {
socket.off('open', onOpen);
socket.off('error', onError);
socket.off('close', onClose);
};
const finish = (error?: Error): void => {
if (settled) return;
settled = true;
cleanup();
if (error === undefined) resolve(socket);
else reject(error);
};
const onOpen = (): void => finish();
const onError = (error: Error) => finish(error);
const onClose = (code: number, reason: Buffer) => {
finish(new Error(`WebSocket closed during handshake (${code} ${reason.toString()})`));
};
socket.once('open', onOpen);
socket.once('error', onError);
socket.once('close', onClose);
});
}
function isWebSocketProtocolToken(value: string): boolean {
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
}
function waitForRelayMessage(socket: WebSocket, timeoutMs: number): Promise<RelayMessage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => finish(new Error('Remote Control registration timed out')), timeoutMs);
const onMessage = (data: RawData): void => {
try {
finish(undefined, parseRelayMessage(data));
} catch (error) {
finish(error);
}
};
const onClose = (code: number, reason: Buffer): void => {
finish(new Error(`Remote Control registration closed (${code} ${reason.toString()})`));
};
const onError = (error: Error): void => finish(error);
const finish = (error?: unknown, message?: RelayMessage): void => {
clearTimeout(timer);
socket.off('message', onMessage);
socket.off('close', onClose);
socket.off('error', onError);
if (error !== undefined) reject(error);
else resolve(message!);
};
socket.once('message', onMessage);
socket.once('close', onClose);
socket.once('error', onError);
});
}
function waitForSocketEnd(socket: WebSocket): Promise<void> {
return new Promise((resolve) => {
socket.once('close', () => resolve());
socket.once('error', () => resolve());
});
}
function parseRelayMessage(data: RawData): RelayMessage {
const parsed = JSON.parse(rawDataText(data)) as Record<string, unknown>;
if (typeof parsed['type'] !== 'string') throw new Error('relay message has no type');
const payload = isRecord(parsed['payload']) ? parsed['payload'] : undefined;
return { type: parsed['type'], payload };
}
function requestLocalHttp(
localOrigin: string,
parsed: ParsedRawHttpRequest,
serverToken: string,
publicPrefix: string,
): Promise<Buffer> {
const origin = new URL(localOrigin);
return new Promise((resolve, reject) => {
const request = httpRequest(
{
protocol: origin.protocol,
hostname: origin.hostname,
port: origin.port,
method: parsed.method,
path: parsed.path,
headers: [
...filterForwardRequestHeaders(parsed.headers, serverToken),
'Host',
origin.host,
],
timeout: HTTP_REQUEST_TIMEOUT_MS,
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
response.once('error', reject);
response.once('end', () => {
void (async (): Promise<Buffer> => {
const contentType = response.headers['content-type'] ?? '';
const receivedBody = Buffer.concat(chunks);
let body =
response.headers['content-encoding'] === undefined
? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix)
: receivedBody;
const rewritten = body !== receivedBody;
const headers = filterResponseHeaders(response.rawHeaders, rewritten);
if (rewritten) {
const etag = rewrittenResponseETag(body);
headers.push('Cache-Control', 'no-cache', 'ETag', etag);
const statusCode = response.statusCode ?? 502;
const revalidatable =
(parsed.method === 'GET' || parsed.method === 'HEAD') &&
statusCode >= 200 &&
statusCode < 300;
if (revalidatable && requestMatchesETag(parsed.headers, etag)) {
return Buffer.from(`HTTP/1.1 304 Not Modified\r\n${headerLines(headers)}\r\n\r\n`);
}
}
const negotiated =
response.headers['content-encoding'] === undefined &&
response.statusCode !== 206 &&
body.length >= GZIP_MIN_BODY_BYTES &&
isGzipCompressibleType(contentType);
if (negotiated) {
let varyCovers = false;
for (let index = 0; index < headers.length; index += 2) {
if (headers[index]!.toLowerCase() !== 'vary') continue;
const tokens = headers[index + 1]!
.toLowerCase()
.split(',')
.map((token) => token.trim());
if (tokens.includes('*') || tokens.includes('accept-encoding')) varyCovers = true;
}
if (!varyCovers) headers.push('Vary', 'Accept-Encoding');
}
if (negotiated && acceptsGzipEncoding(parsed.headers)) {
body = await gzipAsync(body);
headers.push('Content-Encoding', 'gzip');
}
headers.push('Content-Length', String(body.length));
const statusCode = response.statusCode ?? 502;
const statusMessage = response.statusMessage ?? 'Bad Gateway';
return Buffer.concat([
Buffer.from(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n${headerLines(headers)}\r\n\r\n`),
body,
]);
})().then(resolve, reject);
});
},
);
request.once('timeout', () => request.destroy(new Error('local HTTP request timed out')));
request.once('error', reject);
request.end(parsed.body);
});
}
function filterResponseHeaders(rawHeaders: readonly string[], blockCacheValidators = false): string[] {
const connectionHeaders = new Set<string>();
for (let index = 0; index < rawHeaders.length; index += 2) {
if (rawHeaders[index]!.toLowerCase() === 'connection') {
for (const token of rawHeaders[index + 1]!.split(',')) {
connectionHeaders.add(token.trim().toLowerCase());
}
}
}
const result: string[] = [];
for (let index = 0; index < rawHeaders.length; index += 2) {
const name = rawHeaders[index]!;
const lower = name.toLowerCase();
if (BLOCKED_RESPONSE_HEADERS.has(lower) || connectionHeaders.has(lower)) {
continue;
}
if (
blockCacheValidators &&
(lower === 'cache-control' || lower === 'etag' || lower === 'last-modified')
) {
continue;
}
result.push(name, rawHeaders[index + 1]!);
}
return result;
}
function relayHeaders(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
const entries: [string, string][] = [];
for (const [name, raw] of Object.entries(value)) {
if (typeof raw !== 'string') continue;
const lower = name.toLowerCase();
if (BLOCKED_REQUEST_HEADERS.has(lower)) continue;
try {
validateHeaderName(name);
validateHeaderValue(name, raw);
entries.push([name, raw]);
} catch {}
}
return Object.fromEntries(entries);
}
function bridgeSockets(
left: WebSocket,
right: WebSocket,
onClose: () => void,
earlyLeftFrames?: [RawData, boolean][],
): void {
let closed = false;
const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => {
if (closed) return;
closed = true;
onClose();
const safeCode = isValidCloseCode(code) ? code : 1000;
if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason);
if (right.readyState === WebSocket.OPEN) right.close(safeCode, reason);
};
if (earlyLeftFrames !== undefined) {
left.removeAllListeners('message');
for (const [data, isBinary] of earlyLeftFrames) {
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
}
}
left.on('message', (data, isBinary) => {
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
});
right.on('message', (data, isBinary) => {
if (left.readyState === WebSocket.OPEN) left.send(data, { binary: isBinary });
});
left.once('close', closeBoth);
right.once('close', closeBoth);
left.once('error', () => closeBoth(1011));
right.once('error', () => closeBoth(1011));
}
function isValidCloseCode(code: number): boolean {
return (
code === 1000 ||
code === 1001 ||
code === 1002 ||
code === 1003 ||
(code >= 1007 && code <= 1014) ||
(code >= 3000 && code <= 4999)
);
}
function relayWebSocketUrl(origin: string, path: string): string {
const url = new URL(origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const relayPath = url.pathname.replace(/\/+$/, '');
const [pathname, query] = path.split('?', 2);
url.pathname = `${relayPath}${pathname}`;
url.search = query === undefined ? '' : query;
url.hash = '';
return url.toString();
}
function localWebSocketUrl(origin: string, path: string): string {
const url = new URL(origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = path.split('?', 1)[0]!;
const query = path.includes('?') ? path.slice(path.indexOf('?') + 1) : '';
url.search = query;
url.hash = '';
return url.toString();
}
function headerLines(headers: readonly string[]): string {
let result = '';
for (let index = 0; index < headers.length; index += 2) {
result += `${headers[index]}: ${headers[index + 1]}\r\n`;
}
return result.replace(/\r\n$/, '');
}
function buildErrorResponse(status: number): Buffer {
const reason = status === 400 ? 'Bad Request' : 'Bad Gateway';
return Buffer.from(`HTTP/1.1 ${status} ${reason}\r\nContent-Length: 0\r\n\r\n`);
}
function stringField(
value: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const field = value?.[key];
return typeof field === 'string' ? field : undefined;
}
function decodeBase64(value: string): Buffer {
if (value.length % 4 !== 0) {
throw new SyntaxError('invalid HTTP tunnel request base64');
}
let paddingStart = -1;
for (let i = 0; i < value.length; i++) {
const c = value.codePointAt(i)!;
if (c === 0x3d) {
if (paddingStart === -1) paddingStart = i;
continue;
}
if (paddingStart !== -1) {
throw new SyntaxError('invalid HTTP tunnel request base64');
}
const ok =
(c >= 0x41 && c <= 0x5a) ||
(c >= 0x61 && c <= 0x7a) ||
(c >= 0x30 && c <= 0x39) ||
c === 0x2b ||
c === 0x2f;
if (!ok) {
throw new SyntaxError('invalid HTTP tunnel request base64');
}
}
if (paddingStart !== -1 && value.length - paddingStart > 2) {
throw new SyntaxError('invalid HTTP tunnel request base64');
}
return Buffer.from(value, 'base64');
}
function rawDataText(data: RawData): string {
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
return Buffer.from(data as ArrayBuffer).toString('utf8');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}