├── .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 |
28 | You need to enable JavaScript to run this app.
29 |
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 |
61 | You pressed me {count} times
62 |
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 | Hello world!
131 | Primary button!
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 |
--------------------------------------------------------------------------------