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 { ReadOnlyGraph, MixedOutput, Module, SerializerOptions } from 'metro';
8import CountingSet from 'metro/src/lib/CountingSet';
9import countLines from 'metro/src/lib/countLines';
10
11import { SerializerParameters } from './withExpoSerializers';
12
13const debug = require('debug')('expo:metro-config:serializer:env-var') as typeof console.log;
14
15export function replaceEnvironmentVariables(
16  code: string,
17  env: Record<string, string | undefined>
18): string {
19  // match and replace env variables that aren't NODE_ENV or JEST_WORKER_ID
20  // return code.match(/process\.env\.(EXPO_PUBLIC_[A-Z_]+)/g);
21  return code.replace(/process\.env\.([a-zA-Z0-9_]+)/gm, (match) => {
22    const name = match.replace('process.env.', '');
23    if (
24      // Must start with EXPO_PUBLIC_ to be replaced
25      !/^EXPO_PUBLIC_/.test(name)
26    ) {
27      return match;
28    }
29
30    const value = JSON.stringify(env[name] ?? '');
31    debug(`Inlining environment variable "${match}" with ${value}`);
32    return value;
33  });
34}
35
36export function getTransformEnvironment(url: string): string | null {
37  const match = url.match(/[&?]transform\.environment=([^&]+)/);
38  return match ? match[1] : null;
39}
40
41function getAllExpoPublicEnvVars() {
42  // Create an object containing all environment variables that start with EXPO_PUBLIC_
43  const env = {};
44  for (const key in process.env) {
45    if (key.startsWith('EXPO_PUBLIC_')) {
46      // @ts-ignore
47      env[key] = process.env[key];
48    }
49  }
50  return env;
51}
52
53/** Strips the process.env polyfill in server environments to allow for accessing environment variables off the global. */
54export function serverPreludeSerializerPlugin(
55  entryPoint: string,
56  preModules: readonly Module<MixedOutput>[],
57  graph: ReadOnlyGraph,
58  options: SerializerOptions
59): SerializerParameters {
60  if (options.sourceUrl && getTransformEnvironment(options.sourceUrl) === 'node') {
61    const prelude = preModules.find((module) => module.path === '__prelude__');
62    if (prelude) {
63      debug('Stripping environment variable polyfill in server environment.');
64      prelude.output[0].data.code = prelude.output[0].data.code
65        .replace(/process=this\.process\|\|{},/, '')
66        .replace(
67          /process\.env=process\.env\|\|{};process\.env\.NODE_ENV=process\.env\.NODE_ENV\|\|"\w+";/,
68          ''
69        );
70    }
71  }
72  return [entryPoint, preModules, graph, options];
73}
74
75export function environmentVariableSerializerPlugin(
76  entryPoint: string,
77  preModules: readonly Module<MixedOutput>[],
78  graph: ReadOnlyGraph,
79  options: SerializerOptions
80): SerializerParameters {
81  // Skip replacement in Node.js environments.
82  if (options.sourceUrl && getTransformEnvironment(options.sourceUrl) === 'node') {
83    debug('Skipping environment variable inlining in Node.js environment.');
84    return [entryPoint, preModules, graph, options];
85  }
86
87  // Adds about 5ms on a blank Expo Router app.
88  // TODO: We can probably cache the results.
89
90  // In development, we need to add the process.env object to ensure it
91  // persists between Fast Refresh updates.
92  if (options.dev) {
93    // Set the process.env object to the current environment variables object
94    // ensuring they aren't iterable, settable, or enumerable.
95    const str = `process.env=Object.defineProperties(process.env, {${Object.keys(
96      getAllExpoPublicEnvVars()
97    )
98      .map((key) => `${JSON.stringify(key)}: { value: ${JSON.stringify(process.env[key])} }`)
99      .join(',')}});`;
100
101    const [firstModule, ...restModules] = preModules;
102    // const envCode = `var process=this.process||{};${str}`;
103    // process.env
104    return [
105      entryPoint,
106      [
107        // First module defines the process.env object.
108        firstModule,
109        // Second module modifies the process.env object.
110        getEnvPrelude(str),
111        // Now we add the rest
112        ...restModules,
113      ],
114      graph,
115      options,
116    ];
117  }
118
119  // In production, inline all process.env variables to ensure they cannot be iterated and read arbitrarily.
120  for (const value of graph.dependencies.values()) {
121    // Skip node_modules, the feature is a bit too sensitive to allow in arbitrary code.
122    if (/node_modules/.test(value.path)) {
123      continue;
124    }
125
126    for (const index in value.output) {
127      // TODO: This probably breaks source maps.
128      const code = replaceEnvironmentVariables(value.output[index].data.code, process.env);
129      value.output[index].data.code = code;
130    }
131  }
132  return [entryPoint, preModules, graph, options];
133}
134
135function getEnvPrelude(contents: string): Module<MixedOutput> {
136  const code = '// HMR env vars from Expo CLI (dev-only)\n' + contents;
137  const name = '__env__';
138  const lineCount = countLines(code);
139
140  return {
141    dependencies: new Map(),
142    getSource: (): Buffer => Buffer.from(code),
143    inverseDependencies: new CountingSet(),
144    path: name,
145    output: [
146      {
147        type: 'js/script/virtual',
148        data: {
149          code,
150          // @ts-expect-error: typed incorrectly upstream
151          lineCount,
152          map: [],
153        },
154      },
155    ],
156  };
157}
158