Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions packages/expo-atlas/src/data/AtlasFileSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'assert';
import fs from 'fs';
import path from 'path';

import type { AtlasStatsBundle } from './stats-types';
import type { PartialAtlasBundle, AtlasBundle, AtlasSource, AtlasModule } from './types';
import { name, version } from '../../package.json';
import { env } from '../utils/env';
Expand Down Expand Up @@ -91,6 +92,11 @@ export async function readAtlasEntry(filePath: string, id: number): Promise<Atla
/** Simple promise to avoid mixing appended data */
let writeQueue: Promise<any> = Promise.resolve();

/** In-memory accumulator for bundle stats, keyed by bundle ID for deduplication */
const statsAccumulator: Map<string, AtlasStatsBundle> = new Map();
let statsFilePath: string | null = null;
let finalizationScheduled = false;

/**
* Wait until the Atlas file has all data written.
* Note, this is a workaround whenever `process.exit` is required, avoid if possible.
Expand Down Expand Up @@ -164,8 +170,19 @@ export async function createAtlasFile(filePath: string) {
await appendJsonLine(filePath, getAtlasMetdata());
}

/**
* Ensure the .expo directory exists for Atlas files.
*
* @param projectRoot - The root directory of the project
*/
export async function ensureExpoDirExists(projectRoot: string) {
await fs.promises.mkdir(path.join(projectRoot, '.expo'), { recursive: true });
}

/**
* Create the Atlas file if it doesn't exist, or recreate it if it's incompatible.
*
* @param filePath - Path to the atlas.jsonl file
*/
export async function ensureAtlasFileExist(filePath: string) {
try {
Expand All @@ -181,3 +198,164 @@ export async function ensureAtlasFileExist(filePath: string) {

return true;
}

/**
* Asset file extensions to separate from app files
*/
const ASSET_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'svg',
'webp',
'bmp',
'ttf',
'otf',
'woff',
'woff2',
'mp4',
'webm',
'mp3',
'wav',
]);

/**
* Check if a file path is an asset based on extension
*/
function isAsset(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase();
return ext ? ASSET_EXTENSIONS.has(ext) : false;
}

/**
* Convert an AtlasBundle to compact stats format for CI analysis.
* Separates packages, app files, and assets for detailed analysis.
*/
function convertBundleToStats(bundle: AtlasBundle): AtlasStatsBundle {
const packages: Record<string, number> = {};
const files: Record<string, number> = {};
const assets: Record<string, number> = {};

// Helper to categorize and store module size
const addModuleSize = (module: AtlasModule) => {
if (module.package) {
// External package
packages[module.package] = (packages[module.package] ?? 0) + module.size;
} else {
// App code - check if it's an asset or source file
const relativePath = module.relativePath;
if (isAsset(relativePath)) {
assets[relativePath] = module.size;
} else {
files[relativePath] = module.size;
}
}
};

// Process regular modules
for (const module of Array.from(bundle.modules.values())) {
addModuleSize(module);
}

// Process runtime modules (Metro polyfills)
for (const module of bundle.runtimeModules) {
addModuleSize(module);
}

// Calculate total bundle size
const bundleSize =
Object.values(packages).reduce((sum, size) => sum + size, 0) +
Object.values(files).reduce((sum, size) => sum + size, 0) +
Object.values(assets).reduce((sum, size) => sum + size, 0);

// Sort all keys alphabetically for diff-friendly output
const sortRecord = (record: Record<string, number>): Record<string, number> => {
const sorted: Record<string, number> = {};
Object.keys(record)
.sort()
.forEach((key) => {
sorted[key] = record[key];
});
return sorted;
};

return {
platform: bundle.platform,
environment: bundle.environment,
entryPoint: bundle.entryPoint,
bundleSize,
packages: sortRecord(packages),
files: sortRecord(files),
assets: sortRecord(assets),
};
}

/**
* Accumulate stats for a bundle. Call this for each bundle written to atlas.jsonl.
* Stats are held in memory until finalizeAtlasStats() is called.
* Auto-finalizes on process exit if not manually finalized.
*/
export function writeAtlasStatsEntry(filePath: string, entry: AtlasBundle): void {
const statsBundle = convertBundleToStats(entry);
// Use bundle ID as key to handle duplicate writes (last write wins)
statsAccumulator.set(entry.id, statsBundle);

// Store file path for auto-finalization
statsFilePath = filePath;

// Schedule auto-finalization on process exit (once)
if (!finalizationScheduled && statsAccumulator.size > 0) {
finalizationScheduled = true;

// Use beforeExit which allows async operations
process.once('beforeExit', async () => {
if (statsAccumulator.size > 0 && statsFilePath) {
try {
await finalizeAtlasStats(statsFilePath);
} catch (error) {
// Silently fail to avoid breaking the build
console.error('Failed to finalize atlas stats:', error);
}
}
});
}
}

/**
* Write accumulated stats to disk as a single JSON file.
* Call this after all bundles are processed (e.g., at end of Metro export).
*/
export function finalizeAtlasStats(filePath: string): Promise<void> {
const bundles = Array.from(statsAccumulator.values());

// Sort bundles for consistent, diff-friendly output
bundles.sort((a, b) => {
if (a.platform !== b.platform) return a.platform.localeCompare(b.platform);
if (a.environment !== b.environment) return a.environment.localeCompare(b.environment);
return a.entryPoint.localeCompare(b.entryPoint);
});

const statsPath = filePath.replace(/atlas\.jsonl$/, 'atlas-stats.json');

// Queue the write operation using existing pattern
writeQueue = writeQueue.then(async () => {
const content = JSON.stringify(bundles, null, 2) + '\n';
await fs.promises.writeFile(statsPath, content, 'utf-8');
});

// Clear accumulator after successful write
writeQueue = writeQueue.then(() => {
statsAccumulator.clear();
finalizationScheduled = false; // Reset flag after finalization
});

return writeQueue;
}

/**
* Get the default stats file path for a project.
*/
export function getAtlasStatsPath(projectRoot: string): string {
return path.join(projectRoot, '.expo/atlas-stats.json');
}
Loading