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 chalk from 'chalk';
8import resolveFrom from 'resolve-from';
9import { StackFrame } from 'stacktrace-parser';
10import terminalLink from 'terminal-link';
11
12import { Log } from '../../../log';
13import { createMetroEndpointAsync } from '../getStaticRenderFunctions';
14// import type { CodeFrame, MetroStackFrame } from '@expo/metro-runtime/symbolicate';
15
16type CodeFrame = {
17  content: string;
18  location?: {
19    row: number;
20    column: number;
21    [key: string]: any;
22  };
23  fileName: string;
24};
25
26type MetroStackFrame = StackFrame & { collapse?: boolean };
27
28function fill(width: number): string {
29  return Array(width).join(' ');
30}
31
32function formatPaths(config: { filePath: string | null; line?: number; col?: number }) {
33  const filePath = chalk.reset(config.filePath);
34  return (
35    chalk.dim('(') +
36    filePath +
37    chalk.dim(`:${[config.line, config.col].filter(Boolean).join(':')})`)
38  );
39}
40
41export async function logMetroErrorWithStack(
42  projectRoot: string,
43  {
44    stack,
45    codeFrame,
46    error,
47  }: {
48    stack: MetroStackFrame[];
49    codeFrame: CodeFrame;
50    error: Error;
51  }
52) {
53  // process.stdout.write('\u001b[0m'); // Reset attributes
54  // process.stdout.write('\u001bc'); // Reset the terminal
55
56  const { getStackFormattedLocation } = require(
57    resolveFrom(projectRoot, '@expo/metro-runtime/symbolicate')
58  );
59
60  Log.log();
61  Log.log(chalk.red('Metro error: ') + error.message);
62  Log.log();
63
64  if (codeFrame) {
65    const maxWarningLineLength = Math.max(200, process.stdout.columns);
66
67    const lineText = codeFrame.content;
68    const isPreviewTooLong = codeFrame.content
69      .split('\n')
70      .some((line) => line.length > maxWarningLineLength);
71    const column = codeFrame.location?.column;
72    // When the preview is too long, we skip reading the file and attempting to apply
73    // code coloring, this is because it can get very slow.
74    if (isPreviewTooLong) {
75      let previewLine = '';
76      let cursorLine = '';
77
78      const formattedPath = formatPaths({
79        filePath: codeFrame.fileName,
80        line: codeFrame.location?.row,
81        col: codeFrame.location?.column,
82      });
83      // Create a curtailed preview line like:
84      // `...transition:'fade'},k._updatePropsStack=function(){clearImmediate(k._updateImmediate),k._updateImmediate...`
85      // If there is no text preview or column number, we can't do anything.
86      if (lineText && column != null) {
87        const rangeWindow = Math.round(
88          Math.max(codeFrame.fileName?.length ?? 0, Math.max(80, process.stdout.columns)) / 2
89        );
90        let minBounds = Math.max(0, column - rangeWindow);
91        const maxBounds = Math.min(minBounds + rangeWindow * 2, lineText.length);
92        previewLine = lineText.slice(minBounds, maxBounds);
93
94        // If we splice content off the start, then we should append `...`.
95        // This is unlikely to happen since we limit the activation size.
96        if (minBounds > 0) {
97          // Adjust the min bounds so the cursor is aligned after we add the "..."
98          minBounds -= 3;
99          previewLine = chalk.dim('...') + previewLine;
100        }
101        if (maxBounds < lineText.length) {
102          previewLine += chalk.dim('...');
103        }
104
105        // If the column property could be found, then use that to fix the cursor location which is often broken in regex.
106        cursorLine = (column == null ? '' : fill(column) + chalk.reset('^')).slice(minBounds);
107
108        Log.log(
109          [formattedPath, '', previewLine, cursorLine, chalk.dim('(error truncated)')].join('\n')
110        );
111      }
112    } else {
113      Log.log(codeFrame.content);
114    }
115  }
116
117  if (stack?.length) {
118    Log.log();
119    Log.log(chalk.bold`Call Stack`);
120
121    const stackProps = stack.map((frame) => {
122      return {
123        title: frame.methodName,
124        subtitle: getStackFormattedLocation(projectRoot, frame),
125        collapse: frame.collapse,
126      };
127    });
128
129    stackProps.forEach((frame) => {
130      const position = terminalLink.isSupported
131        ? terminalLink(frame.subtitle, frame.subtitle)
132        : frame.subtitle;
133      let lineItem = chalk.gray(`  ${frame.title} (${position})`);
134      if (frame.collapse) {
135        lineItem = chalk.dim(lineItem);
136      }
137      Log.log(lineItem);
138    });
139  } else {
140    Log.log(chalk.gray(`  ${error.stack}`));
141  }
142}
143
144export async function logMetroError(projectRoot: string, { error }: { error: Error }) {
145  const { LogBoxLog, parseErrorStack } = require(
146    resolveFrom(projectRoot, '@expo/metro-runtime/symbolicate')
147  );
148
149  const stack = parseErrorStack(error.stack);
150
151  const log = new LogBoxLog({
152    level: 'static',
153    message: {
154      content: error.message,
155      substitutions: [],
156    },
157    isComponentError: false,
158    stack,
159    category: 'static',
160    componentStack: [],
161  });
162
163  await new Promise((res) => log.symbolicate('stack', res));
164
165  logMetroErrorWithStack(projectRoot, {
166    stack: log.symbolicated?.stack?.stack ?? [],
167    codeFrame: log.codeFrame,
168    error,
169  });
170}
171
172/** @returns the html required to render the static metro error as an SPA. */
173export function logFromError({ error, projectRoot }: { error: Error; projectRoot: string }): {
174  symbolicated: any;
175  symbolicate: (type: string, callback: () => void) => void;
176  codeFrame: CodeFrame;
177} {
178  const { LogBoxLog, parseErrorStack } = require(
179    resolveFrom(projectRoot, '@expo/metro-runtime/symbolicate')
180  );
181
182  const stack = parseErrorStack(error.stack);
183
184  return new LogBoxLog({
185    level: 'static',
186    message: {
187      content: error.message,
188      substitutions: [],
189    },
190    isComponentError: false,
191    stack,
192    category: 'static',
193    componentStack: [],
194  });
195}
196
197/** @returns the html required to render the static metro error as an SPA. */
198export async function logMetroErrorAsync({
199  error,
200  projectRoot,
201}: {
202  error: Error;
203  projectRoot: string;
204}) {
205  const log = logFromError({ projectRoot, error });
206
207  await new Promise<void>((res) => log.symbolicate('stack', res));
208
209  logMetroErrorWithStack(projectRoot, {
210    stack: log.symbolicated?.stack?.stack ?? [],
211    codeFrame: log.codeFrame,
212    error,
213  });
214}
215
216/** @returns the html required to render the static metro error as an SPA. */
217export async function getErrorOverlayHtmlAsync({
218  error,
219  projectRoot,
220}: {
221  error: Error;
222  projectRoot: string;
223}) {
224  const log = logFromError({ projectRoot, error });
225
226  await new Promise<void>((res) => log.symbolicate('stack', res));
227
228  logMetroErrorWithStack(projectRoot, {
229    stack: log.symbolicated?.stack?.stack ?? [],
230    codeFrame: log.codeFrame,
231    error,
232  });
233
234  const logBoxContext = {
235    selectedLogIndex: 0,
236    isDisabled: false,
237    logs: [log],
238  };
239  const html = `<html><head><style>#root,body,html{height:100%}body{overflow:hidden}#root{display:flex}</style></head><body><div id="root"></div><script id="_expo-static-error" type="application/json">${JSON.stringify(
240    logBoxContext
241  )}</script></body></html>`;
242
243  const errorOverlayEntry = await createMetroEndpointAsync(
244    projectRoot,
245    // Keep the URL relative
246    '',
247    resolveFrom(projectRoot, 'expo-router/_error'),
248    {
249      dev: true,
250      platform: 'web',
251      minify: false,
252      environment: 'node',
253    }
254  );
255
256  const htmlWithJs = html.replace('</body>', `<script src=${errorOverlayEntry}></script></body>`);
257  return htmlWithJs;
258}
259