Build VS Code for the browser and serve it from a plain static web server.
The online version served via GitHub pages can be seen at https://christianjann.github.io/vscode-web/.
npm install # ~6 minInstalls all Node.js dependencies for the VS Code project.
npm run download-builtin-extensionsDownloads prebuilt extensions (e.g. js-debug) into .build/builtInExtensions/.
This runs automatically as part of code-web.sh, but can be run manually.
npm run transpile-client # ~10s, fast esbuild transpile
# OR
npm run watch-web # watch mode — recompiles on changesTranspiles TypeScript sources into out/ (unminified, un-mangled).
This is what code-web.sh serves for local development.
./scripts/code-web.sh # serves from out/ on localhost:8080Uses @vscode/test-web to serve VS Code from the repo's out/ directory.
Dynamically generates HTML with configuration injected at runtime.
npm run gulp compile-build-with-mangling # ~50s, with name mangling
# OR
npm run gulp compile-build-without-mangling # ~4 min, no manglingCompiles TypeScript into out-build/ with property mangling for smaller output.
This is the prerequisite for all minify/package tasks below.
Fix required: chatDebugEditor.ts had override setEditorVisible(...) without protected, which widened the visibility and broke the mangler. Fixed by adding protected.
npm run gulp minify-vscode-reh-web # bundles out-build/ → out-vscode-reh-web-min/Reads from out-build/, bundles entry points with esbuild, and minifies.
This is the web server backend bundle (not the standalone web client).
npm run gulp vscode-reh-web-linux-x64-min # ~2 minFull pipeline: compile → bundle → minify → package into ../vscode-reh-web-linux-x64/.
This is the "VS Code Server" for web — requires Node.js to serve.
npm run gulp vscode-web-min # ~1 hourThis is the command that created <build dir>/vscode-web/.
Full pipeline: compile-build-with-mangling → compile-web-extensions-build → esbuild-vscode-web-min (bundle+minify) → packageTask → writes to ../vscode-web/.
The output path is BUILD_ROOT/vscode-web where BUILD_ROOT is the parent of the repo root (<build dir>/), so the output lands at <build dir>/vscode-web/.
The vscode-web package is the official production web client. It contains:
out/— minified + bundled JS/CSSextensions/— compiled web extensionsnode_modules/— production dependenciespackage.json,favicon.ico,manifest.json
Note: Despite being a "web" build, it still needs a Node.js server to dynamically generate the HTML entry point with configuration injected.
Because the official vscode-web output still requires a dynamic server, we created custom scripts that produce fully self-contained static directories:
npm run transpile-client # compile to out/
node scripts/package-web-static.js # package → ../vscode-web-static/
cd ../vscode-web-static && python3 -m http.server 8080npm run gulp vscode-web-min # ~1 hour → ../vscode-web/
node scripts/package-web-static-optimized.js # package → ../vscode-web-static-optimized/
cd ../vscode-web-static-optimized && python3 -m http.server 8080The script (scripts/package-web-static.js) does:
- Copies
out/(compiled JS/CSS) - Patches
webview/browser/pre/index.html:- Skips hostname hash validation (plain servers can't do UUID subdomains)
- Changes CSP from
sha256-...to'unsafe-inline'(since the script was modified)
- Copies
extensions/and.build/builtInExtensions/ - Bundles a custom
workspace-loaderextension (loads ZIP files into in-memory FS) - Copies
resources/server/(favicon, manifest, icons) - Scans extensions for web-compatible ones using
isWebExtension()logic - Generates
index.htmlwith:- Product configuration (without
commit— see below) - Builtin extensions metadata in
<meta>tag - CSS modules import map
- Dynamic webview endpoint override (same-origin)
- ZIP workspace loader redirect
- Product configuration (without
| Problem | Root Cause | Fix |
|---|---|---|
Mangler error during compile-build-with-mangling |
chatDebugEditor.ts widened setEditorVisible from protected to public |
Added protected modifier |
Blank page with code-web.sh |
compile-build-* writes to out-build/, but code-web.sh serves out/ |
Run npm run transpile-client instead |
| No themes in picker | extensionPath was extensions/theme-defaults instead of theme-defaults |
Fixed scanner to use folder name only |
| No extensions loaded at all | commit was set → isBuilt=true → used empty hardcoded array |
Set commit: undefined in product config |
| CORS errors on webview resources | Webviews loaded from vscode-cdn.net, fetched back to localhost |
Override webviewEndpoint to same origin |
| Webview hostname validation error | pre/index.html expects UUID hash as hostname |
Patched to if (true) in output |
| CSP blocks modified inline script | SHA-256 hash no longer matches patched script | Changed CSP to 'unsafe-inline' |
../vscode-web-static/— ~1.8 GB (unminified dev build with all extensions)../vscode-web/(fromvscode-web-min) — production minified build
Package the optimized/minified vscode-web-min build into a self-contained static directory that can be served by any plain web server, without requiring Node.js.
The npm run gulp vscode-web-min command creates ../vscode-web/ with:
- Minified + bundled JS/CSS (
out/) - Compiled web extensions (
extensions/) - Production dependencies (
node_modules/) - Server resources (favicon, manifest, icons)
However, it still requires a Node.js server to dynamically generate the index.html with configuration injected at runtime.
Created scripts/package-web-static-optimized.js that:
- Copies the optimized build from
../vscode-web/(instead of building fromout/+extensions/) - Generates a
workbench.jsbootstrapper — the optimizedwebbuild target intentionally excludes the shell (vs/code/browser/workbench/workbench.js). Only theserver-webtarget includes it. The script generates a self-contained bootstrapper that imports{ create, URI }from the bundledworkbench.web.main.internal.jsand wires up config + workspace provider inline. - Applies webview patches — same as the dev version (hostname validation bypass, CSP
'unsafe-inline') - Bundles the workspace-loader extension for ZIP workspace loading
- Generates static
index.htmlwith:- CSS link to the bundled
workbench.web.main.internal.css - NLS messages loading (
out/nls.messages.js) - Product configuration, extension metadata, webview endpoint overrides
- CSS link to the bundled
- Outputs to
../vscode-web-static-optimized/
| Problem | Root Cause | Fix |
|---|---|---|
workbench.js MIME type error (text/html) |
Optimized web build doesn't include the shell bootstrapper — only bundles workbench.web.main.internal.js |
Generate a self-contained workbench.js that imports { create, URI } from the bundle |
| Missing NLS strings | Optimized build has nls.messages.js (sets globalThis._VSCODE_NLS_MESSAGES) but HTML didn't load it |
Added <script type="module" src="out/nls.messages.js"> before workbench.js |
| Missing CSS | Bundled CSS wasn't linked | Added <link rel="stylesheet"> for workbench.web.main.internal.css |
| ZIP workspace loading broken — extension never activates | Baked-in commit hash in the bundle + JSON.stringify dropping undefined (see detailed explanation below) |
Changed commit: undefined to commit: null in the packaging script |
This was a subtle and important bug. The workspace-loader extension was correctly bundled, correctly listed in the HTML <meta> tag (81 extensions total), and its files were accessible via HTTP — yet its activate() function was never called in the optimized build (while working fine in the dev build).
The chain of events:
-
The optimized build's
workbench.web.main.internal.jshas a baked-in product configuration viaBUILD->INSERT_PRODUCT_CONFIGURATIONinsrc/vs/platform/product/common/product.ts. This includescommit:"4fb8242c..."— the git commit hash at build time. -
Our packaging script set
commit: undefinedin the HTML config'sproductConfiguration, intending to override the baked-in value. -
However,
JSON.stringify({commit: undefined})produces{}—undefinedis not valid JSON and the key is silently dropped. -
At startup,
web.main.tsmerges configs viamixin(product, productConfiguration). Since the HTML config has nocommitkey at all,mixin()never overwrites the baked-in commit hash. -
isBuiltis defined as!!this.productService.commit— with the baked-in commit still present,isBuilt = true. -
The builtin extension scanner in
builtinExtensionsScannerService.tshas two code paths:if (isBuilt)→ uses a hardcoded extension list that was injected at build time (which does NOT include workspace-loader)else→ reads extensions from the DOM<meta id="vscode-workbench-builtin-extensions">tag (which DOES include workspace-loader)
-
Since
isBuiltwastrue, the scanner used the hardcoded list, workspace-loader was never discovered, and itsactivate()was never called.
The fix: Change commit: undefined to commit: null. JSON.stringify({commit: null}) produces {"commit":null}, so mixin() overwrites the baked-in commit with null, making isBuilt = !!null = false, and the scanner falls through to the DOM path where it finds all 81 extensions including workspace-loader.
Key learning: In the dev build, product.ts has product = {} (empty object, no baked-in commit) because BUILD->INSERT_PRODUCT_CONFIGURATION is not replaced. So commit: undefined in the HTML config worked fine — there was nothing to override. The optimized build is fundamentally different because the build pipeline replaces the placeholder with real product data including the commit hash.
# Build the optimized version
npm run gulp vscode-web-min # ~1 hour → ../vscode-web/
# Package it for static serving
node scripts/package-web-static-optimized.js # → ../vscode-web-static-optimized/
# Serve with any web server
cd ../vscode-web-static-optimized && python3 -m http.server 8080- Source:
../vscode-web/(pre-built optimized) vsout/+extensions/(dev build) - Bootstrapper: Generates self-contained
workbench.js(dev build has one already inout/) - CSS: Single bundled
workbench.web.main.internal.csslinked directly (dev uses CSS modules import map) - NLS: Loads pre-built
nls.messages.js(dev build inlines strings) - Extensions: Already compiled and filtered to web-compatible ones
- Size: 109M vs 1.8G due to minification and bundling
../vscode-web-static/— 1.8G (dev build, all extensions, unminified)../vscode-web-static-optimized/— 109M (production build, minified, bundled)
Both ../vscode-web-static/ and ../vscode-web-static-optimized/ support ZIP workspace loading and can be served by any static web server.
We now have two complete static VS Code web builds:
-
Dev Build (
../vscode-web-static/- 1.8G)- Fast compilation (~10s)
- All extensions included
- Unminified for debugging
- Full source maps
-
Optimized Build (
../vscode-web-static-optimized/- 109M)- Production compilation (~1 hour)
- Minified and bundled
- Smaller size, faster loading
- Same features and extensions
Both can be served by any static web server and support:
- Opening local folders (Chrome/Edge File System Access API)
- Loading ZIP files as in-memory workspaces
- All VS Code web features (extensions, themes, etc.)
The build pipeline is now complete from source to static deployment.
| Goal | How | Notes |
|---|---|---|
| Edit local files on disk | File > Open Folder in Chrome/Edge | Uses the browser's File System Access API — reads/writes directly to disk. Zero config. |
| Scratch space (resets on reload) | Navigate to ?folder=tmp%3A%2Fworkspace |
Opens an empty in-memory folder on the tmp:// scheme. |
| Load a ZIP from the server | Navigate to ?zip=./demo.zip |
The workspace-loader extension fetches, extracts, and populates tmp:/workspace. |
| Load a GitHub repo | Navigate to ?zip=https://codeload.github.com/USER/REPO/zip/refs/heads/main |
Same mechanism — GitHub serves ZIP archives at that URL. |
| Persist edits across reloads | Not yet supported | Would require replacing the in-memory tmp:// provider with an IndexedDB-backed one. |
| Clone a git repo in-browser | Not yet supported | Could be added via isomorphic-git in the workspace-loader extension. |
When running the static builds, the browser console will show several errors and warnings. These are all expected and do not affect functionality:
| Console Message | Why It's Expected |
|---|---|
Request for font "..." blocked at visibility level 2 |
Firefox's font visibility policy blocks access to certain system fonts. Not actionable — browser security feature. |
Feature Policy: Skipping unsupported feature name "usb"/"serial"/"hid"/etc. |
VS Code requests hardware permissions that the browser doesn't support. Harmless. |
ENOPRO: No file system provider found for resource 'file:///.claude/agents' (and .copilot/instructions, .copilot/agents, .claude/rules) |
Copilot/Claude agent features try to resolve file:// URIs, but there's no file:// filesystem provider in pure-web mode. Expected without a backend server. |
ERR withProvider → doWatch (file watcher errors) |
Same root cause — no filesystem watcher backend available in static web mode. |
Source map error: request failed with status 404 (CDN URL) |
The minified bundle has a //# sourceMappingURL pointing to vscode-cdn.net. The CDN source maps aren't available locally. Not actionable. |
The web worker extension host is started in a same-origin iframe! |
Expected when serving without UUID-based subdomain isolation. |
An iframe which has both allow-scripts and allow-same-origin... |
Standard browser warning for sandboxed iframes — required for webviews to function. |
Ignoring fetching additional builtin extensions from gallery as it is disabled |
No marketplace backend configured — extensions are served locally. |
Long running operations during shutdown are unsupported in the web |
Fires when closing/navigating away from the tab. VS Code web can't do async shutdown like the desktop app. |
Uncaught (in promise) Canceled: Canceled |
Same — page unload cancels in-flight promises during shutdown. |
No service worker controller found |
No service worker registered in static mode. |
The character encoding of a framed document was not declared |
The web worker extension host iframe doesn't declare charset. Cosmetic. |
Found unexpected null in webview hookupOnLoadHandlers |
Pre-existing VS Code bug: race condition in pre/index.html where newFrame.contentWindow is null after document.write()/document.close() cycle. Not caused by our patches — same code is in the upstream source. The webview still renders correctly. |
Ignoring the error while validating workspace folder tmp:/workspace - file not found |
Workspace validation runs before the workspace-loader extension has finished populating the tmp:/workspace folder. The folder is validated again after files are written and works fine. |
The file is not displayed in the text editor because it is a directory. |
On page reload, VS Code tries to open the workspace root before the ZIP loader extension has finished extracting files. This message appears briefly and disappears as soon as the files are loaded. Harmless race condition. |
Both packaging scripts generate a demo.zip containing a small sample project (HTML, CSS, JS, VS Code settings). Use it to verify the ZIP workspace loader works:
# 1. Build (if not done already)
npm run gulp vscode-web-min
node scripts/package-web-static-optimized.js
# 2. Serve
cd ../vscode-web-static-optimized
python3 -m http.server 8080Then open in your browser:
http://localhost:8080/?zip=./demo.zip
The page redirects once (adding &folder=tmp%3A%2Fworkspace), VS Code opens the in-memory folder, and the workspace-loader extension fetches and extracts the ZIP. You'll see a notification when loading is complete.
npm run transpile-client
node scripts/package-web-static.js
cd ../vscode-web-static
python3 -m http.server 8080Then open: http://localhost:8080/?zip=./demo.zip
demo-project/
├── .vscode/
│ └── settings.json # editor.tabSize=2, formatOnSave
├── README.md # project readme
├── index.html # simple HTML page
├── style.css # basic styles
└── app.js # "Hello from VS Code Web!" script
# Load your own ZIP (copy it into the served directory first)
http://localhost:8080/?zip=./myproject.zip
# Load a public GitHub repository
http://localhost:8080/?zip=https://codeload.github.com/user/repo/zip/refs/heads/mainFiles are editable in-memory — changes are lost on page reload.
To open a local folder from disk instead (Chrome/Edge only), simply navigate to http://localhost:8080/ and use File > Open Folder.
Open a project from a ZIP file (local or remote URL) as an editable in-memory workspace in the static VS Code web build, without requiring a dynamic server.
- Navigate to
http://localhost:8000/?zip=./myproject.zip index.htmlstartup script (runs in main window) detects?zip=and:- Resolves the potentially-relative URL to absolute (
new URL(zipParam, window.location.href).href) - Injects the absolute URL into
configurationDefaults['workspaceLoader.zipUrl'] - Redirects to add
?folder=tmp:/workspaceso VS Code opens the in-memory folder
- Resolves the potentially-relative URL to absolute (
- VS Code boots with
tmp:/workspaceas the workspace folder (using its built-intmp://InMemoryFileSystemProvider) - The bundled
local.workspace-loaderweb extension activates, reads the ZIP URL fromvscode.workspace.getConfiguration('workspaceLoader').get('zipUrl'), fetches the ZIP, strips the common top-level directory (e.g.repo-main/from GitHub archive downloads), and writes all files intotmp:/workspace/viavscode.workspace.fs
# Repackage after any changes to package-web-static.js
npm run transpile-client
node scripts/package-web-static.js
# Serve
cd ../vscode-web-static && python3 -m http.server 8000
# Open a local ZIP (served from the same server)
# → http://localhost:8000/?zip=./myproject.zip
# Open a GitHub repo archive
# → http://localhost:8000/?zip=https://codeload.github.com/USER/REPO/zip/refs/heads/mainFiles are editable in-memory — changes are lost on page reload.
extensions/local.workspace-loader/package.json— web extension manifest; contributesworkspaceLoader.zipUrlconfiguration propertyextensions/local.workspace-loader/extension.js— CommonJS extension with JSZip inlined (UMD preamble forced to CommonJS branch by shadowingdefine)
| Problem | Root Cause | Fix |
|---|---|---|
command 'remoteHub.openRepository' not found |
"Open Folder" in the static build tries RemoteHub (not installed) | Use ZIP approach instead; File System Access API works in Chrome/Edge for local folders |
define is not a function |
Extension was written as AMD (define([...], function(){})) but the web extension host uses plain CommonJS |
Rewrote extension as CommonJS: 'use strict'; exports.activate = ... |
window is not defined |
Web extensions run in a Web Worker — window doesn't exist there |
Read zip URL via vscode.workspace.getConfiguration() instead; inject it into configurationDefaults from the main-window startup script (which does have window) |
Failed to parse URL from ./dark.zip |
Relative URL ./dark.zip resolved against the Web Worker's opaque blob/iframe base URL, not the page URL |
Resolve to absolute in the main window (new URL(param, window.location.href).href) before injecting into config |
tmp:/workspace not found warning |
tmp:///workspace (triple slash) is normalised by VS Code's URI parser to tmp:/workspace (single slash) causing a mismatch |
Use tmp:/workspace everywhere consistently |
When a user visits the page without any URL parameters (no ?zip=, ?folder=, ?workspace=, or ?ew=), the startup script in index.html performs a HEAD request to ./default.zip. If the file exists (HTTP 200), it automatically redirects to ?zip=./default.zip&folder=tmp%3A%2Fworkspace — loading the default workspace.
If default.zip is not present on the server (HTTP 404), nothing happens and the user gets the standard empty VS Code with the option to open a local folder (Chrome/Edge).
Both packaging scripts (package-web-static.js and package-web-static-optimized.js) generate a default.zip that contains a README.md explaining the project, author info, and usage instructions.
After building, the output directory contains everything needed. You can add files to the served directory:
| File | Purpose |
|---|---|
default.zip |
Auto-loaded workspace — replace with your own ZIP to customize what users see by default |
demo.zip |
Demo project — accessed via ?zip=./demo.zip |
*.zip (any name) |
Any additional ZIP files — accessible via ?zip=./filename.zip |
To customize the default workspace for your deployment:
- Create a ZIP file containing your project files
- Name it
default.zip - Place it in the root of the served directory (alongside
index.html) - Users visiting the bare URL will automatically see your project
To disable the default workspace, simply delete default.zip from the served directory.
The script scripts/package-web-static-with-dummy-extension.js patches an existing static web build (produced by package-web-static-optimized.js) to add extensions as built-ins using the official additionalBuiltinExtensions API. It does not call the base packaging script — it assumes ../vscode-web-static-optimized/ already exists with a valid index.html.
The script uses VS Code's additionalBuiltinExtensions API — the official mechanism for adding custom built-in extensions to a web build. Instead of injecting extension metadata directly into the <meta id="vscode-workbench-builtin-extensions"> tag (which would duplicate the approach used for core built-ins), it:
- Verifies that
../vscode-web-static-optimized/exists with anindex.html - Creates a dummy extension in
scripts/dummy-extension/with apackage.json,extension.js, andLICENSE.txt - Packages it as a VSIX using
npx vsce package - Extracts the VSIX into
additional-extensions/dummy-extension/in the web root (separate from the coreextensions/directory) and strips VSIX metadata files - Patches
index.html— adds the extension path to_additionalBuiltinExtensionPaths(a custom array) in the<meta id="vscode-workbench-web-configuration">config, and addsextensionsGallerypointing to open-vsx.org - Patches
workbench.js— injects code before thecreate()call that resolves_additionalBuiltinExtensionPathsto properadditionalBuiltinExtensionsUriComponents at runtime usingnew URL(p, window.location.href)to handle relative paths correctly for subfolder deployments. This ensures the scheme, authority, and path are resolved relative to the current page URL regardless of where the build is served.
At runtime, the bootstrapper resolves 'additional-extensions/dummy-extension' to a full UriComponents using new URL(p, window.location.href) to handle relative paths correctly for subfolder deployments. For example, when served at http://localhost:8080 it becomes { scheme: 'http', authority: 'localhost:8080', path: '/additional-extensions/dummy-extension' }, and when served at https://example.com/subfolder/ it becomes { scheme: 'https', authority: 'example.com', path: '/subfolder/additional-extensions/dummy-extension' }. The extension scanner then fetches <origin>/additional-extensions/dummy-extension/package.json to discover and load the extension. This works with any protocol (HTTP or HTTPS) and any host/port combination, including subfolder deployments.ks with any protocol (HTTP or HTTPS) and any host/port combination.
# 1. Build the optimized static web build (if not done already)
npm run gulp vscode-web-min
node scripts/package-web-static-optimized.js
# 2. Patch in the dummy extension (can be re-run independently)
node scripts/package-web-static-with-dummy-extension.js
# 3. Serve
cd ../vscode-web-static-optimized && python3 -m http.server 8080The script can be run multiple times — it's idempotent (removes previous entries before adding). The dummy extension registers a command "Hello World from Dummy" in the Command Palette. It appears as a built-in extension (visible under @builtin in the Extensions view, not under "INSTALLED").
The script also configures the Open VSX Registry as the extensions marketplace, so users can browse, search, and install additional extensions from the Extensions view. This is the open-source alternative to the Visual Studio Marketplace, used by Code OSS and other non-Microsoft VS Code distributions.
To bundle a different extension, modify package-web-static-with-dummy-extension.js:
- Create or obtain a web-compatible extension (must have
"browser"field inpackage.jsonor only use web APIs) - Package it as a VSIX (
vsce package) - Extract it into
additional-extensions/<name>/in the web root - Add its path (e.g.
'/additional-extensions/<name>') to_additionalBuiltinExtensionPathsin the config
Note: Only web-compatible extensions work in the browser. Extensions that require Node.js APIs or native modules will not load.
