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