import { existsSync } from "node:fs"; import { createWriteStream } from "node:fs"; import { mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import archiver from "archiver"; import { CURRENT_MANAGED_DEPLOYMENT_MANIFEST_VERSION, type ManagedDeploymentManifest, type ManagedManifestBindings, normalizeManagedAssetsBinding, validateManagedAssetsConfigInput, validateManagedDeploymentManifest, } from "./managed-manifest.ts"; import { type AssetManifest, computeAssetHash } from "./asset-hash.ts"; import type { BuildOutput, WranglerConfig } from "./build-helper.ts"; import { debug } from "./debug.ts"; import { formatSize } from "./format.ts"; import { scanProjectFiles } from "./storage/file-filter.ts"; export interface ZipPackageResult { bundleZipPath: string; sourceZipPath: string; manifestPath: string; schemaPath: string | null; secretsPath: string | null; assetsZipPath: string | null; assetManifest: AssetManifest | null; cleanup: () => Promise; } export type ManifestData = ManagedDeploymentManifest; export type ZipProgressCallback = (current: number, total: number) => void; /** * Creates a ZIP archive from source directory * @param outputPath - Absolute path for output ZIP file * @param sourceDir - Absolute path to directory to archive * @param files - Optional list of specific files to include (relative to sourceDir) * @param onProgress - Optional callback for file-count progress updates * @returns Promise that resolves when ZIP is created */ async function createZipArchive( outputPath: string, sourceDir: string, files?: string[], onProgress?: ZipProgressCallback, ): Promise { return new Promise((resolve, reject) => { const output = createWriteStream(outputPath); const archive = archiver("zip", { zlib: { level: 9 } }); output.on("close", () => resolve()); archive.on("error", (err) => reject(err)); archive.pipe(output); if (files) { // Add specific files with progress tracking const total = files.length; for (const [i, file] of files.entries()) { const filePath = join(sourceDir, file); archive.file(filePath, { name: file }); onProgress?.(i + 1, total); } } else { // Add entire directory (no per-file progress available) archive.directory(sourceDir, false); } archive.finalize(); }); } /** * Creates source.zip from project files without building. * Used for prebuilt deployments that skip the full package flow. * @param projectPath - Absolute path to project directory * @returns Path to the created source.zip (caller responsible for cleanup) */ export async function createSourceZip(projectPath: string): Promise { const packageDir = join(tmpdir(), `jack-source-${Date.now()}`); await mkdir(packageDir, { recursive: true }); const sourceZipPath = join(packageDir, "source.zip"); const projectFiles = await scanProjectFiles(projectPath); // Debug output for source file statistics const totalSize = projectFiles.reduce((sum, f) => sum + f.size, 0); const firstFile = projectFiles[0]; const largest = firstFile ? projectFiles.reduce((max, f) => (f.size > max.size ? f : max), firstFile) : null; debug(`Source: ${projectFiles.length} files, ${formatSize(totalSize)} uncompressed`); if (largest) { debug(`Largest: ${largest.path} (${formatSize(largest.size)})`); } const sourceFiles = projectFiles.map((f) => f.path); await createZipArchive(sourceZipPath, projectPath, sourceFiles); // Debug output for compression statistics const zipStats = await stat(sourceZipPath); const ratio = totalSize > 0 ? ((1 - zipStats.size / totalSize) * 100).toFixed(0) : 0; debug(`Compressed: ${formatSize(zipStats.size)} (${ratio}% reduction)`); return sourceZipPath; } /** * Recursively collects all file paths in a directory */ async function collectAllFiles(dir: string, baseDir: string = dir): Promise { const entries = await readdir(dir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await collectAllFiles(fullPath, baseDir))); } else if (entry.isFile()) { files.push(fullPath); } } return files; } /** * Creates an asset manifest for all files in a directory. * Maps paths starting with "/" (e.g., "/index.html", "/assets/app.js") * to their content-addressable hashes. * * @param assetsDir - Absolute path to the assets directory * @returns Asset manifest mapping paths to hash entries */ export async function createAssetManifest(assetsDir: string): Promise { const allFiles = await collectAllFiles(assetsDir); const entries = await Promise.all( allFiles.map(async (filePath) => { const content = await readFile(filePath); const hash = await computeAssetHash(new Uint8Array(content), filePath); const fileStats = await stat(filePath); const relativePath = "/" + relative(assetsDir, filePath); return [relativePath, { hash, size: fileStats.size }] as const; }), ); return Object.fromEntries(entries); } /** * Extracts binding intent from wrangler config for the manifest. * Returns undefined if no bindings are configured. */ function extractBindingsFromConfig(config?: WranglerConfig): ManagedManifestBindings | undefined { if (!config) return undefined; const bindings: ManagedManifestBindings = {}; // Extract D1 database binding (use first one if multiple) if (config.d1_databases && config.d1_databases.length > 0) { const firstDb = config.d1_databases[0]; if (firstDb) { bindings.d1 = { binding: firstDb.binding }; } } // Extract AI binding (default binding name: "AI") if (config.ai) { bindings.ai = { binding: config.ai.binding || "AI" }; } // Extract assets binding (defaults: binding="ASSETS", directory="./dist") if (config.assets) { const validation = validateManagedAssetsConfigInput(config.assets, "assets"); if (!validation.valid) { throw new Error(validation.errors.join("\n")); } bindings.assets = normalizeManagedAssetsBinding(config.assets); } // Extract vars if (config.vars && Object.keys(config.vars).length > 0) { bindings.vars = config.vars; } // Extract R2 bucket bindings (support multiple) if (config.r2_buckets && config.r2_buckets.length > 0) { bindings.r2 = config.r2_buckets.map((bucket) => ({ binding: bucket.binding, bucket_name: bucket.bucket_name, })); } // Extract KV namespace bindings if (config.kv_namespaces && config.kv_namespaces.length > 0) { bindings.kv = config.kv_namespaces.map((kv) => ({ binding: kv.binding, })); } // Extract Vectorize bindings if (config.vectorize && config.vectorize.length > 0) { bindings.vectorize = config.vectorize.map((vec) => ({ binding: vec.binding, preset: vec.preset, dimensions: vec.dimensions, metric: vec.metric, })); } // Extract Durable Object bindings if (config?.durable_objects?.bindings && config.durable_objects.bindings.length > 0) { bindings.durable_objects = config.durable_objects.bindings.map((dob) => ({ binding: dob.name, class_name: dob.class_name, })); } // Return undefined if no bindings were extracted return Object.keys(bindings).length > 0 ? bindings : undefined; } export interface PackageOptions { projectPath: string; buildOutput: BuildOutput; config?: WranglerConfig; onProgress?: ZipProgressCallback; } /** * Packages a built project for deployment to jack cloud * @param options - Package options including paths, build output, config, and optional progress callback * @returns Package result with ZIP paths and cleanup function */ export async function packageForDeploy(options: PackageOptions): Promise; /** * @deprecated Use options object instead */ export async function packageForDeploy( projectPath: string, buildOutput: BuildOutput, config?: WranglerConfig, ): Promise; export async function packageForDeploy( optionsOrPath: PackageOptions | string, buildOutputArg?: BuildOutput, configArg?: WranglerConfig, ): Promise { // Support both old and new signatures const options: PackageOptions = typeof optionsOrPath === "string" ? { projectPath: optionsOrPath, buildOutput: buildOutputArg!, config: configArg } : optionsOrPath; const { projectPath, buildOutput, config, onProgress } = options; // Create temp directory for package artifacts const packageId = `jack-package-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const packageDir = join(tmpdir(), packageId); await mkdir(packageDir, { recursive: true }); // Define artifact paths const bundleZipPath = join(packageDir, "bundle.zip"); const sourceZipPath = join(packageDir, "source.zip"); const manifestPath = join(packageDir, "manifest.json"); // 1. Create bundle.zip from build output directory await createZipArchive(bundleZipPath, buildOutput.outDir); // 2. Create source.zip from project files (filtered) const projectFiles = await scanProjectFiles(projectPath); const sourceFiles = projectFiles.map((f) => f.path); await createZipArchive(sourceZipPath, projectPath, sourceFiles, onProgress); // 3. Create manifest.json const manifest: ManifestData = { version: CURRENT_MANAGED_DEPLOYMENT_MANIFEST_VERSION, entrypoint: buildOutput.entrypoint, compatibility_date: buildOutput.compatibilityDate, compatibility_flags: buildOutput.compatibilityFlags.length > 0 ? buildOutput.compatibilityFlags : undefined, module_format: "esm", assets_dir: buildOutput.assetsDir ? "assets" : undefined, built_at: new Date().toISOString(), bindings: extractBindingsFromConfig(config), }; // Extract migrations (top-level, for DO support) if (config?.migrations?.length) { manifest.migrations = config.migrations.map((m) => ({ tag: m.tag, new_sqlite_classes: m.new_sqlite_classes, deleted_classes: m.deleted_classes, renamed_classes: m.renamed_classes, })); } const manifestValidation = validateManagedDeploymentManifest(manifest); if (!manifestValidation.valid) { throw new Error(manifestValidation.errors.join("\n")); } await Bun.write(manifestPath, JSON.stringify(manifest, null, 2)); // 4. Check for optional files (schema.sql and .secrets.json) let schemaPath: string | null = null; const schemaSrcPath = join(projectPath, "schema.sql"); if (existsSync(schemaSrcPath)) { schemaPath = join(packageDir, "schema.sql"); await Bun.write(schemaPath, await readFile(schemaSrcPath)); } let secretsPath: string | null = null; const secretsSrcPath = join(projectPath, ".secrets.json"); if (existsSync(secretsSrcPath)) { secretsPath = join(packageDir, ".secrets.json"); await Bun.write(secretsPath, await readFile(secretsSrcPath)); } // 5. If assets directory exists, create assets.zip and asset manifest let assetsZipPath: string | null = null; let assetManifest: AssetManifest | null = null; if (buildOutput.assetsDir) { // Create zip and manifest in parallel for speed const [, manifest] = await Promise.all([ createZipArchive(join(packageDir, "assets.zip"), buildOutput.assetsDir), createAssetManifest(buildOutput.assetsDir), ]); assetsZipPath = join(packageDir, "assets.zip"); assetManifest = manifest; } // Return package result with cleanup function return { bundleZipPath, sourceZipPath, manifestPath, schemaPath, secretsPath, assetsZipPath, assetManifest, cleanup: async () => { await rm(packageDir, { recursive: true, force: true }); // Also cleanup build output directory await rm(buildOutput.outDir, { recursive: true, force: true }); }, }; }