diff --git a/documentation/docs/experimental/mapAndLogError.mdx b/documentation/docs/experimental/mapAndLogError.mdx new file mode 100644 index 0000000000..b9eff0b5ec --- /dev/null +++ b/documentation/docs/experimental/mapAndLogError.mdx @@ -0,0 +1,60 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- +# mapAndLogError + +The **`mapAndLogError()`** function calls `mapError` on an `Error` object and sends the output to standard error. This includes the error name, message, and a call stack. + +If `--enable-stack-traces` is specified during the build, the call stack will be mapped using source maps. + +If `--enable-stack-traces` is specified and `--exclude-sources` is not specified during the build, then this will also include a code dump of neighboring lines of user code. + +## Syntax + +```js +mapAndLogError(error) +``` + +### Parameters + +- `error` _: Error _ or _string_ + - The error to retrieve information about. If a `string` is provided, then it is first converted to an `Error`. + +### Return value + +`undefined`. + +## Examples + +In this example, build the application using the `--enable-stack-traces` flag. + +```js +addEventListener('fetch', e => e.respondWith(handler(e))); +async function handler(event) { + try { + throw new TypeError('foo'); + } catch (err) { + mapAndLogError(mapError(err)); + } + return new Response('ok'); +} +``` + +The following is output to the error log. + +``` +TypeError: foo + at handler (src/index.ts:4:11) + 1 | addEventListener('fetch', e => e.respondWith(handler(e))); + 2 | async function handler(event) { + 3 | try { +> 4 | throw new TypeError('foo'); + ^ + 5 | } catch (err) { + 6 | mapAndLogError(mapError(err)); + 7 | } + at src/index.ts:1:45 +``` diff --git a/documentation/docs/experimental/mapError.mdx b/documentation/docs/experimental/mapError.mdx new file mode 100644 index 0000000000..498aa31c24 --- /dev/null +++ b/documentation/docs/experimental/mapError.mdx @@ -0,0 +1,60 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- +# mapError + +The **`mapError()`** function extracts information from an `Error` object as a human-readable array of strings. This includes the error name, message, and a call stack. + +If `--enable-stack-traces` is specified during the build, the call stack will be mapped using source maps. + +If `--enable-stack-traces` is specified and `--exclude-sources` is not specified during the build, then this will also include a code dump of neighboring lines of user code. + +## Syntax + +```js +mapError(error) +``` + +### Parameters + +- `error` _: Error _ or _string_ + - The error to retrieve information about. If a `string` is provided, then it is first converted to an `Error`. + +### Return value + +Returns an array of `string`s. + +## Examples + +In this example, build the application using the `--enable-stack-traces` flag. + +```js +addEventListener('fetch', e => e.respondWith(handler(e))); +async function handler(event) { + try { + throw new TypeError('foo'); + } catch (err) { + console.error(mapError(err)); + } + return new Response('ok'); +} +``` + +The following is output to the error log. + +``` +TypeError: foo + at handler (src/index.ts:4:11) + 1 | addEventListener('fetch', e => e.respondWith(handler(e))); + 2 | async function handler(event) { + 3 | try { +> 4 | throw new TypeError('foo'); + ^ + 5 | } catch (err) { + 6 | console.error(mapError(err)); + 7 | } + at src/index.ts:1:45 +``` diff --git a/js-compute-runtime-cli.js b/js-compute-runtime-cli.js index 76037415cb..c05f48b040 100755 --- a/js-compute-runtime-cli.js +++ b/js-compute-runtime-cli.js @@ -12,6 +12,9 @@ const { enableExperimentalHighResolutionTimeMethods, moduleMode, bundle, + enableStackTraces, + excludeSources, + debugIntermediateFilesDir, wasmEngine, input, output, @@ -34,7 +37,7 @@ if (version) { const { compileApplicationToWasm } = await import( './src/compileApplicationToWasm.js' ); - await compileApplicationToWasm( + await compileApplicationToWasm({ input, output, wasmEngine, @@ -42,9 +45,12 @@ if (version) { enableExperimentalHighResolutionTimeMethods, enableAOT, aotCache, + enableStackTraces, + excludeSources, + debugIntermediateFilesDir, moduleMode, - bundle, + doBundle: bundle, env, - ); + }); await addSdkMetadataField(output, enableAOT); } diff --git a/package-lock.json b/package-lock.json index 0aed4cdd04..2ee256a087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,13 @@ "@bytecodealliance/jco": "^1.7.0", "@bytecodealliance/weval": "^0.3.2", "@bytecodealliance/wizer": "^7.0.5", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/trace-mapping": "^0.3.31", "acorn": "^8.13.0", "acorn-walk": "^8.3.4", "esbuild": "^0.25.0", "magic-string": "^0.30.12", + "picomatch": "^4.0.3", "regexpu-core": "^6.1.1" }, "bin": { @@ -968,6 +971,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1003,9 +1016,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4449,10 +4462,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 4b63e39ddf..78ef9070b8 100644 --- a/package.json +++ b/package.json @@ -57,12 +57,15 @@ }, "dependencies": { "@bytecodealliance/jco": "^1.7.0", - "@bytecodealliance/wizer": "^7.0.5", "@bytecodealliance/weval": "^0.3.2", + "@bytecodealliance/wizer": "^7.0.5", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/trace-mapping": "^0.3.31", "acorn": "^8.13.0", "acorn-walk": "^8.3.4", "esbuild": "^0.25.0", "magic-string": "^0.30.12", + "picomatch": "^4.0.3", "regexpu-core": "^6.1.1" }, "peerDependencies": { diff --git a/src/bundle.js b/src/bundle.js index 3b9ee4bd09..a28d9bdbec 100644 --- a/src/bundle.js +++ b/src/bundle.js @@ -1,6 +1,14 @@ +import { rename } from 'node:fs/promises'; +import { dirname, basename, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; -let fastlyPlugin = { +import { swallowTopLevelExportsPlugin } from './swallowTopLevelExportsPlugin.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const fastlyPlugin = { name: 'fastly', setup(build) { build.onResolve({ filter: /^fastly:.*/ }, (args) => { @@ -74,6 +82,8 @@ export const setBaseURL = Object.getOwnPropertyDescriptor(globalThis.fastly, 'ba export const setDefaultBackend = Object.getOwnPropertyDescriptor(globalThis.fastly, 'defaultBackend').set; export const allowDynamicBackends = Object.getOwnPropertyDescriptor(globalThis.fastly, 'allowDynamicBackends').set; export const sdkVersion = globalThis.fastly.sdkVersion; +export const mapAndLogError = (e) => globalThis.__fastlyMapAndLogError(e); +export const mapError = (e) => globalThis.__fastlyMapError(e); `, }; } @@ -143,14 +153,40 @@ export const TransactionCacheEntry = globalThis.TransactionCacheEntry; }, }; -export async function bundle(input, moduleMode = false) { - return await build({ +export async function bundle( + input, + outfile, + { moduleMode = false, enableStackTraces = false }, +) { + // Build output file in cwd first to build sourcemap with correct paths + const bundle = resolve(basename(outfile)); + + const plugins = [fastlyPlugin]; + if (moduleMode) { + plugins.push(swallowTopLevelExportsPlugin({ entry: input })); + } + + const inject = []; + if (enableStackTraces) { + inject.push(resolve(__dirname, './rsrc/trace-mapping.inject.js')); + } + + await build({ conditions: ['fastly'], entryPoints: [input], bundle: true, - write: false, + write: true, + outfile: bundle, + sourcemap: 'external', + sourcesContent: true, format: moduleMode ? 'esm' : 'iife', tsconfig: undefined, - plugins: [fastlyPlugin], + plugins, + inject, }); + + await rename(bundle, outfile); + if (enableStackTraces) { + await rename(bundle + '.map', outfile + '.map'); + } } diff --git a/src/compileApplicationToWasm.js b/src/compileApplicationToWasm.js index 356e7d0cab..ea7e77a45d 100644 --- a/src/compileApplicationToWasm.js +++ b/src/compileApplicationToWasm.js @@ -1,14 +1,22 @@ import { dirname, resolve, sep, normalize } from 'node:path'; import { tmpdir, freemem } from 'node:os'; import { spawnSync } from 'node:child_process'; -import { mkdir, readFile, mkdtemp, writeFile } from 'node:fs/promises'; +import { + mkdir, + readFile, + mkdtemp, + writeFile, + copyFile, +} from 'node:fs/promises'; import { rmSync } from 'node:fs'; -import { isFile } from './isFile.js'; -import { isFileOrDoesNotExist } from './isFileOrDoesNotExist.js'; import wizer from '@bytecodealliance/wizer'; import weval from '@bytecodealliance/weval'; -import { precompile } from './precompile.js'; + +import { isFile } from './isFile.js'; +import { isFileOrDoesNotExist } from './isFileOrDoesNotExist.js'; +import { postbundle } from './postbundle.js'; import { bundle } from './bundle.js'; +import { composeSourcemaps } from './composeSourcemaps.js'; const maybeWindowsPath = process.platform === 'win32' @@ -21,7 +29,7 @@ async function getTmpDir() { return await mkdtemp(normalize(tmpdir() + sep)); } -export async function compileApplicationToWasm( +export async function compileApplicationToWasm({ input, output, wasmEngine, @@ -29,10 +37,13 @@ export async function compileApplicationToWasm( enableExperimentalHighResolutionTimeMethods = false, enableAOT = false, aotCache = '', + enableStackTraces, + excludeSources, + debugIntermediateFilesDir, moduleMode = false, doBundle = false, env, -) { +}) { try { if (!(await isFile(input))) { console.error( @@ -40,7 +51,7 @@ export async function compileApplicationToWasm( ); process.exit(1); } - } catch (error) { + } catch { console.error( `Error: The \`input\` path points to a non-existent file: ${input}`, ); @@ -64,7 +75,7 @@ export async function compileApplicationToWasm( ); process.exit(1); } - } catch (error) { + } catch { console.error( `Error: The \`wasmEngine\` path points to a non-existent file: ${wasmEngine}`, ); @@ -97,28 +108,137 @@ export async function compileApplicationToWasm( process.exit(1); } + if (debugIntermediateFilesDir != null) { + try { + console.log( + `Preparing \`debug-intermediate-files\` directory: ${debugIntermediateFilesDir}`, + ); + await mkdir(debugIntermediateFilesDir, { + recursive: true, + }); + } catch (error) { + console.error( + `Error: Failed to create the \`debug-intermediate-files\` (${debugIntermediateFilesDir}) directory`, + error.message, + ); + process.exit(1); + } + } + let tmpDir; if (doBundle) { - let contents; + tmpDir = await getTmpDir(); + + const sourceMaps = []; + + const bundleFilename = '__input_bundled.js'; + const bundleOutputFilePath = resolve(tmpDir, bundleFilename); + + // esbuild respects input source map, works if it's linked via sourceMappingURL + // either inline or as separate file try { - contents = await bundle(input, moduleMode); + await bundle(input, bundleOutputFilePath, { + moduleMode, + enableStackTraces, + }); } catch (error) { console.error(`Error:`, error.message); process.exit(1); } - const precompiled = precompile( - contents.outputFiles[0].text, - undefined, + if (debugIntermediateFilesDir != null) { + await copyFile( + bundleOutputFilePath, + resolve(debugIntermediateFilesDir, '__1_bundled.js'), + ); + if (enableStackTraces) { + await copyFile( + bundleOutputFilePath + '.map', + resolve(debugIntermediateFilesDir, '__1_bundled.js.map'), + ); + } + } + if (enableStackTraces) { + sourceMaps.push({ f: bundleFilename, s: bundleOutputFilePath + '.map' }); + } + + const postbundleFilename = '__fastly_post_bundle.js'; + const postbundleOutputFilepath = resolve(tmpDir, postbundleFilename); + + await postbundle(bundleOutputFilePath, postbundleOutputFilepath, { moduleMode, - ); + enableStackTraces, + }); - tmpDir = await getTmpDir(); - const outPath = resolve(tmpDir, 'input.js'); - await writeFile(outPath, precompiled); + if (debugIntermediateFilesDir != null) { + await copyFile( + postbundleOutputFilepath, + resolve(debugIntermediateFilesDir, '__2_postbundled.js'), + ); + if (enableStackTraces) { + await copyFile( + postbundleOutputFilepath + '.map', + resolve(debugIntermediateFilesDir, '__2_postbundled.js.map'), + ); + } + } + if (enableStackTraces) { + sourceMaps.push({ + f: postbundleFilename, + s: postbundleOutputFilepath + '.map', + }); + } - // the bundled output is now the Wizer input - input = outPath; + if (enableStackTraces) { + // Compose source maps + const replaceSourceMapToken = '__FINAL_SOURCE_MAP__'; + let excludePatterns = ['forbid-entry:/**', 'node_modules/**']; + if (excludeSources) { + excludePatterns = [() => true]; + } + const composed = await composeSourcemaps(sourceMaps, excludePatterns); + + const outputWithSourcemaps = '__fastly_bundle_with_sourcemaps.js'; + const outputWithSourcemapsFilePath = resolve( + tmpDir, + outputWithSourcemaps, + ); + + const postBundleContent = await readFile(postbundleOutputFilepath, { + encoding: 'utf-8', + }); + const outputWithSourcemapsContent = postBundleContent.replace( + replaceSourceMapToken, + () => JSON.stringify(composed), + ); + await writeFile( + outputWithSourcemapsFilePath, + outputWithSourcemapsContent, + ); + + if (debugIntermediateFilesDir != null) { + await copyFile( + outputWithSourcemapsFilePath, + resolve(debugIntermediateFilesDir, 'fastly_bundle.js'), + ); + await writeFile( + resolve(debugIntermediateFilesDir, 'fastly_sourcemaps.json'), + composed, + ); + } + + // the output with sourcemaps is now the Wizer input + input = outputWithSourcemapsFilePath; + } else { + // the bundled output is now the Wizer input + input = postbundleOutputFilepath; + if (debugIntermediateFilesDir != null) { + await copyFile( + postbundleOutputFilepath, + resolve(debugIntermediateFilesDir, 'fastly_bundle.js'), + ); + } + } } const spawnOpts = { diff --git a/src/composeSourcemaps.js b/src/composeSourcemaps.js new file mode 100644 index 0000000000..ee3f7895df --- /dev/null +++ b/src/composeSourcemaps.js @@ -0,0 +1,48 @@ +import { readFile } from 'node:fs/promises'; +import remapping from '@jridgewell/remapping'; +import { TraceMap } from '@jridgewell/trace-mapping'; +import picomatch from 'picomatch'; + +async function readSourcemap(e) { + const sourceMapJson = await readFile(e.s, { encoding: 'utf-8' }); + return JSON.parse(sourceMapJson); +} + +export async function composeSourcemaps(sourceMaps, excludePatterns = []) { + const top = new TraceMap(await readSourcemap(sourceMaps.pop())); + + const priors = {}; + for (const sourceMap of sourceMaps) { + priors[sourceMap.f] = await readSourcemap(sourceMap); + } + + // Loader: given a source name from mapXZ, return a TraceMap for that source (if any). + const loader = (source) => { + const m = priors[source]; + if (!m) return null; // no earlier map for this source + return new TraceMap(m); + }; + + const raw = JSON.parse(remapping(top, loader, false).toString()); + + return JSON.stringify(stripSourcesContent(raw, excludePatterns)); +} + +function stripSourcesContent(map, excludes) { + const matchers = excludes.map((p) => + typeof p === 'string' ? picomatch(p) : p, + ); + + for (let i = 0; i < map.sources.length; i++) { + const src = map.sources[i]; + + // [Windows] normalize slashes + const normalized = src.replace(/\\/g, '/'); + + if (matchers.some((fn) => fn(normalized))) { + map.sourcesContent[i] = null; + } + } + + return map; +} diff --git a/src/parseInputs.js b/src/parseInputs.js index f5027bf0aa..1f85670c90 100644 --- a/src/parseInputs.js +++ b/src/parseInputs.js @@ -19,6 +19,9 @@ export async function parseInputs(cliInputs) { let input = join(process.cwd(), 'bin/index.js'); let customOutputSet = false; let output = join(process.cwd(), 'bin/main.wasm'); + let enableStackTraces = false; + let excludeSources = false; + let debugIntermediateFilesDir = undefined; let cliInput; const envParser = new EnvParser(); @@ -120,6 +123,23 @@ export async function parseInputs(cliInputs) { } break; } + case '--enable-stack-traces': { + enableStackTraces = true; + break; + } + case '--exclude-sources': { + excludeSources = true; + break; + } + case '--debug-intermediate-files': { + const value = cliInputs.shift(); + if (isAbsolute(value)) { + debugIntermediateFilesDir = value; + } else { + debugIntermediateFilesDir = join(process.cwd(), value); + } + break; + } default: { if (cliInput.startsWith('--engine-wasm=')) { if (customEngineSet) { @@ -173,6 +193,9 @@ export async function parseInputs(cliInputs) { bundle, enableAOT, aotCache, + enableStackTraces, + excludeSources, + debugIntermediateFilesDir, input, output, wasmEngine, diff --git a/src/postbundle.js b/src/postbundle.js new file mode 100644 index 0000000000..86521129e7 --- /dev/null +++ b/src/postbundle.js @@ -0,0 +1,126 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import regexpuc from 'regexpu-core'; +import { parse } from 'acorn'; +import MagicString from 'magic-string'; +import { simple as simpleWalk } from 'acorn-walk'; + +export async function postbundle( + input, + outfile, + { moduleMode = false, enableStackTraces = false }, +) { + const source = await readFile(input, { encoding: 'utf8' }); + const magicString = new MagicString(source); + + // PRECOMPILE REGEXES + // Emit a block of javascript that will pre-compile the regular expressions given. As spidermonkey + // will intern regular expressions, duplicating them at the top level and testing them with both + // an ascii and utf8 string should ensure that they won't be re-compiled when run in the fetch + // handler. + const PREAMBLE = `(function(){ + // Precompiled regular expressions + const precompile = (r) => { r.exec('a'); r.exec('\\u1000'); };`; + const POSTAMBLE = '})();'; + + const ast = parse(source, { + ecmaVersion: 'latest', + sourceType: moduleMode ? 'module' : 'script', + }); + + const precompileCalls = []; + simpleWalk(ast, { + Literal(node) { + if (!node.regex) return; + let transpiledPattern; + try { + transpiledPattern = regexpuc(node.regex.pattern, node.regex.flags, { + unicodePropertyEscapes: 'transform', + }); + } catch { + // swallow regex parse errors here to instead throw them at the engine level + // this then also avoids regex parser bugs being thrown unnecessarily + transpiledPattern = node.regex.pattern; + } + const transpiledRegex = `/${transpiledPattern}/${node.regex.flags}`; + precompileCalls.push(`precompile(${transpiledRegex});`); + magicString.overwrite(node.start, node.end, transpiledRegex); + }, + }); + + if (precompileCalls.length) { + magicString.prepend(`${PREAMBLE}${precompileCalls.join('')}${POSTAMBLE}\n`); + } + + if (enableStackTraces) { + // INSERT init guard + let initGuardPre, initGuardPost; + if (moduleMode) { + initGuardPre = `\ +await(async function __fastly_init_guard__() { + `; + initGuardPost = `\ +})().catch(e => { + console.error('Unhandled error while running top level module code'); + try { globalThis.__fastlyMapAndLogError(e); } catch { /* swallow */ } + console.error('Raw error below:'); + throw e; +}); +`; + } else { + initGuardPre = `\ +(function __fastly_init_guard__() { try { +`; + initGuardPost = `\ +} catch (e) { + console.error('Unhandled error while running top level script'); + try { globalThis.__fastlyMapAndLogError(e); } catch { /* swallow */ } + console.error('Raw error below:'); + throw e; +} +})(); +`; + } + + magicString.prepend(initGuardPre); + magicString.append(initGuardPost); + + // SOURCE MAPPING HEADER + const STACK_MAPPING_HEADER = `\ +globalThis.__FASTLY_SOURCE_MAP = JSON.parse(__FINAL_SOURCE_MAP__); +`; + magicString.prepend(STACK_MAPPING_HEADER); + } + + // MISC HEADER + const SOURCE_FILE_NAME = 'fastly:app.js'; + const STACK_MAPPING_HEADER = `\ +//# sourceURL=${SOURCE_FILE_NAME} +globalThis.__FASTLY_GEN_FILE = "${SOURCE_FILE_NAME}"; +globalThis.__orig_console_error = console.error.bind(console); +globalThis.__fastlyMapAndLogError = (e) => { + for (const line of globalThis.__fastlyMapError(e)) { + globalThis.__orig_console_error(line); + } +}; +globalThis.__fastlyMapError = (e) => { + return [ + '(Raw error) - build with --enable-stack-traces for mapped stack information.', + e, + ]; +}; +`; + magicString.prepend(STACK_MAPPING_HEADER); + + await writeFile(outfile, magicString.toString()); + + if (enableStackTraces) { + const map = magicString.generateMap({ + source: basename(input), + hires: true, + includeContent: true, + }); + + await writeFile(outfile + '.map', map.toString()); + } +} diff --git a/src/precompile.js b/src/precompile.js deleted file mode 100644 index dbf2aed128..0000000000 --- a/src/precompile.js +++ /dev/null @@ -1,57 +0,0 @@ -import regexpuc from 'regexpu-core'; -import { parse } from 'acorn'; -import MagicString from 'magic-string'; -import { simple as simpleWalk } from 'acorn-walk'; - -const PREAMBLE = `;{ - // Precompiled regular expressions - const precompile = (r) => { r.exec('a'); r.exec('\\u1000'); };`; -const POSTAMBLE = '}'; - -/// Emit a block of javascript that will pre-compile the regular expressions given. As spidermonkey -/// will intern regular expressions, duplicating them at the top level and testing them with both -/// an ascii and utf8 string should ensure that they won't be re-compiled when run in the fetch -/// handler. -export function precompile(source, filename = '', moduleMode = false) { - const magicString = new MagicString(source, { - filename, - }); - - const ast = parse(source, { - ecmaVersion: 'latest', - sourceType: moduleMode ? 'module' : 'script', - }); - - const precompileCalls = []; - simpleWalk(ast, { - Literal(node) { - if (!node.regex) return; - let transpiledPattern; - try { - transpiledPattern = regexpuc(node.regex.pattern, node.regex.flags, { - unicodePropertyEscapes: 'transform', - }); - } catch { - // swallow regex parse errors here to instead throw them at the engine level - // this then also avoids regex parser bugs being thrown unnecessarily - transpiledPattern = node.regex.pattern; - } - const transpiledRegex = `/${transpiledPattern}/${node.regex.flags}`; - precompileCalls.push(`precompile(${transpiledRegex});`); - magicString.overwrite(node.start, node.end, transpiledRegex); - }, - }); - - if (!precompileCalls.length) return source; - - magicString.prepend(`${PREAMBLE}${precompileCalls.join('\n')}${POSTAMBLE}`); - - // When we're ready to pipe in source maps: - // const map = magicString.generateMap({ - // source: 'source.js', - // file: 'converted.js.map', - // includeContent: true - // }); - - return magicString.toString(); -} diff --git a/src/printHelp.js b/src/printHelp.js index 565c3b26d0..52bfdf492d 100644 --- a/src/printHelp.js +++ b/src/printHelp.js @@ -25,6 +25,9 @@ OPTIONS: --enable-aot Enable AOT compilation for performance --enable-experimental-high-resolution-time-methods Enable experimental fastly.now() method --enable-experimental-top-level-await Enable experimental top level await + --enable-stack-traces Enable stack traces + --exclude-sources Don't include sources in stack traces + --debug-intermediate-files Output intermediate files in directory ARGS: The input JS script's file path [default: bin/index.js] diff --git a/src/rsrc/trace-mapping.inject.js b/src/rsrc/trace-mapping.inject.js new file mode 100644 index 0000000000..a11e9d6ddc --- /dev/null +++ b/src/rsrc/trace-mapping.inject.js @@ -0,0 +1,200 @@ +import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; + +let _traceMap; +function getTraceMap() { + if (!_traceMap) { + _traceMap = new TraceMap(globalThis.__FASTLY_SOURCE_MAP); + } + return _traceMap; +} + +function buildErrorHeading(e) { + const name = e?.name || 'Error'; + // Prefer e.cause.message if message is missing and cause is informative + const msg = + (typeof e?.message === 'string' && e.message) || + (e?.cause && typeof e.cause?.message === 'string' && e.cause.message) || + ''; + return msg ? `${name}: ${msg}` : name; +} + +function getSourceContext(source, line, col, { radius = 3 } = {}) { + const tm = getTraceMap(); + if (!tm) return null; + + const idx = tm.sources.indexOf(source); + if (idx < 0) return null; + + const content = tm.sourcesContent?.[idx]; + if (!content) return null; // gracefully skip + + const lines = content.split('\n'); + + const start = Math.max(0, line - 1 - radius); + const end = Math.min(lines.length - 1, line - 1 + radius); + + const result = []; + + for (let i = start; i <= end; i++) { + const prefix = i === (line - 1) ? '>' : ' '; + const num = String(i + 1).padStart(5); + result.push(`${prefix} ${num} | ${lines[i]}`); + if (i === line - 1) { + result.push(` ${''.padStart(col)}^`); + } + } + + return result; +} + +function parseStarlingMonkeyFrame(line) { + line = String(line).trim().replace(/\)?$/, ""); // strip trailing ')' + const m = line.match(/:(\d+):(\d+)\s*$/); + if (!m) return null; + + const col = +m[2]; + const lineNo = +m[1]; + const head = line.slice(0, m.index); + if (head.startsWith('@')) { // no function name + return { fn: null, file: head.slice(1), line: lineNo, col, }; + } + const at = head.lastIndexOf("@"); + if (at > 0) { // fn@file + return { fn: head.slice(0, at), file: head.slice(at + 1), line: lineNo, col, }; + } + // file only + return { fn: null, file: head, line: lineNo, col, }; +} + +function mapStack(raw) { + const tm = getTraceMap(); + const out = []; + const lines = String(raw).split(/\r?\n/); + + for (const line of lines) { + if (line.startsWith('node_modules/@fastly/js-compute/src/rsrc/trace-mapping.inject.js')) { + // If the line comes from this file, skip it + continue; + } + if (line === '') { out.push({l: line}); continue; } + + const m = parseStarlingMonkeyFrame(line); + if (!m) { out.push({e:'(frame not parsed)', l:line}); continue; } + + const { fn, file, line: l, col: c } = m; + + const genLine = Number(l); + const genCol = Number(c); + + // Only map frames that come from the generated bundle + if (file !== globalThis.__FASTLY_GEN_FILE) { out.push({e:'(frame not mapped)', l:line}); continue; } + + const pos = originalPositionFor(tm, { line: genLine, column: Math.max(0, genCol - 1) }); + if (!pos?.source) { + continue; + } + + out.push({m:{pos,fn}, l: line}); + } + + return out; +} + +function mapError(e) { + const lines = []; + + const raw = e?.stack ?? String(e); + + lines.push(buildErrorHeading(e)); + try { + const stack = mapStack(raw); + let contextOutputted = false; + for (const line of stack) { + const { e, m, l } = line; + if (l === '') { + lines.push(''); + continue; + } + if (e != null) { + lines.push(`${e} ${l}`); + continue; + } + + let formatted; + let ctx; + if (m == null) { + formatted = l; + } else { + const {pos, fn} = m; + + let name = pos.name; + if (fn == null || fn === '') { + name = null; + } else if (fn[fn.length-1] === '<') { + name = '(anonymous function)'; + } else { + name = fn; + } + + const filePos = `${pos.source}:${pos.line}:${pos.column != null ? pos.column + 1 : 0}`; + formatted = name ? `${name} (${filePos})` : filePos; + if (!contextOutputted) { + ctx = getSourceContext(pos.source, pos.line, pos.column); + contextOutputted = true; + } + } + lines.push(` at ${formatted}`); + if (ctx) { + lines.push(...ctx); + } + } + } catch { + lines.push('(Raw error)'); + lines.push(raw); + } + return lines; +} + +// Monkey patch addEventListener('fetch') +const _orig_addEventListener = globalThis.addEventListener; +globalThis.addEventListener = function (type, listener) { + if (type !== 'fetch') { + return _orig_addEventListener.call(this, type, listener); + } + + const _orig_listener = listener; + return _orig_addEventListener.call(this, type, (event) => { + + // Patch respondWith on this event instance + const _orig_respondWith = event.respondWith.bind(event); + event.respondWith = (value) => { + const wrappedValue = Promise + .resolve(value) + .catch((err) => { + console.error('Unhandled error while running request handler'); + try { globalThis.__fastlyMapAndLogError(err); } catch { /* swallow */ } + console.error('Raw error below:'); + throw err; + }); + try { + return _orig_respondWith(wrappedValue); + } catch (err) { + console.error('Unhandled error sending response'); + try { globalThis.__fastlyMapAndLogError(err); } catch { /* swallow */ } + console.error('Raw error below:'); + throw err; + } + }; + + try { + return _orig_listener.call(this, event); + } catch (err) { + console.error('Unhandled error running event listener'); + try { globalThis.__fastlyMapAndLogError(err); } catch { /* swallow */ } + console.error('Raw error below:'); + throw err; + } + }); +}; + +globalThis.__fastlyMapError = mapError diff --git a/src/swallowTopLevelExportsPlugin.js b/src/swallowTopLevelExportsPlugin.js new file mode 100644 index 0000000000..fbe7a1f541 --- /dev/null +++ b/src/swallowTopLevelExportsPlugin.js @@ -0,0 +1,37 @@ +import { dirname, isAbsolute, resolve } from 'node:path'; + +export function swallowTopLevelExportsPlugin(opts) { + const { entry } = opts; + + const name = 'swallow-top-level-exports'; + const namespace = 'swallow-top-level'; + if (!entry) throw new Error(`[${name}] You must provide opts.entry`); + + // Normalize once so our onResolve comparison is exact. + const normalizedEntry = resolve(entry); + + return { + name, + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + const maybeEntry = isAbsolute(args.path) + ? args.path + : resolve(args.resolveDir || process.cwd(), args.path); + if (args.kind === 'entry-point' && maybeEntry === normalizedEntry) { + return { path: normalizedEntry, namespace }; + } + return null; + }); + build.onLoad({ filter: /.*/, namespace }, (args) => { + // Generate a JS wrapper that imports the real entry + // This swallows the top level exports for the entry file + // and runs any side effects, such as addEventListener(). + return { + contents: `import ${JSON.stringify(args.path)};`, + loader: 'js', + resolveDir: dirname(args.path), + }; + }); + }, + }; +} diff --git a/types/experimental.d.ts b/types/experimental.d.ts index 5d49421199..0329d46e08 100644 --- a/types/experimental.d.ts +++ b/types/experimental.d.ts @@ -67,4 +67,21 @@ declare module 'fastly:experimental' { firstByteTimeout?: number; betweenBytesTimeout?: number; }): void; + + /** + * Get information about an error as a ready-to-print array of strings. + * This includes the error name, message, and a call stack. + * If --enable-stack-traces is specified during the build, the call stack + * will be mapped using source maps. + * If --enable-stack-traces is specified and --exclude-sources is not specified, + * then this will also include a code dump of neighboring lines of user code. + * @param error + */ + export function mapError(error: Error | string): (Error | string)[]; + + /** + * Calls mapError(error) and outputs the results to stderr output. + * @param error + */ + export function mapAndLogError(error: Error | string): void; }