mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
* fix(cli): the browser the CLI launches exits with it, even on kill -9 * fix(build): the root build runs on Windows * perf(cli): a repeat check runs in a process that already has the CLI and Chrome up * fix(cli): a warm check delivers all its output before it exits * fix(cli): the warm check only talks to a server that proves it is the user's The warm socket now lives in a private, owner-verified directory (XDG_RUNTIME_DIR, ~/.cache or LOCALAPPDATA) next to a 0600 token. Client and server prove the token to each other over fresh nonces before any request is sent, the request carries an HMAC of the environment instead of the environment itself, and frames are capped. * fix(cli): a warm check prints its stdout even after something went to stderr The client stopped writing frames once any frame had been written, so a check that printed a notice to stderr first lost its JSON report. * test(cli): list the warm-check client fixture as a fallow entry instead of an inline ignore * fix(cli): a check started while a warm one is busy runs cold at once on Windows The warm server stopped listening when it accepted a client. On Windows the pipe name stays alive through the served connection, so a second client's connect saw a busy pipe and waited 30 s for a free instance before running cold. It now keeps listening while serving (accept already declines anyone else) and stops when the served check finishes. * test(cli): the orphan test reads the check's own Chrome and watchdog instead of racing a poll On Windows each process listing is a PowerShell call of about a second, so a check that finishes quickly could start and close Chrome between two polls, and the test failed its own 'Chrome was seen' precondition. A fixture now records the Chrome tree and the watchdog from inside the check when its first page opens.
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Writable } from "node:stream";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const WARM_IDLE_MS = 5 * 60_000;
|
|
export const HANDSHAKE_TIMEOUT_MS = 2_000;
|
|
const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
|
|
export const Frame = {
|
|
Stdout: 1,
|
|
Stderr: 2,
|
|
Exit: 3,
|
|
Declined: 4,
|
|
Signal: 5,
|
|
Request: 6,
|
|
Hello: 7,
|
|
Proof: 8,
|
|
} as const;
|
|
export type Frame = (typeof Frame)[keyof typeof Frame];
|
|
|
|
export interface WarmRequest {
|
|
argv: string[];
|
|
script: string;
|
|
cwd: string;
|
|
envDigest: string;
|
|
proof: string;
|
|
}
|
|
|
|
export interface WarmEndpoint {
|
|
address: string;
|
|
token: string;
|
|
}
|
|
|
|
const VOLATILE_ENV = new Set(["_", "PWD", "OLDPWD"]);
|
|
|
|
function privateToMe(path: string, kind: "dir" | "file"): boolean {
|
|
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
if (!stat || stat.isSymbolicLink()) return false;
|
|
if (kind === "dir" ? !stat.isDirectory() : !stat.isFile()) return false;
|
|
if (process.platform === "win32") return true;
|
|
return stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0;
|
|
}
|
|
|
|
export function warmCheckDirectory(env = process.env): string | undefined {
|
|
const dir =
|
|
process.platform === "win32"
|
|
? join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "hyperframes", "warm")
|
|
: env.XDG_RUNTIME_DIR
|
|
? join(env.XDG_RUNTIME_DIR, "hyperframes-warm")
|
|
: join(homedir(), ".cache", "hyperframes", "warm");
|
|
try {
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
return privateToMe(dir, "dir") ? dir : undefined;
|
|
}
|
|
|
|
function readToken(dir: string): string | undefined {
|
|
const path = join(dir, "token");
|
|
try {
|
|
writeFileSync(path, randomBytes(32).toString("hex"), { flag: "wx", mode: 0o600 });
|
|
} catch {}
|
|
if (!privateToMe(path, "file")) return undefined;
|
|
const token = readFileSync(path, "utf8");
|
|
return /^[0-9a-f]{64}$/.test(token) ? token : undefined;
|
|
}
|
|
|
|
export function keyed(token: string, ...parts: string[]): string {
|
|
return createHmac("sha256", token).update(parts.join("\0")).digest("hex");
|
|
}
|
|
|
|
export function sameProof(expected: string, received: unknown): boolean {
|
|
const a = Buffer.from(expected);
|
|
const b = Buffer.from(typeof received === "string" ? received : "");
|
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
}
|
|
|
|
export function envDigest(token: string, env: Record<string, string | undefined>): string {
|
|
const entries = Object.keys(env)
|
|
.filter((key) => !VOLATILE_ENV.has(key) && env[key] !== undefined)
|
|
.sort()
|
|
.map((key) => `${key}=${env[key]}`);
|
|
return keyed(token, "env", ...entries);
|
|
}
|
|
|
|
export function warmCheckEndpoint(env = process.env): WarmEndpoint | undefined {
|
|
const dir = warmCheckDirectory(env);
|
|
const token = dir && readToken(dir);
|
|
if (!dir || !token) return undefined;
|
|
const self = statSync(fileURLToPath(import.meta.url));
|
|
const build = createHash("sha256")
|
|
.update(
|
|
[import.meta.url, self.size, self.mtimeMs, process.execPath, process.version].join("\0"),
|
|
)
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
const address =
|
|
process.platform === "win32"
|
|
? `\\\\.\\pipe\\hyperframes-warm-${keyed(token, "pipe", build).slice(0, 32)}`
|
|
: join(dir, `${build}.sock`);
|
|
return { address, token };
|
|
}
|
|
|
|
export function encodeFrame(type: Frame, data: string | Uint8Array): Buffer {
|
|
const body = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
|
const head = Buffer.alloc(5);
|
|
head.writeUInt8(type, 0);
|
|
head.writeUInt32BE(body.length, 1);
|
|
return Buffer.concat([head, body]);
|
|
}
|
|
|
|
export function frameReader(
|
|
onFrame: (type: Frame, body: Buffer) => void,
|
|
): (chunk: Buffer) => boolean {
|
|
let pending = Buffer.alloc(0);
|
|
let refused = false;
|
|
return (chunk) => {
|
|
if (refused) return false;
|
|
pending = Buffer.concat([pending, chunk]);
|
|
while (pending.length >= 5) {
|
|
const length = pending.readUInt32BE(1);
|
|
if (length > MAX_FRAME_BYTES) return !(refused = true);
|
|
if (pending.length < 5 + length) return true;
|
|
onFrame(pending.readUInt8(0) as Frame, pending.subarray(5, 5 + length));
|
|
pending = pending.subarray(5 + length);
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
export function frameForwarder(
|
|
socket: { write(frame: Buffer): unknown },
|
|
type: Frame,
|
|
): typeof process.stdout.write {
|
|
const sink = new Writable({
|
|
write(chunk: Buffer, _encoding, next) {
|
|
socket.write(encodeFrame(type, chunk));
|
|
next();
|
|
},
|
|
});
|
|
return sink.write.bind(sink) as typeof process.stdout.write;
|
|
}
|