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 = lineText.length > maxWarningLineLength;
69    const column = codeFrame.location?.column;
70    // When the preview is too long, we skip reading the file and attempting to apply
71    // code coloring, this is because it can get very slow.
72    if (isPreviewTooLong) {
73      let previewLine = '';
74      let cursorLine = '';
75
76      const formattedPath = formatPaths({
77        filePath: codeFrame.fileName,
78        line: codeFrame.location?.row,
79        col: codeFrame.location?.column,
80      });
81      // Create a curtailed preview line like:
82      // `...transition:'fade'},k._updatePropsStack=function(){clearImmediate(k._updateImmediate),k._updateImmediate...`
83      // If there is no text preview or column number, we can't do anything.
84      if (lineText && column != null) {
85        const rangeWindow = Math.round(
86          Math.max(codeFrame.fileName?.length ?? 0, Math.max(80, process.stdout.columns)) / 2
87        );
88        let minBounds = Math.max(0, column - rangeWindow);
89        const maxBounds = Math.min(minBounds + rangeWindow * 2, lineText.length);
90        previewLine = lineText.slice(minBounds, maxBounds);
91
92        // If we splice content off the start, then we should append `...`.
93        // This is unlikely to happen since we limit the activation size.
94        if (minBounds > 0) {
95          // Adjust the min bounds so the cursor is aligned after we add the "..."
96          minBounds -= 3;
97          previewLine = chalk.dim('...') + previewLine;
98        }
99        if (maxBounds < lineText.length) {
100          previewLine += chalk.dim('...');
101        }
102
103        // If the column property could be found, then use that to fix the cursor location which is often broken in regex.
104        cursorLine = (column == null ? '' : fill(column) + chalk.reset('^')).slice(minBounds);
105
106        Log.log(
107          [formattedPath, '', previewLine, cursorLine, chalk.dim('(error truncated)')].join('\n')
108        );
109      }
110    } else {
111      Log.log(codeFrame.content);
112    }
113  }
114
115  if (stack?.length) {
116    Log.log();
117    Log.log(chalk.bold`Call Stack`);
118
119    const stackProps = stack.map((frame) => {
120      return {
121        title: frame.methodName,
122        subtitle: getStackFormattedLocation(projectRoot, frame),
123        collapse: frame.collapse,
124      };
125    });
126
127    stackProps.forEach((frame) => {
128      const position = terminalLink.isSupported
129        ? terminalLink(frame.subtitle, frame.subtitle)
130        : frame.subtitle;
131      let lineItem = chalk.gray(`  ${frame.title} (${position})`);
132      if (frame.collapse) {
133        lineItem = chalk.dim(lineItem);
134      }
135      Log.log(lineItem);
136    });
137  } else {
138    Log.log(chalk.gray(`  ${error.stack}`));
139  }
140}
141
142export async function logMetroError(projectRoot: string, { error }: { error: Error }) {
143  const { LogBoxLog, parseErrorStack } = require(
144    resolveFrom(projectRoot, '@expo/metro-runtime/symbolicate')
145  );
146
147  const stack = parseErrorStack(error.stack);
148
149  const log = new LogBoxLog({
150    level: 'static',
151    message: {
152      content: error.message,
153      substitutions: [],
154    },
155    isComponentError: false,
156    stack,
157    category: 'static',
158    componentStack: [],
159  });
160
161  await new Promise((res) => log.symbolicate('stack', res));
162
163  logMetroErrorWithStack(projectRoot, {
164    stack: log.symbolicated?.stack?.stack ?? [],
165    codeFrame: log.codeFrame,
166    error,
167  });
168}
169
170/** @returns the html required to render the static metro error as an SPA. */
171export function logFromError({ error, projectRoot }: { error: Error; projectRoot: string }): {
172  symbolicated: any;
173  symbolicate: (type: string, callback: () => void) => void;
174  codeFrame: CodeFrame;
175} {
176  const { LogBoxLog, parseErrorStack } = require(
177    resolveFrom(projectRoot, '@expo/metro-runtime/symbolicate')
178  );
179
180  const stack = parseErrorStack(error.stack);
181
182  return new LogBoxLog({
183    level: 'static',
184    message: {
185      content: error.message,
186      substitutions: [],
187    },
188    isComponentError: false,
189    stack,
190    category: 'static',
191    componentStack: [],
192  });
193}
194
195/** @returns the html required to render the static metro error as an SPA. */
196export async function logMetroErrorAsync({
197  error,
198  projectRoot,
199}: {
200  error: Error;
201  projectRoot: string;
202}) {
203  const log = logFromError({ projectRoot, error });
204
205  await new Promise<void>((res) => log.symbolicate('stack', res));
206
207  logMetroErrorWithStack(projectRoot, {
208    stack: log.symbolicated?.stack?.stack ?? [],
209    codeFrame: log.codeFrame,
210    error,
211  });
212}
213
214/** @returns the html required to render the static metro error as an SPA. */
215export async function getErrorOverlayHtmlAsync({
216  error,
217  projectRoot,
218}: {
219  error: Error;
220  projectRoot: string;
221}) {
222  const log = logFromError({ projectRoot, error });
223
224  await new Promise<void>((res) => log.symbolicate('stack', res));
225
226  logMetroErrorWithStack(projectRoot, {
227    stack: log.symbolicated?.stack?.stack ?? [],
228    codeFrame: log.codeFrame,
229    error,
230  });
231
232  const logBoxContext = {
233    selectedLogIndex: 0,
234    isDisabled: false,
235    logs: [log],
236  };
237  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(
238    logBoxContext
239  )}</script></body></html>`;
240
241  const errorOverlayEntry = await createMetroEndpointAsync(
242    projectRoot,
243    // Keep the URL relative
244    '',
245    resolveFrom(projectRoot, 'expo-router/_error'),
246    {
247      dev: true,
248      platform: 'web',
249      minify: false,
250      environment: 'node',
251    }
252  );
253
254  const htmlWithJs = html.replace('</body>', `<script src=${errorOverlayEntry}></script></body>`);
255  return htmlWithJs;
256}
257