iTranslated by AI
Building a VS Code-like Frontend Development Experience in the Browser
This article is a follow-up to the one I previously wrote titled Built a tool to compile and run React and TypeScript code in the browser.
First, please take a look at the video.
As you can see by trying the URL below, IntelliSense for the types of the loaded libraries works properly in the editor, and you can execute React and TypeScript code directly in the browser.
Additionally, by listing the libraries you want to load in package.json, you can run those libraries in the browser. Naturally, types are resolved in the editor, and autocompletion works as well.
Why I built it
While tools like sandpack and webcontainer already exist for bundling and running react and typescript in the browser, I found them difficult to use for the following reasons:
sandpack
- Type autocompletion does not work
- It is difficult to add libraries on the browser side
webcontainer
- Since an editor is not built-in, features like type autocompletion must be self-implemented
- I didn't want to make it that complex
How it is implemented
This implementation uses monaco editor and esm.sh.
monaco editor is a library for bringing a VS Code-like editor to the browser, and esm.sh is a service for retrieving npm libraries from a CDN.
1. Setting up TypeScript in Monaco Editor
Since monaco-editor contains a built-in TypeScript compiler, we first configure the TypeScript options using that compiler. A key point here is to set typeRoots to node_modules/@types.
export const setCompilerOptions = () => {
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2016,
allowNonTsExtensions: true,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
module: monaco.languages.typescript.ModuleKind.CommonJS,
jsx: monaco.languages.typescript.JsxEmit.React,
noEmit: true,
allowJs: true,
esModuleInterop: true,
typeRoots: ["node_modules/@types"],
lib: ["es2016", "dom"],
});
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
};
2. Retrieving types for libraries loaded in package.json
The reason type autocompletion works correctly for loaded libraries in the editor is that it retrieves the types for the libraries specified in package.json. We use the esm.sh service to retrieve the types for the libraries listed in package.json as follows:
export const resolveModuleType = async (
module: string,
): Promise<void> => {
const res = await fetch(`https://esm.sh/${module}`);
const dtsUrl = res.headers.get("x-typescript-types");
if (!dtsUrl) return;
const dts = await fetch(dtsUrl);
const dtsText = await dts.text();
monaco.languages.typescript.typescriptDefaults.addExtraLib(
dtsText,
`file:///node_modules/@types/${module}/index.d.ts`
);
};
// Retrieve types for react
resolveModuleType("react");
When you retrieve a library from the esm.sh site, the x-typescript-types header contains the URL to the type information, so we retrieve that and add it to monaco.
We use the monaco.languages.typescript.typescriptDefaults.addExtraLib method to add the type information retrieved earlier. Since we already set node_modules/@types in the TypeScript typeRoots, we store the retrieved type information there. Paths starting with file:/// are part of the monaco specification and are necessary to retrieve type information stored in node_modules/@types.
3. Highlighting JSX in Monaco Editor
Since monaco-editor does not currently support jsx highlighting out of the box, we use a library called monaco-textmate to achieve jsx highlighting.
The code is as follows:
import { wireTmGrammars } from "monaco-editor-textmate";
import { Registry } from "monaco-textmate"; // peer dependency
import { loadWASM } from "onigasm"; // peer dependency of 'monaco-textmate'
const textmatePath =
"https://esm.sh/githubusercontent/microsoft/vscode/main/extensions";
const registry = new Registry({
getGrammarDefinition: async (scopeName) => {
if (scopeName === "source.tsx") {
const response = await fetch(
`${textmatePath}/typescript-basics/syntaxes/TypeScriptReact.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
if (scopeName === "source.ts") {
const response = await fetch(
`${textmatePath}/typescript-basics/syntaxes/TypeScriptReact.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
if (scopeName === "source.js") {
const response = await fetch(
`${textmatePath}/javascript/syntaxes/JavaScriptReact.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
if (scopeName === "source.jsx") {
const response = await fetch(
`${textmatePath}/javascript/syntaxes/JavaScriptReact.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
if (scopeName === "source.css") {
const response = await fetch(
`${textmatePath}/css/syntaxes/css.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
if (scopeName === "text.html.basic") {
const response = await fetch(
`${textmatePath}/html/syntaxes/html.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
if (scopeName === "source.json") {
const response = await fetch(
`${textmatePath}/json/syntaxes/JSON.tmLanguage.json`
);
return {
format: "json",
content: await response.text(),
};
}
return {
format: "json",
content: "",
};
},
});
export const initEditor = async () => {
await loadWASM("https://esm.sh/onigasm@2.2.5/lib/onigasm.wasm");
monaco.editor.defineTheme("myTheme", theme);
monaco.editor.setTheme("myTheme");
const grammers = new Map();
grammers.set("typescript", "source.tsx");
grammers.set("javascript", "source.jsx");
grammers.set("css", "source.css");
grammers.set("html", "text.html.basic");
grammers.set("json", "source.json");
await wireTmGrammars(monaco, registry, grammers);
};
The library onigasm is a dependency of monaco-textmate, which uses onigasm to handle regular expressions. Since onigasm is implemented in WebAssembly, you need to load onigasm.wasm separately.
In the code above, onigasm.wasm is retrieved from esm.sh where it is available.
monaco-editor-textmate is a library for registering tmLanguage with monaco-editor using monaco-textmate. By using this, you can finally achieve jsx highlighting.
4. Executing the Code
As mentioned in the previous article, dependencies for imported files are resolved roughly as follows.
When import is written without relative paths
When an import is written without a relative path, the module is retrieved from a CDN using esm.sh.
const importRegex = /import\s+.*\s+from\s+['"][^'.].*['"];?/g
const importStatements = code.match(importRegex)
let importCode = ""
importStatements?.forEach((importStatement) => {
const convertedCode = importStatement.replace(/from\s*['"]([^'"]*)['"]/g, function(_match, p1) {
return `from 'https://esm.sh/${p1}'`;
});
importCode += convertedCode + "\n"
})
When import is written with relative paths
When an import is written with a relative path, the imported file is converted to a Blob URL, and the import statement is rewritten.
const relativeImportRegex = /import\s+.*\s+from\s+['"][.].*['"];?/g
const relativeImportStatements = code.match(relativeImportRegex)
if (relativeImportStatements) {
await Promise.all(relativeImportStatements.map(async (relativeImportStatement) => {
const convertedCode = await replaceAsync(relativeImportStatement, /from\s*['"]([^'"]*)['"]/g, async (_match, p1) => {
const file = files[p1]
const { code: converted } = await transformCode(file, options)
if (file) {
const blob = new Blob([converted], { type: 'text/javascript' })
const blobURL = URL.createObjectURL(blob)
return `from '${blobURL}'`;
} else {
return `from '${p1}'`;
}
});
importCode += convertedCode + "\n"
}))
}
5. Delaying reflection process to the iframe
Since updating the iframe every time the code changes would make the process heavy, I use debounce to delay the reflection. For details, please check the code.
Summary
In this article, I introduced how to achieve a VS Code-like development experience in the browser. I found that by using esm.sh and monaco-editor, it is possible to realize a development experience that is very close to VS Code in terms of feel.
Furthermore, I heard that ESLint and Emmet can also be integrated into monaco-editor, so I believe the development experience in the browser will improve even further in the future.
I am aiming for a world where you can write and run React and TypeScript code without any environment setup, so please continue to support me!
The tool I created this time is published in the following repository.
Discussion