diff --git a/src/compileApplicationToWasm.ts b/src/compileApplicationToWasm.ts index e19d9e3824..a327833d10 100644 --- a/src/compileApplicationToWasm.ts +++ b/src/compileApplicationToWasm.ts @@ -1,24 +1,21 @@ -import { dirname, resolve, sep, normalize } from 'node:path'; +import { dirname, sep, normalize } from 'node:path'; import { tmpdir, freemem } from 'node:os'; import { spawnSync, type SpawnSyncOptionsWithStringEncoding, } from 'node:child_process'; -import { - mkdir, - readFile, - mkdtemp, - writeFile, - copyFile, -} from 'node:fs/promises'; +import { mkdir, readFile, mkdtemp } from 'node:fs/promises'; import { rmSync } from 'node:fs'; import weval from '@bytecodealliance/weval'; import wizer from '@bytecodealliance/wizer'; import { isDirectory, isFile } from './files.js'; -import { postbundle } from './postbundle.js'; -import { bundle } from './bundle.js'; -import { composeSourcemaps, ExcludePattern } from './composeSourcemaps.js'; +import { CompilerContext } from './compilerPipeline.js'; +import { bundleStep } from './compiler-steps/bundle.js'; +import { precompileRegexesStep } from './compiler-steps/precompileRegexes.js'; +import { addStackMappingHelpersStep } from './compiler-steps/addStackMappingHelpers.js'; +import { addFastlyHelpersStep } from './compiler-steps/addFastlyHelpers.js'; +import { composeSourcemapsStep } from './compiler-steps/composeSourcemaps.js'; const maybeWindowsPath = process.platform === 'win32' @@ -155,239 +152,156 @@ export async function compileApplicationToWasm( } } - let tmpDir; - if (doBundle) { - tmpDir = await getTmpDir(); - - const sourceMaps = []; + const tmpDir = await getTmpDir(); - 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 { - await bundle(input, bundleOutputFilePath, { + try { + if (doBundle) { + const ctx = new CompilerContext( + input, + tmpDir, + debugIntermediateFilesDir, moduleMode, enableStackTraces, - }); - } catch (maybeError: unknown) { - const error = - maybeError instanceof Error - ? maybeError - : new Error(String(maybeError)); - console.error(`Error:`, error.message); - process.exit(1); - } - - if (debugIntermediateFilesDir != null) { - await copyFile( - bundleOutputFilePath, - resolve(debugIntermediateFilesDir, '__1_bundled.js'), + excludeSources, ); - 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); + // bundle input -> apply esbuild (bundle package imports, apply Fastly Plugin) + ctx.addCompilerPipelineStep(bundleStep); - await postbundle(bundleOutputFilePath, postbundleOutputFilepath, { - moduleMode, - enableStackTraces, - }); + // precompile regexes + ctx.addCompilerPipelineStep(precompileRegexesStep); - if (debugIntermediateFilesDir != null) { - await copyFile( - postbundleOutputFilepath, - resolve(debugIntermediateFilesDir, '__2_postbundled.js'), - ); + // add stack mapping helpers if (enableStackTraces) { - await copyFile( - postbundleOutputFilepath + '.map', - resolve(debugIntermediateFilesDir, '__2_postbundled.js.map'), - ); + ctx.addCompilerPipelineStep(addStackMappingHelpersStep); } - } - if (enableStackTraces) { - sourceMaps.push({ - f: postbundleFilename, - s: postbundleOutputFilepath + '.map', - }); - } - if (enableStackTraces) { - // Compose source maps - const replaceSourceMapToken = '__FINAL_SOURCE_MAP__'; - let excludePatterns: ExcludePattern[] = [ - 'forbid-entry:/**', - 'node_modules/**', - ]; - if (excludeSources) { - excludePatterns = [() => true]; - } - const composed = await composeSourcemaps(sourceMaps, excludePatterns); + // add Fastly helpers + ctx.addCompilerPipelineStep(addFastlyHelpersStep); - 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, - ); + // compile sourcemaps up to this point and inject into bundle + if (enableStackTraces) { + ctx.addCompilerPipelineStep(composeSourcemapsStep); } - // 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'), - ); - } + await ctx.applyCompilerPipeline(); + await ctx.maybeWriteDebugIntermediateFile('fastly_bundle.js'); + + // the output of the pipeline is the Wizer/Weval input + input = ctx.outFilepath; } - } - const spawnOpts = { - stdio: [null, process.stdout, process.stderr], - input: maybeWindowsPath(input), - shell: true, - encoding: 'utf-8', - env: { - ...env, - ENABLE_EXPERIMENTAL_HIGH_RESOLUTION_TIME_METHODS: - enableExperimentalHighResolutionTimeMethods ? '1' : '0', - ENABLE_EXPERIMENTAL_HTTP_CACHE: enableHttpCache ? '1' : '0', - RUST_MIN_STACK: String( - Math.max(8 * 1024 * 1024, Math.floor(freemem() * 0.1)), - ), - }, - } satisfies SpawnSyncOptionsWithStringEncoding; + const spawnOpts = { + stdio: [null, process.stdout, process.stderr], + input: maybeWindowsPath(input), + shell: true, + encoding: 'utf-8', + env: { + ...env, + ENABLE_EXPERIMENTAL_HIGH_RESOLUTION_TIME_METHODS: + enableExperimentalHighResolutionTimeMethods ? '1' : '0', + ENABLE_EXPERIMENTAL_HTTP_CACHE: enableHttpCache ? '1' : '0', + RUST_MIN_STACK: String( + Math.max(8 * 1024 * 1024, Math.floor(freemem() * 0.1)), + ), + }, + } satisfies SpawnSyncOptionsWithStringEncoding; - try { - if (!doBundle) { - if (enableAOT) { - const wevalBin = await weval(); + try { + if (!doBundle) { + if (enableAOT) { + const wevalBin = await weval(); - const wevalProcess = spawnSync( - `"${wevalBin}"`, - [ - 'weval', - '-v', - ...(aotCache ? [`--cache-ro ${aotCache}`] : []), - `--dir="${maybeWindowsPath(process.cwd())}"`, - '-w', - `-i "${wasmEngine}"`, - `-o "${output}"`, - ], - spawnOpts, - ); - if (wevalProcess.status !== 0) { - throw new Error(`Weval initialization failure`); + const wevalProcess = spawnSync( + `"${wevalBin}"`, + [ + 'weval', + '-v', + ...(aotCache ? [`--cache-ro ${aotCache}`] : []), + `--dir="${maybeWindowsPath(process.cwd())}"`, + '-w', + `-i "${wasmEngine}"`, + `-o "${output}"`, + ], + spawnOpts, + ); + if (wevalProcess.status !== 0) { + throw new Error(`Weval initialization failure`); + } + process.exitCode = wevalProcess.status; + } else { + const wizerProcess = spawnSync( + `"${wizer}"`, + [ + '--allow-wasi', + `--wasm-bulk-memory=true`, + `--dir="${maybeWindowsPath(process.cwd())}"`, + '--inherit-env=true', + '-r _start=wizer.resume', + `-o="${output}"`, + `"${wasmEngine}"`, + ], + spawnOpts, + ); + if (wizerProcess.status !== 0) { + throw new Error(`Wizer initialization failure`); + } + process.exitCode = wizerProcess.status; } - process.exitCode = wevalProcess.status; } else { - const wizerProcess = spawnSync( - `"${wizer}"`, - [ - '--allow-wasi', - `--wasm-bulk-memory=true`, - `--dir="${maybeWindowsPath(process.cwd())}"`, - '--inherit-env=true', - '-r _start=wizer.resume', - `-o="${output}"`, - `"${wasmEngine}"`, - ], - spawnOpts, - ); - if (wizerProcess.status !== 0) { - throw new Error(`Wizer initialization failure`); - } - process.exitCode = wizerProcess.status; - } - } else { - spawnOpts.input = `${maybeWindowsPath(input)}${moduleMode ? '' : ' --legacy-script'}`; - if (enableAOT) { - const wevalBin = await weval(); + spawnOpts.input = `${maybeWindowsPath(input)}${moduleMode ? '' : ' --legacy-script'}`; + if (enableAOT) { + const wevalBin = await weval(); - const wevalProcess = spawnSync( - `"${wevalBin}"`, - [ - 'weval', - '-v', - ...(aotCache ? [`--cache-ro ${aotCache}`] : []), - '--dir .', - `--dir ${maybeWindowsPath(dirname(input))}`, - '-w', - `-i "${wasmEngine}"`, - `-o "${output}"`, - ], - spawnOpts, - ); - if (wevalProcess.status !== 0) { - throw new Error(`Weval initialization failure`); + const wevalProcess = spawnSync( + `"${wevalBin}"`, + [ + 'weval', + '-v', + ...(aotCache ? [`--cache-ro ${aotCache}`] : []), + '--dir .', + `--dir ${maybeWindowsPath(dirname(input))}`, + '-w', + `-i "${wasmEngine}"`, + `-o "${output}"`, + ], + spawnOpts, + ); + if (wevalProcess.status !== 0) { + throw new Error(`Weval initialization failure`); + } + process.exitCode = wevalProcess.status; + } else { + const wizerProcess = spawnSync( + `"${wizer}"`, + [ + '--inherit-env=true', + '--allow-wasi', + '--dir=.', + `--dir=${maybeWindowsPath(dirname(input))}`, + '-r _start=wizer.resume', + `--wasm-bulk-memory=true`, + `-o="${output}"`, + `"${wasmEngine}"`, + ], + spawnOpts, + ); + if (wizerProcess.status !== 0) { + throw new Error(`Wizer initialization failure`); + } + process.exitCode = wizerProcess.status; } - process.exitCode = wevalProcess.status; - } else { - const wizerProcess = spawnSync( - `"${wizer}"`, - [ - '--inherit-env=true', - '--allow-wasi', - '--dir=.', - `--dir=${maybeWindowsPath(dirname(input))}`, - '-r _start=wizer.resume', - `--wasm-bulk-memory=true`, - `-o="${output}"`, - `"${wasmEngine}"`, - ], - spawnOpts, - ); - if (wizerProcess.status !== 0) { - throw new Error(`Wizer initialization failure`); - } - process.exitCode = wizerProcess.status; } + } catch (maybeError: unknown) { + const error = + maybeError instanceof Error + ? maybeError + : new Error(String(maybeError)); + throw new Error( + `Error: Failed to compile JavaScript to Wasm:\n${error.message}`, + ); } - } catch (maybeError: unknown) { - const error = - maybeError instanceof Error ? maybeError : new Error(String(maybeError)); - throw new Error( - `Error: Failed to compile JavaScript to Wasm:\n${error.message}`, - ); } finally { - if (doBundle && tmpDir != null) { - rmSync(tmpDir, { recursive: true }); - } + rmSync(tmpDir, { recursive: true }); } } diff --git a/src/compiler-steps/addFastlyHelpers.ts b/src/compiler-steps/addFastlyHelpers.ts new file mode 100644 index 0000000000..cfc5bf86fa --- /dev/null +++ b/src/compiler-steps/addFastlyHelpers.ts @@ -0,0 +1,35 @@ +import { CompilerPipelineStep } from '../compilerPipeline.js'; + +// Compiler Step - Add Fastly Helpers +// This step usually runs last before composing sourcemaps. + +export const addFastlyHelpersStep: CompilerPipelineStep = { + outFilename: '__fastly_helpers.js', + async fn(ctx, index) { + await ctx.magicStringWriter(this.outFilename, async (magicString) => { + // 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 ctx.maybeWriteDebugIntermediateFiles( + `__${index + 1}_fastly_helpers.js`, + ); + }, +}; diff --git a/src/compiler-steps/addStackMappingHelpers.ts b/src/compiler-steps/addStackMappingHelpers.ts new file mode 100644 index 0000000000..fe40f1d77e --- /dev/null +++ b/src/compiler-steps/addStackMappingHelpers.ts @@ -0,0 +1,54 @@ +import { CompilerPipelineStep } from '../compilerPipeline.js'; + +// Compiler Step - Add Stack Mapping Helpers +// This step runs only when stack tracing is enabled, and +// any time after bundling + +export const addStackMappingHelpersStep: CompilerPipelineStep = { + outFilename: '__stack_mapping_helpers.js', + async fn(ctx, index) { + await ctx.magicStringWriter(this.outFilename, async (magicString) => { + // INSERT init guard + let initGuardPre, initGuardPost; + if (ctx.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); + }); + + await ctx.maybeWriteDebugIntermediateFiles( + `__${index + 1}_stack_mapping_helpers.js`, + ); + }, +}; diff --git a/src/compiler-steps/bundle.ts b/src/compiler-steps/bundle.ts new file mode 100644 index 0000000000..eca64a34a6 --- /dev/null +++ b/src/compiler-steps/bundle.ts @@ -0,0 +1,81 @@ +import { dirname, basename, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +import { moveFile } from '../files.js'; +import { CompilerPipelineStep } from '../compilerPipeline.js'; +import { fastlyPlugin } from '../esbuild-plugins/fastlyPlugin.js'; +import { swallowTopLevelExportsPlugin } from '../esbuild-plugins/swallowTopLevelExportsPlugin.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Compiler Step - bundle +// This step usually runs first. + +// Runs esbuild: +// - bundles imported modules +// - applies the Fastly plugin, which resolves fastly:* imports +// - applies the Top level exports plugin, allowing top level file to contain any exports. +// - sets the named condition 'fastly' + +// If stack traces are enabled: +// - injects 'trace-mapping.inject.js', which contains error mapping code +// - enables source maps and writes it as an external file + +export const bundleStep: CompilerPipelineStep = { + outFilename: '__input_bundled.js', + async fn(ctx, index) { + // esbuild respects input source map, works if it's linked via sourceMappingURL + // either inline or as separate file + try { + const bundleFilename = basename(ctx.outFilepath); + + // Put build() output in cwd to build bundle and sourcemap with correct paths + const outfile = resolve(bundleFilename); + + const plugins = [fastlyPlugin]; + if (ctx.moduleMode) { + plugins.push(swallowTopLevelExportsPlugin({ entry: ctx.inFilepath })); + } + + const inject = []; + if (ctx.enableStackTraces) { + inject.push(resolve(__dirname, '../../rsrc/trace-mapping.inject.js')); + } + + await build({ + conditions: ['fastly'], + entryPoints: [ctx.inFilepath], + bundle: true, + write: true, + outfile, + sourcemap: ctx.enableStackTraces ? 'external' : undefined, + sourcesContent: ctx.enableStackTraces ? true : undefined, + format: ctx.moduleMode ? 'esm' : 'iife', + tsconfig: undefined, + plugins, + inject, + }); + + // Move build() output to outFilepath + await moveFile(outfile, ctx.outFilepath); + if (ctx.enableStackTraces) { + await moveFile(outfile + '.map', ctx.outFilepath + '.map'); + ctx.sourceMaps.push({ + f: this.outFilename, + s: ctx.outFilepath + '.map', + }); + } + } catch (maybeError: unknown) { + const error = + maybeError instanceof Error + ? maybeError + : new Error(String(maybeError)); + console.error(`Error:`, error.message); + process.exit(1); + } + + await ctx.maybeWriteDebugIntermediateFiles(`__${index + 1}_bundled.js`); + }, +}; diff --git a/src/composeSourcemaps.ts b/src/compiler-steps/composeSourcemaps.ts similarity index 56% rename from src/composeSourcemaps.ts rename to src/compiler-steps/composeSourcemaps.ts index 8910d4f1e8..d3ecac5d4a 100644 --- a/src/composeSourcemaps.ts +++ b/src/compiler-steps/composeSourcemaps.ts @@ -1,4 +1,5 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import remapping, { SourceMap, type SourceMapInput, @@ -7,11 +8,49 @@ import remapping, { import { TraceMap } from '@jridgewell/trace-mapping'; import picomatch from 'picomatch'; +import { CompilerPipelineStep, SourceMapInfo } from '../compilerPipeline.js'; + export type ExcludePattern = string | ((file: string) => boolean); -export type SourceMapInfo = { - f: string; // Filename - s: string; // Sourcemap filename +// Compiler Step - Compose Sourcemaps +// This step usually runs at the end. + +// This step composes all the source maps up to this point into a single +// source map, and injects it into the bundle. + +// Be careful: Do not run any steps after this step. Such steps will not be +// reflected in downstream source maps. + +export const composeSourcemapsStep: CompilerPipelineStep = { + outFilename: '__fastly_bundle_with_sourcemaps.js', + async fn(ctx) { + // Compose source maps + const replaceSourceMapToken = '__FINAL_SOURCE_MAP__'; + let excludePatterns: ExcludePattern[] = [ + 'forbid-entry:/**', + 'node_modules/**', + ]; + if (ctx.excludeSources) { + excludePatterns = [() => true]; + } + const composed = await composeSourcemaps(ctx.sourceMaps, excludePatterns); + + const postBundleContent = await readFile(ctx.inFilepath, { + encoding: 'utf-8', + }); + const outputWithSourcemapsContent = postBundleContent.replace( + replaceSourceMapToken, + () => JSON.stringify(composed), + ); + await writeFile(ctx.outFilepath, outputWithSourcemapsContent); + + if (ctx.debugIntermediateFilesDir != null) { + await writeFile( + resolve(ctx.debugIntermediateFilesDir, 'fastly_sourcemaps.json'), + composed, + ); + } + }, }; async function readSourcemap(e: SourceMapInfo) { diff --git a/src/compiler-steps/precompileRegexes.ts b/src/compiler-steps/precompileRegexes.ts new file mode 100644 index 0000000000..5ac8917980 --- /dev/null +++ b/src/compiler-steps/precompileRegexes.ts @@ -0,0 +1,67 @@ +import { parse } from 'acorn'; +import { simple as simpleWalk } from 'acorn-walk'; +import regexpuc from 'regexpu-core'; + +import { CompilerPipelineStep } from '../compilerPipeline.js'; + +// Compiler Step - Precompile regexes +// This step runs any time after bundling + +export const precompileRegexesStep: CompilerPipelineStep = { + outFilename: '__fastly_precompiled_regexes.js', + async fn(ctx, index) { + await ctx.magicStringWriter( + this.outFilename, + async (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: ctx.moduleMode ? 'module' : 'script', + }); + + const precompileCalls: string[] = []; + 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`, + ); + } + }, + ); + + await ctx.maybeWriteDebugIntermediateFiles( + `__${index + 1}_precompiled_regexes.js`, + ); + }, +}; diff --git a/src/compilerPipeline.ts b/src/compilerPipeline.ts new file mode 100644 index 0000000000..56c189c86c --- /dev/null +++ b/src/compilerPipeline.ts @@ -0,0 +1,120 @@ +import { copyFile, readFile, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import MagicString from 'magic-string'; +import { pipeline } from './pipeline.js'; + +export type SourceMapInfo = { + f: string; // Filename + s: string; // Sourcemap filename +}; + +export class CompilerContext { + inFilepath: string; + outFilepath: string; + tmpDir: string; + debugIntermediateFilesDir: string | undefined; + sourceMaps: SourceMapInfo[]; + moduleMode: boolean; + enableStackTraces: boolean; + excludeSources: boolean; + compilerPipelineSteps: CompilerPipelineStep[]; + + constructor( + input: string, + tmpDir: string, + debugIntermediateFilesDir: string | undefined, + moduleMode: boolean, + enableStackTraces: boolean, + excludeSources: boolean, + ) { + this.inFilepath = input; + this.outFilepath = ''; // This is filled in by the eventual steps of the compiler + this.tmpDir = tmpDir; + this.debugIntermediateFilesDir = debugIntermediateFilesDir; + this.sourceMaps = []; + this.moduleMode = moduleMode; + this.enableStackTraces = enableStackTraces; + this.excludeSources = excludeSources; + this.compilerPipelineSteps = []; + } + + addCompilerPipelineStep(step: CompilerPipelineStep) { + this.compilerPipelineSteps.push(step); + } + + async applyCompilerPipeline() { + await pipeline( + this.compilerPipelineSteps.map( + (step) => async (args: CompilerContext, index) => { + await step.fn.call(step, args, index); + return args; + }, + ), + this, + { + beforeStep: (args: CompilerContext, index: number) => { + const step = this.compilerPipelineSteps[index]; + args.outFilepath = resolve(this.tmpDir, step.outFilename); + }, + afterStep: (args: CompilerContext) => { + args.inFilepath = args.outFilepath; + }, + }, + ); + } + + async magicStringWriter( + filename: string, + fn: (magicString: MagicString, source: string) => void | Promise, + ) { + const source = await readFile(this.inFilepath, { encoding: 'utf8' }); + const magicString = new MagicString(source); + + await fn(magicString, source); + + await writeFile(this.outFilepath, magicString.toString()); + + if (this.enableStackTraces != null) { + const map = magicString.generateMap({ + source: basename(this.inFilepath), + hires: true, + includeContent: true, + }); + + await writeFile(this.outFilepath + '.map', map.toString()); + this.sourceMaps.push({ f: filename, s: this.outFilepath + '.map' }); + } + } + + async maybeWriteDebugIntermediateFile(outFilename: string) { + if (this.debugIntermediateFilesDir != null) { + await copyFile( + this.outFilepath, + resolve(this.debugIntermediateFilesDir, outFilename), + ); + } + } + + async maybeWriteDebugIntermediateSourceMapFile(outFilename: string) { + if (this.enableStackTraces && this.debugIntermediateFilesDir != null) { + await copyFile( + this.outFilepath + '.map', + resolve(this.debugIntermediateFilesDir, outFilename), + ); + } + } + + async maybeWriteDebugIntermediateFiles(outFilename: string) { + await this.maybeWriteDebugIntermediateFile(outFilename); + await this.maybeWriteDebugIntermediateSourceMapFile(outFilename + '.map'); + } +} + +export type CompilerPipelineStep = { + outFilename: string; + fn: ( + this: CompilerPipelineStep, + args: CompilerContext, + index: number, + ) => void | PromiseLike; +}; diff --git a/src/bundle.ts b/src/esbuild-plugins/fastlyPlugin.ts similarity index 80% rename from src/bundle.ts rename to src/esbuild-plugins/fastlyPlugin.ts index 9f0c2b6ee8..a4e96c88a8 100644 --- a/src/bundle.ts +++ b/src/esbuild-plugins/fastlyPlugin.ts @@ -1,14 +1,6 @@ -import { dirname, basename, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { build, type Plugin } from 'esbuild'; +import { type Plugin } from 'esbuild'; -import { moveFile } from './files.js'; -import { swallowTopLevelExportsPlugin } from './swallowTopLevelExportsPlugin.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const fastlyPlugin: Plugin = { +export const fastlyPlugin: Plugin = { name: 'fastly', setup(build) { build.onResolve({ filter: /^fastly:.*/ }, (args) => { @@ -164,41 +156,3 @@ export const TransactionCacheEntry = globalThis.TransactionCacheEntry; }); }, }; - -export async function bundle( - input: string, - outfile: string, - { 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: true, - outfile: bundle, - sourcemap: 'external', - sourcesContent: true, - format: moduleMode ? 'esm' : 'iife', - tsconfig: undefined, - plugins, - inject, - }); - - await moveFile(bundle, outfile); - if (enableStackTraces) { - await moveFile(bundle + '.map', outfile + '.map'); - } -} diff --git a/src/swallowTopLevelExportsPlugin.ts b/src/esbuild-plugins/swallowTopLevelExportsPlugin.ts similarity index 98% rename from src/swallowTopLevelExportsPlugin.ts rename to src/esbuild-plugins/swallowTopLevelExportsPlugin.ts index 20157bc532..e28d071469 100644 --- a/src/swallowTopLevelExportsPlugin.ts +++ b/src/esbuild-plugins/swallowTopLevelExportsPlugin.ts @@ -7,7 +7,7 @@ export type SwallowTopLevelExportsPluginParams = { export function swallowTopLevelExportsPlugin( opts?: SwallowTopLevelExportsPluginParams, -) { +): Plugin { const { entry } = opts ?? {}; const name = 'swallow-top-level-exports'; @@ -40,5 +40,5 @@ export function swallowTopLevelExportsPlugin( }; }); }, - } satisfies Plugin; + }; } diff --git a/src/files.ts b/src/files.ts index 1e4fd42414..69aa3efbe5 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,4 +1,5 @@ import { stat, rename, copyFile, unlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; export async function isFile(path: string) { const stats = await stat(path); diff --git a/src/pipeline.ts b/src/pipeline.ts new file mode 100644 index 0000000000..b8e5d94c0e --- /dev/null +++ b/src/pipeline.ts @@ -0,0 +1,36 @@ +// From https://github.com/harmony7/js-async-pipeline + +import { resolve } from 'node:path'; + +export type PipelineOpts = { + beforeStep?: ( + args: TValue, + index: number, + arr: PipelineStep[], + ) => void | PromiseLike; + afterStep?: ( + args: TValue, + index: number, + arr: PipelineStep[], + ) => void | PromiseLike; +}; + +export type PipelineStep = ( + acc: TValue, + index: number, + arr: PipelineStep[], +) => TValue | PromiseLike; + +export async function pipeline( + steps: PipelineStep[], + initialValue: TValue, + opts?: PipelineOpts, +): Promise { + let val = initialValue; + for (const [index, step] of steps.entries()) { + await opts?.beforeStep?.call(opts, val, index, steps); + val = await step(val, index, steps); + await opts?.afterStep?.call(opts, val, index, steps); + } + return val; +} diff --git a/src/postbundle.ts b/src/postbundle.ts deleted file mode 100644 index 790ddd95fd..0000000000 --- a/src/postbundle.ts +++ /dev/null @@ -1,126 +0,0 @@ -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: string, - outfile: string, - { 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: string[] = []; - 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()); - } -}