The Visual Studio Code (VSCode) website explains how to configure the editor to debug Node.js applications as well as how to debug applications that are written in TypeScript and transpiled to JavaScript. I found it challenging to get it working with Node.js applications written in TypeScript. The how this works part isn’t covered well in my opinion… once I understood this, it was easier to set it up. In this post I’ll explain how it works, the guts of JavaScript sourcemaps & how to take control over them to get debugging to work in VSCode.
You need to understand what sourcemaps are and why you should care about them. I’ll go into more depth explaining how they work for those interested. Next, I’ll explain the part that I found challenging: understanding how VSCode deals with sourcemaps. I found the available explanations I came across lacking in helping bridge the gap I needed to make sure a build process would support no only F5 style debugging in VSCode, but also attaching to a running process on my laptop as well as attaching to the Node.js process running within a local Docker container.
Most, if not all, of this post, applies to sourcemaps in general. However keep in mind I’m going to focus on using sourcemaps for TypeScript-based projects. While they are also applicable to both client-side & server-side projects, I’m also focusing primarily on the Node.js scenarios: authoring TypeScript to be transpiled to JavaScript and executed by Node.js.
The Skinny on Sourcemaps
Why should you care about these things called sourcemaps? If you’re familiar with debugging symbols (the *.pdb
files you get when building .NET applications in debug mode), these are similar. Sourcemaps are used in JavaScript & other languages / technologies, but JavaScript is where we seem to use them the most. The JavaScript the execution environment is running, be that the browser for client-side applications or Node.js for server-side applications is not always the same code we write. This is typical because of one or two reasons.
First, for client-side applications, it’s common to not only concatenate multiple script files into one to reduce the number of round trips to the server (which is slower than pulling big file). In this case, you need to have a way to map the line of code you want to inspect back to the line of code in a specific file.
The other case is, regardless if it is a client/server-side application, you might transpile from one language such as TypeScript, CoffeeScript or ES6 to another form of JavaScript (ES5). Just like above, you don’t want to debug the generated stuff, you want to debug your source code.
Sourcemap Implementation Options
There are a few different ways sourcemaps are implemented; I’ll focus on the two most common options: external files & inline.
External File
One option is for the sourcemap to live in it’s own file beside the generated file. This is where you commonly see a file named index.js.map
next to the generated index.js
file. At the bottom of the generated JavaScript file, you’ll see a code comment with a sourceMappingURL=
reference. This points to the mapping file. It might be beside the generated file, or it might be located somewhere remote like in a CDN.
Inline in the Generated File’s Footer
The other option (and the one I prefer) is to have the entire sourcemap located at the end of the file. Just like in the above description, there’s a sourceMappingURL=
reference, but the value is set to data:application/json;base64,
followed by a Base64 encoded JSON structure describing the sourcemap. I like this option because it’s less stuff to manage and you never get things out of sync. When deploying a client-side application to production I don’t build sourcemaps so the filesize isn’t a concern.
Sourcemap Structure
Sourcemaps are encoded as Base64 strings. Once you decode this string you can see it’s actually a big JSON object that is pretty easy to read.
I like to use either the Encode Decode or vscode-base64 extensions for VSCode to quickly decode & encode strings, including Base64 strings like those used in sourcemaps when getting things set up. Just don’t forget to encode the sourcemap after decoding it because otherwise, it won’t work.
The JSON structure for a sourcemap, specifically version 3 of sourcemaps, contains the following fields (some may be blank depending the use case such as TypeScript=>JS or JavaScript=>minified JS):
version: The sourcemap schema version; this should be equal to
3
today.file: The filename of the generated code; for instance if your source code is in
index.ts
, the value of this field will beindex.js
in most cases.sources: An array of files that were used to create the generated file; there may be multiple in the case where you have concatenated multiple files into a single file (common in client-side apps).
sourcesRoot: This field lets you prepend the sources field with a folder structure, such as a remote URL or folder.
mappings: This field contains a CSV string of Base64 VLQ values. In layman’s terms, these are encoded values that map a line in the generated file to a line in the source file. This also depends on the order of files in the sources field… it’s complex how they are built & really doesn’t matter.
If you want to learn more about how this works, here are two good references:
sourcesContent: This field might be empty, but one thing you can do is include the entire source within the sourcemap. This is something that isn’t required, but it’s recommended as a good fallback. You’ll see how this can help in a moment.
So now you know what sourcemaps are, how to view the structure and how the structure works. So how are they used?
How VSCode Uses Sourcemaps
So you know how sourcemaps work, but how does VSCode use them? There’s nothing special, but when you’re having trouble getting your project configured so you can debug it in VSCode, it helps to understand how it works to resolve the issues.
First, keep in mind that when you want to debug a Node.js application, you have to launch the node process in debug mode. This is done by adding --debug
argument to the node process. Node will then expose a port on the local machine, by default port 5858, that editors can attach to. Using this port, the editors can catch breakpoints, get values on watch expressions, inspect the call stack, etc.
When VSCode attaches to the Node.js process, it will use information published over port 5858 to figure out what the running code is and how it maps to other code using sourcemaps. There is a three-step fallback that VSCode uses to make this mapping work. The goal is to get the first one working, but when it isn’t working, it can really frustrate you as things don’t seem to be working as you’d expect. For instance, if it isn’t set up correctly while debugging works, you’ll notice that breakpoints set prior to the debugging session won’t get hit… instead, they show up not with the expected red circle, but rather with a hollow gray circle:
Oh man, this is SOOOO frustrating when you see this! But you think “wait… it’s just JavaScript… so if I find the same line of the generated JavaScript and add a breakpoint, maybe that will work?” Yes, it will work… sort of, and it will tell you exactly what’s wrong.
Let’s say I’ve found a line in the generated JavaScript (server.js
) and set a breakpoint. When Node.js hit that line, VSCode opened a new tab server.ts
and stopped where I set the breakpoint. But where’s the red circle… and you think “wait, if it saw the TypeScript file, then why can’t I set a breakpoint in it?” Ah… that’s because it’s not really your TypeScript file… it’s the code being streamed back from the Node.js process that’s in the sourcemap. You can tell by hovering over the filename:
While this isn’t what you want, it explains why you can’t set your own breakpoints in TypeScript. The other scenario is when VSCode basically throws up its arms and says “I can’t find anything that maps to this code… so here’s the generated stuff” which is what this picture shows:
This is the ultimate fallback that VSCode uses… you can still debug, but it’s not a rich experience because now you are debugging the generated code. Not so bad in the picture above, but if this was minified JavaScript… good luck reading that mess!
As I mentioned above, VSCode will try to load sourcemaps a few different ways. If one option fails, it moves onto the next option, ultimately ignoring all sourcemaps and just showing the generated JavaScript. Here’s how it works:
- Use original TypeScript source file - Using the information in the sourcemap, VSCode will try to locate the correct TypeScript file in your project that was used to generate the executing JavaScript. This is the ideal option because with this option, you can set breakpoints in your TypeScript that will get hit and will persist across debugging sessions.
- Use inlined TypeScript source by the Node.js process - This is what the first two pictures above are leading you to. If when you transpiled your TypeScript to JavaScript, you inlined the sourcecode in the sourcemap, the Node.js debugger will return the code to the connected editor. VSCode will use this, if it can’t find the original TypeScript file, as the source you can use to debug. What sort of stinks is that VSCode doesn’t make this terribly clear in the latest builds that this is what’s going on, but as you can see from figures 1 & 2 above, you can figure it out.
- Use the JavaScript returned by the Node.js process - If VSCode can’t find any source, it just shows you the JavaScript that’s running as shown in figure 3 above.
Armed with this understanding, you can figure out your build process and makes sure everything is set up correctly.
Configuring Your TypeScript => JavaScript Build Pipeline for Sourcemaps
In a previous post, I explained how I like to structure my projects:
Node.js, TypeScript & Building to Different Folders. In a nutshell, I like to have all my source in /src
and build it to /dist
. I also like to use gulp to automate a few tasks, such as transpiling my TypeScript to JavaScript.
My build process is set so that it will always include the source TypeScript within the sourcemap and include sourcemaps inline. Let me show you what one of these tasks looks like… for some context, you can see the full source of the build task in this gist build.ts which references a barrel for all plugins I have in my project (gulpPlugins.ts).
My use of gulp involves creating tasks as TypeScript classes and having them dynamically loaded at runtime instead of one big
gulpfile.js
like most demos show. If you want to learn more about it, check out this post: Dynamically Loading Gulp Tasks For Simplified Reuse and Maintenance.
Within the Gulp task, I first create a TypeScript project by loading the tsconfig.json
:
// set up parameters for gulp-typescript
let tsProjectParams: gulpTypeScript.Params = {
declaration: true,
noExternalResolve: true
};
// load typescript project
let tsProject: gulpTypeScript.Project = gulpTypeScript.createProject('tsconfig.json', tsProjectParams);
Next, configure how you’re going to handle sourcemaps:
// set the sourcemap write options if building app
let writeOptions: gulpSourcemaps.WriteOptions = null;
writeOptions = <gulpSourcemaps.WriteOptions>{
// inline the source typescript into the sourcemap
includeContent: true,
// point to the root of the project
sourceRoot: function (file: any): string {
return BuildConfig.GetAbsolutePath(ProjectArtifact.Source);
}
};
// compile all TypeScript files, including sourcemaps inline in the generated JavaScript
let tsResult: any = tsProject.src()
.pipe(gulpPlumber())
.pipe(gulpIf(args.verbose, gulpPrint()))
.pipe(gulpSourcemaps.init())
.pipe(gulpTypeScript(tsProject));
// merge dts & js output streams...
return merge([
// type definitions
tsResult.dts
.pipe(gulp.dest(outputPath)),
// javascript
tsResult.js
.pipe(gulpSourcemaps.write(writeOptions))
.pipe(gulp.dest(outputPath))
]);
Notice how I’m creating an options object to tell the gulp-sourcemaps plugin to include the content and also how to figure out what the source root property should be for each file that’s processed. Finally, after running my source through the gulp-typescript plugin, I’m writing the generated JavaScript out.
The trick here was getting the sourceRoot
property on the sourcemaps correct. Looking at the code I came up with, it amazes me that I spent so much time on what now is a single return statement… say it with me… “I love my job, I love my job” :)
Configuring VSCode for Debugging
The last step is to get VSCode set up for debugging to work. There are some helpful links on the VSCode site for working with tasks & launch configurations.
I needed a single task mapped to my gulp build task so that when I hit F5 , VSCode would transpile my TypeScript to JavaScript, then startup & attach to the Node.js process in debug mode.
First things first, I need a task. Within my tasks.json
file within the .vscode
folder, that will run my gulp task build. I also pass the --app
argument on the command line as this task can build my project TypeScript (gulp tasks, etc) or the actual application code:
{
"version": "0.1.0",
"command": "gulp",
"isShellCommand": true,
"args": ["--no-color"],
"showOutput": "always",
"tasks": [
{
"taskName": "build",
"args": ["--app"],
"isBuildCommand": true,
"isWatching": false
}
]
}
Next set up is to create three different launch configurations:
- Launch LOCAL Node.js: This will start the Node.js process in debug mode, after running the build task. Take note of the fields
program
,cwd
,outDir
, &sourceMaps
. These were a bit tricky to get set up correctly, but they were required for mysetup. - Attach to LOCAL Node.js: This will attach to a currently running Node.js process running on my laptop. It assumes the process was started in debug mode.
- Attach to DOCKER Node.js: This one is the same as above, but what it does is connect to a Node.js process running in Docker. It doesn’t specifically connect to a Docker container, but just to port 5858. When I run the Docker container, it exposes port 5858. Take note of the
remoteRoot
field. That’s the absolute path to the location where the application is running within the Docker container (here’s the Dockerfile for reference on how the container was created that this project uses).
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch LOCAL Node.js",
"type": "node",
"request": "launch",
"program": "${workspaceRoot}/src/server/index.ts",
"stopOnEntry": false,
"args": [],
"cwd": "${workspaceRoot}",
"outDir": "${workspaceRoot}/dist",
"preLaunchTask": "build",
"runtimeExecutable": null,
"runtimeArgs": [
"--nolazy"
],
"env": {
"NODE_ENV": "development"
},
"externalConsole": false,
"sourceMaps": true
},
{
"name": "Attach to LOCAL Node.js",
"type": "node",
"request": "attach",
"port": 5858,
"outDir": "${workspaceRoot}/dist",
"restart": false,
"sourceMaps": true
},
{
"name": "Attach to DOCKER Node.js",
"type": "node",
"request": "attach",
"port": 5858,
"sourceMaps": true,
"localRoot": "${workspaceRoot}",
"remoteRoot": "/voyager/app",
"outDir": "${workspaceRoot}/dist"
}
]
}
To use one of these configurations, jump to the debug pane in VSCode and select the launch configuration desired. Pressing F5 will run the launch configuration.