1/**
2 * Copyright © 2022 650 Industries.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7import { isJscSafeUrl, toNormalUrl } from 'jsc-safe-url';
8import { Module, MixedOutput } from 'metro';
9import baseJSBundle from 'metro/src/DeltaBundler/Serializers/baseJSBundle';
10// @ts-expect-error
11import sourceMapString from 'metro/src/DeltaBundler/Serializers/sourceMapString';
12import bundleToString from 'metro/src/lib/bundleToString';
13import { InputConfigT, SerializerConfigT } from 'metro-config';
14import path from 'path';
15
16import {
17  serverPreludeSerializerPlugin,
18  environmentVariableSerializerPlugin,
19} from './environmentVariableSerializerPlugin';
20import { fileNameFromContents, getCssSerialAssets } from './getCssDeps';
21import { SerialAsset } from './serializerAssets';
22import { env } from '../env';
23
24export type Serializer = NonNullable<SerializerConfigT['customSerializer']>;
25
26export type SerializerParameters = Parameters<Serializer>;
27
28// A serializer that processes the input and returns a modified version.
29// Unlike a serializer, these can be chained together.
30export type SerializerPlugin = (...props: SerializerParameters) => SerializerParameters;
31
32export function withExpoSerializers(config: InputConfigT): InputConfigT {
33  const processors: SerializerPlugin[] = [];
34  processors.push(serverPreludeSerializerPlugin);
35  if (!env.EXPO_NO_CLIENT_ENV_VARS) {
36    processors.push(environmentVariableSerializerPlugin);
37  }
38
39  return withSerializerPlugins(config, processors);
40}
41
42// There can only be one custom serializer as the input doesn't match the output.
43// Here we simply run
44export function withSerializerPlugins(
45  config: InputConfigT,
46  processors: SerializerPlugin[]
47): InputConfigT {
48  const originalSerializer = config.serializer?.customSerializer;
49
50  return {
51    ...config,
52    serializer: {
53      ...config.serializer,
54      customSerializer: createSerializerFromSerialProcessors(processors, originalSerializer),
55    },
56  };
57}
58
59function getDefaultSerializer(fallbackSerializer?: Serializer | null): Serializer {
60  const defaultSerializer =
61    fallbackSerializer ??
62    (async (...params: SerializerParameters) => {
63      const bundle = baseJSBundle(...params);
64      const outputCode = bundleToString(bundle).code;
65      return outputCode;
66    });
67  return async (
68    ...props: SerializerParameters
69  ): Promise<string | { code: string; map: string }> => {
70    const [entryPoint, preModules, graph, options] = props;
71
72    const jsCode = await defaultSerializer(entryPoint, preModules, graph, options);
73
74    if (!options.sourceUrl) {
75      return jsCode;
76    }
77    const sourceUrl = isJscSafeUrl(options.sourceUrl)
78      ? toNormalUrl(options.sourceUrl)
79      : options.sourceUrl;
80    const url = new URL(sourceUrl, 'https://expo.dev');
81    if (
82      url.searchParams.get('platform') !== 'web' ||
83      url.searchParams.get('serializer.output') !== 'static'
84    ) {
85      // Default behavior if `serializer.output=static` is not present in the URL.
86      return jsCode;
87    }
88
89    const includeSourceMaps = url.searchParams.get('serializer.map') === 'true';
90
91    const cssDeps = getCssSerialAssets<MixedOutput>(graph.dependencies, {
92      projectRoot: options.projectRoot,
93      processModuleFilter: options.processModuleFilter,
94    });
95
96    const jsAssets: SerialAsset[] = [];
97
98    if (jsCode) {
99      const stringContents = typeof jsCode === 'string' ? jsCode : jsCode.code;
100      const jsFilename = fileNameFromContents({
101        filepath: url.pathname,
102        src: stringContents,
103      });
104      jsAssets.push({
105        filename: options.dev ? 'index.js' : `_expo/static/js/web/${jsFilename}.js`,
106        originFilename: 'index.js',
107        type: 'js',
108        metadata: {},
109        source: stringContents,
110      });
111
112      if (
113        // Only include the source map if the `options.sourceMapUrl` option is provided and we are exporting a static build.
114        includeSourceMaps &&
115        options.sourceMapUrl
116      ) {
117        const sourceMap = typeof jsCode === 'string' ? serializeToSourceMap(...props) : jsCode.map;
118
119        // Make all paths relative to the server root to prevent the entire user filesystem from being exposed.
120        const parsed = JSON.parse(sourceMap);
121        // TODO: Maybe we can do this earlier.
122        parsed.sources = parsed.sources.map(
123          // TODO: Maybe basePath support
124          (value: string) => {
125            if (value.startsWith('/')) {
126              return '/' + path.relative(options.serverRoot ?? options.projectRoot, value);
127            }
128            // Prevent `__prelude__` from being relative.
129            return value;
130          }
131        );
132
133        jsAssets.push({
134          filename: options.dev ? 'index.map' : `_expo/static/js/web/${jsFilename}.js.map`,
135          originFilename: 'index.map',
136          type: 'map',
137          metadata: {},
138          source: JSON.stringify(parsed),
139        });
140      }
141    }
142
143    return JSON.stringify([...jsAssets, ...cssDeps]);
144  };
145}
146
147function getSortedModules(
148  graph: SerializerParameters[2],
149  {
150    createModuleId,
151  }: {
152    createModuleId: (path: string) => number;
153  }
154): readonly Module<any>[] {
155  const modules = [...graph.dependencies.values()];
156  // Assign IDs to modules in a consistent order
157  for (const module of modules) {
158    createModuleId(module.path);
159  }
160  // Sort by IDs
161  return modules.sort(
162    (a: Module<any>, b: Module<any>) => createModuleId(a.path) - createModuleId(b.path)
163  );
164}
165
166function serializeToSourceMap(...props: SerializerParameters): string {
167  const [, prepend, graph, options] = props;
168
169  const modules = [
170    ...prepend,
171    ...getSortedModules(graph, {
172      createModuleId: options.createModuleId,
173    }),
174  ];
175
176  return sourceMapString(modules, {
177    ...options,
178  });
179}
180
181export function createSerializerFromSerialProcessors(
182  processors: (SerializerPlugin | undefined)[],
183  originalSerializer?: Serializer | null
184): Serializer {
185  const finalSerializer = getDefaultSerializer(originalSerializer);
186  return (...props: SerializerParameters): ReturnType<Serializer> => {
187    for (const processor of processors) {
188      if (processor) {
189        props = processor(...props);
190      }
191    }
192
193    return finalSerializer(...props);
194  };
195}
196
197export { SerialAsset };
198