Skip to content

Conversation

@harmony7
Copy link
Member

This PR adds two features to the JS Compute Runtime, both using just JavaScript means (rather than by modifying the C++).

Stack tracing (--enable-stack-traces)

Consider the following example code.

addEventListener('fetch', e => e.respondWith(handler(e)));

async function buildResult(fn: () => number): Promise<Response> {
  const x = fn();
  return new Response(String(x));
}

async function handler(_e: FetchEvent) {
  const result = await buildResult(() => {
    throw new TypeError('foo');
  });
  result.headers.set('content-type', 'text/plain');
  return result;
}

By enabling the new option, the above code can be made to return an error stack like this:

2025-11-14T07:01:15.535095Z  INFO request{id=0}: handling request GET http://localhost:7676/
Unhandled error while running request handler
TypeError: foo
  at (anonymous function) (src/index.ts:10:11)
      7 | 
      8 | async function handler(_e: FetchEvent) {
      9 |   const result = await buildResult(() => {
>    10 |     throw new TypeError('foo');
                    ^
     11 |   });
     12 |   result.headers.set('content-type', 'text/plain');
     13 |   return result;
  at buildResult (src/index.ts:4:13)
  at handler (src/index.ts:9:24)
  at src/index.ts:1:46

Raw error below:
Error while running request handler: foo
Stack:
  handler/result<@fastly:app.js:722:11
  buildResult@fastly:app.js:717:13
  handler@fastly:app.js:721:24
  @fastly:app.js:715:48
  node_modules/@fastly/js-compute/src/rsrc/trace-mapping.inject.js/globalThis.addEventListener/<@fastly:app.js:694:33

2025-11-14T07:01:15.571068Z  INFO request{id=0}: response status: 500
2025-11-14T07:01:15.571332Z  INFO request{id=0}: request completed using 7.7 MB of WebAssembly heap
2025-11-14T07:01:15.571339Z  INFO request{id=0}: request completed in 36ms

instead of

2025-11-14T07:00:32.689743Z  INFO request{id=0}: handling request GET http://localhost:7676/
Error while running request handler: foo
Stack:
  handler/result<@fastly:app.js:23:11
  buildResult@fastly:app.js:18:13
  handler@fastly:app.js:22:24
  @fastly:app.js:16:48

2025-11-14T07:00:32.692283Z  INFO request{id=0}: response status: 500
2025-11-14T07:00:32.692612Z  INFO request{id=0}: request completed using 7.7 MB of WebAssembly heap
2025-11-14T07:00:32.692623Z  INFO request{id=0}: request completed in 3ms

This also catches errors at the top level. Consider the following top-level code:

async function init() {
  throw new TypeError('foo');
}
await init();
export {};

This causes an error such as the following during the Wizer step

Unhandled error while running top level module code
TypeError: foo
  at init (src/index.ts:2:9)
      1 | async function init() {
>     2 |   throw new TypeError('foo');
                  ^
      3 | }
      4 | await init();
      5 | export {};
  at __fastly_init_guard__ (src/index.ts:4:7)

Raw error below:
Exception while evaluating top-level script
__fastly_bundle_with_sourcemaps.js:716:9 TypeError: foo
Additionally, some promises were rejected, but the rejection never handled:
Promise rejected but never handled: foo
Stack:
  init@fastly:app.js:716:9
  __fastly_init_guard__@fastly:app.js:718:7
  @fastly:app.js:719:3

instead of

Exception while evaluating top-level script
__fastly_post_bundle.js:17:9 TypeError: foo
Additionally, some promises were rejected, but the rejection never handled:
Promise rejected but never handled: foo
Stack:
  init@fastly:app.js:17:9
  @fastly:app.js:20:7

This is done by injecting the composed source map from esbuild and magic-string, as well as a trace mapping script into the resulting bundle.

It also works with any internal or external source maps that may already exist on the input file referenced by sourceMappingURL (external or internal data-url), for example if there is an additional bundler that runs (Webpack, tsc, etc.) before js-compute-runtime.

Source code contents are only included from user code (i.e., excludes node_modules and any injected code that results from transforms), and the code dump is provided for the top-most frame that exists in user code. However, this can be removed by specifying the --exclude-sources flag, resulting in the mapped stack trace only.

This mode also makes two exported functions available from fastly:experimental:

  /**
   * 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 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;

This makes mapped error stacks available to user error handling code, e.g.

import { mapError } from 'fastly:experimental';
addEventListener('fetch', e => e.respondWith(handler(e)));
async function handler(_e: FetchEvent) {
  let result: Response;
  try {
    throw new TypeError('foo');
    result = new Response();
  } catch(err) {
    return new Response(mapError(err).join('\n'));
  }
  return result;
}

Then curling this would give:

% curl http://localhost:7676/
TypeError: foo
  at handler (src/index.ts:12:11)
      9 | async function handler(_e: FetchEvent) {
     10 |   let result: Response;
     11 |   try {
>    12 |     throw new TypeError('foo');
                    ^
     13 |     result = new Response();
     14 |   } catch(err) {
     15 |     return new Response(mapError(err).join('\n'));
  at src/index.ts:3:46

This feature adds a small amount of data to the Wasm package, but parsing the sourcemap does not occur on a hot code path, so runtime performance is not expected to be impacted strongly.

Outputting intermediate files (--debug-intermediate-files <dir>)

The bundling process has the following steps

input file
  --(bundle: esbuild)--> intermediate file 1
  --(postbundle: magic-string)--> intermediate file 2
  --(inject sourcemap)--> final file to pass to wizer
  • The bundle step uses esbuild to transform modules and apply the Fastly plugin (for example fastly:* namespaces)
    • in the new mode, this also injects the stack trace mapping functionality
  • The postbundle uses magic-string to inject pre-compiled regexes
    • in the new mode, this also injects the initialization guard and placeholder for inserting sourcemap
  • The inject sourcemap step is new and replaces a marker with the final composed sourcemap content

After package has been built, the runtime deletes all of the temporary files. However it is sometimes useful to have access to view the intermediate files. By specifying the --debug-intermediate-files <dir> flag, these intermediate files are dumped into the specified directory to aid in debugging. For example:

js-compute-runtime --debug-intermediate-files ./bin src/index.ts ./bin/main.wasm

This will output the files __1_bundled.js and __2_postbundled.js as well as fastly_bundle.js to the ./bin directory, which can be examined. If --enable-stack-traces is also set, then intermediate source map files __1_bundled.js.map, __2_postbundled.js.map, as well as the final source map file fastly_sourcemaps.json will also be emitted.

@harmony7 harmony7 requested review from TartanLlama and zkat November 14, 2025 07:44
@TartanLlama
Copy link
Contributor

@harmony7 I think the test failures here are legit ones, can you have a look?

@harmony7
Copy link
Member Author

@TartanLlama

Thanks, I have been able to fix some of the problems.
However, I don't think the others relate to my changes. can you take a look and help?

  1. The format job is giving this
> @fastly/[email protected] format:check
> prettier --check *.js src/*.js integration-tests

Checking formatting...
[warn] integration-tests/js-compute/test.js
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
Error: Process completed with exit code 1.

However, on my local:

 % npm run format:check

> @fastly/[email protected] format:check
> prettier --check *.js src/*.js integration-tests

Checking formatting...
All matched files use Prettier code style!

So I'm not sure how to clear this one up.

  1. The sdktest ones are failing on /kv-store-e2e/list and /acl returning 200 instead of 500 from the fixture. This are probably unrelated to my changes. can you help figure these out?

@harmony7
Copy link
Member Author

Additionally, the docusaurus one is failing due to OOM it looks like.

@TartanLlama
Copy link
Contributor

The code looks all reasonable to me, but @zkat would likely be a better reviewer for it as our JS build expert 😄

@harmony7
Copy link
Member Author

@TartanLlama I've applied your suggestions =) let me know if there is anything else I should do

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants