{
  "$type": "site.standard.document",
  "description": "Running the Deno LSP to make our TypeScript language tools dramatically better",
  "path": "/vtlsp",
  "publishedAt": "2025-09-09T00:00:00.000Z",
  "site": "at://did:plc:a2rdzfdxkjwerrfrpbwcipb2/site.standard.publication/3jd443afc2222",
  "textContent": "<div style=\"position: relative; padding-top: 41.92546583850932%;\">\n  <iframe\n    src=\"https://customer-cchzc454ej3jhb72.cloudflarestream.com/0b88bd223690574d62c3a7c00c00ba78/iframe?autoplay=true&poster=https%3A%2F%2Fcustomer-cchzc454ej3jhb72.cloudflarestream.com%2F0b88bd223690574d62c3a7c00c00ba78%2Fthumbnails%2Fthumbnail.jpg%3Ftime%3D%26height%3D600\"\n    loading=\"lazy\"\n    style=\"border: none; position: absolute; top: 0; left: 0; height: 100%; width: 100%;\"\n    allow=\"accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;\"\n    allowfullscreen=\"true\"\n  ></iframe>\n</div>\n\nVal Town makes it easy to ship TypeScript automations and applications to the\ninternet via an integrated web editor experience. We strive to offer a magical\ntight feedback loop, with 100ms deploys on save.\n\nThat online editor experience should be great: we should support high-quality\nhighlighting, autocompletion, information for when you hover over bits of code.\nBut unfortunately it hasn't been so: our previous editor has been buggy and slow\nto give useful TypeScript feedback.\n\nBut now, we've rewritten our editor's TypeScript integration from scratch. It's\navailable to all Val Town users, is fast and accurate, and\nthe code is open source.\n\nOur old system: running TypeScript in a Web Worker\n\nOur previous language integration was entirely client-side. We ran a TypeScript\nLanguage Service Host\nin a Web Worker, to isolate it from the top frame's thread, and communicated\nbetween the Web Worker and top frame using\nComlink.\n\nThe system looked like this:\n\n\n\nAnd we bundled it into\ncodemirror-ts, a\nCodeMirror extension, and\nDeno-ATA, an incomplete implementation\nof Deno's import resolution logic grafted onto TypeScript's capabilities.\n\nThis solution worked great in the simplest cases, but stumbled when importing\ncertain NPM packages, and required more and more workarounds. The main two\nissues we were facing were these:\n\n1. TypeScript isn't written for Deno. At Val Town, we run Deno, a modern\n   JavaScript runtime that differs from standard TypeScript. Deno supports URL\n   imports, provides server-side APIs through the Deno global (like environment\n   variables), and introduces its own quirks. Sometimes we’ve been able to work\n   around these differences. For example, we could use Deno type definitions.\n   But in other cases, like handling URL imports, it requires us to interpret\n   files differently. Deno is distinct enough that it ships its own language\n   server, built in Rust and wrapping tsserver.\n2. NPM modules can be gigantic and installing dependencies is no joke. Huge\n   import trees for NPM modules are nothing new, but at least when you're\n   installing NPM modules locally, you have the brilliant minds of the package\n   manager implementers to do module resolution: to install the minimal number\n   of packages by comparing semver ranges. We didn't have that luxury, and often\n   referencing an NPM module would trigger an avalanche of HTTP requests and\n   bytes downloaded, which would overload the Web Worker and make the editor's\n   language tools unresponsive.\n\nBringing DenoLS to Val Town\n\nSo, we redesigned our editor's TypeScript handling. Instead of running TSserver\nin a Web Worker, we now run the official Deno Language Server remotely in cloud\ncontainers.\n\nWe no longer suffer writing our own workarounds to the mismatch between\nTypeScript and Deno, because the Deno project's\nRust code that wraps around a TypeScript instance\nsolves all those problems. Your browser doesn't struggle to download huge NPM\ndependency trees because a beefy server does that for you, from a faster\nconnection.\n\nNow, when you visit our editor, we launch a containerized server that exposes a\nWebSocket and speaks the LSP protocol. The architecture was partially inspired\nMahmud Ridwan's great writeup of connecting CodeMirror & an\nLSP,\nwith the main difference being that we directly map stdio to the WebSocket\nrather than serializing messages, because\nvscode-jsonrpc can do that for us!.\n\nOur open source implementation\n\nTo tweak the language server for our unique purpose, while keeping the\nCodemirror extensions LSP-generic, we also took inspiration from the official\nVS Code LSP client library,\nwhich we couldn't use directly because of its reliance on VS Code globals. Their\nclient provides a way to use\nmiddleware and URI transforms\nso that you can easily tweak the language server at the client level when\nwriting VS Code plugins. Transforming URIs makes it easy to spawn the language\nserver from a temp directory but map file paths as if they were relative to the\nroot, and middleware modify the language server for our unique use case, like\nautomatically downloading dependencies when the server sends the client a red\nsquiggle saying a package isn't installed. We built a similar style system as a\nLanguage Server proxy server library.\nIt acts as a language server of its own, but can arbitrarily modify messages\npassing through it.\n\nTo actually host the LSP as a WebSocket server, there are various subtleties\nthat were important for our use case. We want to keep connections persisted even\nwhen the editor leaves, and allow multiple clients to connect to the same\nlanguage server instance (to support multi tab, or even multi-browser/device\nediting). Our implementation uses a stream\nWebSocket wrapper and pipes stdio directly, and manages multicasting connections\nso many clients can talk to the same process at once.\n\nBringing it to the Browser\n\n<video src=\"https://github.com/user-attachments/assets/5abbc5a3-b397-40fb-beed-f9595021f7a3\"\n  style=\"border: 1px solid #000; width:100%; border-radius: 2px;\"\n   width=\"640\" height=\"360\" controls autoplay loop></video>\n\nOnce we had a language server server in place, we needed a client. This will\nbe querying for hover information on symbol hovers, displaying red squiggles,\nand all of the rest of the language-specific tooling. The\nLSP specification\nis quite sprawling – there are many fun features to support, like code actions\n(buttons such as \"infer return type\") and method suggestions (that pop up as you\ncall functions). Meanwhile we need the client to keep documents synced with the\nlanguage server, and send document update events.\n\nThere are\nsome existing\nCodeMirror language server\nclient implementations,\nwhich we pulled from when building\nour own. We wrote our\nown so that we could support more arbitrary transports, in our case WebSockets\nwith message chunking, external renderers for language server UIs (like to be\nable to use libraries like react, highlight.js, or remark), and take external\ncallback inputs (so that you can implement things like going to definition on an\nexternal document).\n\nShipping on Cloudflare Containers\n\nFor deploying our language servers, it was important that we kept user workloads\nisolated because code is private. Even though we are running language server\nprocesses in temporary directories, you can still infer types of libraries in\nother directories by importing upwards \"../../\", and possibly even hop to their\ndefinition.\n\nWe also wanted servers to live for as long as the user's session. Someone might\nbe editing code for two hours, so a solution like traditional AWS Lambda would\nbe a tough fit. Finally, we wanted to restrict users to using a limited amount\nof language server resources at a time.\n\nInitially, fly seemed like a great option. We could spin up\ncontainers on the fly (🥁) and shut them down when not needed. The issues we saw\nwith fly were that we'd need to manually manage the lifecycles of our\ncontainers, routing individual users to unique containers, and make sure\ncontainers shut down after some amount of time not sending heartbeats from the\nclient.\n\nWhen Cloudflare announced\nCloudflare containers, they\nimmediately seemed like a perfect choice. Cloudflare containers fit within their\nworker/durable object ecosystem and are tenants of durable objects. This means\nthat they are routable by an arbitrary ID, and that the durable object layer (a\npersistent, serverless, JavaScript class instance) can internally manage\ncontainer lifecycles. In our case, we're routing users to a durable object with\nthe ID that is their literal user ID, and then using their\ncontainer library to\nshut containers down after inactivity.\n\nThis means that we didn't actually need to implement any stateful routing layer\nourselves. When you want to connect to a Val Town language server, you simply\nhit our Cloudflare worker with a signed cookie containing your user ID, which\nroutes you directly to a already-running, or brand-new durable object/container\nthat boots your LSP. In the future, it will also be easy to hook into\nCloudflare's built in worker sqlite db to internally manage utilization too.\n\nAll together, the architecture ends up looking like this:\n\n\n\nA server replaced the WebWorker, and instead of communicating by postMessage\n(via Comlink, to a WebWorker), we now use a WebSocket. But the biggest win here\nis using the Deno Language Server and an isolated server for running language\ntooling: this lets us piggy-back on the stock implementation of module\nresolution and keep those huge NPM dependency trees out of the browser's\nresponsibilities.\n\nTry it out\n\nThe easiest way to see this all in action is to\nsign up for Val Town and write some code!\nWhile we'll continue striving for perfection, it's nice to know that we've\ngotten a lot closer to it this summer.\n\nOut is the editor that was slow, buggy, and required a lot of custom\nworkarounds. Now every user has the full, luxurious Deno language server\nexperience.\n\nNow that our editor is in production, it will only continue to improve. We have\nplans to add more Val Town specific language server functionality, like\nsuggesting Val Town standard library function imports, giving useful diagnostics\nabout aspects of Deno that behave differently on our platform, and adding more\nlanguage server features.\n\nWe've also open-sourced everything you need to ship your own cloud container\nWebSocket language server as vtlsp. This\nrepo includes the client, server, and proxy, which you can see in the demo\nbelow.\n\n<img style=\"border: 1px solid #000; width:100%; border-radius: 2px;\" alt='Open Source Demo'\nsrc='https://camo.githubusercontent.com/3939b36c2739eff6a75954445a74f69424e1aacf64cfe0a7e4c68e93867cb235/68747470733a2f2f66696c6564756d707468696e672e76616c2e72756e2f626c6f622f626c6f625f66696c655f313735353132363236343733345f6f75747075742e676966'\n/>",
  "title": "Building a better online editor for TypeScript"
}