Notes from making a language server extension run in the browser

13.08.2022 by William Killerud in Some Sass

I recently updated Some Sass, a Visual Studio Code extension, to work as a web extension. This means the extension can run alongside Visual Studio Code in the browser, for instance on GitHub.

Language server extensions running in the browser is a fairly niche topic. I figured I’d share some notes from that work, in case anybody finds themselves in a similar situation. For context, the existing language server extension was written in TypeScript. Of course, to run in the browser the language server itself needs to either be JavaScript or compiled to WebAssembly.

One false start

The documentation on how to become a web extenion starts with configuring Webpack. I began there, but after a while had accomplished very little, other than being yelled at by Webpack for importing things I really shouldn’t when targeting browsers.

Shift in strategy

I decided to start refactoring instead.

The same guide linked above has a list of techniques for code reuse between web and regular extensions. The good stuff for a language server extension is at the bottom.

Separate your code in a browser part, Node.js part, and common part. In common, only use code that works in both the browser and Node.js runtime. Create abstractions for functionality that has different implementations in Node.js and the browser.

Look out for usages of path, URI.file, context.extensionPath, rootPath. uri.fsPath. These will not work with virtual workspaces (non-file system) as they are used in VS Code for the Web. Instead use URIs with URI.parse, context.extensionUri. The vscode-uri node module provides joinPath, dirName, baseName, extName, resolvePath.

Look out for usages of fs. Replace by using vscode workspace.fs.

My goals with refactoring were now:

  • No import of fs anywhere but in one file - the abstraction layer when running in a Node context.
  • No use of fsPaths in URIs, except for in that file system abstraction layer for Node.
  • Put any Node-specifics in the client and server in separate files - the new entry points for the extension.

Once I was happy with the state of things I ran tests to confirm I hadn’t broken anything. If you’re interested, this is the pull request where I do most of the refactoring.

Browser entries

Happy with the refactoring, I added new entry files for the browser client and server. Then I was off to configure Webpack again.

This time, Webpack only complained a little. The fixes were simple enough. Add this fallback. Provide that alias. The documentation for both Webpack and Visual Studio Code gave helpful tips and examples.

Then came the bugs.

Browser extension refusing to start

I think this issue cost me the most time during this whole experience.

In the existing Node version, the WorkspaceConfiguration object was sent as-is to the server as an initialization parameter, without complaints. In the browser version however, this doesn’t work. The thing is, you don’t get any indication that this is the problem. The only thing you see is this message:

Sending request failed.

Try as I might, I could not find the culprit for hours. In hindsight, what I should have done from the start is set a breakpoint in the client and step through line for line, node_modules and all. Once I found the source of that message I added more logging to see the source of that failed request.

Failed to execute 'postMessage' on 'Worker': has(D){return typeof h(c,D)!="undefined"} could not be cloned.

This put me on the right track. I’ve seen errors from serializing fancy objects before. This put me on the hunt for any non-primitive inputs to the server. And as it turns out, WorkspaceConfiguration has a has function.

Once I made a plain old JavaScript object of all the settings and sent that instead, the extension finally started running in the browser.

File system access

While the extension was technically running, it wasn’t very useful. For one, I had assumed I could use workspace.fs from the server. As it turns out, only the client has access to the workspace file system.

Thankfully the CSS language features extension from the main vscode repo had a working setup I could be inspired by. Their request provider pattern gave me the clues I needed to set up the client to handle file system access on behalf of the server, in those cases where the server doesn’t have direct file system access.

In that request-response cycle, stringify all the things on the client and parse them on the server. Otherwise that toString() on the server side may not do what you think it does. Looking at you, URI.

Everything and the kitchen sink

If you’re interested in code examples you can check out the full before and after between tags 2.5.0 and 2.6.0 in wkillerud/vscode-scss (53 commits, 137 files changed).

There are two pull requests, where the latter is maybe the most interesting:

Only the fridge

In case you don’t want to dig through that large changeset (wouldn’t blame you), the most interesting files to peruse are probably these ones:

  • src/server/server.ts (previously src/unsafe/server.ts )
  • src/server/node-server.ts
  • src/server/browser-server.ts
  • src/server/node-file-system.ts
  • src/server/virtual-file-system.ts
  • src/client/client.ts (previously src/client.ts )
  • src/client/node-client.ts
  • src/client/browser-client.ts

You’ll find those files and more over at wkillerud/vscode-scss ().

I’d love to hear from you

Did this help you at all? If so, I’d love to hear from you! Tell me all about your new web extension and how much of a pain in the ass it was to get running on Twitter.