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
53export function environmentVariableSerializerPlugin(
54  entryPoint: string,
55  preModules: readonly Module<MixedOutput>[],
56  graph: ReadOnlyGraph,
57  options: SerializerOptions
58): SerializerParameters {
59  // Skip replacement in Node.js environments.
60  if (options.sourceUrl && getTransformEnvironment(options.sourceUrl) === 'node') {
61    debug('Skipping environment variable inlining in Node.js environment.');
62    return [entryPoint, preModules, graph, options];
63  }
64
65  // Adds about 5ms on a blank Expo Router app.
66  // TODO: We can probably cache the results.
67
68  // In development, we need to add the process.env object to ensure it
69  // persists between Fast Refresh updates.
70  if (options.dev) {
71    // Set the process.env object to the current environment variables object
72    // ensuring they aren't iterable, settable, or enumerable.
73    const str = `process.env=Object.defineProperties(process.env, {${Object.keys(
74      getAllExpoPublicEnvVars()
75    )
76      .map((key) => `${JSON.stringify(key)}: { value: ${JSON.stringify(process.env[key])} }`)
77      .join(',')}});`;
78
79    const [firstModule, ...restModules] = preModules;
80    // const envCode = `var process=this.process||{};${str}`;
81    // process.env
82    return [
83      entryPoint,
84      [
85        // First module defines the process.env object.
86        firstModule,
87        // Second module modifies the process.env object.
88        getEnvPrelude(str),
89        // Now we add the rest
90        ...restModules,
91      ],
92      graph,
93      options,
94    ];
95  }
96
97  // In production, inline all process.env variables to ensure they cannot be iterated and read arbitrarily.
98  for (const value of graph.dependencies.values()) {
99    // Skip node_modules, the feature is a bit too sensitive to allow in arbitrary code.
100    if (/node_modules/.test(value.path)) {
101      continue;
102    }
103
104    for (const index in value.output) {
105      // TODO: This probably breaks source maps.
106      const code = replaceEnvironmentVariables(value.output[index].data.code, process.env);
107      value.output[index].data.code = code;
108    }
109  }
110  return [entryPoint, preModules, graph, options];
111}
112
113function getEnvPrelude(contents: string): Module<MixedOutput> {
114  const code = '// HMR env vars from Expo CLI (dev-only)\n' + contents;
115  const name = '__env__';
116  const lineCount = countLines(code);
117
118  return {
119    dependencies: new Map(),
120    getSource: (): Buffer => Buffer.from(code),
121    inverseDependencies: new CountingSet(),
122    path: name,
123    output: [
124      {
125        type: 'js/script/virtual',
126        data: {
127          code,
128          // @ts-expect-error: typed incorrectly upstream
129          lineCount,
130          map: [],
131        },
132      },
133    ],
134  };
135}
136