mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
* perf(aws-lambda): run chunks through a Distributed Map to lift the 40-chunk cap The inline Map runs at most 40 chunk renders at once. Both state machine definitions (CDK and SAM) now use a Distributed Map with STANDARD child executions, capped at the function's reserved concurrency so chunks past it queue instead of being throttled. Progress reads each chunk's child execution history so frames, cost and chunk errors still show up. * fix(aws-lambda): retry Lambda throttles for minutes and stop on oversized chunks A throttled task used to burn its four catch-all retries in about 30 s. It now gets its own jittered backoff of several minutes, because another render on the same function can still hold the reserved slots. Plan treats CHUNK_EXCEEDS_WORKER_TIMEOUT as terminal in both definitions. * chore(aws-lambda): trim stack comments to keep the comment share flat * fix(aws-lambda): keep render --wait working at hundreds of chunks Progress polls read the chunk Map Run's counts (one DescribeMapRun) instead of every child execution's history, take exact chunk frames and cost from the Map output once it exits, and read failed children only when the counts show a failure. The SDK client retries throttled calls up to 8 times with backoff. * feat(aws-lambda): size each render's concurrency to the account instead of a fixed 8 * fix(lambda): keep the concurrency SDK external in published modules * fix(cli): keep an existing stack's reserved concurrency on redeploy Redeploying without --concurrency sent ReservedConcurrency=-1, which removed the cap of 8 that earlier CLI versions reserved by default and stopped recording it in the state file. The override is now left out when the flag is omitted, so SAM keeps the stack's current value (a new stack still gets the template's unreserved default). The state file records the deployed value, and deploy prints the kept reservation. * fix(aws-lambda): keep progress working for callers without DescribeMapRun Callers whose IAM policy came from an earlier `lambda policies user` lack states:DescribeMapRun, so reading a running Distributed Map threw AccessDeniedException out of `lambda progress` and `render --wait` while the render itself went on to succeed. getRenderProgress now catches only that error around the map-run read, returns the parent snapshot (status, plan and assemble progress) and adds a warning to re-apply the policy, which both commands print. Other errors still throw. * fix(cli): let the documented deploy policy drop a reserved concurrency When a redeploy removes a function's reserved concurrency, CloudFormation calls lambda:DeleteFunctionConcurrency. The policy printed by `lambda policies role|user` granted only PutFunctionConcurrency, so the update failed and rolled back. Grant it, and pin it with states:DescribeMapRun in the required-actions test. * fix(aws-lambda): read smoke chunk hashes from the Distributed Map output With chunks in a Distributed Map, their task results live in child executions, so the smoke gate's chunk-hash check found 0 chunks in the parent history and failed every v1/v2 comparison. Read Chunks[] from the RenderChunks / RenderChunksV2 MapStateExited output instead, as getRenderProgress does. The stack this script deploys only has the Distributed Map, so the inline-Map path is gone. * fix(producer): cap a pinned chunk size at the worker concurrency, not 16 With --chunk-size or --target-chunk-frames and no --max-parallel-chunks, the plan still capped the render at 16 chunks however many workers the account allowed, so --chunk-size 30 on a 60 s render gave 16 chunks of 113 frames. The cap is now one wave across the worker concurrency, as the --max-parallel-chunks docs say; 16 remains when the concurrency is unknown. * feat(cli): remove a stack's reserved concurrency with --concurrency=none Since an omitted --concurrency now keeps the stack's reservation, there was no CLI way back to the unreserved default. --concurrency=none sends ReservedConcurrency=-1, the state file records the stack as unreserved and deploy says the reservation was removed. Positive N and an omitted flag behave as before; any other value is rejected instead of being silently ignored. * chore(aws-lambda): raise the package size budget for account-sized concurrency Measured with pnpm@10.17.1 pack after rebasing on main with the S3 assembly change: 67,763 packed / 244,685 unpacked; the budget is the check's own 5% headroom over that.
532 lines
21 KiB
TypeScript
532 lines
21 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Build the AWS Lambda deployment ZIP.
|
|
*
|
|
* Pack layout (paths inside the ZIP are relative to Lambda's
|
|
* `/var/task/`):
|
|
*
|
|
* handler.mjs — bundled entry, set as Lambda's Handler
|
|
* handler.mjs.map — sourcemap (debugging aid; small)
|
|
* bin/ffmpeg — ffmpeg-static binary
|
|
* bin/chrome-headless-shell — fallback Chrome (only when CHROME_SOURCE=shell)
|
|
* node_modules/@sparticuz/chromium/
|
|
* — primary Chrome (lives under node_modules so
|
|
* runtime `import("@sparticuz/chromium")`
|
|
* resolves; the package's own tarball stays
|
|
* inside).
|
|
*
|
|
* The handler bundle (esbuild) externalises modules whose binary assets
|
|
* must be present at runtime — `@sparticuz/chromium` for its bin tarball,
|
|
* `puppeteer-core` because Lambda runtime resolves it via Node module
|
|
* resolution from `node_modules/`. Everything else is inlined for cold
|
|
* start speed.
|
|
*
|
|
* Run:
|
|
* bun run --cwd packages/aws-lambda build:zip
|
|
* bun run --cwd packages/aws-lambda build:zip -- --source=chrome-headless-shell
|
|
*
|
|
* Outputs the resolved ZIP path + size to stdout and writes a sidecar
|
|
* JSON (`dist/handler.zip.manifest.json`) describing the contents.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
closeSync,
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import * as esbuild from "esbuild";
|
|
import { formatBytes } from "./_formatBytes.js";
|
|
import { HANDLER_BANNER } from "./_handlerBanner.js";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const packageRoot = resolve(scriptDir, "..");
|
|
const monorepoRoot = resolve(packageRoot, "../..");
|
|
const distDir = join(packageRoot, "dist");
|
|
|
|
interface BuildOptions {
|
|
source: "sparticuz" | "chrome-headless-shell";
|
|
/** Hard upper bound on the unzipped bundle size in bytes (Lambda limit is 250 MiB). */
|
|
maxUnzippedBytes: number;
|
|
/** Hard upper bound on the ZIP file size in bytes. */
|
|
maxZippedBytes: number;
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: BuildOptions = {
|
|
source: "sparticuz",
|
|
// Lambda's hard ceiling for ZIP-deployed functions is 250 MiB unzipped
|
|
// (AWS docs label it "250 MB" but the 262144000-byte value is 250
|
|
// binary mebibytes). We gate at 248 MiB to keep ~2 MiB of headroom —
|
|
// the sparticuz Chrome (~70 MiB) + ffmpeg (~80 MiB) + ffprobe (~62
|
|
// MiB) + bundled Node deps put us close to the ceiling. Chrome itself
|
|
// decompresses into Lambda's `/tmp` at cold start, which has its own
|
|
// 10 GiB budget, so the unzipped /var/task footprint above is what
|
|
// actually competes with Lambda's 250 MiB limit.
|
|
maxUnzippedBytes: 248 * 1024 * 1024,
|
|
// Lambda's only zipped-size cap is for direct console/CLI uploads (50
|
|
// MiB); S3-deployed functions are bounded by the unzipped ceiling. We
|
|
// gate at 150 MiB to flag a sudden bundle-size regression without
|
|
// false-failing on the natural ~100 MiB sparticuz + ffmpeg payload.
|
|
maxZippedBytes: 150 * 1024 * 1024,
|
|
};
|
|
|
|
function parseArgs(argv: string[]): BuildOptions {
|
|
const opts = { ...DEFAULT_OPTIONS };
|
|
for (const arg of argv.slice(2)) {
|
|
if (arg.startsWith("--source=")) {
|
|
const v = arg.slice("--source=".length);
|
|
if (v !== "sparticuz" && v !== "chrome-headless-shell") {
|
|
throw new Error(`--source must be 'sparticuz' or 'chrome-headless-shell' (got ${v})`);
|
|
}
|
|
opts.source = v;
|
|
} else if (arg.startsWith("--max-unzipped=")) {
|
|
opts.maxUnzippedBytes = Number.parseInt(arg.slice("--max-unzipped=".length), 10);
|
|
} else if (arg.startsWith("--max-zipped=")) {
|
|
opts.maxZippedBytes = Number.parseInt(arg.slice("--max-zipped=".length), 10);
|
|
} else if (arg === "--help") {
|
|
console.log(
|
|
"Usage: tsx build-zip.ts [--source=sparticuz|chrome-headless-shell]\n" +
|
|
" [--max-unzipped=<bytes>] [--max-zipped=<bytes>]",
|
|
);
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown flag: ${arg}`);
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const opts = parseArgs(process.argv);
|
|
const start = Date.now();
|
|
|
|
rmSync(distDir, { recursive: true, force: true });
|
|
mkdirSync(distDir, { recursive: true });
|
|
|
|
const stagingDir = join(distDir, "staging");
|
|
mkdirSync(stagingDir, { recursive: true });
|
|
|
|
console.log(`[build-zip] source=${opts.source}`);
|
|
|
|
// 1. Bundle the handler.
|
|
await bundleHandler(stagingDir);
|
|
|
|
// 2. Stage runtime modules (puppeteer-core + @sparticuz/chromium or the
|
|
// fallback chrome-headless-shell tar).
|
|
stageRuntimeModules(stagingDir, opts.source);
|
|
|
|
// 3. Stage the ffmpeg binary.
|
|
stageFfmpeg(stagingDir);
|
|
|
|
// 3b. Stage the hyperframe runtime manifest + IIFE as siblings of
|
|
// handler.mjs. The producer's `hyperframeRuntimeLoader` checks
|
|
// SIBLING_MANIFEST_PATH first, so dropping the manifest alongside
|
|
// the bundled handler at /var/task/hyperframe.manifest.json lets
|
|
// renderChunk find it without needing PRODUCER_HYPERFRAME_MANIFEST_PATH.
|
|
stageHyperframeRuntime(stagingDir);
|
|
|
|
// 4. If we're on the chrome-headless-shell fallback, stage that binary.
|
|
if (opts.source === "chrome-headless-shell") {
|
|
stageChromeHeadlessShell(stagingDir);
|
|
}
|
|
|
|
// 5. Compute the unzipped size BEFORE zipping so we fail loud when over budget.
|
|
const unzippedBytes = directorySizeBytes(stagingDir);
|
|
console.log(`[build-zip] unzipped staging size: ${formatBytes(unzippedBytes)}`);
|
|
if (unzippedBytes > opts.maxUnzippedBytes) {
|
|
throw new Error(
|
|
`[build-zip] unzipped bundle ${formatBytes(unzippedBytes)} exceeds limit ${formatBytes(
|
|
opts.maxUnzippedBytes,
|
|
)} (Lambda ZIP ceiling: 250 MiB unzipped). ` +
|
|
`Switch --source to the lighter option, or move Chrome to a Lambda Layer.`,
|
|
);
|
|
}
|
|
|
|
// 6. Build the ZIP.
|
|
const zipPath = join(distDir, "handler.zip");
|
|
zipDirectory(stagingDir, zipPath);
|
|
const zippedBytes = statSync(zipPath).size;
|
|
console.log(`[build-zip] zip size: ${formatBytes(zippedBytes)} → ${zipPath}`);
|
|
if (zippedBytes > opts.maxZippedBytes) {
|
|
throw new Error(
|
|
`[build-zip] zip ${formatBytes(zippedBytes)} exceeds ZIP size limit ${formatBytes(
|
|
opts.maxZippedBytes,
|
|
)}.`,
|
|
);
|
|
}
|
|
|
|
// 7. Sidecar manifest.
|
|
const manifest = {
|
|
builtAt: new Date().toISOString(),
|
|
durationMs: Date.now() - start,
|
|
source: opts.source,
|
|
unzippedBytes,
|
|
zippedBytes,
|
|
maxUnzippedBytes: opts.maxUnzippedBytes,
|
|
maxZippedBytes: opts.maxZippedBytes,
|
|
};
|
|
writeFileSync(join(distDir, "handler.zip.manifest.json"), JSON.stringify(manifest, null, 2));
|
|
|
|
// 8. Cleanup staging.
|
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
console.log(`[build-zip] done in ${Date.now() - start}ms`);
|
|
}
|
|
|
|
async function bundleHandler(stagingDir: string): Promise<void> {
|
|
const entry = join(packageRoot, "src/handler.ts");
|
|
const outfile = join(stagingDir, "handler.mjs");
|
|
|
|
const workspaceAliasPlugin: esbuild.Plugin = {
|
|
name: "workspace-alias",
|
|
setup(build) {
|
|
build.onResolve({ filter: /^@hyperframes\/producer\/distributed$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/producer/src/distributed.ts"),
|
|
}));
|
|
build.onResolve({ filter: /^@hyperframes\/producer$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/producer/src/index.ts"),
|
|
}));
|
|
build.onResolve({ filter: /^@hyperframes\/engine$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/engine/src/index.ts"),
|
|
}));
|
|
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/engine/src/utils/alphaBlit.ts"),
|
|
}));
|
|
build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/engine/src/utils/shaderTransitions.ts"),
|
|
}));
|
|
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/core/src/index.ts"),
|
|
}));
|
|
build.onResolve({ filter: /^@hyperframes\/core\/lint$/ }, () => ({
|
|
path: resolve(monorepoRoot, "packages/core/src/lint/index.ts"),
|
|
}));
|
|
},
|
|
};
|
|
|
|
await esbuild.build({
|
|
bundle: true,
|
|
platform: "node",
|
|
target: "node22",
|
|
format: "esm",
|
|
// Externalise binary-shipped modules so node module resolution picks
|
|
// them up at runtime. esbuild would otherwise try to inline their
|
|
// postinstall-extracted binaries, which it cannot do.
|
|
external: [
|
|
"@sparticuz/chromium",
|
|
"puppeteer-core",
|
|
"puppeteer",
|
|
// AWS SDK v3 is pre-installed in the Lambda Node 22 runtime; mark
|
|
// external so we don't double-bundle 3+ MiB of SDK.
|
|
"@aws-sdk/client-lambda",
|
|
"@aws-sdk/client-s3",
|
|
],
|
|
plugins: [workspaceAliasPlugin],
|
|
minify: false,
|
|
// sourcemap=false: the ZIP is tight on Lambda's 250 MiB unzipped cap
|
|
// (Chrome ~70 MiB + ffmpeg ~80 MiB + ffprobe ~62 MiB + Node deps). A
|
|
// 4-5 MiB sourcemap puts us over. Re-enable for local debugging by passing
|
|
// --sourcemap; the bundle's stack traces stay readable enough without
|
|
// it because we don't minify.
|
|
sourcemap: false,
|
|
entryPoints: [entry],
|
|
outfile,
|
|
// See HANDLER_BANNER (_handlerBanner.ts) for why the ESM bundle needs the
|
|
// CJS require/__filename/__dirname shims.
|
|
banner: {
|
|
js: HANDLER_BANNER,
|
|
},
|
|
});
|
|
console.log(`[build-zip] bundled handler → ${outfile}`);
|
|
}
|
|
|
|
function stageRuntimeModules(stagingDir: string, source: BuildOptions["source"]): void {
|
|
// Bun's isolated-install layout means cpSync(@sparticuz/chromium) only
|
|
// copies the package's own files, missing transitive deps like `tar-fs`.
|
|
// The clean cross-package-manager solution: write a tiny package.json
|
|
// into staging/ that declares the production deps, then `npm install`
|
|
// there. npm flattens transitive deps into staging/node_modules/.
|
|
const pkg: Record<string, unknown> = {
|
|
name: "hyperframes-aws-lambda-bundled",
|
|
version: "0.0.0",
|
|
private: true,
|
|
dependencies: {
|
|
"puppeteer-core": readDepVersion("puppeteer-core"),
|
|
},
|
|
};
|
|
if (source === "sparticuz") {
|
|
(pkg.dependencies as Record<string, string>)["@sparticuz/chromium"] =
|
|
readDepVersion("@sparticuz/chromium");
|
|
}
|
|
writeFileSync(join(stagingDir, "package.json"), JSON.stringify(pkg, null, 2));
|
|
|
|
// --no-package-lock so we don't pollute staging with a lockfile we don't
|
|
// ship; --no-audit/--no-fund just for log noise.
|
|
const result = spawnSync(
|
|
"npm",
|
|
["install", "--no-package-lock", "--no-audit", "--no-fund", "--omit=dev", "--omit=optional"],
|
|
{
|
|
cwd: stagingDir,
|
|
stdio: "inherit",
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`[build-zip] npm install into staging failed (status ${result.status})`);
|
|
}
|
|
console.log(`[build-zip] staged node_modules via npm install`);
|
|
}
|
|
|
|
function readDepVersion(moduleName: string): string {
|
|
// Resolve the EXACT version bun installed into the workspace, not the
|
|
// semver range declared in package.json. The staging-dir npm install
|
|
// runs with `--no-package-lock`, so a caret range would float to the
|
|
// latest registry version at build time — diverging from what the
|
|
// workspace tests ran against and breaking ZIP-content determinism
|
|
// across consecutive builds. The lockfile pin gives us reproducibility.
|
|
const lockText = readFileSync(join(monorepoRoot, "bun.lock"), "utf-8");
|
|
// bun.lock lines look like:
|
|
// "puppeteer-core": ["puppeteer-core@24.43.1", "", { ... }, "sha512-..."],
|
|
const re = new RegExp(
|
|
`"${moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}":\\s*\\["${moduleName.replace(
|
|
/[.*+?^${}()|[\]\\]/g,
|
|
"\\$&",
|
|
)}@([^"]+)"`,
|
|
);
|
|
const match = re.exec(lockText);
|
|
if (!match || !match[1]) {
|
|
// Fall back to the manifest range — better than failing the build
|
|
// entirely if bun.lock's format changes between bun versions.
|
|
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8")) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
return manifest.dependencies?.[moduleName] ?? "latest";
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
function resolveModuleDir(moduleName: string): string {
|
|
// Walk up from packageRoot to find a matching node_modules entry.
|
|
// Used by stageFfmpeg below; the @sparticuz/chromium + puppeteer-core
|
|
// paths now go through npm install instead.
|
|
let dir = packageRoot;
|
|
for (let i = 0; i < 5; i++) {
|
|
const candidate = join(dir, "node_modules", moduleName);
|
|
if (existsSync(candidate)) return candidate;
|
|
dir = dirname(dir);
|
|
}
|
|
throw new Error(
|
|
`[build-zip] could not resolve ${moduleName} from ${packageRoot} — run 'bun install' first.`,
|
|
);
|
|
}
|
|
|
|
function stageHyperframeRuntime(stagingDir: string): void {
|
|
const coreDist = resolve(monorepoRoot, "packages/core/dist");
|
|
const manifestSrc = join(coreDist, "hyperframe.manifest.json");
|
|
const iifeSrc = join(coreDist, "hyperframe.runtime.iife.js");
|
|
if (!existsSync(manifestSrc) || !existsSync(iifeSrc)) {
|
|
throw new Error(
|
|
`[build-zip] hyperframe runtime artifacts missing under ${coreDist}. ` +
|
|
`Run 'bun run --filter @hyperframes/core build:hyperframes-runtime:modular' first.`,
|
|
);
|
|
}
|
|
cpSync(manifestSrc, join(stagingDir, "hyperframe.manifest.json"));
|
|
cpSync(iifeSrc, join(stagingDir, "hyperframe.runtime.iife.js"));
|
|
console.log(`[build-zip] staged hyperframe.manifest.json + hyperframe.runtime.iife.js`);
|
|
}
|
|
|
|
// ELF header constants used by `assertLinuxX86_64Elf`. Header layout:
|
|
// bytes 0..3 magic `0x7F 'E' 'L' 'F'`, byte 4 EI_CLASS (`2` = ELFCLASS64),
|
|
// bytes 18..19 e_machine little-endian (`0x3E` = EM_X86_64).
|
|
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
const ELF_CLASS_64 = 2;
|
|
const ELF_MACHINE_X86_64 = 0x3e;
|
|
const ELF_HEADER_BYTES = 20;
|
|
|
|
/**
|
|
* Verify the binary at `path` is a Linux x86-64 ELF executable, the only
|
|
* shape the Lambda runtime can exec. Throws with the canonical workaround
|
|
* (Docker `--platform=linux/amd64` or `npm_config_platform` /
|
|
* `npm_config_arch` overrides) so the build doesn't ship a silently
|
|
* broken zip when a non-Linux host's postinstall fetched the wrong arch.
|
|
*/
|
|
function assertLinuxX86_64Elf(path: string, label: string): void {
|
|
const head = readFileHead(path, ELF_HEADER_BYTES, label);
|
|
const isElf = head.subarray(0, 4).equals(ELF_MAGIC);
|
|
const isElf64 = head[4] === ELF_CLASS_64;
|
|
const machine = head.readUInt16LE(18);
|
|
if (isElf && isElf64 && machine === ELF_MACHINE_X86_64) return;
|
|
|
|
const magicHex = head.subarray(0, 4).toString("hex");
|
|
throw new Error(
|
|
`[build-zip] ${label} at ${path} is not a Linux x86-64 ELF executable ` +
|
|
`(magic=0x${magicHex}, ei_class=${head[4]}, e_machine=0x${machine.toString(16)}). ` +
|
|
`This usually means the deploy host's postinstall fetched a host-platform binary ` +
|
|
`(e.g. macOS arm64 ffmpeg) instead of the linux/x64 binary Lambda needs. ` +
|
|
`Re-run the build inside a linux/amd64 container, or pre-install with ` +
|
|
`\`npm_config_platform=linux npm_config_arch=x64\` so the package fetches the right binary.`,
|
|
);
|
|
}
|
|
|
|
function readFileHead(path: string, byteCount: number, label: string): Buffer {
|
|
const fd = openSync(path, "r");
|
|
try {
|
|
const buf = Buffer.alloc(byteCount);
|
|
const bytesRead = readSync(fd, buf, 0, byteCount, 0);
|
|
if (bytesRead < byteCount) {
|
|
throw new Error(
|
|
`[build-zip] ${label} at ${path} is too short — read ${bytesRead} of ${byteCount} bytes.`,
|
|
);
|
|
}
|
|
return buf;
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function stageFfmpeg(stagingDir: string): void {
|
|
const binDir = join(stagingDir, "bin");
|
|
mkdirSync(binDir, { recursive: true });
|
|
|
|
// ffmpeg from `ffmpeg-static`. The package only ships the encoder
|
|
// binary; the audio pad/trim path also needs ffprobe, which comes
|
|
// from `ffprobe-static`.
|
|
//
|
|
// `ffmpeg-static`'s postinstall fetches a binary for the host platform
|
|
// (e.g. arm64 Mach-O on Apple Silicon macOS). Lambda runs Linux x86-64,
|
|
// so a build from a non-Linux host silently produces a zip that boots
|
|
// but fails at first ffmpeg invocation with `cannot execute binary file`.
|
|
// Verify the ELF header up front and bail with a clear message instead.
|
|
const ffmpegBinary = join(resolveModuleDir("ffmpeg-static"), "ffmpeg");
|
|
if (!existsSync(ffmpegBinary)) {
|
|
throw new Error(
|
|
`[build-zip] ffmpeg-static binary missing at ${ffmpegBinary}. Did postinstall run?`,
|
|
);
|
|
}
|
|
assertLinuxX86_64Elf(ffmpegBinary, "ffmpeg-static binary");
|
|
const ffmpegDest = join(binDir, "ffmpeg");
|
|
cpSync(ffmpegBinary, ffmpegDest);
|
|
chmodSync(ffmpegDest, 0o755);
|
|
|
|
// ffprobe lives at `ffprobe-static/bin/<platform>/<arch>/ffprobe`.
|
|
// The producer's `audioPadTrim` spawns `ffprobe` from PATH so we need
|
|
// it alongside ffmpeg under /var/task/bin/.
|
|
const ffprobeModule = resolveModuleDir("ffprobe-static");
|
|
const ffprobeCandidates = [
|
|
join(ffprobeModule, "bin", "linux", "x64", "ffprobe"),
|
|
join(ffprobeModule, "bin", "linux", "arm64", "ffprobe"),
|
|
];
|
|
const ffprobeBinary = ffprobeCandidates.find((p) => existsSync(p));
|
|
if (!ffprobeBinary) {
|
|
throw new Error(
|
|
`[build-zip] ffprobe-static binary not found under ${ffprobeModule}/bin/linux/. Did postinstall run?`,
|
|
);
|
|
}
|
|
const ffprobeDest = join(binDir, "ffprobe");
|
|
cpSync(ffprobeBinary, ffprobeDest);
|
|
chmodSync(ffprobeDest, 0o755);
|
|
|
|
console.log(`[build-zip] staged ffmpeg + ffprobe → bin/`);
|
|
}
|
|
|
|
function stageChromeHeadlessShell(stagingDir: string): void {
|
|
// The fallback path bundles the same chrome-headless-shell binary the
|
|
// K8s deploy uses. The binary is fetched via `@puppeteer/browsers` on
|
|
// first build into the host's `~/.cache/puppeteer/`; the build script
|
|
// re-uses that cache rather than redownloading.
|
|
const home = process.env.HOME ?? "/root";
|
|
const baseDir = join(home, ".cache", "puppeteer", "chrome-headless-shell");
|
|
if (!existsSync(baseDir)) {
|
|
throw new Error(
|
|
`[build-zip] chrome-headless-shell cache missing at ${baseDir}. Run\n` +
|
|
` npx --yes @puppeteer/browsers install chrome-headless-shell@stable --path ${home}/.cache/puppeteer\n` +
|
|
`before --source=chrome-headless-shell.`,
|
|
);
|
|
}
|
|
// Sort by numeric semver descending. `sort().reverse()` is lexicographic,
|
|
// which silently picks "99.0.0" over "131.0.0" once Chrome ships
|
|
// three-digit majors that aren't strictly width-aligned. `compareSemver`
|
|
// returns negative/zero/positive on (a, b), so descending = `b - a`.
|
|
const versions = readdirSync(baseDir).sort((a, b) => compareSemver(b, a));
|
|
for (const v of versions) {
|
|
const candidate = join(baseDir, v, "chrome-headless-shell-linux64", "chrome-headless-shell");
|
|
if (existsSync(candidate)) {
|
|
const dest = join(stagingDir, "bin", "chrome-headless-shell");
|
|
mkdirSync(dirname(dest), { recursive: true });
|
|
cpSync(candidate, dest);
|
|
chmodSync(dest, 0o755);
|
|
console.log(`[build-zip] staged chrome-headless-shell (${v}) → bin/chrome-headless-shell`);
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(`[build-zip] no linux64 chrome-headless-shell binary found under ${baseDir}.`);
|
|
}
|
|
|
|
/**
|
|
* Compare two semver-shaped strings like "131.0.6778.108". Treats any
|
|
* non-numeric directory name as `-Infinity` so it sorts to the bottom
|
|
* (Puppeteer's cache layout sometimes includes `latest` or branch tags).
|
|
* Used by `stageChromeHeadlessShell` to pick the newest cached Chrome
|
|
* without tripping on the lexicographic "99 > 131" trap.
|
|
*/
|
|
function compareSemver(a: string, b: string): number {
|
|
const partsA = a.split(".").map((s) => Number.parseInt(s, 10));
|
|
const partsB = b.split(".").map((s) => Number.parseInt(s, 10));
|
|
const len = Math.max(partsA.length, partsB.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const ai = partsA[i] ?? 0;
|
|
const bi = partsB[i] ?? 0;
|
|
if (Number.isNaN(ai) && Number.isNaN(bi)) continue;
|
|
if (Number.isNaN(ai)) return -1;
|
|
if (Number.isNaN(bi)) return 1;
|
|
if (ai !== bi) return ai - bi;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function zipDirectory(sourceDir: string, zipPath: string): void {
|
|
const result = spawnSync("zip", ["-rq", zipPath, "."], { cwd: sourceDir, stdio: "inherit" });
|
|
if (result.status !== 0) {
|
|
throw new Error(`[build-zip] zip exited with status ${result.status}`);
|
|
}
|
|
}
|
|
|
|
function directorySizeBytes(dir: string): number {
|
|
// Use spawnSync (no shell) instead of execSync so `dir` is passed as
|
|
// an argv element rather than interpolated into a shell command —
|
|
// CodeQL's `js/shell-command-injected-from-environment` rule fires
|
|
// on the latter even with JSON-quoting. `du -sb` is Linux-only;
|
|
// build-zip is CI-side where Linux coreutils is present.
|
|
const result = spawnSync("du", ["-sb", dir], { encoding: "utf-8" });
|
|
if (result.status === 0 && result.stdout) {
|
|
const bytes = Number.parseInt(result.stdout.split(/\s+/)[0] ?? "0", 10);
|
|
if (!Number.isNaN(bytes)) return bytes;
|
|
}
|
|
return walkSize(dir);
|
|
}
|
|
|
|
function walkSize(dir: string): number {
|
|
let total = 0;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) total += walkSize(full);
|
|
else if (entry.isFile()) total += statSync(full).size;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
void main().catch((err) => {
|
|
console.error("[build-zip] failed:", err instanceof Error ? err.message : String(err));
|
|
if (err instanceof Error && err.stack) console.error(err.stack);
|
|
process.exit(1);
|
|
});
|