Skip to content

Commit 19794bf

Browse files
authored
feat(wrangler): improve script source display on pretty error screen (#9971)
* feat(wrangler): improve script source display on pretty error screen * avoid relying on fs in the patch with style refined * drop native module script source support
1 parent 154acf7 commit 19794bf

File tree

12 files changed

+171
-35
lines changed

12 files changed

+171
-35
lines changed

.changeset/good-friends-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Improved script source display on the pretty error screen

.changeset/neat-comics-guess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
feat: add stripDisablePrettyError option to control whether the header is stripped

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@
8484
8585
8686
"postal-mime": "patches/postal-mime.patch",
87-
"@changesets/assemble-release-plan": "patches/@changesets__assemble-release-plan.patch"
87+
"@changesets/assemble-release-plan": "patches/@changesets__assemble-release-plan.patch",
88+
8889
}
8990
}
9091
}

packages/miniflare/src/plugins/core/errors/index.ts

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
SourceOptions,
1212
} from "../modules";
1313
import { getSourceMapper } from "./sourcemap";
14-
import type { UrlAndMap } from "@cspotcode/source-map-support";
14+
import type { RawSourceMap, UrlAndMap } from "@cspotcode/source-map-support";
1515

1616
// Subset of core worker options that define Worker source code.
1717
// These are the possible cases, and corresponding reported source files in
@@ -139,7 +139,11 @@ function getHeaders(request: Request) {
139139

140140
function getSourceMappedStack(
141141
workerSrcOpts: NameSourceOptions[],
142-
error: Error
142+
error: Error,
143+
onSourceMap?: (
144+
sourcemap: RawSourceMap,
145+
sourceMapPath: string
146+
) => RawSourceMap | void
143147
) {
144148
// This function needs to match the signature of the `retrieveSourceMap`
145149
// option from the "source-map-support" package.
@@ -160,6 +164,22 @@ function getSourceMappedStack(
160164
const sourceMapFile = maybeGetDiskFile(sourceMapPath);
161165
if (sourceMapFile === undefined) return null;
162166

167+
if (onSourceMap) {
168+
try {
169+
const sourcemap = JSON.parse(sourceMapFile.contents);
170+
const result = onSourceMap(sourcemap, sourceMapFile.path);
171+
172+
if (result !== undefined) {
173+
return {
174+
map: JSON.stringify(result),
175+
url: sourceMapFile.path,
176+
};
177+
}
178+
} catch {
179+
return null;
180+
}
181+
}
182+
163183
return { map: sourceMapFile.contents, url: sourceMapFile.path };
164184
}
165185

@@ -201,14 +221,18 @@ const ALLOWED_ERROR_SUBCLASS_CONSTRUCTORS: StandardErrorConstructor[] = [
201221
];
202222
export function reviveError(
203223
workerSrcOpts: NameSourceOptions[],
204-
jsonError: JsonError
224+
jsonError: JsonError,
225+
onSourceMap?: (
226+
sourcemap: RawSourceMap,
227+
sourceMapPath: string
228+
) => RawSourceMap | void
205229
): Error {
206230
// At a high level, this function takes a JSON-serialisable representation of
207231
// an `Error`, and converts it to an `Error`. `Error`s may have `cause`s, so
208232
// we need to do this recursively.
209233
let cause: Error | undefined;
210234
if (jsonError.cause !== undefined) {
211-
cause = reviveError(workerSrcOpts, jsonError.cause);
235+
cause = reviveError(workerSrcOpts, jsonError.cause, onSourceMap);
212236
}
213237

214238
// If this is one of the built-in error types, construct an instance of that.
@@ -236,7 +260,7 @@ export function reviveError(
236260

237261
// Try to apply source-mapping to the stack trace
238262
error.stack = processStackTrace(
239-
getSourceMappedStack(workerSrcOpts, error),
263+
getSourceMappedStack(workerSrcOpts, error, onSourceMap),
240264
(line, location) =>
241265
!location.includes(".wrangler/tmp") &&
242266
!location.includes("wrangler/templates/middleware")
@@ -255,11 +279,28 @@ export async function handlePrettyErrorRequest(
255279
// Parse and validate the error we've been given from user code
256280
const caught = JsonErrorSchema.parse(await request.json());
257281

258-
// Convert the error into a regular `Error` object and try to source-map it.
259-
// We need to give `name`, `message` and `stack` to Youch, but StackTracy,
260-
// Youch's dependency for parsing `stack`s, will only extract `stack` from
261-
// an object if it's an `instanceof Error`.
262-
const error = reviveError(workerSrcOpts, caught);
282+
// We might populate the source in Youch with the sources content from the sourcemap
283+
// in case the source file does not exist on disk
284+
const sourcesContentMap = new Map<string, string>();
285+
const error = reviveError(
286+
workerSrcOpts,
287+
caught,
288+
(sourceMap, sourceMapPath) => {
289+
for (let i = 0; i < sourceMap.sources.length; i++) {
290+
const source = sourceMap.sources[i];
291+
const sourceContent = sourceMap.sourcesContent?.[i];
292+
293+
if (sourceContent) {
294+
const fullSourcePath = path.resolve(
295+
path.dirname(sourceMapPath),
296+
sourceMap.sourceRoot ?? "",
297+
source
298+
);
299+
sourcesContentMap.set(fullSourcePath, sourceContent);
300+
}
301+
}
302+
}
303+
);
263304

264305
// Log source-mapped error to console if logging enabled
265306
log.error(error);
@@ -281,10 +322,53 @@ export async function handlePrettyErrorRequest(
281322
// Lazily import `youch` when required
282323
// eslint-disable-next-line @typescript-eslint/no-require-imports
283324
const { Youch }: typeof import("youch") = require("youch");
284-
// `cause` is usually more useful than the error itself, display that instead
285-
// TODO(someday): would be nice if we could display both
286325
const youch = new Youch();
287326

327+
youch.defineSourceLoader(async (stackFrame) => {
328+
if (!stackFrame.fileName) {
329+
return;
330+
}
331+
332+
if (
333+
stackFrame.type === "native" ||
334+
stackFrame.fileName.startsWith("cloudflare:") ||
335+
stackFrame.fileName.startsWith("node:")
336+
) {
337+
stackFrame.type = "native";
338+
return;
339+
}
340+
341+
if (!fs.existsSync(stackFrame.fileName)) {
342+
// Mark the file type as undefined to skip the link to the source file
343+
stackFrame.fileType = undefined;
344+
}
345+
346+
// Check if it is an inline script, which are reported as "script-<workerIndex>"
347+
const workerIndex = maybeGetStringScriptPathIndex(stackFrame.fileName);
348+
if (workerIndex !== undefined) {
349+
const srcOpts = workerSrcOpts[workerIndex];
350+
if ("script" in srcOpts && srcOpts.script !== undefined) {
351+
return { contents: srcOpts.script };
352+
}
353+
}
354+
355+
// Check if the source map has the source content for this file so we don't need to read it from disk
356+
const sourcesContent = sourcesContentMap.get(stackFrame.fileName);
357+
if (sourcesContent) {
358+
return { contents: sourcesContent };
359+
}
360+
361+
try {
362+
const contents = fs.readFileSync(stackFrame.fileName, {
363+
encoding: "utf8",
364+
});
365+
366+
return { contents };
367+
} catch {
368+
// If we can't read the file, just return undefined
369+
return;
370+
}
371+
});
288372
youch.useTransformer((error) => {
289373
error.hint = [
290374
'<a href="https://developers.cloudflare.com/workers/" target="_blank" style="text-decoration:none;font-style:normal;padding:5px">📚 Workers Docs</a>',

packages/miniflare/src/plugins/core/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ export const CoreSharedOptionsSchema = z.object({
245245
// Path to the root directory for persisting data
246246
// Used as the default for all plugins with the plugin name as the subdirectory name
247247
defaultPersistRoot: z.string().optional(),
248+
// Strip the MF-DISABLE_PRETTY_ERROR header from user request
249+
stripDisablePrettyError: z.boolean().default(true),
248250
});
249251

250252
export const CORE_PLUGIN_NAME = "core";
@@ -917,6 +919,10 @@ export function getGlobalServices({
917919
name: CoreBindings.DATA_PROXY_SECRET,
918920
data: PROXY_SECRET,
919921
},
922+
{
923+
name: CoreBindings.STRIP_DISABLE_PRETTY_ERROR,
924+
json: JSON.stringify(sharedOptions.stripDisablePrettyError),
925+
},
920926
// Add `proxyBindings` here, they'll be added to the `ProxyServer` `env`
921927
...proxyBindings,
922928
];

packages/miniflare/src/workers/core/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const CoreBindings = {
3434
DATA_PROXY_SHARED_SECRET: "MINIFLARE_PROXY_SHARED_SECRET",
3535
TRIGGER_HANDLERS: "TRIGGER_HANDLERS",
3636
LOG_REQUESTS: "LOG_REQUESTS",
37+
STRIP_DISABLE_PRETTY_ERROR: "STRIP_DISABLE_PRETTY_ERROR",
3738
} as const;
3839

3940
export const ProxyOps = {

packages/miniflare/src/workers/core/entry.worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type Env = {
2929
[CoreBindings.DATA_PROXY_SHARED_SECRET]?: ArrayBuffer;
3030
[CoreBindings.TRIGGER_HANDLERS]: boolean;
3131
[CoreBindings.LOG_REQUESTS]: boolean;
32+
[CoreBindings.STRIP_DISABLE_PRETTY_ERROR]: boolean;
3233
} & {
3334
[K in `${typeof CoreBindings.SERVICE_USER_ROUTE_PREFIX}${string}`]:
3435
| Fetcher
@@ -124,7 +125,9 @@ function getUserRequest(
124125

125126
request.headers.delete(CoreHeaders.PROXY_SHARED_SECRET);
126127
request.headers.delete(CoreHeaders.ORIGINAL_URL);
127-
request.headers.delete(CoreHeaders.DISABLE_PRETTY_ERROR);
128+
if (env[CoreBindings.STRIP_DISABLE_PRETTY_ERROR]) {
129+
request.headers.delete(CoreHeaders.DISABLE_PRETTY_ERROR);
130+
}
128131
return request;
129132
}
130133

packages/miniflare/test/plugins/core/errors/index.spec.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -305,20 +305,31 @@ test("responds with pretty error page", async (t) => {
305305
log,
306306
modules: true,
307307
script: `
308-
function doSomething() {
308+
import { connect } from "cloudflare:sockets";
309+
310+
// A function to test error thrown by native code
311+
async function connectSocket(request) {
309312
try {
310-
oops();
313+
// The following line will throw an error because the port is invalid
314+
const socket = connect({ hostname: "gopher.floodgap.com", port: "invalid" });
315+
316+
const writer = socket.writable.getWriter();
317+
const url = new URL(request.url);
318+
const encoder = new TextEncoder();
319+
const encoded = encoder.encode(url.pathname + "\\r\\n");
320+
await writer.write(encoded);
321+
await writer.close();
322+
323+
return new Response(socket.readable, {
324+
headers: { "Content-Type": "text/plain" },
325+
});
311326
} catch (e) {
312-
throw new Error("Unusual oops!", {
327+
throw new Error("Unusual oops!", {
313328
cause: e,
314-
})
329+
});
315330
}
316331
}
317332
318-
function oops() {
319-
throw new Error("Test error");
320-
}
321-
322333
// This emulates the reduceError function in the Wrangler middleware template
323334
// See packages/wrangler/templates/middleware/middleware-miniflare3-json-error.ts
324335
function reduceError(e) {
@@ -331,17 +342,17 @@ test("responds with pretty error page", async (t) => {
331342
}
332343
333344
export default {
334-
async fetch() {
345+
async fetch(request) {
335346
try {
336-
doSomething();
347+
return await connectSocket(request);
337348
} catch (e) {
338349
const error = reduceError(e);
339350
return Response.json(error, {
340351
status: 500,
341352
headers: { "MF-Experimental-Error-Stack": "true" },
342353
});
343354
}
344-
}
355+
},
345356
}`,
346357
});
347358
t.teardown(() => mf.dispose());
@@ -360,17 +371,22 @@ test("responds with pretty error page", async (t) => {
360371
t.regex(text, /Method.+POST/is);
361372
t.regex(text, /URL.+some-unusual-path/is);
362373
t.regex(text, /X-Unusual-Key.+some-unusual-value/is);
374+
// Check if the stack trace is included
375+
t.regex(text, /cloudflare\:sockets/);
376+
t.regex(text, /connectSocket/);
377+
t.regex(text, /connect/);
378+
t.regex(text, /Object\.fetch/);
363379

364380
// Check error logged
365381
const errorLogs = log.getLogs(LogLevel.ERROR);
366382
t.deepEqual(errorLogs, [
367383
`Error: Unusual oops!
368-
at doSomething (script-0:6:12)
369-
at Object.fetch (script-0:30:6)
370-
Caused by: Error: Test error
371-
at oops (script-0:13:11)
372-
at doSomething (script-0:4:5)
373-
at Object.fetch (script-0:30:6)`,
384+
at connectSocket (script-0:21:11)
385+
at Object.fetch (script-0:41:19)
386+
Caused by: TypeError: The value cannot be converted because it is not an integer.
387+
at connect (cloudflare:sockets:7:20)
388+
at connectSocket (script-0:8:20)
389+
at Object.fetch (script-0:41:19)`,
374390
]);
375391

376392
// Check `fetch()` accepting HTML returns pretty-error page

packages/wrangler/src/api/startDevWorker/ProxyController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class ProxyController extends Controller<ProxyControllerEventMap> {
8686
https: this.latestConfig.dev?.server?.secure,
8787
httpsCert: cert?.cert,
8888
httpsKey: cert?.key,
89-
89+
stripDisablePrettyError: false,
9090
workers: [
9191
{
9292
name: "ProxyWorker",

packages/wrangler/templates/startDevWorker/ProxyWorker.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ export class ProxyWorker implements DurableObject {
129129
request.url
130130
);
131131
headers.set("MF-Original-URL", innerUrl.href);
132-
headers.set("MF-Disable-Pretty-Error", "true"); // disables the UserWorker miniflare instance from rendering the pretty error -- instead the ProxyWorker miniflare instance will intercept the json error response and render the pretty error page
133132

134133
// Preserve client `Accept-Encoding`, rather than using Worker's default
135134
// of `Accept-Encoding: br, gzip`

0 commit comments

Comments
 (0)