1import { MetroConfig } from '@expo/metro-config'; 2import crypto from 'crypto'; 3import type { Module } from 'metro'; 4import { getJsOutput, isJsModule } from 'metro/src/DeltaBundler/Serializers/helpers/js'; 5// @ts-expect-error: TODO: Use Metro upstream types: https://github.com/facebook/metro/commit/07732e778b0587ce7e92571e66243c7546781f7c 6import type IncrementalBundler from 'metro/src/IncrementalBundler'; 7import splitBundleOptions from 'metro/src/lib/splitBundleOptions'; 8import path from 'path'; 9 10// import { getAssetData } from 'metro/src/Assets'; 11export type ReadOnlyDependencies<T = any> = Map<string, Module<T>>; 12 13type Options = { 14 processModuleFilter: (modules: Module) => boolean; 15 assetPlugins: readonly string[]; 16 platform?: string | null; 17 projectRoot: string; 18 publicPath: string; 19}; 20 21type MetroModuleCSSMetadata = { 22 code: string; 23 lineCount: number; 24 map: any[]; 25}; 26 27export type CSSAsset = { 28 // 'styles.css' 29 originFilename: string; 30 // '_expo/static/css/bc6aa0a69dcebf8e8cac1faa76705756.css' 31 filename: string; 32 // '\ndiv {\n background: cyan;\n}\n\n' 33 source: string; 34}; 35 36// s = static 37const STATIC_EXPORT_DIRECTORY = '_expo/static/css'; 38 39/** @returns the static CSS assets used in a given bundle. CSS assets are only enabled if the `@expo/metro-config` `transformerPath` is used. */ 40export async function getCssModulesFromBundler( 41 config: MetroConfig, 42 incrementalBundler: IncrementalBundler, 43 options: any 44): Promise<CSSAsset[]> { 45 // Static CSS is a web-only feature. 46 if (options.platform !== 'web') { 47 return []; 48 } 49 50 const { entryFile, onProgress, resolverOptions, transformOptions } = splitBundleOptions(options); 51 52 const dependencies = await incrementalBundler.getDependencies( 53 [entryFile], 54 transformOptions, 55 resolverOptions, 56 { onProgress, shallow: false } 57 ); 58 59 return getCssModules(dependencies, { 60 processModuleFilter: config.serializer.processModuleFilter, 61 assetPlugins: config.transformer.assetPlugins, 62 platform: transformOptions.platform, 63 projectRoot: config.server.unstable_serverRoot ?? config.projectRoot, 64 publicPath: config.transformer.publicPath, 65 }); 66} 67 68function hashString(str: string) { 69 return crypto.createHash('md5').update(str).digest('hex'); 70} 71 72function getCssModules( 73 dependencies: ReadOnlyDependencies, 74 { processModuleFilter, projectRoot }: Options 75) { 76 const promises = []; 77 78 for (const module of dependencies.values()) { 79 if ( 80 isJsModule(module) && 81 processModuleFilter(module) && 82 getJsOutput(module).type === 'js/module' && 83 path.relative(projectRoot, module.path) !== 'package.json' 84 ) { 85 const cssMetadata = getCssMetadata(module); 86 if (cssMetadata) { 87 const contents = cssMetadata.code; 88 const filename = path.join( 89 // Consistent location 90 STATIC_EXPORT_DIRECTORY, 91 // Hashed file contents + name for caching 92 getFileName(module.path) + '-' + hashString(module.path + contents) + '.css' 93 ); 94 promises.push({ 95 originFilename: path.relative(projectRoot, module.path), 96 filename, 97 source: contents, 98 }); 99 } 100 } 101 } 102 103 return promises; 104} 105 106function getCssMetadata(module: Module): MetroModuleCSSMetadata | null { 107 const data = module.output[0]?.data; 108 if (data && typeof data === 'object' && 'css' in data) { 109 if (typeof data.css !== 'object' || !('code' in (data as any).css)) { 110 throw new Error( 111 `Unexpected CSS metadata in Metro module (${module.path}): ${JSON.stringify(data.css)}` 112 ); 113 } 114 return data.css as MetroModuleCSSMetadata; 115 } 116 return null; 117} 118 119export function getFileName(module: string) { 120 return path.basename(module).replace(/\.[^.]+$/, ''); 121} 122