import { cp, mkdtemp, realpath, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { normalizeArtifactSymlinks } from "./artifact-postprocess.ts"; import type { BuildArtifact } from "./build-strategy.ts"; import { defaultHttpPortForBuildType, type FrameworkBuildType, } from "./config/frameworks.ts"; import { stageConfiguredArtifactDependencies } from "./configured-artifact-trace.ts"; export async function stageConfiguredArtifact(options: { appPath: string; outputDirectory: string; entrypoint: string; buildType: FrameworkBuildType; label: string; missingEntrypointMessage?: string; traceDependencies?: boolean; signal?: AbortSignal; }): Promise { options.signal?.throwIfAborted(); const outputDir = resolvePathInside( options.appPath, options.outputDirectory, `${options.label} build output directory must be a relative path inside the app directory`, ); const outputStat = await stat(outputDir).catch(() => null); if (!outputStat?.isDirectory()) { throw new Error( `${options.label} build output directory does not exist: ${options.outputDirectory}`, ); } const [realAppPath, realOutputDir] = await Promise.all([ realpath(path.resolve(options.appPath)), realpath(outputDir), ]); if (!isPathWithin(realAppPath, realOutputDir)) { throw new Error( `${options.label} build output directory must stay inside the app directory`, ); } const entrypoint = await resolveConfiguredArtifactEntrypoint({ directory: realOutputDir, entrypoint: options.entrypoint, label: options.label, displayDirectory: options.outputDirectory, missingEntrypointMessage: options.missingEntrypointMessage, }); const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-")); try { const artifactDir = path.join(outDir, "app"); await cp(realOutputDir, artifactDir, { recursive: true, verbatimSymlinks: true, }); await normalizeArtifactSymlinks( artifactDir, options.appPath, options.signal, ); if (options.traceDependencies) { await stageConfiguredArtifactDependencies({ appPath: realAppPath, artifactDir, outputDirectory: realOutputDir, entrypoint, signal: options.signal, }); } return { directory: artifactDir, entrypoint, defaultPortMapping: { http: defaultHttpPortForBuildType(options.buildType), }, cleanup: () => rm(outDir, { recursive: true, force: true }), }; } catch (error) { await rm(outDir, { recursive: true, force: true }); throw error; } } export async function resolveConfiguredArtifactEntrypoint(options: { directory: string; entrypoint: string; label: string; displayDirectory?: string; missingEntrypointMessage?: string; }): Promise { const entrypoint = normalizeConfiguredArtifactEntrypoint( options.entrypoint, options.label, ); const entryPath = path.join(options.directory, entrypoint); const entryStat = await stat(entryPath).catch(() => null); if (!entryStat?.isFile()) { if (options.missingEntrypointMessage) { throw new Error(options.missingEntrypointMessage); } const directory = options.displayDirectory ?? options.directory; throw new Error( `${options.label} build entrypoint does not exist: ${directory}/${entrypoint}`, ); } return entrypoint; } export function normalizeConfiguredArtifactEntrypoint( entrypoint: string, label: string, ): string { const normalized = path.posix.normalize( entrypoint.trim().replace(/\\/g, "/"), ); if ( normalized.length === 0 || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../") || path.win32.isAbsolute(entrypoint) || path.posix.isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized) ) { throw new Error( `${label} build entrypoint must be a relative path inside the output directory`, ); } return normalized; } function resolvePathInside( root: string, relativePath: string, errorMessage: string, ): string { const raw = relativePath.trim().replace(/\\/g, "/"); const normalized = path.posix.normalize(raw); if ( raw.length === 0 || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../") || path.win32.isAbsolute(relativePath) || path.posix.isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized) ) { throw new Error(errorMessage); } const resolved = path.resolve(root, normalized); const relative = path.relative(root, resolved); if ( relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) ) { throw new Error(errorMessage); } return resolved; } function isPathWithin(root: string, candidate: string): boolean { const relative = path.relative(root, candidate); return ( relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) ); }