Skip to content

Commit 9fb149d

Browse files
Throw module server errors from runJS (#190)
Co-authored-by: Gerardo Rodriguez <gerardo@cloudfour.com>
1 parent 0df14e2 commit 9fb149d

14 files changed

Lines changed: 437 additions & 141 deletions

.changeset/funny-ghosts-dance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': minor
3+
---
4+
5+
Improve error message output for resolution errors and syntax errors/transform errors

src/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ModuleServerOpts } from './module-server';
2020
import { createModuleServer } from './module-server';
2121
import { cleanupClientRuntimeServer } from './module-server/client-runtime-server';
2222
import { Console } from 'console';
23+
import { createBuildStatusTracker } from './module-server/build-status-tracker';
2324

2425
export { JSHandle, ElementHandle } from 'puppeteer';
2526
koloristOpts.enabled = true;
@@ -265,6 +266,16 @@ const createTab = async ({
265266
// If the text includes %c, then it probably came from the jest output being forwarded into the browser
266267
// So we don't need to print it _again_ in node, since it already came from node
267268
if (text.includes('%c')) return;
269+
// This is intended so that transpilation errors from the module server,
270+
// which will get a nice code frame in node,
271+
// do not also log "Failed to load resource: the server responded with a status of 500"
272+
if (
273+
/Failed to load resource: the server responded with a status of 500/.test(
274+
text,
275+
) &&
276+
message.location().url?.includes(`http://localhost:${port}`)
277+
)
278+
return;
268279
const type = message.type();
269280
// Create a new console instance instead of using the global one
270281
// Because the global one is overridden by Jest, and it adds a misleading second stack trace and code frame below it
@@ -282,6 +293,7 @@ const createTab = async ({
282293

283294
await page.goto(`http://localhost:${port}`);
284295

296+
/** Runs page.evaluate but it includes forgot-await detection */
285297
const safeEvaluate = async (
286298
caller: (...params: any) => any,
287299
...args: Parameters<typeof page.evaluate>
@@ -304,9 +316,10 @@ const createTab = async ({
304316
const runJS: PleasantestUtils['runJS'] = async (code, args) => {
305317
// For some reason encodeURIComponent doesn't encode '
306318
const encodedCode = encodeURIComponent(code).replace(/'/g, '%27');
319+
const buildStatus = createBuildStatusTracker();
307320
// This uses the testPath as the url so that if there are relative imports
308321
// in the inline code, the relative imports are resolved relative to the test file
309-
const url = `http://localhost:${port}/${testPath}?inline-code=${encodedCode}`;
322+
const url = `http://localhost:${port}/${testPath}?inline-code=${encodedCode}&build-id=${buildStatus.buildId}`;
310323
const res = (await safeEvaluate(
311324
runJS,
312325
new Function(
@@ -322,13 +335,14 @@ const createTab = async ({
322335
) as () => any,
323336
...(Array.isArray(args) ? (args as any) : []),
324337
)) as undefined | { message: string; stack: string };
338+
339+
const errorsFromBuild = buildStatus.complete();
340+
// It only throws the first one but that is probably OK
341+
if (errorsFromBuild) throw errorsFromBuild[0];
342+
325343
if (res === undefined) return;
326344
if (typeof res !== 'object') throw res;
327-
let { message, stack } = res;
328-
if (message.includes(`Failed to fetch dynamically imported module: ${url}`))
329-
message =
330-
'Failed to load runJS code (most likely due to a transpilation error)';
331-
345+
const { message, stack } = res;
332346
const parsedStack = parseStackTrace(stack);
333347
const modifiedStack = parsedStack.map(async (stackItem) => {
334348
if (stackItem.raw.startsWith(stack.slice(0, stack.indexOf('\n'))))
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// This makes it so that errors thrown by the module server get re-thrown inside of runJS/loadJS.
2+
// This is necessary because the network is between the runJS call and the module server, so errors do not propagate
3+
// By tracking which runJS/loadJS initiated the request for each file, the module server can know which runJS/loadJS needs to reject.
4+
// When runJS finishes, it will check to see if the buildStatuses map has any errors corresponding to its buildId.
5+
// If there are any errors, it throws the first one.
6+
7+
const buildStatuses = new Map<number, Error[]>();
8+
9+
let buildIds = 0;
10+
11+
/**
12+
* Add an error for a specific buildId.
13+
* If the build is already finished (resolved),
14+
* triggers an uncaught rejection, with the hopes that it will fail the test.
15+
*/
16+
export const rejectBuild = (buildId: number, error: Error) => {
17+
const statusArray = buildStatuses.get(buildId);
18+
if (statusArray) statusArray.push(error);
19+
// Uncaught promise rejection!
20+
// Hope that Jest will catch it and fail the test, otherwise it is just logged by Node
21+
else Promise.reject(error);
22+
};
23+
24+
export const createBuildStatusTracker = () => {
25+
const buildId = ++buildIds;
26+
buildStatuses.set(buildId, []);
27+
return {
28+
buildId,
29+
complete() {
30+
const status = buildStatuses.get(buildId);
31+
// This should never happen
32+
if (!status) throw new Error('Build already completed');
33+
buildStatuses.delete(buildId);
34+
if (status.length > 0) return status;
35+
},
36+
};
37+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as colors from 'kolorist';
2+
import { createCodeFrame } from 'simple-code-frame';
3+
import { promises as fs } from 'fs';
4+
5+
export class ErrorWithLocation extends Error {
6+
filename?: string;
7+
line: number;
8+
column?: number;
9+
constructor({
10+
message,
11+
filename,
12+
line,
13+
column,
14+
}: {
15+
message: string;
16+
filename?: string;
17+
line: number;
18+
column?: number;
19+
}) {
20+
super(message);
21+
this.filename = filename;
22+
this.line = line;
23+
this.column = column;
24+
}
25+
26+
async toCodeFrame() {
27+
if (!this.filename)
28+
throw new Error('filename missing in ErrorWithLocation');
29+
30+
const originalCode = await fs.readFile(this.filename, 'utf8');
31+
const frame = createCodeFrame(
32+
originalCode,
33+
this.line - 1,
34+
this.column || 0,
35+
);
36+
const message = `${colors.red(this.message)}
37+
38+
${colors.blue(
39+
`${this.filename}:${this.line}${
40+
this.column === undefined ? '' : `:${this.column + 1}`
41+
}`,
42+
)}
43+
44+
${frame}`;
45+
const modifiedError = new Error(message);
46+
modifiedError.stack = message;
47+
return modifiedError;
48+
}
49+
}

src/module-server/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const createModuleServer = async ({
4848
else normalPlugins.push(plugin);
4949
}
5050

51-
const plugins: (Plugin | false | undefined)[] = [
51+
const plugins: Plugin[] = [
5252
...prePlugins,
5353

5454
...normalPlugins,
@@ -57,19 +57,19 @@ export const createModuleServer = async ({
5757
environmentVariablesPlugin(envVars),
5858
npmPlugin({ root, envVars }),
5959

60-
esbuildOptions && esbuildPlugin(esbuildOptions),
60+
...(esbuildOptions ? [esbuildPlugin(esbuildOptions)] : []),
6161
cssPlugin({ root }),
6262

6363
...postPlugins,
6464
];
65-
const filteredPlugins = plugins.filter(Boolean) as Plugin[];
6665
const requestCache = new Map<string, SourceDescription>();
6766
const middleware: polka.Middleware[] = [
6867
indexHTMLMiddleware,
69-
jsMiddleware({ root, plugins: filteredPlugins, requestCache }),
68+
jsMiddleware({ root, plugins, requestCache }),
7069
cssMiddleware({ root }),
7170
staticMiddleware({ root }),
7271
];
72+
7373
return {
7474
...(await createServer({ middleware })),
7575
requestCache,

src/module-server/middleware/js.ts

Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { dirname, posix, relative, resolve, sep } from 'path';
22
import type polka from 'polka';
3-
import type { SourceDescription } from 'rollup';
3+
import type {
4+
PartialResolvedId,
5+
ResolveIdResult,
6+
SourceDescription,
7+
} from 'rollup';
48
import type { Plugin } from '../plugin';
59
import { createPluginContainer } from '../rollup-plugin-container';
610
import { promises as fs } from 'fs';
@@ -11,29 +15,54 @@ import type {
1115
} from '@ampproject/remapping/dist/types/types';
1216
import MagicString from 'magic-string';
1317
import { jsExts } from '../extensions-and-detection';
14-
import * as esbuild from 'esbuild';
15-
import { createCodeFrame } from 'simple-code-frame';
16-
import * as colors from 'kolorist';
17-
import { Console } from 'console';
18+
import { rejectBuild } from '../build-status-tracker';
19+
import { ErrorWithLocation } from '../error-with-location';
1820

1921
interface JSMiddlewareOpts {
2022
root: string;
2123
plugins: Plugin[];
2224
requestCache: Map<string, SourceDescription>;
2325
}
2426

27+
const getResolveCacheKey = (spec: string, from: string) =>
28+
`${spec}%%FROM%%${from}`;
29+
2530
// Minimal version of https://github.com/preactjs/wmr/blob/main/packages/wmr/src/wmr-middleware.js
2631

2732
export const jsMiddleware = ({
2833
root,
2934
plugins,
3035
requestCache,
3136
}: JSMiddlewareOpts): polka.Middleware => {
37+
interface ResolveCacheEntry {
38+
buildId: number;
39+
resolved: PartialResolvedId;
40+
}
41+
/**
42+
* The resolve cache is used so that if something has already been resolved from a previous build,
43+
* the buildId from the previous build gets used rather than the current buildId.
44+
* That way, modules can get correctly deduped in the browser,
45+
* and syntax/transform errors will get thrown from the _first_ runJS/loadJS that they were imported from.
46+
*/
47+
const resolveCache = new Map<string, ResolveCacheEntry>();
48+
49+
const setInResolveCache = (
50+
spec: string,
51+
from: string,
52+
buildId: number,
53+
resolved: PartialResolvedId,
54+
) => resolveCache.set(getResolveCacheKey(spec, from), { buildId, resolved });
55+
56+
const getFromResolveCache = (spec: string, from: string) =>
57+
resolveCache.get(getResolveCacheKey(spec, from));
58+
3259
const rollupPlugins = createPluginContainer(plugins);
3360

3461
rollupPlugins.buildStart();
3562

3663
return async (req, res, next) => {
64+
const buildId =
65+
req.query['build-id'] !== undefined && Number(req.query['build-id']);
3766
try {
3867
// Normalized path starting with slash
3968
const path = posix.normalize(req.path);
@@ -53,6 +82,7 @@ export const jsMiddleware = ({
5382
const params = new URLSearchParams(req.query as Record<string, string>);
5483
params.delete('import');
5584
params.delete('inline-code');
85+
params.delete('build-id');
5686

5787
// Remove trailing =
5888
// This is necessary for rollup-plugin-vue, which ads ?lang.ts at the end of the id,
@@ -111,14 +141,37 @@ export const jsMiddleware = ({
111141
// Resolve all the imports and replace them, and inline the resulting resolved paths
112142
// This makes different ways of importing the same path (e.g. extensionless imports, etc.)
113143
// all dedupe to the same module so it is only executed once
114-
code = await transformImports(code, id, {
144+
code = await transformImports(code, id, map, {
115145
async resolveId(spec) {
146+
const addBuildId = (specifier: string) => {
147+
const delimiter = /\?/.test(specifier) ? '&' : '?';
148+
return `${specifier}${delimiter}build-id=${localBuildId}`;
149+
};
150+
151+
// Default to the buildId corresponding to this module
152+
// But for any module which has previously been imported from another buildId,
153+
// Use the previous buildId (for module deduplication in the browser)
154+
let localBuildId = buildId;
116155
if (/^(data:|https?:|\/\/)/.test(spec)) return spec;
117156

118-
const resolved = await rollupPlugins.resolveId(spec, file);
157+
const cached = getFromResolveCache(spec, file);
158+
let resolved: ResolveIdResult;
159+
if (cached) {
160+
resolved = cached.resolved;
161+
localBuildId = cached.buildId;
162+
} else {
163+
resolved = await rollupPlugins.resolveId(spec, file);
164+
if (resolved && buildId)
165+
setInResolveCache(
166+
spec,
167+
file,
168+
buildId,
169+
typeof resolved === 'object' ? resolved : { id: resolved },
170+
);
171+
}
119172
if (resolved) {
120173
spec = typeof resolved === 'object' ? resolved.id : resolved;
121-
if (spec.startsWith('@npm/')) return `/${spec}`;
174+
if (spec.startsWith('@npm/')) return addBuildId(`/${spec}`);
122175
if (/^(\/|\\|[a-z]:\\)/i.test(spec)) {
123176
// Change FS-absolute paths to relative
124177
spec = relative(dirname(file), spec).split(sep).join(posix.sep);
@@ -130,20 +183,20 @@ export const jsMiddleware = ({
130183

131184
spec = relative(root, spec).split(sep).join(posix.sep);
132185
if (!/^(\/|[\w-]+:)/.test(spec)) spec = `/${spec}`;
133-
return spec;
186+
return addBuildId(spec);
134187
}
135188
}
136189

137-
// If it wasn't resovled, and doesn't have a js-like extension
138-
// add the ?import query param so it is clear
190+
// If it wasn't resolved, and doesn't have a js-like extension
191+
// add the ?import query param to make it clear
139192
// that the request needs to end up as JS that can be imported
140193
if (!jsExts.test(spec)) {
141194
// If there is already a query parameter, add &import
142195
const delimiter = /\?/.test(spec) ? '&' : '?';
143-
return `${spec}${delimiter}import`;
196+
return addBuildId(`${spec}${delimiter}import`);
144197
}
145198

146-
return spec;
199+
return addBuildId(spec);
147200
},
148201
});
149202

@@ -153,29 +206,18 @@ export const jsMiddleware = ({
153206
'Content-Length': Buffer.byteLength(code, 'utf-8'),
154207
});
155208
res.end(code);
156-
157-
// Start a esbuild build (just for the sake of parsing)
158-
// That way, if there is a parsing error in the code resulting from the rollup transforms,
159-
// we can display an error/code frame in the console
160-
// instead of just a generic message from the browser saying it couldn't parse
161-
// We are *not awaiting* this because we don't want to slow down sending the HTTP response
162-
esbuild.transform(code, { loader: 'js' }).catch((error) => {
163-
const err = error.errors[0];
164-
const { line, column } = err.location;
165-
const frame = createCodeFrame(code as string, line - 1, column);
166-
const message = `${colors.red(colors.bold(err.text))}
167-
168-
${colors.red(`${id}:${line}:${(column as number) + 1}`)}
169-
170-
${frame}
171-
`;
172-
173-
// Create a new console instance instead of using the global one
174-
// Because the global one is overridden by Jest, and it adds a misleading second stack trace and code frame below it
175-
const console = new Console(process.stdout, process.stderr);
176-
console.error(message);
177-
});
178209
} catch (error) {
210+
if (buildId) {
211+
rejectBuild(
212+
Number(buildId),
213+
error instanceof ErrorWithLocation
214+
? await error.toCodeFrame().catch(() => error)
215+
: error,
216+
);
217+
218+
res.statusCode = 500;
219+
return res.end();
220+
}
179221
next(error);
180222
}
181223
};

0 commit comments

Comments
 (0)