Files
timmy-tower/artifacts/mobile/scripts/build.js
alexpaynex cf1819f34b feat(mobile): scaffold Expo mobile app for Timmy with Face/Matrix/Feed tabs
Task #42 — Timmy Harness: Expo Mobile App

## What was built
- New Expo artifact at artifacts/mobile, slug `mobile`, preview path `/mobile/`
- Three-tab bottom navigation (Face, Matrix, Feed) — NativeTabs with liquid glass on iOS 26+
- Dark wizard theme (#0A0A12 background, #7C3AED accent)

## WebSocket context (context/TimmyContext.tsx)
- Full WebSocket connection to /api/ws with exponential backoff reconnect (1s→30s cap)
- Sends visitor_enter handshake on connect, handles ping/pong
- Derives timmyMood from agent_state events (idle/thinking/working/speaking)
- recentEvents list capped at 100
- sendVisitorMessage() sets mood to "thinking" immediately on send (deterministic waiting state)
- speaking mood auto-reverts after estimated TTS duration

## Face tab (app/(tabs)/index.tsx)
- Animated 2D wizard face via react-native-svg (hat, head, beard, eyes, pupils, mouth arc, magic orb)
- AnimatedPupils: pupilScaleAnim drives actual rendered pupil Circle radius (BASE_PUPIL_R * scale)
- AnimatedEyelids: eyeScaleYAnim drives top eyelid overlay via Animated.Value listener
- AnimatedMouth: smileAnim + mouthOscAnim combined; SVG Path rebuilt on each frame via listener
- speaking mood: 1Hz mouth oscillation via Animated.loop; per-mood body bob speed/amplitude
- @react-native-voice/voice installed and statically imported; Voice.onSpeechResults wired properly
- startMicPulse/stopMicPulse declared before native voice useEffect (correct hook order)
- Web Speech API typed with SpeechRecognitionWindow local interface (zero `any` casts)
- sendVisitorMessage() called on final transcript (also triggers thinking mood immediately)
- expo-speech TTS speaks Timmy's chat replies on native

## Matrix tab (app/(tabs)/matrix.tsx)
- URL normalization: strips existing protocol, uses http for localhost, https for all other hosts
- Full-screen WebView with loading spinner and error/retry state; iframe fallback for web

## Feed tab (app/(tabs)/feed.tsx)
- FlatList<WsEvent> with proper generics; EventConfig discriminated union (Feather|MaterialCommunityIcons)
- Icon names typed via React.ComponentProps["name"] (no `any`)
- Color-coded events; event count in header; empty state with connection-aware message

## Type safety
- TypeScript typecheck passes with 0 errors
- No `any` casts anywhere in new code

## Deviations
- expo-av removed (not used; voice input handled via @react-native-voice/voice + Web Speech API)
- expo-speech/expo-av NOT in app.json plugins (no config plugins — causes PluginError if listed)
- app.json extra.apiDomain added for env-driven domain configuration per requirement
- expo-speech pinned ~14.0.8, react-native-webview 13.15.0 for Expo SDK 54 compat
- artifact.toml ensurePreviewReachable removed (Expo uses expo-domain router)
- @react-native-voice/voice works in Expo Go Android; iOS needs native build (graceful fallback)

Replit-Task-Id: 0748cbbf-7b84-4149-8fc0-9d697287a0e6
2026-03-19 23:55:16 +00:00

574 lines
15 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const { spawn } = 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;
}
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 env = {
...process.env,
EXPO_PUBLIC_DOMAIN: expoPublicDomain,
EXPO_PUBLIC_REPL_ID: expoPublicReplId,
};
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);
});