585 lines
15 KiB
JavaScript
585 lines
15 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const { spawn, execSync } = require("child_process");
|
|
const { Readable } = require("stream");
|
|
const { pipeline } = require("stream/promises");
|
|
|
|
let metroProcess = null;
|
|
|
|
const projectRoot = path.resolve(__dirname, "..");
|
|
|
|
function findWorkspaceRoot(startDir) {
|
|
let dir = startDir;
|
|
while (dir !== path.dirname(dir)) {
|
|
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
|
|
return dir;
|
|
}
|
|
dir = path.dirname(dir);
|
|
}
|
|
throw new Error("Could not find workspace root (no pnpm-workspace.yaml found)");
|
|
}
|
|
|
|
const workspaceRoot = findWorkspaceRoot(projectRoot);
|
|
const basePath = (process.env.BASE_PATH || "/").replace(/\/+$/, "");
|
|
|
|
function exitWithError(message) {
|
|
console.error(message);
|
|
if (metroProcess) {
|
|
metroProcess.kill();
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
function setupSignalHandlers() {
|
|
const cleanup = () => {
|
|
if (metroProcess) {
|
|
console.log("Cleaning up Metro process...");
|
|
metroProcess.kill();
|
|
}
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on("SIGINT", cleanup);
|
|
process.on("SIGTERM", cleanup);
|
|
process.on("SIGHUP", cleanup);
|
|
}
|
|
|
|
function stripProtocol(domain) {
|
|
let urlString = domain.trim();
|
|
|
|
if (!/^https?:\/\//i.test(urlString)) {
|
|
urlString = `https://${urlString}`;
|
|
}
|
|
|
|
return new URL(urlString).host;
|
|
}
|
|
|
|
function getDeploymentDomain() {
|
|
if (process.env.REPLIT_INTERNAL_APP_DOMAIN) {
|
|
return stripProtocol(process.env.REPLIT_INTERNAL_APP_DOMAIN);
|
|
}
|
|
|
|
if (process.env.REPLIT_DEV_DOMAIN) {
|
|
return stripProtocol(process.env.REPLIT_DEV_DOMAIN);
|
|
}
|
|
|
|
if (process.env.EXPO_PUBLIC_DOMAIN) {
|
|
return stripProtocol(process.env.EXPO_PUBLIC_DOMAIN);
|
|
}
|
|
|
|
console.error(
|
|
"ERROR: No deployment domain found. Set REPLIT_INTERNAL_APP_DOMAIN, REPLIT_DEV_DOMAIN, or EXPO_PUBLIC_DOMAIN",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
function prepareDirectories(timestamp) {
|
|
console.log("Preparing build directories...");
|
|
|
|
const staticBuild = path.join(projectRoot, "static-build");
|
|
if (fs.existsSync(staticBuild)) {
|
|
fs.rmSync(staticBuild, { recursive: true });
|
|
}
|
|
|
|
const dirs = [
|
|
path.join(staticBuild, timestamp, "_expo", "static", "js", "ios"),
|
|
path.join(staticBuild, timestamp, "_expo", "static", "js", "android"),
|
|
path.join(staticBuild, "ios"),
|
|
path.join(staticBuild, "android"),
|
|
];
|
|
|
|
for (const dir of dirs) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
console.log("Build:", timestamp);
|
|
}
|
|
|
|
function clearMetroCache() {
|
|
console.log("Clearing Metro cache...");
|
|
|
|
const cacheDirs = [
|
|
path.join(projectRoot, ".metro-cache"),
|
|
path.join(projectRoot, "node_modules/.cache/metro"),
|
|
];
|
|
|
|
for (const dir of cacheDirs) {
|
|
if (fs.existsSync(dir)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log("Cache cleared");
|
|
}
|
|
|
|
async function checkMetroHealth() {
|
|
try {
|
|
const response = await fetch("http://localhost:8081/status", {
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getExpoPublicReplId() {
|
|
return process.env.REPL_ID || process.env.EXPO_PUBLIC_REPL_ID;
|
|
}
|
|
|
|
function getGitSha() {
|
|
try {
|
|
return execSync("git rev-parse HEAD", { cwd: workspaceRoot }).toString().trim();
|
|
} catch (error) {
|
|
console.warn("Could not get git commit hash:", error.message);
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
async function startMetro(expoPublicDomain, expoPublicReplId) {
|
|
const isRunning = await checkMetroHealth();
|
|
if (isRunning) {
|
|
console.log("Metro already running");
|
|
return;
|
|
}
|
|
|
|
console.log("Starting Metro...");
|
|
console.log(`Setting EXPO_PUBLIC_DOMAIN=${expoPublicDomain}`);
|
|
const gitSha = getGitSha();
|
|
const env = {
|
|
...process.env,
|
|
EXPO_PUBLIC_DOMAIN: expoPublicDomain,
|
|
EXPO_PUBLIC_REPL_ID: expoPublicReplId,
|
|
EXPO_PUBLIC_GIT_SHA: gitSha,
|
|
};
|
|
|
|
if (expoPublicReplId) {
|
|
console.log(`Setting EXPO_PUBLIC_REPL_ID=${expoPublicReplId}`);
|
|
}
|
|
|
|
metroProcess = spawn(
|
|
"pnpm",
|
|
[
|
|
"exec",
|
|
"expo",
|
|
"start",
|
|
"--no-dev",
|
|
"--minify",
|
|
"--localhost",
|
|
],
|
|
{
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: false,
|
|
cwd: projectRoot,
|
|
env,
|
|
},
|
|
);
|
|
|
|
if (metroProcess.stdout) {
|
|
metroProcess.stdout.on("data", (data) => {
|
|
const output = data.toString().trim();
|
|
if (output) console.log(`[Metro] ${output}`);
|
|
});
|
|
}
|
|
if (metroProcess.stderr) {
|
|
metroProcess.stderr.on("data", (data) => {
|
|
const output = data.toString().trim();
|
|
if (output) console.error(`[Metro Error] ${output}`);
|
|
});
|
|
}
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
const healthy = await checkMetroHealth();
|
|
if (healthy) {
|
|
console.log("Metro ready");
|
|
return;
|
|
}
|
|
}
|
|
|
|
console.error("Metro timeout");
|
|
process.exit(1);
|
|
}
|
|
|
|
async function downloadFile(url, outputPath) {
|
|
const controller = new AbortController();
|
|
const fiveMinMS = 5 * 60 * 1_000;
|
|
const timeoutId = setTimeout(() => controller.abort(), fiveMinMS);
|
|
|
|
try {
|
|
console.log(`Downloading: ${url}`);
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const file = fs.createWriteStream(outputPath);
|
|
await pipeline(Readable.fromWeb(response.body), file);
|
|
|
|
const fileSize = fs.statSync(outputPath).size;
|
|
|
|
if (fileSize === 0) {
|
|
fs.unlinkSync(outputPath);
|
|
throw new Error("Downloaded file is empty");
|
|
}
|
|
} catch (error) {
|
|
if (fs.existsSync(outputPath)) {
|
|
fs.unlinkSync(outputPath);
|
|
}
|
|
|
|
if (error.name === "AbortError") {
|
|
throw new Error(`Download timeout after 5m: ${url}`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
async function downloadBundle(platform, timestamp) {
|
|
const entryPath = path.resolve(projectRoot, "node_modules", "expo-router", "entry");
|
|
const bundlePath = path.relative(workspaceRoot, entryPath);
|
|
const url = new URL(`http://localhost:8081/${bundlePath}.bundle`);
|
|
url.searchParams.set("platform", platform);
|
|
url.searchParams.set("dev", "false");
|
|
url.searchParams.set("hot", "false");
|
|
url.searchParams.set("lazy", "false");
|
|
url.searchParams.set("minify", "true");
|
|
|
|
const output = path.join(
|
|
"static-build",
|
|
timestamp,
|
|
"_expo",
|
|
"static",
|
|
"js",
|
|
platform,
|
|
"bundle.js",
|
|
);
|
|
|
|
console.log(`Fetching ${platform} bundle...`);
|
|
await downloadFile(url.toString(), output);
|
|
console.log(`${platform} bundle ready`);
|
|
}
|
|
|
|
async function downloadManifest(platform) {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 300_000);
|
|
|
|
try {
|
|
console.log(`Fetching ${platform} manifest...`);
|
|
const response = await fetch("http://localhost:8081/manifest", {
|
|
headers: { "expo-platform": platform },
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const manifest = await response.json();
|
|
console.log(`${platform} manifest ready`);
|
|
return manifest;
|
|
} catch (error) {
|
|
if (error.name === "AbortError") {
|
|
throw new Error(
|
|
`Manifest download timeout after 5m for platform: ${platform}`,
|
|
);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
async function downloadBundlesAndManifests(timestamp) {
|
|
console.log("Downloading bundles and manifests...");
|
|
console.log("This may take several minutes for production builds...");
|
|
|
|
try {
|
|
// Bundles are sequential — Metro can't handle both platforms simultaneously
|
|
// without stalling. Manifests are cheap and run in parallel after.
|
|
await downloadBundle("ios", timestamp);
|
|
await downloadBundle("android", timestamp);
|
|
|
|
const [iosManifest, androidManifest] = await Promise.all([
|
|
downloadManifest("ios"),
|
|
downloadManifest("android"),
|
|
]);
|
|
|
|
console.log("All downloads completed successfully");
|
|
return { ios: iosManifest, android: androidManifest };
|
|
} catch (error) {
|
|
exitWithError(`Download failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function extractAssets(timestamp) {
|
|
const staticBuild = path.join(projectRoot, "static-build");
|
|
const bundles = {
|
|
ios: fs.readFileSync(
|
|
path.join(staticBuild, timestamp, "_expo", "static", "js", "ios", "bundle.js"),
|
|
"utf-8",
|
|
),
|
|
android: fs.readFileSync(
|
|
path.join(staticBuild, timestamp, "_expo", "static", "js", "android", "bundle.js"),
|
|
"utf-8",
|
|
),
|
|
};
|
|
|
|
const assetsMap = new Map();
|
|
const assetPattern =
|
|
/httpServerLocation:"([^"]+)"[^}]*hash:"([^"]+)"[^}]*name:"([^"]+)"[^}]*type:"([^"]+)"/g;
|
|
|
|
const extractFromBundle = (bundle, platform) => {
|
|
for (const match of bundle.matchAll(assetPattern)) {
|
|
const originalPath = match[1];
|
|
const filename = match[3] + "." + match[4];
|
|
|
|
const tempUrl = new URL(`http://localhost:8081${originalPath}`);
|
|
const unstablePath = tempUrl.searchParams.get("unstable_path");
|
|
|
|
if (!unstablePath) {
|
|
throw new Error(`Asset missing unstable_path: ${originalPath}`);
|
|
}
|
|
|
|
const decodedPath = decodeURIComponent(unstablePath);
|
|
const key = path.posix.join(decodedPath, filename);
|
|
|
|
if (!assetsMap.has(key)) {
|
|
const asset = {
|
|
url: path.posix.join("/", decodedPath, filename),
|
|
originalPath: originalPath,
|
|
filename: filename,
|
|
relativePath: decodedPath,
|
|
hash: match[2],
|
|
platforms: new Set(),
|
|
};
|
|
|
|
assetsMap.set(key, asset);
|
|
}
|
|
assetsMap.get(key).platforms.add(platform);
|
|
}
|
|
};
|
|
|
|
extractFromBundle(bundles.ios, "ios");
|
|
extractFromBundle(bundles.android, "android");
|
|
|
|
return Array.from(assetsMap.values());
|
|
}
|
|
|
|
async function downloadAssets(assets, timestamp) {
|
|
if (assets.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
console.log("Copying assets...");
|
|
let successCount = 0;
|
|
const failures = [];
|
|
|
|
const downloadPromises = assets.map(async (asset) => {
|
|
const tempUrl = new URL(`http://localhost:8081${asset.originalPath}`);
|
|
const unstablePath = tempUrl.searchParams.get("unstable_path");
|
|
|
|
if (!unstablePath) {
|
|
throw new Error(`Asset missing unstable_path: ${asset.originalPath}`);
|
|
}
|
|
|
|
const decodedPath = decodeURIComponent(unstablePath);
|
|
|
|
const outputDir = path.join(
|
|
projectRoot,
|
|
"static-build",
|
|
timestamp,
|
|
"_expo",
|
|
"static",
|
|
"js",
|
|
asset.relativePath,
|
|
);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const output = path.join(outputDir, asset.filename);
|
|
|
|
try {
|
|
const candidates = [
|
|
path.join(projectRoot, decodedPath, asset.filename),
|
|
path.join(workspaceRoot, decodedPath, asset.filename),
|
|
];
|
|
const found = candidates.find((p) => fs.existsSync(p));
|
|
if (!found) {
|
|
throw new Error(`Asset not found on disk: ${asset.filename}`);
|
|
}
|
|
fs.copyFileSync(found, output);
|
|
successCount++;
|
|
} catch (error) {
|
|
failures.push({
|
|
filename: asset.filename,
|
|
error: error.message,
|
|
url: asset.originalPath,
|
|
});
|
|
}
|
|
});
|
|
|
|
await Promise.all(downloadPromises);
|
|
|
|
if (failures.length > 0) {
|
|
const errorMsg =
|
|
`Failed to download ${failures.length} asset(s):\n` +
|
|
failures
|
|
.map((f) => ` - ${f.filename}: ${f.error} (${f.url})`)
|
|
.join("\n");
|
|
exitWithError(errorMsg);
|
|
}
|
|
|
|
console.log(`Copied ${successCount} assets`);
|
|
return successCount;
|
|
}
|
|
|
|
function updateBundleUrls(timestamp, baseUrl) {
|
|
const updateForPlatform = (platform) => {
|
|
const bundlePath = path.join(
|
|
projectRoot,
|
|
"static-build",
|
|
timestamp,
|
|
"_expo",
|
|
"static",
|
|
"js",
|
|
platform,
|
|
"bundle.js",
|
|
);
|
|
let bundle = fs.readFileSync(bundlePath, "utf-8");
|
|
|
|
bundle = bundle.replace(
|
|
/httpServerLocation:"(\/[^"]+)"/g,
|
|
(_match, capturedPath) => {
|
|
const tempUrl = new URL(`http://localhost:8081${capturedPath}`);
|
|
const unstablePath = tempUrl.searchParams.get("unstable_path");
|
|
|
|
if (!unstablePath) {
|
|
throw new Error(
|
|
`Asset missing unstable_path in bundle: ${capturedPath}`,
|
|
);
|
|
}
|
|
|
|
const decodedPath = decodeURIComponent(unstablePath);
|
|
return `httpServerLocation:"${baseUrl}${basePath}/${timestamp}/_expo/static/js/${decodedPath}"`;
|
|
},
|
|
);
|
|
|
|
fs.writeFileSync(bundlePath, bundle);
|
|
};
|
|
|
|
updateForPlatform("ios");
|
|
updateForPlatform("android");
|
|
console.log("Updated bundle URLs");
|
|
}
|
|
|
|
function updateManifests(manifests, timestamp, baseUrl, assetsByHash) {
|
|
const updateForPlatform = (platform, manifest) => {
|
|
if (!manifest.launchAsset || !manifest.extra) {
|
|
exitWithError(`Malformed manifest for ${platform}`);
|
|
}
|
|
|
|
manifest.launchAsset.url = `${baseUrl}${basePath}/${timestamp}/_expo/static/js/${platform}/bundle.js`;
|
|
manifest.launchAsset.key = `bundle-${timestamp}`;
|
|
manifest.createdAt = new Date(
|
|
Number(timestamp.split("-")[0]),
|
|
).toISOString();
|
|
manifest.extra.expoClient.hostUri =
|
|
baseUrl.replace("https://", "") + "/" + platform;
|
|
manifest.extra.expoGo.debuggerHost =
|
|
baseUrl.replace("https://", "") + "/" + platform;
|
|
manifest.extra.expoGo.packagerOpts.dev = false;
|
|
|
|
if (manifest.assets && manifest.assets.length > 0) {
|
|
manifest.assets.forEach((asset) => {
|
|
if (!asset.url) return;
|
|
|
|
const hash = asset.hash;
|
|
if (!hash) return;
|
|
|
|
const assetInfo = assetsByHash.get(hash);
|
|
if (!assetInfo) return;
|
|
|
|
asset.url = `${baseUrl}${basePath}/${timestamp}/_expo/static/js/${assetInfo.relativePath}/${assetInfo.filename}`;
|
|
});
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
path.join(projectRoot, "static-build", platform, "manifest.json"),
|
|
JSON.stringify(manifest, null, 2),
|
|
);
|
|
};
|
|
|
|
updateForPlatform("ios", manifests.ios);
|
|
updateForPlatform("android", manifests.android);
|
|
console.log("Manifests updated");
|
|
}
|
|
|
|
async function main() {
|
|
console.log("Building static Expo Go deployment...");
|
|
|
|
setupSignalHandlers();
|
|
|
|
const domain = getDeploymentDomain();
|
|
const expoPublicReplId = getExpoPublicReplId();
|
|
const baseUrl = `https://${domain}`;
|
|
const timestamp = `${Date.now()}-${process.pid}`;
|
|
|
|
prepareDirectories(timestamp);
|
|
clearMetroCache();
|
|
|
|
await startMetro(domain, expoPublicReplId);
|
|
|
|
const downloadTimeout = 600000;
|
|
const downloadPromise = downloadBundlesAndManifests(timestamp);
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
setTimeout(() => {
|
|
reject(
|
|
new Error(
|
|
`Overall download timeout after ${downloadTimeout / 1000} seconds. ` +
|
|
"Metro may be struggling to generate bundles. Check Metro logs above.",
|
|
),
|
|
);
|
|
}, downloadTimeout);
|
|
});
|
|
|
|
const manifests = await Promise.race([downloadPromise, timeoutPromise]);
|
|
|
|
console.log("Processing assets...");
|
|
const assets = extractAssets(timestamp);
|
|
console.log("Found", assets.length, "unique asset(s)");
|
|
|
|
const assetsByHash = new Map();
|
|
for (const asset of assets) {
|
|
assetsByHash.set(asset.hash, {
|
|
relativePath: asset.relativePath,
|
|
filename: asset.filename,
|
|
});
|
|
}
|
|
|
|
const assetCount = await downloadAssets(assets, timestamp);
|
|
|
|
if (assetCount > 0) {
|
|
updateBundleUrls(timestamp, baseUrl);
|
|
}
|
|
|
|
console.log("Updating manifests and creating landing page...");
|
|
updateManifests(manifests, timestamp, baseUrl, assetsByHash);
|
|
|
|
console.log("Build complete! Deploy to:", baseUrl);
|
|
|
|
if (metroProcess) {
|
|
metroProcess.kill();
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("Build failed:", error.message);
|
|
if (metroProcess) {
|
|
metroProcess.kill();
|
|
}
|
|
process.exit(1);
|
|
});
|