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