#!/usr/bin/env node // // Build or restore the next-swc-builder Docker image using turbo remote cache. // // Computes a cache key from the Dockerfile + rust-toolchain.toml contents, // then checks the turbo cache API via scripts/turbo-cache.mjs. // Uses docker export/import (flat filesystem) instead of save/load (layered) // to avoid including redundant base image layers. Compressed with zstd. // // Usage: // node scripts/docker-image-cache.js # restore from cache or build + upload // node scripts/docker-image-cache.js --force # always rebuild and re-upload const { execSync } = require('child_process') const crypto = require('crypto') const { createHash } = crypto const path = require('path') const fs = require('fs') const os = require('os') const { parseArgs } = require('node:util') const { values: flags } = parseArgs({ args: process.argv.slice(2), options: { force: { type: 'boolean', default: false }, }, }) const REPO_ROOT = path.resolve(__dirname, '..') const IMAGE_NAME = 'next-swc-builder:latest' // docker export/import strips all image metadata. These --change flags // restore the ENV and WORKDIR that the Dockerfile sets, so that tools // like cargo, rustc, napi, sccache are found in PATH. const DOCKER_IMPORT_CHANGES = [ 'ENV PATH=/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'ENV DEBIAN_FRONTEND=noninteractive', 'WORKDIR /build', ] // Files baked into the Docker image — only these affect the image content. // Scripts that run on the host (docker-image-cache.js, docker-native-build.*) // are NOT included since they're mounted at runtime, not COPY'd. const CACHE_INPUTS = [ path.join(REPO_ROOT, 'scripts/native-builder.Dockerfile'), path.join(REPO_ROOT, 'rust-toolchain.toml'), ] function computeCacheKey() { // Turbo cache keys must be hex-only (^[a-fA-F0-9]+$). const hash = createHash('sha256') hash.update('docker-image-v4\0') // Include host architecture — the image contains native binaries // (Rust toolchain, cargo-xwin, etc.) that are arch-specific. hash.update(`arch:${os.arch()}\0`) for (const file of CACHE_INPUTS) { hash.update(file + '\0') hash.update(fs.readFileSync(file)) } return hash.digest('hex') } function buildImage() { console.log(`Building Docker image: ${IMAGE_NAME}`) const ctx = fs.mkdtempSync(path.join(os.tmpdir(), 'next-swc-docker-')) fs.copyFileSync( path.join(REPO_ROOT, 'rust-toolchain.toml'), path.join(ctx, 'rust-toolchain.toml') ) try { execSync( `docker build -t ${IMAGE_NAME} -f ${path.join(REPO_ROOT, 'scripts/native-builder.Dockerfile')} ${ctx}`, { stdio: 'inherit' } ) } finally { fs.rmSync(ctx, { recursive: true, force: true }) } } function tmpFile(name) { const suffix = crypto.randomBytes(6).toString('hex') return path.join(process.env.RUNNER_TEMP || os.tmpdir(), `${name}.${suffix}`) } function sh(cmd) { execSync(cmd, { stdio: 'inherit', shell: true }) } async function main() { const cache = await import('./turbo-cache.mjs') const key = computeCacheKey() // Show redacted endpoint for debugging (scheme + first 2 chars of host) const apiUrl = new URL(process.env.TURBO_API || 'https://vercel.com') const redactedApi = `${apiUrl.protocol}//${apiUrl.hostname.slice(0, 2)}***` console.log(`Docker image: ${IMAGE_NAME}`) console.log(`Cache key: ${key}`) console.log(`Cache endpoint: ${redactedApi}`) if (!process.env.TURBO_TOKEN) { console.log('No TURBO_TOKEN — building without cache') buildImage() return } // Try to restore from cache (unless --force) if (!flags.force) { const hit = await cache.exists(key) console.log(hit ? 'Cache HIT' : 'Cache MISS') if (hit) { const zstFile = tmpFile('docker-image-cache.tar.zst') let restored = false for (let attempt = 1; attempt <= 3; attempt++) { try { console.log( `Downloading cached image${attempt > 1 ? ` (retry ${attempt})` : ''}...` ) const result = await cache.getToFile(key, zstFile, { retries: 0 }) if (!result.ok) throw new Error('download failed') if (result.stats) { console.log(`Downloaded: ${cache.formatStats(result.stats)}`) } console.log('Decompressing and importing into Docker...') const changeFlags = DOCKER_IMPORT_CHANGES.map( (c) => `--change '${c}'` ).join(' ') sh( `zstd -d -c --long=27 --threads=0 ${zstFile} | docker import ${changeFlags} - ${IMAGE_NAME}` ) console.log('Docker image restored from turbo cache') restored = true break } catch (e) { console.log(`WARNING: Attempt ${attempt} failed: ${e.message}`) try { execSync(`docker rmi -f ${IMAGE_NAME}`, { stdio: 'ignore' }) } catch {} } finally { try { fs.unlinkSync(zstFile) } catch {} } } if (restored) return console.log('All restore attempts failed — rebuilding from scratch') } } // Cache miss or --force: always rebuild since inputs changed buildImage() // Export and compress with zstd (docker export produces uncompressed tar). const zstFile = tmpFile('docker-image-cache.tar.zst') const containerName = `next-swc-export-${process.pid}` try { sh(`docker create --name ${containerName} ${IMAGE_NAME} true`) sh(`docker export ${containerName} | zstd -1 -T0 --long=27 -o ${zstFile}`) sh(`docker rm ${containerName}`) const size = fs.statSync(zstFile).size console.log( `Exported + compressed: ${(size / 1024 / 1024).toFixed(0)} MB — uploading...` ) try { // Stream upload from file (avoids 2GB Buffer limit) await cache.put(key, zstFile) console.log('Docker image uploaded to turbo cache') } catch (e) { console.log(`WARNING: Failed to upload: ${e.message}`) } } finally { try { fs.unlinkSync(zstFile) } catch {} } } main().catch((e) => { console.error(e) process.exit(1) })