diff --git a/packages/expo-atlas/src/data/AtlasFileSource.ts b/packages/expo-atlas/src/data/AtlasFileSource.ts index 024bb68..075ac40 100644 --- a/packages/expo-atlas/src/data/AtlasFileSource.ts +++ b/packages/expo-atlas/src/data/AtlasFileSource.ts @@ -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'; @@ -91,6 +92,11 @@ export async function readAtlasEntry(filePath: string, id: number): Promise = Promise.resolve(); +/** In-memory accumulator for bundle stats, keyed by bundle ID for deduplication */ +const statsAccumulator: Map = 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. @@ -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 { @@ -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 = {}; + const files: Record = {}; + const assets: Record = {}; + + // 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): Record => { + const sorted: Record = {}; + 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 { + 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'); +} diff --git a/packages/expo-atlas/src/data/__tests__/stats.test.ts b/packages/expo-atlas/src/data/__tests__/stats.test.ts new file mode 100644 index 0000000..296f467 --- /dev/null +++ b/packages/expo-atlas/src/data/__tests__/stats.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; + +import { + getAtlasPath, + getAtlasStatsPath, + writeAtlasStatsEntry, + finalizeAtlasStats, +} from '../AtlasFileSource'; +import type { AtlasStatsFile } from '../stats-types'; +import type { AtlasBundle, AtlasModule } from '../types'; + +describe('getAtlasStatsPath', () => { + it('returns default path `/.expo/atlas-stats.json`', () => { + expect(getAtlasStatsPath('')).toBe('/.expo/atlas-stats.json'); + }); +}); + +describe('writeAtlasStatsEntry', () => { + it('accumulates stats for a single bundle', async () => { + const projectRoot = createTestProject('stats-single'); + const file = getAtlasPath(projectRoot); + const bundle = createMockBundle({ + id: '1', + platform: 'ios', + environment: 'client', + entryPoint: '/path/to/app/index.js', + modules: new Map([ + [ + '/path/to/app/App.tsx', + createMockModule({ package: undefined, relativePath: 'App.tsx', size: 1000 }), + ], + [ + '/path/to/node_modules/react/index.js', + createMockModule({ package: 'react', size: 2000 }), + ], + ]), + runtimeModules: [createMockModule({ package: 'metro-runtime', size: 500 })], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats).toHaveLength(1); + expect(stats[0]).toMatchObject({ + platform: 'ios', + environment: 'client', + entryPoint: '/path/to/app/index.js', + bundleSize: 3500, + packages: { + 'metro-runtime': 500, + react: 2000, + }, + files: { + 'App.tsx': 1000, + }, + }); + }); + + it('aggregates multiple modules from same package', async () => { + const projectRoot = createTestProject('stats-aggregated'); + const file = getAtlasPath(projectRoot); + const bundle = createMockBundle({ + modules: new Map([ + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 1000 })], + ['/node_modules/react/jsx-runtime.js', createMockModule({ package: 'react', size: 500 })], + [ + '/node_modules/react/jsx-dev-runtime.js', + createMockModule({ package: 'react', size: 300 }), + ], + ]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats[0].packages.react).toBe(1800); + }); + + it('includes runtime modules in aggregation', async () => { + const projectRoot = createTestProject('stats-runtime'); + const file = getAtlasPath(projectRoot); + const bundle = createMockBundle({ + modules: new Map([['/app/index.js', createMockModule({ package: undefined, size: 1000 })]]), + runtimeModules: [ + createMockModule({ package: 'metro-runtime', size: 500 }), + createMockModule({ package: 'metro-runtime', size: 300 }), + ], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats[0].packages['metro-runtime']).toBe(800); + }); + + it('sorts package names alphabetically', async () => { + const projectRoot = createTestProject('stats-sorted'); + const file = getAtlasPath(projectRoot); + const bundle = createMockBundle({ + modules: new Map([ + ['/node_modules/zebra/index.js', createMockModule({ package: 'zebra', size: 100 })], + ['/node_modules/apple/index.js', createMockModule({ package: 'apple', size: 200 })], + ['/node_modules/metro/index.js', createMockModule({ package: 'metro', size: 300 })], + ['/app/index.js', createMockModule({ package: undefined, size: 400 })], + ]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + const packageNames = Object.keys(stats[0].packages); + expect(packageNames).toEqual(['apple', 'metro', 'zebra']); + }); + + it('handles duplicate bundle IDs (last write wins)', async () => { + const projectRoot = createTestProject('stats-duplicate'); + const file = getAtlasPath(projectRoot); + const bundle1 = createMockBundle({ + id: '1', + modules: new Map([['/app/v1.js', createMockModule({ package: undefined, size: 1000 })]]), + }); + const bundle2 = createMockBundle({ + id: '1', + modules: new Map([['/app/v2.js', createMockModule({ package: undefined, size: 2000 })]]), + }); + + writeAtlasStatsEntry(file, bundle1); + writeAtlasStatsEntry(file, bundle2); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats).toHaveLength(1); + expect(stats[0].bundleSize).toBe(2000); + }); +}); + +describe('finalizeAtlasStats', () => { + it('writes pretty-printed JSON to .expo/atlas-stats.json', async () => { + const projectRoot = createTestProject('stats-pretty'); + const file = getAtlasPath(projectRoot); + const bundle = createMockBundle({ + modules: new Map([['/app/index.js', createMockModule({ package: undefined, size: 1000 })]]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const statsPath = getAtlasStatsPath(projectRoot); + const content = await fs.promises.readFile(statsPath, 'utf-8'); + + // Check for pretty-printing (should have indentation) + expect(content).toContain('\n '); + expect(content).toEndWith('\n'); + + // Verify it's valid JSON + expect(() => JSON.parse(content)).not.toThrow(); + }); + + it('sorts bundles by platform, environment, entryPoint', async () => { + const projectRoot = createTestProject('stats-sorted-bundles'); + const file = getAtlasPath(projectRoot); + + // Add bundles in random order + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '1', + platform: 'web', + environment: 'client', + entryPoint: '/app/web.js', + }) + ); + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '2', + platform: 'ios', + environment: 'node', + entryPoint: '/app/server.js', + }) + ); + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '3', + platform: 'ios', + environment: 'client', + entryPoint: '/app/main.js', + }) + ); + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '4', + platform: 'android', + environment: 'client', + entryPoint: '/app/android.js', + }) + ); + + await finalizeAtlasStats(file); + const stats = await readStatsFile(projectRoot); + + // Should be sorted by platform first + expect(stats[0].platform).toBe('android'); + expect(stats[1].platform).toBe('ios'); + expect(stats[2].platform).toBe('ios'); + expect(stats[3].platform).toBe('web'); + + // Within same platform, sorted by environment + expect(stats[1].environment).toBe('client'); + expect(stats[2].environment).toBe('node'); + }); + + it('handles empty accumulator gracefully (writes [])', async () => { + const projectRoot = createTestProject('stats-empty'); + const file = getAtlasPath(projectRoot); + + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats).toEqual([]); + }); + + it('handles empty bundle (no modules)', async () => { + const projectRoot = createTestProject('stats-no-modules'); + const file = getAtlasPath(projectRoot); + const bundle = createMockBundle({ + modules: new Map(), + runtimeModules: [], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats[0]).toMatchObject({ + bundleSize: 0, + packages: {}, + }); + }); + +}); + +describe('integration', () => { + it('writes multiple bundles and produces correct stats file', async () => { + const projectRoot = createTestProject('stats-integration'); + const file = getAtlasPath(projectRoot); + + // Create iOS client bundle + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '1', + platform: 'ios', + environment: 'client', + entryPoint: '/app/ios/index.js', + modules: new Map([ + [ + '/app/App.tsx', + createMockModule({ package: undefined, relativePath: 'App.tsx', size: 5000 }), + ], + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 85000 })], + [ + '/node_modules/react-native/index.js', + createMockModule({ package: 'react-native', size: 450000 }), + ], + ]), + }) + ); + + // Create Android client bundle + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '2', + platform: 'android', + environment: 'client', + entryPoint: '/app/android/index.js', + modules: new Map([ + [ + '/app/App.tsx', + createMockModule({ package: undefined, relativePath: 'App.tsx', size: 5000 }), + ], + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 85000 })], + [ + '/node_modules/react-native/index.js', + createMockModule({ package: 'react-native', size: 460000 }), + ], + ]), + }) + ); + + await finalizeAtlasStats(file); + + const stats = await readStatsFile(projectRoot); + expect(stats).toHaveLength(2); + + // Android should come first (alphabetical) + expect(stats[0].platform).toBe('android'); + expect(stats[0].bundleSize).toBe(550000); + expect(stats[0].packages).toMatchObject({ + react: 85000, + 'react-native': 460000, + }); + expect(stats[0].files).toMatchObject({ + 'App.tsx': 5000, + }); + + // iOS should come second + expect(stats[1].platform).toBe('ios'); + expect(stats[1].bundleSize).toBe(540000); + expect(stats[1].packages).toMatchObject({ + react: 85000, + 'react-native': 450000, + }); + expect(stats[1].files).toMatchObject({ + 'App.tsx': 5000, + }); + }); + + it('file size stays reasonable for realistic bundle', async () => { + const projectRoot = createTestProject('stats-size-check'); + const file = getAtlasPath(projectRoot); + const modules = new Map(); + + // Create ~500 packages (realistic max) + for (let i = 0; i < 500; i++) { + modules.set( + `/node_modules/package-${i}/index.js`, + createMockModule({ package: `package-${i}`, size: 10000 }) + ); + } + + const bundle = createMockBundle({ modules }); + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const statsPath = getAtlasStatsPath(projectRoot); + const stats = await fs.promises.stat(statsPath); + + // Should be under 100KB for 500 packages + expect(stats.size).toBeLessThan(100 * 1024); + }); +}); + +// Helper functions + +function createMockBundle(overrides: Partial = {}): AtlasBundle { + return { + id: '1', + platform: 'ios', + environment: 'client', + projectRoot: '/path/to/project', + sharedRoot: '/path/to/project', + entryPoint: '/path/to/project/index.js', + runtimeModules: [], + modules: new Map(), + transformOptions: {}, + serializeOptions: {}, + ...overrides, + }; +} + +function createMockModule(overrides: Partial = {}): AtlasModule { + return { + id: Math.random(), + absolutePath: '/path/to/module.js', + relativePath: 'module.js', + size: 1000, + imports: [], + importedBy: [], + ...overrides, + }; +} + +async function readStatsFile(projectRoot: string): Promise { + const statsPath = getAtlasStatsPath(projectRoot); + const content = await fs.promises.readFile(statsPath, 'utf-8'); + return JSON.parse(content); +} + +/** + * Create a temporary project root for testing + */ +function createTestProject(name: string): string { + const projectRoot = path.join(__dirname, '__temp__', name); + fs.mkdirSync(path.join(projectRoot, '.expo'), { recursive: true }); + return projectRoot; +} diff --git a/packages/expo-atlas/src/data/stats-types.ts b/packages/expo-atlas/src/data/stats-types.ts new file mode 100644 index 0000000..f42cc34 --- /dev/null +++ b/packages/expo-atlas/src/data/stats-types.ts @@ -0,0 +1,132 @@ +/** + * Type definitions for the Atlas stats export format. + * + * The stats file (`.expo/atlas-stats.json`) is a lightweight CI-friendly version of + * the full atlas.jsonl output. It contains package-level size aggregations optimized + * for diffing and scripting in CI/CD workflows. + * + * @example + * ```json + * [ + * { + * "platform": "ios", + * "environment": "client", + * "entryPoint": "/path/to/app/index.js", + * "bundleSize": 2458624, + * "packages": { + * "react": 85234, + * "react-native": 456789, + * "expo": 123456, + * "@company/ui-lib": 234567 + * }, + * "files": { + * "app/components/Button.tsx": 2345, + * "app/screens/HomeScreen.tsx": 5678, + * "app/utils/helpers.ts": 1234 + * }, + * "assets": { + * "assets/images/logo.png": 12345, + * "assets/fonts/custom.ttf": 23456 + * } + * } + * ] + * ``` + */ + +/** + * Root type for the atlas-stats.json file. + * Contains an array of bundle stats, one per bundle in the export. + */ +export type AtlasStatsFile = AtlasStatsBundle[]; + +/** + * Stats for a single bundle, containing package-level size aggregations. + * + * Each bundle in the export (e.g., iOS, Android, different environments) + * gets its own stats entry with metadata and package size totals. + */ +export type AtlasStatsBundle = { + /** + * Target platform for this bundle. + * Matches the platform specified in Metro's serialization options. + */ + platform: 'ios' | 'android' | 'web' | 'unknown'; + + /** + * Runtime environment for this bundle. + * - "client": Standard React Native client bundle + * - "node": Server-side bundle for Node.js environments + * - "react-server": React Server Components bundle + * - "dom": DOM-based web bundle + */ + environment: 'client' | 'node' | 'react-server' | 'dom'; + + /** + * Absolute path to the bundle's entry point file. + * This is the starting file from which Metro builds the dependency graph. + */ + entryPoint: string; + + /** + * Total size of the entire bundle in bytes (transformed output). + * This equals the sum of all package sizes, files, and assets. + */ + bundleSize: number; + + /** + * Package-level size aggregations for external dependencies. + * + * Maps each NPM package to its total size in bytes. + * Sizes represent transformed/bundled output after Metro processing. + * + * Package names are sorted alphabetically for diff-friendly output. + * + * @example + * ```json + * { + * "@babel/runtime": 12345, + * "expo": 123456, + * "react": 85234, + * "react-native": 456789 + * } + * ``` + */ + packages: Record; + + /** + * Individual app source files with their sizes. + * + * Maps relative file paths (from project root) to their size in bytes. + * Only includes JavaScript/TypeScript source files from your app code. + * + * Paths are sorted alphabetically for diff-friendly output. + * + * @example + * ```json + * { + * "app/components/Button.tsx": 2345, + * "app/screens/HomeScreen.tsx": 5678, + * "app/utils/helpers.ts": 1234 + * } + * ``` + */ + files: Record; + + /** + * Asset files (images, fonts, etc.) with their sizes. + * + * Maps relative asset paths to their size in bytes. + * Includes common asset types: png, jpg, gif, svg, ttf, otf, woff, etc. + * + * Paths are sorted alphabetically for diff-friendly output. + * + * @example + * ```json + * { + * "assets/images/logo.png": 12345, + * "assets/fonts/custom-font.ttf": 23456 + * } + * ``` + */ + assets: Record; +}; diff --git a/packages/expo-atlas/src/index.ts b/packages/expo-atlas/src/index.ts index 5a1f1ac..eb944d7 100644 --- a/packages/expo-atlas/src/index.ts +++ b/packages/expo-atlas/src/index.ts @@ -1,14 +1,17 @@ import './utils/global'; export type * from './data/types'; +export type * from './data/stats-types'; export { MetroGraphSource } from './data/MetroGraphSource'; export { AtlasFileSource, createAtlasFile, ensureAtlasFileExist, - validateAtlasFile, + ensureExpoDirExists, getAtlasMetdata, getAtlasPath, + getAtlasStatsPath, + validateAtlasFile, } from './data/AtlasFileSource'; export { AtlasError, AtlasValidationError } from './utils/errors'; diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index 44e5687..9d457cc 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -3,16 +3,27 @@ import { type MetroConfig } from 'metro-config'; import { createAtlasFile, ensureAtlasFileExist, + ensureExpoDirExists, + finalizeAtlasStats, getAtlasPath, + getAtlasStatsPath, writeAtlasEntry, + writeAtlasStatsEntry, } from './data/AtlasFileSource'; import { convertGraph, convertMetroConfig } from './data/MetroGraphSource'; +import { env } from './utils/env'; export { waitUntilAtlasFileReady } from './data/AtlasFileSource'; type ExpoAtlasOptions = Partial<{ /** The output of the atlas file, defaults to `.expo/atlas.json` */ atlasFile: string; + /** + * Only generate the lightweight atlas-stats.json file, skip the full atlas.jsonl. + * Can also be enabled via EXPO_ATLAS_STATS_ONLY=true environment variable. + * @default false + */ + statsOnly: boolean; }>; /** @@ -30,6 +41,15 @@ type ExpoAtlasOptions = Partial<{ * * module.exports = withExpoAtlas(config) * ``` + * + * @example Stats-only mode: + * ```js + * module.exports = withExpoAtlas(config, { statsOnly: true }) + * ``` + * Or via environment variable: + * ```bash + * EXPO_ATLAS_STATS_ONLY=true npx expo export + * ``` */ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = {}) { const projectRoot = config.projectRoot; @@ -39,19 +59,33 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { throw new Error('No "projectRoot" configured in Metro config.'); } + // Check both option and env var (option takes precedence) + const statsOnly = options?.statsOnly ?? env.EXPO_ATLAS_STATS_ONLY; const atlasFile = options?.atlasFile ?? getAtlasPath(projectRoot); const metroConfig = convertMetroConfig(config); - // Note(cedric): we don't have to await this, Metro would never bundle before this is finishes - ensureAtlasFileExist(atlasFile); + // Note(cedric): we don't have to await this, Metro would never bundle before this finishes + ensureExpoDirExists(projectRoot); + if (!statsOnly) { + ensureAtlasFileExist(atlasFile); + } // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => { + const atlasBundle = convertGraph({ + projectRoot, + entryPoint, + preModules, + graph, + serializeOptions, + metroConfig, + }); + // Note(cedric): we don't have to await this, it has a built-in write queue - writeAtlasEntry( - atlasFile, - convertGraph({ projectRoot, entryPoint, preModules, graph, serializeOptions, metroConfig }) - ); + if (!statsOnly) { + writeAtlasEntry(atlasFile, atlasBundle); + } + writeAtlasStatsEntry(atlasFile, atlasBundle); return originalSerializer(entryPoint, preModules, graph, serializeOptions); }; @@ -62,9 +96,35 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { /** * Fully reset, or recreate, the Expo Atlas file containing all Metro information. * This method should only be called once per exporting session, to avoid overwriting data with mutliple Metro instances. + * + * @param projectRoot - The root directory of the project */ export async function resetExpoAtlasFile(projectRoot: string) { const filePath = getAtlasPath(projectRoot); await createAtlasFile(filePath); return filePath; } + +/** + * Finalize and write the atlas-stats.json file. + * Call this after all Metro bundles are exported. + * + * @example + * ```ts + * import { resetExpoAtlasFile, finalizeExpoAtlasStats } from 'expo-atlas/metro'; + * + * // At start of export + * await resetExpoAtlasFile(projectRoot); + * + * // ... Metro bundling happens ... + * + * // After export completes + * const statsPath = await finalizeExpoAtlasStats(projectRoot); + * console.log(`Stats written to: ${statsPath}`); + * ``` + */ +export async function finalizeExpoAtlasStats(projectRoot: string): Promise { + const atlasPath = getAtlasPath(projectRoot); + await finalizeAtlasStats(atlasPath); + return getAtlasStatsPath(projectRoot); +} diff --git a/packages/expo-atlas/src/utils/env.ts b/packages/expo-atlas/src/utils/env.ts index 5c09474..decce36 100644 --- a/packages/expo-atlas/src/utils/env.ts +++ b/packages/expo-atlas/src/utils/env.ts @@ -7,4 +7,7 @@ export const env = { get EXPO_ATLAS_NO_VALIDATION() { return boolish('EXPO_ATLAS_NO_VALIDATION', false); }, + get EXPO_ATLAS_STATS_ONLY() { + return boolish('EXPO_ATLAS_STATS_ONLY', false); + }, };