Home

Building a Browser Code Editor: The Preview Side

I spent years wondering how the browser turns text into a running page. You type characters, the browser parses them, and something on screen reacts to a mouse. The gap between the string and the result kept me at CodeSandbox for a while.

Before CodeSandbox there were CodePen and JSFiddle. Early in my career I learned by opening other people’s pens, changing a value, and watching what broke. That loop made me want to understand the browser.

This post is about the preview side of renderer, a code editor where the preview runs entirely in the open tab. No npm install, no build server, no backend. Four pieces make it work, and each one removes a server from the picture.


Try it: two frames, one browser

Type in the middle window. The right panel rebuilds on every keystroke. Open the dependencies panel, type a package name, press Enter, and watch an import map land in the project.

The editor and the preview are two iframes on two different domains. They share one contract: a short list of postMessage messages.

Why the preview only accepts files

The preview frame does not know about the editor, this blog, or the domain hosting it. It waits for one kind of message: here is a project, run it.

Every file below is a string inside this page. When the frame loads, this page sends an add-files message with { files, entry }, and the preview fetches what it needs, bundles the code, and runs it in place.

Todo app idle

Watch the network tab while it runs. The only outgoing requests go to a CDN for react and react-dom. Your files never leave this page. A web worker inside the frame built the bundle; no server touched it.

Import maps: bare names get URLs

You write import React from 'react' and the machine is supposed to know where react lives. In Node it resolves through node_modules. In a browser, a bare specifier throws unless the browser is told what it means. An import map is that instruction:

{
  "imports": {
    "react": "https://ga.jspm.io/npm:react@17.0.2/dev.index.js"
  }
}

Put that block in a <script type="importmap"> tag and the browser resolves every bare import against it at fetch time. The todo app above proved it. This next one proves the same thing with a single dependency and zero bundler work on the page:

IDs from a CDN idle

The import map points nanoid at https://cdn.jsdelivr.net/npm/nanoid@5.0.7/index.browser.js. The browser walked the bare import straight to that URL. No lockfile, no install.

Real packages pull in real sub-dependencies, and those come back as bare specifiers too. react-dom wants scheduler, and scheduler wants scheduler/tracing. A scopes entry says: for anything hosted at ga.jspm.io, resolve these names this way. Without scopes, the first scheduler import would fail. Native import maps cover modern browsers; es-module-shims polyfills the rest, and the preview runs it in shim mode so dynamically imported blobs resolve against the same map.

When hand-writing the map stops working

Writing an import map by hand works for two packages. A package with fifteen transitive dependencies and conditional exports deserves a tool. In renderer that tool is @jspm/generator. The editor sends add-dependency with a package name, and the preview hands it to a Generator instance:

import { Generator } from "@jspm/generator";

const generator = new Generator({
  inputMap: {},
  env: ["browser", "development", "module", "import"],
  defaultRegistry: "npm",
});

await generator.install("react-dom");
const importMap = generator.getMap();

The generator walks the npm registry, resolves a version range, applies the browser and module conditions, and returns a complete map with imports and scopes. No tarball unpacked. The map is the dependency tree, and it fits in a script tag.

The same generator runs on generator.jspm.io. Type a package name and you get the map back.

The dependency panel takes the name, the map lands in index.html, and the preview rebuilds:

typing canvas-confetti into the editor's dependency panel generates an import map and rebuilds the preview

Why bundle, and why in a worker

The import map handles packages: a bare specifier becomes a URL, and nothing has to change on disk first. Your .jsx, .tsx, and .css files are a different problem. JSX and TypeScript are not valid browser modules, .css is not a module at all, and a relative import './Button' has no meaning until something resolves it to a file. None of that can run until a bundler has worked through it.

Bundling is CPU work, and the preview rebuilds on every keystroke. On the main thread that build would freeze the editor. So the bundler runs in a web worker, and the main thread only ever sees the finished chunk.

The preview keeps the project in memory. Files go into a cache, and the main thread asks the worker to build through comlink:

import { rollup } from "rollup/dist/rollup.browser.js";

const compiler = await rollup({
  input: "src/index.jsx",
  plugins: [resolvePlugins, virtual(transpiledFiles), json(), postcssPlugin],
});

const { output } = await compiler.generate({ format: "esm" });

Two custom plugins do the work. A resolver takes a relative import, resolves it against the importing file, tries .js, .jsx, .ts, .tsx, falls back to index files, and turns images into data URIs. The transform plugin compiles JSX and TypeScript with sucrase before Rollup parses. Anything the resolver cannot place, react or a https://... URL, is marked external and stays bare in the output. The browser resolves those at runtime through the import map.

This clock shows the same pieces moving, without React:

Clock example idle

clock.ts is TypeScript, compiled in the worker. The worker extracts the two CSS files with rollup-plugin-postcss and injects them into the frame head as style tags. CSS never makes a network round trip.

The build output is one ESM chunk. The preview wraps it in a Blob and imports it, which triggers the frame’s module loader against the import map:

const url = URL.createObjectURL(new Blob([bundle], { type: "application/javascript" }));
await import(/* @vite-ignore */ url);

The worker reports building, then running, then success or failed on the same message channel. The whole build runs off the main thread, so every keystroke can rebuild without freezing the editor.

Why the worker ships a pocket Node

Rollup assumes Node. Even rollup/dist/rollup.browser.js touches process at import time, and its plugins do worse. rollup-plugin-postcss calls path. @rollup/plugin-json checks the filesystem. The worker has none of that. It has self, and it has the files in memory.

The preview ships a pocket Node. src/modules/ holds four shims that stand in for the runtime those libraries assume:

  • process.js is a plain object: version, platform: 'browser', cwd: () => '/', nextTick as a microtask
  • path.js wraps path-browserify and re-exports parse, resolve, join, dirname, extname, sep
  • fs.js reads from the in-memory cache; statSync fakes an mtimeMs, readFileSync returns self[id] || ''
  • fast-glob.js makes every glob match: sync: (patterns) => [].concat(patterns)

The last one earns its keep. Postcss runs fast-glob on build to hunt for entry stylesheets, and the preview already knows all the files, so the shim hands back the patterns as literal matches. The pieces stay small:

// src/modules/fs.js
readFileSync: (id) => self[id] || "",
statSync: () => ({ mtimeMs: ++i }),

// src/modules/fast-glob.js
module.exports = { sync: (patterns) => [].concat(patterns) },

The preview assigns process to self before the first rollup import, so a library that checks typeof process !== "undefined" finds process defined. Every module the bundler loads, including your uncompiled .jsx, reads from the same fake filesystem.

The pipeline: one message, one build

A keystroke becomes one message, and one message becomes one build:

file-update { fileName, content } from the editor
cache.files[key] = content one key, one write
processFile transpile js/ts · parse index.html · encode assets
rollup worker virtual → resolver → postcss
import(url) blob URL against the import map
success the frame posts a status back to the host

That last arrow is the dynamic import that matters. The generated ESM blob loads against the import map, and the frame posts success when the module graph settles.

The cache decides what the cycle runs on

The pipeline runs for one file on a keystroke, not for the whole project. updateFile writes one key to cache.files, calls processFile once on that path, then rebuilds. Everything else comes off the shelf. The worker receives the full two maps, but the expensive stages are keyed by filename: a remote stylesheet is fetched once, an image becomes a base64 string once, the index.html parse runs only when a project arrives, and the jspm generator memoizes what it already resolved. A path present in transpiledFiles never touches the network again. A full add-files clears both maps and walks every file to prime them. After that, the only bytes going through the cycle are the ones you typed.

The protocol between two frames

The editor and the preview share one thing: a list of message types. Every message is a plain object with a type and an identifier; the payload rides in message.

messagedirectionwhat it does
handshakeeditor to previewclaim an identifier for the session
add-fileseditor to previewfull snapshot of the project, first paint
file-update / file-create / file-delete / file-renameeditor to previewincremental edits as you type
add-dependency / remove-dependency / get-import-mapeditor to previewpackage management
bundler-statuspreview to editorloaded, building, running, success, failed
importmap-update / index-html-updatepreview to editorthe resolved map and the rewritten html

add-files and file-update carry the same JSON this page posts into the three preview frames above, with file contents abbreviated:

{
  "type": "add-files",
  "identifier": "editor-4fk2mn",
  "message": {
    "entry": "src/index.jsx",
    "files": {
      "public/index.html": "<!DOCTYPE html>…",
      "src/index.jsx": "import React, { useState } from 'react'…",
      "src/utils.js": "export function uid() {…}",
      "src/styles.css": "body {…}"
    }
  }
}

A keystroke later, the editor sends the one file that changed, whole:

{
  "type": "file-update",
  "identifier": "editor-4fk2mn",
  "message": {
    "fileName": "src/index.jsx",
    "content": "import React, { useState } from 'react'…"
  }
}

The preview answers on the same channel, tagged so the right host can pick it up:

{
  "bundler": true,
  "identifier": "editor-4fk2mn",
  "type": "bundler-status",
  "message": { "status": "running" }
}

The editor sends a full tree once, then diffs. The file store subscribes to changes and, after a 300ms debounce, walks the before and after snapshots to send only create, update, delete, and rename messages. A keystroke becomes a couple of file-update messages, not a re-upload.

The identifier is what lets the same preview page serve every host. This page uses its own identifier for the three examples above, and each example filters bundler-status replies by it. Open them side by side and the status badges stay on their own threads. The same contract works for the embedded editor, a notebook, or any page that can build a message.

The preview also polls get-import-map on its own schedule so the dependency panel reflects whatever the generator last resolved, even if nobody touched the add button.

Why a different origin

Code written in an editor is untrusted, and so are packages fetched through an import map. If that code ran in the same document as the editor, a snippet could read editor state or reach into its localStorage. Running the preview on its own origin makes the document a box: the untrusted code can mutate everything inside its own frame and nothing outside it. The browser enforces the border, not the editor.

postMessage is the only door, and it has a rulebook. Messages must carry a known type; the preview ignores anything else. No DOM access, no shared state, no hidden callback. If an attacker compromises the preview, they get a frame on preview.jkrishna.dev and a message protocol. The editor’s files and tokens stay on the other side of the origin. You can also lock the preview down with CSP headers and cookies disabled without touching the editor, because the two sides only ever exchange strings.

The point

Import maps resolve names, the generator writes the maps, and Rollup in a worker bundles what you wrote. Two frames on two origins trade a handful of message types. There is no backend and no deploy step between the text and the pixels. One of the todo apps at the top of this post is running right now in the same tab you are reading, and the bytes that built it never left this page.

That is what kept pulling me towards browser rendering. A string, a few maps, and some modules become a live program with only the browser in the loop.