import { FRAMEWORKS, type FrameworkBuildType, type FrameworkDescriptor, frameworkByKey, } from "./frameworks.ts"; import type { ComputeFramework } from "./types.ts"; export interface ComputeAppManifest { main?: unknown; scripts?: unknown; dependencies?: unknown; devDependencies?: unknown; peerDependencies?: unknown; } export interface DetectComputeAppInput { root: string; manifest: ComputeAppManifest; filePaths: Iterable; } export interface DetectedComputeApp { framework: ComputeFramework; frameworkName: string; buildType: FrameworkBuildType; httpPort: number; entrypoint: string | null; evidence: { kind: "dependency" | "config" | "entrypoint"; path: string; value: string; }; } /** * Detects one deployable app from an in-memory repository snapshot. * Paths are repository-relative so remote consumers do not need a checkout. */ export function detectComputeApp( input: DetectComputeAppInput, ): DetectedComputeApp | null { const root = normalizeRoot(input.root); const filePaths = new Set(input.filePaths); const dependencies = new Set([ ...recordKeys(input.manifest.dependencies), ...recordKeys(input.manifest.devDependencies), ]); for (const framework of FRAMEWORKS) { const dependency = framework.detectPackages.find((name) => dependencies.has(name), ); if (dependency) { return detectedApp( framework, inferEntrypoint(framework, input, filePaths), { kind: "dependency", path: joinRepoPath(root, "package.json"), value: dependency, }, ); } const configFile = framework.detectConfigFiles.find((fileName) => filePaths.has(joinRepoPath(root, fileName)), ); if (configFile) { return detectedApp( framework, inferEntrypoint(framework, input, filePaths), { kind: "config", path: joinRepoPath(root, configFile), value: configFile, }, ); } } const runtimeScript = readRuntimeScript(input.manifest.scripts); if (runtimeScript) { const bun = frameworkByKey("bun"); const scriptedEntrypoint = extractScriptEntrypoint(runtimeScript.command); const scriptReferencesExistingFile = scriptedEntrypoint !== null && filePaths.has(joinRepoPath(root, scriptedEntrypoint)); const entrypoint = scriptReferencesExistingFile ? scriptedEntrypoint : inferEntrypoint(bun, input, filePaths); if (entrypoint) { return detectedApp(bun, entrypoint, { kind: "entrypoint", path: joinRepoPath(root, entrypoint), value: `scripts.${runtimeScript.name}`, }); } } return null; } function detectedApp( framework: FrameworkDescriptor, entrypoint: string | null, evidence: DetectedComputeApp["evidence"], ): DetectedComputeApp { return { framework: framework.key, frameworkName: framework.displayName, buildType: framework.buildType, httpPort: framework.defaultHttpPort, entrypoint, evidence, }; } function inferEntrypoint( framework: FrameworkDescriptor, input: DetectComputeAppInput, filePaths: Set, ): string | null { if (!framework.usesEntrypoint) { return null; } const root = normalizeRoot(input.root); if (typeof input.manifest.main === "string") { const main = normalizeEntrypoint(input.manifest.main); if (main && filePaths.has(joinRepoPath(root, main))) { return main; } } const candidates = [ framework.defaultEntrypoint, "src/index.ts", "src/index.js", "src/server.ts", "src/server.js", "index.ts", "index.js", "server.ts", "server.js", ].filter((value): value is string => Boolean(value)); return ( candidates.find((candidate) => filePaths.has(joinRepoPath(root, candidate)), ) ?? null ); } function normalizeEntrypoint(value: string): string | null { if ( !value || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || /^[A-Za-z]:/.test(value) ) { return null; } const normalized = value.replace(/^\.\/+/, "").replace(/\/+/g, "/"); if ( !normalized || normalized === "." || normalized.split("/").some((segment) => segment === "..") ) { return null; } return normalized; } function readRuntimeScript( scripts: unknown, ): { name: "start" | "serve"; command: string } | null { if (!scripts || typeof scripts !== "object") { return null; } const values = scripts as Record; for (const name of ["start", "serve"] as const) { const command = values[name]; if (typeof command === "string" && command.trim()) { return { name, command }; } } return null; } const SCRIPT_OPTIONS_WITH_FILE_ARGUMENT = [ "--experimental-loader", "--import", "--loader", "--preload", "--require", "-r", ] as const; function extractScriptEntrypoint(command: string): string | null { const tokens = command.match(/"[^"]*"|'[^']*'|`[^`]*`|\S+/g) ?? []; const candidates: string[] = []; let skipNextToken = false; for (const rawToken of tokens) { if (skipNextToken) { skipNextToken = false; continue; } const quote = rawToken[0]; const token = (quote === '"' || quote === "'" || quote === "`") && rawToken.at(-1) === quote ? rawToken.slice(1, -1) : rawToken; const optionWithFileArgument = SCRIPT_OPTIONS_WITH_FILE_ARGUMENT.find( (option) => token === option || token.startsWith(`${option}=`), ); if (optionWithFileArgument) { skipNextToken = token === optionWithFileArgument; continue; } if (!/^(?:\.{1,2}\/)?[\w@./-]+\.(?:[cm]?[jt]sx?)$/.test(token)) { continue; } const candidate = normalizeEntrypoint(token); if (candidate) { candidates.push(candidate); } } return candidates.length === 1 ? (candidates[0] ?? null) : null; } function recordKeys(value: unknown): string[] { return value && typeof value === "object" ? Object.keys(value) : []; } function normalizeRoot(root: string): string { const normalized = root.replace(/^\.?\/+|\/+$/g, ""); return normalized === "." ? "" : normalized; } function joinRepoPath(...parts: string[]): string { return parts.filter(Boolean).join("/").replace(/\/+/g, "/"); }