├── .gitignore ├── README.md ├── src ├── index.tsx ├── index.css ├── sandpack-components │ ├── SandpackTypescript.tsx │ ├── CodeEditor.tsx │ └── codemirror-extensions.ts └── App.tsx ├── .codesandbox └── workspace.json ├── tsconfig.json ├── package.json └── public ├── index.html └── workers ├── tsserver.js └── tsserver.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sandpack-tsserver 2 | Created with CodeSandbox 3 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "react-dom"; 2 | 3 | import App from "./App"; 4 | 5 | const rootElement = document.getElementById("root"); 6 | render(, rootElement); 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | color: #151515; 4 | line-height: 1.4; 5 | } 6 | 7 | .content { 8 | width: 960px; 9 | margin: 0 auto; 10 | } 11 | 12 | .sp-wrapper { 13 | --sp-layout-height: 370px; 14 | 15 | margin: 0 -25px 25px; 16 | } 17 | -------------------------------------------------------------------------------- /.codesandbox/workspace.json: -------------------------------------------------------------------------------- 1 | { 2 | "responsive-preview": { 3 | "Mobile": [ 4 | 320, 5 | 675 6 | ], 7 | "Tablet": [ 8 | 1024, 9 | 765 10 | ], 11 | "Desktop": [ 12 | 1400, 13 | 800 14 | ], 15 | "Desktop HD": [ 16 | 1920, 17 | 1080 18 | ] 19 | } 20 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "lib": [ 10 | "dom", 11 | "es2015" 12 | ], 13 | "jsx": "react-jsx", 14 | "target": "es5", 15 | "allowJs": true, 16 | "allowSyntheticDefaultImports": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/sandpack-components/SandpackTypescript.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SandpackConsumer, 3 | SandpackLayout, 4 | SandpackPreview, 5 | SandpackProvider, 6 | SandpackSetup, 7 | SandpackThemeProvider, 8 | SandpackPredefinedTemplate, 9 | } from "@codesandbox/sandpack-react"; 10 | import "@codesandbox/sandpack-react/dist/index.css"; 11 | import { CodeEditor } from "./CodeEditor"; 12 | 13 | export const SandpackTypescript: React.FC<{ 14 | customSetup: SandpackSetup; 15 | template: SandpackPredefinedTemplate; 16 | }> = ({ customSetup, template }) => { 17 | return ( 18 | 19 | 20 | 21 | 22 | {(state) => } 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-lsp", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "@codesandbox/sandpack-react": "0.13.1", 9 | "@okikio/emitter": "2.1.7", 10 | "@typescript/vfs": "1.3.5", 11 | "debounce-async": "0.0.2", 12 | "lodash.debounce": "4.0.8", 13 | "react": "17.0.2", 14 | "react-dom": "17.0.2", 15 | "react-scripts": "5.0.0" 16 | }, 17 | "devDependencies": { 18 | "@types/lodash.debounce": "^4.0.6", 19 | "@types/react": "17.0.20", 20 | "@types/react-dom": "17.0.9", 21 | "esbuild": "^0.14.13", 22 | "typescript": "4.4.2" 23 | }, 24 | "scripts": { 25 | "start": "TSC_COMPILE_ON_ERROR=true react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test --env=jsdom", 28 | "eject": "react-scripts eject", 29 | "worker:watch": "esbuild --target=ES2021 --watch public/workers/tsserver.ts --bundle --outfile=public/workers/tsserver.js" 30 | }, 31 | "browserslist": [ 32 | ">0.2%", 33 | "not dead", 34 | "not ie <= 11", 35 | "not op_mini all" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/sandpack-components/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import { SandpackCodeEditor, useSandpack } from "@codesandbox/sandpack-react"; 2 | 3 | import { EventEmitter } from "@okikio/emitter"; 4 | import { codemirrorTypescriptExtensions } from "./codemirror-extensions"; 5 | import { memo, useEffect, useRef } from "react"; 6 | 7 | export const CodeEditor: React.FC<{ activePath?: string }> = memo( 8 | ({ activePath }) => { 9 | const tsServer = useRef( 10 | new Worker(new URL("/workers/tsserver.js", window.location.origin), { 11 | name: "ts-server", 12 | }) 13 | ); 14 | const emitter = useRef(new EventEmitter()); 15 | const { sandpack } = useSandpack(); 16 | 17 | useEffect(function listener() { 18 | const serverMessageCallback = ({ 19 | data: { event, details }, 20 | }: MessageEvent<{ event: string; details: any }>) => { 21 | emitter.current.emit(event, details); 22 | }; 23 | 24 | tsServer.current.addEventListener("message", serverMessageCallback); 25 | 26 | return () => { 27 | tsServer.current.removeEventListener("message", serverMessageCallback); 28 | }; 29 | }, []); 30 | 31 | useEffect(function init() { 32 | emitter.current.on("ready", () => { 33 | const getTypescriptCache = () => { 34 | const cache = new Map(); 35 | const keys = Object.keys(localStorage); 36 | 37 | keys.forEach((key) => { 38 | if (key.startsWith("ts-lib-")) { 39 | cache.set(key, localStorage.getItem(key)); 40 | } 41 | }); 42 | 43 | return cache; 44 | }; 45 | 46 | tsServer.current.postMessage({ 47 | event: "create-system", 48 | details: { 49 | files: sandpack.files, 50 | entry: sandpack.activePath, 51 | fsMapCached: getTypescriptCache(), 52 | }, 53 | }); 54 | }); 55 | 56 | emitter.current.on( 57 | "cache-typescript-fsmap", 58 | ({ 59 | version, 60 | fsMap, 61 | }: { 62 | version: string; 63 | fsMap: Map; 64 | }) => { 65 | fsMap.forEach((file, lib) => { 66 | const cacheKey = "ts-lib-" + version + "-" + lib; 67 | localStorage.setItem(cacheKey, file); 68 | }); 69 | } 70 | ); 71 | }, []); 72 | 73 | const extensions = codemirrorTypescriptExtensions( 74 | tsServer.current, 75 | emitter.current, 76 | activePath 77 | ); 78 | 79 | return ; 80 | } 81 | ); 82 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { SandpackTypescript } from "./sandpack-components/SandpackTypescript"; 2 | import "./index.css"; 3 | 4 | export default function App() { 5 | return ( 6 |
7 |

Sandpack + TypeScript LSP

8 |

9 | It implements an interface between Sandpack, which uses CodeMirror under 10 | the hood, and TypeScript Virtual File System to consume all the benefits 11 | a language server protocol can provide, but inside a browser. 12 |

13 | 14 |
    15 |
  • IntelliSense;
  • 16 |
  • Tooltip error;
  • 17 |
  • Multiple files;
  • 18 |
  • Support tsconfig.json;
  • 19 |
  • Automatically dependency-types fetching (CodeSandbox CDN);
  • 20 |
  • In-browser dependency cache;
  • 21 |
22 | 23 |
24 | 25 |

Vanilla TypeScript

26 | = R[] 33 | 34 | const data: List = [123, "foo"] 35 | const selector = document.getElementById("app") 36 | 37 | selector.innerHTML = \` 38 |

Hello Vanilla!

39 |

\${data}

40 | \`;`, 41 | }, 42 | }} 43 | /> 44 | 45 |

Basic React

46 | ("0"); 54 | 55 | function handleClick() { 56 | setCount(count + 1); 57 | } 58 | 59 | return ( 60 | 63 | ); 64 | }`, 65 | }, 66 | }} 67 | /> 68 | 69 |

React + Dependency

70 | 92 |

Hello world!

93 | 94 | ) 95 | }`, 96 | }, 97 | }, 98 | }} 99 | /> 100 | 101 |

React + Dependency + Multiple files

102 | \` 112 | /* This renders the buttons above... Edit me! */ 113 | background: transparent; 114 | border: 2px solid palevioletred; 115 | color: palevioletred; 116 | margin: 1em; 117 | padding: 0.25em 1em; 118 | 119 | \${props => props.primary && css\` 120 | background: palevioletred; 121 | color: white; 122 | \`}; 123 | \``, 124 | "/App.tsx": `import React from "react" 125 | import { Button } from "./Button" 126 | 127 | export default function App(): JSX.Element { 128 | return ( 129 |
130 | 131 | 132 |
133 | ) 134 | }`, 135 | }, 136 | }} 137 | /> 138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /src/sandpack-components/codemirror-extensions.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "@okikio/emitter"; 2 | import { EditorView, ViewUpdate } from "@codemirror/view"; 3 | import { 4 | autocompletion, 5 | completeFromList, 6 | CompletionContext, 7 | CompletionResult, 8 | Completion, 9 | } from "@codemirror/autocomplete"; 10 | import { hoverTooltip, Tooltip } from "@codemirror/tooltip"; 11 | import { Diagnostic, linter } from "@codemirror/lint"; 12 | 13 | import debounce from "lodash.debounce"; 14 | import debounceAsync from "debounce-async"; 15 | 16 | export const codemirrorTypescriptExtensions = ( 17 | tsServer: Worker, 18 | emitter: EventEmitter, 19 | filePath?: string 20 | ) => [ 21 | EditorView.updateListener.of( 22 | debounce((update: ViewUpdate) => { 23 | tsServer.postMessage({ 24 | event: "updateText", 25 | details: { 26 | filePath, 27 | content: update.state.doc.text.join("\n"), 28 | }, 29 | }); 30 | }, 150) 31 | ), 32 | 33 | autocompletion({ 34 | activateOnTyping: true, 35 | override: [ 36 | debounceAsync( 37 | async (ctx: CompletionContext): Promise => { 38 | const { pos } = ctx; 39 | 40 | try { 41 | tsServer.postMessage({ 42 | event: "autocomplete-request", 43 | details: { pos, filePath }, 44 | }); 45 | 46 | const completions = await new Promise((resolve) => { 47 | emitter.on("autocomplete-results", (completions) => { 48 | resolve(completions); 49 | }); 50 | }); 51 | 52 | if (!completions) { 53 | console.log("Unable to get completions", { pos }); 54 | return null; 55 | } 56 | 57 | return completeFromList( 58 | // @ts-ignore 59 | completions.entries.map((c, i) => { 60 | let suggestions: Completion = { 61 | type: c.kind, 62 | label: c.name, 63 | // TODO:: populate details and info 64 | boost: 1 / Number(c.sortText), 65 | }; 66 | 67 | return suggestions; 68 | }) 69 | )(ctx); 70 | } catch (e) { 71 | console.log("Unable to get completions", { pos, error: e }); 72 | return null; 73 | } 74 | }, 75 | 200 76 | ), 77 | ], 78 | }), 79 | 80 | hoverTooltip( 81 | async (_: EditorView, pos: number): Promise => { 82 | tsServer.postMessage({ 83 | event: "tooltip-request", 84 | details: { pos, filePath }, 85 | }); 86 | 87 | const { result: quickInfo, tootltipText } = await new Promise( 88 | (resolve) => { 89 | emitter.on("tooltip-results", (completions) => { 90 | resolve(completions); 91 | }); 92 | } 93 | ); 94 | 95 | if (!quickInfo) return null; 96 | 97 | return { 98 | pos, 99 | create() { 100 | const dom = document.createElement("div"); 101 | dom.setAttribute("class", "quickinfo-tooltip"); 102 | dom.textContent = tootltipText; 103 | 104 | return { dom }; 105 | }, 106 | }; 107 | }, 108 | { hideOnChange: true } 109 | ), 110 | 111 | linter( 112 | async (): Promise => { 113 | tsServer.postMessage({ 114 | event: "lint-request", 115 | details: { filePath }, 116 | }); 117 | 118 | const diagnostics = (await new Promise((resolve) => { 119 | emitter.once("lint-results", (completions) => { 120 | resolve(completions); 121 | }); 122 | })) as Diagnostic[]; 123 | 124 | return diagnostics ? diagnostics : []; 125 | }, 126 | { delay: 400 } 127 | ), 128 | EditorView.baseTheme({ 129 | ".quickinfo-tooltip": { 130 | padding: "6px 3px 6px 8px", 131 | marginLeft: "-1px", 132 | borderLeft: "5px solid #999", 133 | }, 134 | }), 135 | ]; 136 | -------------------------------------------------------------------------------- /public/workers/tsserver.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // public/workers/tsserver.ts 3 | importScripts("https://unpkg.com/@typescript/vfs@1.3.5/dist/vfs.globals.js"); 4 | importScripts("https://cdnjs.cloudflare.com/ajax/libs/typescript/4.4.3/typescript.min.js"); 5 | importScripts("https://unpkg.com/@okikio/emitter@2.1.7/lib/api.js"); 6 | var { 7 | createDefaultMapFromCDN, 8 | createSystem, 9 | createVirtualTypeScriptEnvironment 10 | } = globalThis.tsvfs; 11 | var ts = globalThis.ts; 12 | var EventEmitter = globalThis.emitter.EventEmitter; 13 | var _emitter = new EventEmitter(); 14 | globalThis.localStorage = globalThis.localStorage ?? {}; 15 | var BUCKET_URL = "https://prod-packager-packages.codesandbox.io/v1/typings"; 16 | var TYPES_REGISTRY = "https://unpkg.com/types-registry@latest/index.json"; 17 | var fetchDependencyTyping = async ({ 18 | name, 19 | version 20 | }) => { 21 | try { 22 | const url = `${BUCKET_URL}/${name}/${version}.json`; 23 | const { files } = await fetch(url).then((data) => data.json()); 24 | return files; 25 | } catch { 26 | } 27 | }; 28 | var getCompileOptions = (tsconfigFile) => { 29 | const defaultValue = { 30 | target: ts.ScriptTarget.ES2021, 31 | module: ts.ScriptTarget.ES2020, 32 | lib: ["es2021", "es2020", "dom", "webworker"], 33 | esModuleInterop: true 34 | }; 35 | if (tsconfigFile.compilerOptions) { 36 | const { compilerOptions } = tsconfigFile; 37 | if (compilerOptions.moduleResolution === "node") { 38 | compilerOptions.moduleResolution = 2 /* NodeJs */; 39 | } 40 | return compilerOptions; 41 | } 42 | return defaultValue; 43 | }; 44 | var processTypescriptCacheFromStorage = (fsMapCached) => { 45 | const cache = /* @__PURE__ */ new Map(); 46 | const matchVersion = Array.from(fsMapCached.keys()).every((file) => file.startsWith(`ts-lib-${ts.version}`)); 47 | if (!matchVersion) 48 | cache; 49 | fsMapCached.forEach((value, key) => { 50 | const cleanLibName = key.replace(`ts-lib-${ts.version}-`, ""); 51 | cache.set(cleanLibName, value); 52 | }); 53 | return cache; 54 | }; 55 | var isValidTypeModule = (key, value) => key.endsWith(".d.ts") || key.endsWith("/package.json") && value?.module?.code; 56 | (async function lspTypescriptWorker() { 57 | let env; 58 | postMessage({ 59 | event: "ready", 60 | details: [] 61 | }); 62 | const createTsSystem = async (files, entry, fsMapCached) => { 63 | const tsFiles = /* @__PURE__ */ new Map(); 64 | const rootPaths = []; 65 | const dependenciesMap = /* @__PURE__ */ new Map(); 66 | let tsconfig = null; 67 | let packageJson = null; 68 | let typeVersionsFromRegistry; 69 | for (const filePath in files) { 70 | const content = files[filePath].code; 71 | if (filePath === "tsconfig.json" || filePath === "/tsconfig.json") { 72 | tsconfig = content; 73 | } else if (filePath === "package.json" || filePath === "/package.json") { 74 | packageJson = content; 75 | } else if (/^[^.]+.tsx?$/.test(filePath)) { 76 | tsFiles.set(filePath, content); 77 | rootPaths.push(filePath); 78 | } 79 | } 80 | const compilerOpts = getCompileOptions(JSON.parse(tsconfig)); 81 | let fsMap = processTypescriptCacheFromStorage(fsMapCached); 82 | if (fsMap.size === 0) { 83 | fsMap = await createDefaultMapFromCDN(compilerOpts, ts.version, false, ts); 84 | } 85 | postMessage({ 86 | event: "cache-typescript-fsmap", 87 | details: { fsMap, version: ts.version } 88 | }); 89 | tsFiles.forEach((content, filePath) => { 90 | fsMap.set(filePath, content); 91 | }); 92 | const { dependencies, devDependencies } = JSON.parse(packageJson); 93 | for (const dep in devDependencies ?? {}) { 94 | dependenciesMap.set(dep, devDependencies[dep]); 95 | } 96 | for (const dep in dependencies ?? {}) { 97 | if (!dependenciesMap.has(`@types/${dep}`)) { 98 | dependenciesMap.set(dep, dependencies[dep]); 99 | } 100 | } 101 | dependenciesMap.forEach(async (version, name) => { 102 | const files2 = await fetchDependencyTyping({ name, version }); 103 | const hasTypes = Object.keys(files2).some((key) => key.startsWith("/" + name) && key.endsWith(".d.ts")); 104 | if (hasTypes) { 105 | Object.entries(files2).forEach(([key, value]) => { 106 | if (isValidTypeModule(key, value)) { 107 | fsMap.set(`/node_modules${key}`, value.module.code); 108 | } 109 | }); 110 | return; 111 | } 112 | if (!typeVersionsFromRegistry) { 113 | typeVersionsFromRegistry = await fetch(TYPES_REGISTRY).then((data) => data.json()).then((data) => data.entries); 114 | } 115 | const typingName = `@types/${name}`; 116 | if (typeVersionsFromRegistry[name]) { 117 | const atTypeFiles = await fetchDependencyTyping({ 118 | name: typingName, 119 | version: typeVersionsFromRegistry[name].latest 120 | }); 121 | Object.entries(atTypeFiles).forEach(([key, value]) => { 122 | if (isValidTypeModule(key, value)) { 123 | fsMap.set(`/node_modules${key}`, value.module.code); 124 | } 125 | }); 126 | } 127 | }); 128 | const system = createSystem(fsMap); 129 | env = createVirtualTypeScriptEnvironment(system, rootPaths, ts, compilerOpts); 130 | lintSystem(entry); 131 | }; 132 | const updateFile = (filePath, content) => { 133 | env.updateFile(filePath, content); 134 | }; 135 | const autocompleteAtPosition = (pos, filePath) => { 136 | let result = env.languageService.getCompletionsAtPosition(filePath, pos, {}); 137 | postMessage({ 138 | event: "autocomplete-results", 139 | details: result 140 | }); 141 | }; 142 | const infoAtPosition = (pos, filePath) => { 143 | let result = env.languageService.getQuickInfoAtPosition(filePath, pos); 144 | postMessage({ 145 | event: "tooltip-results", 146 | details: result ? { 147 | result, 148 | tootltipText: ts.displayPartsToString(result.displayParts) + (result.documentation?.length ? "\n" + ts.displayPartsToString(result.documentation) : "") 149 | } : { result, tooltipText: "" } 150 | }); 151 | }; 152 | const lintSystem = (filePath) => { 153 | if (!env) 154 | return; 155 | let SyntacticDiagnostics = env.languageService.getSyntacticDiagnostics(filePath); 156 | let SemanticDiagnostic = env.languageService.getSemanticDiagnostics(filePath); 157 | let SuggestionDiagnostics = env.languageService.getSuggestionDiagnostics(filePath); 158 | let result = [].concat(SyntacticDiagnostics, SemanticDiagnostic, SuggestionDiagnostics); 159 | postMessage({ 160 | event: "lint-results", 161 | details: result.reduce((acc, result2) => { 162 | const from = result2.start; 163 | const to = result2.start + result2.length; 164 | const messagesErrors = (message) => { 165 | if (typeof message === "string") 166 | return [message]; 167 | const messageList = []; 168 | const getMessage = (loop) => { 169 | messageList.push(loop.messageText); 170 | if (loop.next) { 171 | loop.next.forEach((item) => { 172 | getMessage(item); 173 | }); 174 | } 175 | }; 176 | getMessage(message); 177 | return messageList; 178 | }; 179 | const severity = [ 180 | "warning", 181 | "error", 182 | "info", 183 | "info" 184 | ]; 185 | messagesErrors(result2.messageText).forEach((message) => { 186 | acc.push({ 187 | from, 188 | to, 189 | message, 190 | source: result2?.source, 191 | severity: severity[result2.category] 192 | }); 193 | }); 194 | return acc; 195 | }, []) 196 | }); 197 | }; 198 | _emitter.once("create-system", async (payload) => { 199 | createTsSystem(payload.files, payload.entry, payload.fsMapCached); 200 | }); 201 | _emitter.on("lint-request", (payload) => lintSystem(payload.filePath)); 202 | _emitter.on("updateText", (payload) => updateFile(payload.filePath, payload.content)); 203 | _emitter.on("autocomplete-request", (payload) => { 204 | autocompleteAtPosition(payload.pos, payload.filePath); 205 | }); 206 | _emitter.on("tooltip-request", (payload) => { 207 | infoAtPosition(payload.pos, payload.filePath); 208 | }); 209 | })(); 210 | addEventListener("message", ({ data }) => { 211 | let { event, details } = data; 212 | _emitter.emit(event, details); 213 | }); 214 | })(); 215 | -------------------------------------------------------------------------------- /public/workers/tsserver.ts: -------------------------------------------------------------------------------- 1 | import { VirtualTypeScriptEnvironment } from "@typescript/vfs"; 2 | import { CompilerOptions } from "typescript"; 3 | 4 | enum ModuleResolutionKind { 5 | Classic = 1, 6 | NodeJs = 2, 7 | } 8 | 9 | importScripts("https://unpkg.com/@typescript/vfs@1.3.5/dist/vfs.globals.js"); 10 | importScripts( 11 | "https://cdnjs.cloudflare.com/ajax/libs/typescript/4.4.3/typescript.min.js" 12 | ); 13 | importScripts("https://unpkg.com/@okikio/emitter@2.1.7/lib/api.js"); 14 | 15 | export type VFS = typeof import("@typescript/vfs"); 16 | export type EVENT_EMITTER = import("@okikio/emitter").EventEmitter; 17 | export type Diagnostic = import("@codemirror/lint").Diagnostic; 18 | 19 | var { 20 | createDefaultMapFromCDN, 21 | createSystem, 22 | createVirtualTypeScriptEnvironment, 23 | } = globalThis.tsvfs as VFS; 24 | var ts = globalThis.ts; // as TS 25 | 26 | var EventEmitter = globalThis.emitter.EventEmitter; 27 | var _emitter: EVENT_EMITTER = new EventEmitter(); 28 | 29 | globalThis.localStorage = globalThis.localStorage ?? ({} as Storage); 30 | 31 | const BUCKET_URL = "https://prod-packager-packages.codesandbox.io/v1/typings"; 32 | const TYPES_REGISTRY = "https://unpkg.com/types-registry@latest/index.json"; 33 | 34 | /** 35 | * Fetch dependencies types from CodeSandbox CDN 36 | */ 37 | const fetchDependencyTyping = async ({ 38 | name, 39 | version, 40 | }: { 41 | name: string; 42 | version: string; 43 | }): Promise> => { 44 | try { 45 | const url = `${BUCKET_URL}/${name}/${version}.json`; 46 | const { files } = await fetch(url).then((data) => data.json()); 47 | 48 | return files; 49 | } catch {} 50 | }; 51 | 52 | /** 53 | * Process the TS compile options or default to 54 | */ 55 | const getCompileOptions = ( 56 | tsconfigFile: Record 57 | ): CompilerOptions => { 58 | const defaultValue = { 59 | target: ts.ScriptTarget.ES2021, 60 | module: ts.ScriptTarget.ES2020, 61 | lib: ["es2021", "es2020", "dom", "webworker"], 62 | esModuleInterop: true, 63 | }; 64 | 65 | if (tsconfigFile.compilerOptions) { 66 | const { compilerOptions } = tsconfigFile; 67 | // Hard fixes 68 | if (compilerOptions.moduleResolution === "node") { 69 | compilerOptions.moduleResolution = ModuleResolutionKind.NodeJs; 70 | } 71 | 72 | return compilerOptions; 73 | } 74 | 75 | return defaultValue; 76 | }; 77 | 78 | const processTypescriptCacheFromStorage = ( 79 | fsMapCached: Map 80 | ): Map => { 81 | const cache = new Map(); 82 | const matchVersion = Array.from(fsMapCached.keys()).every((file) => 83 | file.startsWith(`ts-lib-${ts.version}`) 84 | ); 85 | 86 | if (!matchVersion) cache; 87 | 88 | fsMapCached.forEach((value, key) => { 89 | const cleanLibName = key.replace(`ts-lib-${ts.version}-`, ""); 90 | cache.set(cleanLibName, value); 91 | }); 92 | 93 | return cache; 94 | }; 95 | 96 | const isValidTypeModule = (key: string, value?: { module: { code: string } }) => 97 | key.endsWith(".d.ts") || 98 | (key.endsWith("/package.json") && value?.module?.code); 99 | 100 | /** 101 | * Main worker function 102 | */ 103 | (async function lspTypescriptWorker() { 104 | let env: VirtualTypeScriptEnvironment; 105 | 106 | postMessage({ 107 | event: "ready", 108 | details: [], 109 | }); 110 | 111 | const createTsSystem = async ( 112 | files: Record, 113 | entry: string, 114 | fsMapCached: Map 115 | ) => { 116 | const tsFiles = new Map(); 117 | const rootPaths = []; 118 | const dependenciesMap = new Map(); 119 | let tsconfig = null; 120 | let packageJson = null; 121 | let typeVersionsFromRegistry: Record; 122 | 123 | /** 124 | * Collect files 125 | */ 126 | for (const filePath in files) { 127 | const content = files[filePath].code; 128 | 129 | // TODO: normalize path 130 | if (filePath === "tsconfig.json" || filePath === "/tsconfig.json") { 131 | tsconfig = content; 132 | } else if (filePath === "package.json" || filePath === "/package.json") { 133 | packageJson = content; 134 | } else if (/^[^.]+.tsx?$/.test(filePath)) { 135 | // Only ts files 136 | tsFiles.set(filePath, content); 137 | rootPaths.push(filePath); 138 | } 139 | } 140 | 141 | const compilerOpts = getCompileOptions(JSON.parse(tsconfig)); 142 | 143 | /** 144 | * Process cache or get a fresh one 145 | */ 146 | let fsMap = processTypescriptCacheFromStorage(fsMapCached); 147 | if (fsMap.size === 0) { 148 | fsMap = await createDefaultMapFromCDN( 149 | compilerOpts, 150 | ts.version, 151 | false, 152 | ts 153 | ); 154 | } 155 | 156 | /** 157 | * Post CDN payload to cache in the browser storage 158 | */ 159 | postMessage({ 160 | event: "cache-typescript-fsmap", 161 | details: { fsMap, version: ts.version }, 162 | }); 163 | 164 | /** 165 | * Add local files to the file-system 166 | */ 167 | tsFiles.forEach((content, filePath) => { 168 | fsMap.set(filePath, content); 169 | }); 170 | 171 | /** 172 | * Get dependencies from package.json 173 | */ 174 | const { dependencies, devDependencies } = JSON.parse(packageJson); 175 | for (const dep in devDependencies ?? {}) { 176 | dependenciesMap.set(dep, devDependencies[dep]); 177 | } 178 | 179 | for (const dep in dependencies ?? {}) { 180 | // Avoid redundant requests 181 | if (!dependenciesMap.has(`@types/${dep}`)) { 182 | dependenciesMap.set(dep, dependencies[dep]); 183 | } 184 | } 185 | 186 | /** 187 | * Fetch dependencies types 188 | */ 189 | dependenciesMap.forEach(async (version, name) => { 190 | // 1. CodeSandbox CDN 191 | const files = await fetchDependencyTyping({ name, version }); 192 | const hasTypes = Object.keys(files).some( 193 | (key) => key.startsWith("/" + name) && key.endsWith(".d.ts") 194 | ); 195 | 196 | // 2. Types found 197 | if (hasTypes) { 198 | Object.entries(files).forEach(([key, value]) => { 199 | if (isValidTypeModule(key, value)) { 200 | fsMap.set(`/node_modules${key}`, value.module.code); 201 | } 202 | }); 203 | 204 | return; 205 | } 206 | 207 | // 3. Types found: fetch types version from registry 208 | if (!typeVersionsFromRegistry) { 209 | typeVersionsFromRegistry = await fetch(TYPES_REGISTRY) 210 | .then((data) => data.json()) 211 | .then((data) => data.entries); 212 | } 213 | 214 | // 4. Types found: no Look for types in @types register 215 | const typingName = `@types/${name}`; 216 | if (typeVersionsFromRegistry[name]) { 217 | const atTypeFiles = await fetchDependencyTyping({ 218 | name: typingName, 219 | version: typeVersionsFromRegistry[name].latest, 220 | }); 221 | 222 | Object.entries(atTypeFiles).forEach(([key, value]) => { 223 | if (isValidTypeModule(key, value)) { 224 | fsMap.set(`/node_modules${key}`, value.module.code); 225 | } 226 | }); 227 | } 228 | }); 229 | 230 | const system = createSystem(fsMap); 231 | 232 | env = createVirtualTypeScriptEnvironment( 233 | system, 234 | rootPaths, 235 | ts, 236 | compilerOpts 237 | ); 238 | 239 | lintSystem(entry); 240 | }; 241 | 242 | const updateFile = (filePath: string, content: string) => { 243 | env.updateFile(filePath, content); 244 | }; 245 | 246 | const autocompleteAtPosition = (pos: number, filePath: string) => { 247 | let result = env.languageService.getCompletionsAtPosition( 248 | filePath, 249 | pos, 250 | {} 251 | ); 252 | 253 | postMessage({ 254 | event: "autocomplete-results", 255 | details: result, 256 | }); 257 | }; 258 | 259 | const infoAtPosition = (pos: number, filePath: string) => { 260 | let result = env.languageService.getQuickInfoAtPosition(filePath, pos); 261 | 262 | postMessage({ 263 | event: "tooltip-results", 264 | details: result 265 | ? { 266 | result, 267 | tootltipText: 268 | ts.displayPartsToString(result.displayParts) + 269 | (result.documentation?.length 270 | ? "\n" + ts.displayPartsToString(result.documentation) 271 | : ""), 272 | } 273 | : { result, tooltipText: "" }, 274 | }); 275 | }; 276 | 277 | const lintSystem = (filePath: string) => { 278 | if (!env) return; 279 | 280 | let SyntacticDiagnostics = 281 | env.languageService.getSyntacticDiagnostics(filePath); 282 | let SemanticDiagnostic = 283 | env.languageService.getSemanticDiagnostics(filePath); 284 | let SuggestionDiagnostics = 285 | env.languageService.getSuggestionDiagnostics(filePath); 286 | 287 | type Diagnostics = typeof SyntacticDiagnostics & 288 | typeof SemanticDiagnostic & 289 | typeof SuggestionDiagnostics; 290 | let result: Diagnostics = [].concat( 291 | SyntacticDiagnostics, 292 | SemanticDiagnostic, 293 | SuggestionDiagnostics 294 | ); 295 | 296 | postMessage({ 297 | event: "lint-results", 298 | details: result.reduce((acc, result) => { 299 | const from = result.start; 300 | const to = result.start + result.length; 301 | // const codeActions = env.languageService.getCodeFixesAtPosition( 302 | // filePath, 303 | // from, 304 | // to, 305 | // [result.category], 306 | // {}, 307 | // {} 308 | // ); 309 | 310 | type ErrorMessageObj = { 311 | messageText: string; 312 | next?: ErrorMessageObj[]; 313 | }; 314 | type ErrorMessage = ErrorMessageObj | string; 315 | 316 | const messagesErrors = (message: ErrorMessage): string[] => { 317 | if (typeof message === "string") return [message]; 318 | 319 | const messageList = []; 320 | const getMessage = (loop: ErrorMessageObj) => { 321 | messageList.push(loop.messageText); 322 | 323 | if (loop.next) { 324 | loop.next.forEach((item) => { 325 | getMessage(item); 326 | }); 327 | } 328 | }; 329 | 330 | getMessage(message); 331 | 332 | return messageList; 333 | }; 334 | 335 | const severity: Diagnostic["severity"][] = [ 336 | "warning", 337 | "error", 338 | "info", 339 | "info", 340 | ]; 341 | 342 | messagesErrors(result.messageText).forEach((message) => { 343 | acc.push({ 344 | from, 345 | to, 346 | message, 347 | source: result?.source, 348 | severity: severity[result.category], 349 | // actions: codeActions as any as Diagnostic["actions"] 350 | }); 351 | }); 352 | 353 | return acc; 354 | }, [] as Diagnostic[]), 355 | }); 356 | }; 357 | 358 | /** 359 | * Listeners 360 | */ 361 | _emitter.once( 362 | "create-system", 363 | async (payload: { 364 | files: Record; 365 | entry: string; 366 | fsMapCached: Map; 367 | }) => { 368 | createTsSystem(payload.files, payload.entry, payload.fsMapCached); 369 | } 370 | ); 371 | _emitter.on("lint-request", (payload: { filePath: string }) => 372 | lintSystem(payload.filePath) 373 | ); 374 | _emitter.on("updateText", (payload: { filePath: string; content: string }) => 375 | updateFile(payload.filePath, payload.content) 376 | ); 377 | _emitter.on( 378 | "autocomplete-request", 379 | (payload: { pos: number; filePath: string }) => { 380 | autocompleteAtPosition(payload.pos, payload.filePath); 381 | } 382 | ); 383 | _emitter.on( 384 | "tooltip-request", 385 | (payload: { pos: number; filePath: string }) => { 386 | infoAtPosition(payload.pos, payload.filePath); 387 | } 388 | ); 389 | })(); 390 | 391 | addEventListener( 392 | "message", 393 | ({ data }: MessageEvent<{ event: string; details: any }>) => { 394 | let { event, details } = data; 395 | _emitter.emit(event, details); 396 | } 397 | ); 398 | --------------------------------------------------------------------------------