feat(client): use typescript in challenges (#56253)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-10-28 21:40:11 +01:00
committed by GitHub
parent e40a60bc60
commit e9a4e92955
16 changed files with 483 additions and 237 deletions

7
.gitignore vendored
View File

@@ -157,12 +157,7 @@ shared/config/curriculum.json
shared/config/*.js
### Generated utils files ###
shared/utils/get-lines.js
shared/utils/get-lines.test.js
shared/utils/validate.js
shared/utils/validate.test.js
shared/utils/is-audited.js
shared/utils/shuffle-array.js
shared/utils/*.js
### Old Generated files ###
# These files are no longer generated by the client, but can

View File

@@ -1,9 +1,12 @@
import { HandlerProps } from 'react-reflex';
import { SuperBlocks } from '../../../shared/config/curriculum';
import { BlockLayouts, BlockTypes } from '../../../shared/config/blocks';
import type { ChallengeFile, Ext } from '../../../shared/utils/polyvinyl';
import { Themes } from '../components/settings/theme';
import { type CertTitle } from '../../config/cert-and-project-map';
export type { ChallengeFile, Ext };
export type Steps = {
isHonest?: boolean;
currentCerts?: Array<CurrentCert>;
@@ -386,8 +389,12 @@ export type CompletedChallenge = {
examResults?: GeneratedExamResults;
};
export type Ext = 'js' | 'html' | 'css' | 'jsx';
export type FileKey = 'scriptjs' | 'indexhtml' | 'stylescss' | 'indexjsx';
export type FileKey =
| 'scriptjs'
| 'indexts'
| 'indexhtml'
| 'stylescss'
| 'indexjsx';
export type ChallengeMeta = {
block: string;
@@ -422,21 +429,6 @@ export type FileKeyChallenge = {
tail: string;
};
export type ChallengeFile = {
fileKey: string;
ext: Ext;
name: string;
editableRegionBoundaries?: number[];
usesMultifileEditor?: boolean;
error?: unknown;
head: string;
tail: string;
seed: string;
contents: string;
id: string;
history: string[];
};
export type ChallengeFiles = ChallengeFile[] | null;
export interface UserFetchState {

View File

@@ -9,7 +9,7 @@ import {
stubTrue
} from 'lodash-es';
import sassData from '../../../../../client/config/browser-scripts/sass-compile.json';
import sassData from '../../../../config/browser-scripts/sass-compile.json';
import {
transformContents,
transformHeadTailAndContents,
@@ -18,6 +18,10 @@ import {
createSource
} from '../../../../../shared/utils/polyvinyl';
import { WorkerExecutor } from '../utils/worker-executor';
import {
compileTypeScriptCode,
initTypeScriptService
} from '../utils/typescript-worker-handler';
const { filename: sassCompile } = sassData;
@@ -97,12 +101,13 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx');
const testTypeScript = matchesProperty('ext', 'ts');
const testHTML = matchesProperty('ext', 'html');
const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX);
const testHTML$JS$JSX$TS = overSome(testHTML, testJS, testJSX, testTypeScript);
const replaceNBSP = cond([
[
testHTML$JS$JSX,
testHTML$JS$JSX$TS,
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
],
[stubTrue, identity]
@@ -112,19 +117,19 @@ const babelTransformer = loopProtectOptions => {
return cond([
[
testJS,
async code => {
async challengeFile => {
await loadBabel();
await loadPresetEnv();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return transformHeadTailAndContents(
babelTransformCode(babelOptions),
code
challengeFile
);
}
],
[
testJSX,
async code => {
async challengeFile => {
await loadBabel();
await loadPresetReact();
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
@@ -134,7 +139,22 @@ const babelTransformer = loopProtectOptions => {
babelTransformCode(babelOptions)
),
partial(setExt, 'js')
)(code);
)(challengeFile);
}
],
[
testTypeScript,
async challengeFile => {
await loadBabel();
await initTypeScriptService();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return flow(
partial(transformHeadTailAndContents, compileTypeScriptCode),
partial(
transformHeadTailAndContents,
babelTransformCode(babelOptions)
)
)(challengeFile);
}
],
[stubTrue, identity]
@@ -197,7 +217,7 @@ async function transformScript(documentElement) {
// This does the final transformations of the files needed to embed them into
// HTML.
export const embedFilesInHtml = async function (challengeFiles) {
const { indexHtml, stylesCss, scriptJs, indexJsx } =
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs } =
challengeFilesToObject(challengeFiles);
const embedStylesAndScript = (documentElement, contentDocument) => {
@@ -207,6 +227,10 @@ export const embedFilesInHtml = async function (challengeFiles) {
const script =
documentElement.querySelector('script[src="script.js"]') ??
documentElement.querySelector('script[src="./script.js"]');
const tsScript =
documentElement.querySelector('script[src="index.ts"]') ??
documentElement.querySelector('script[src="./index.ts"]');
if (link) {
const style = contentDocument.createElement('style');
style.classList.add('fcc-injected-styles');
@@ -222,6 +246,11 @@ export const embedFilesInHtml = async function (challengeFiles) {
script.removeAttribute('src');
script.setAttribute('data-src', 'script.js');
}
if (tsScript) {
tsScript.innerHTML = indexTs?.contents;
tsScript.removeAttribute('src');
tsScript.setAttribute('data-src', 'index.ts');
}
return documentElement.innerHTML;
};
@@ -235,8 +264,10 @@ export const embedFilesInHtml = async function (challengeFiles) {
return [challengeFiles, `<script>${indexJsx.contents}</script>`];
} else if (scriptJs) {
return [challengeFiles, `<script>${scriptJs.contents}</script>`];
} else if (indexTs) {
return [challengeFiles, `<script>${indexTs.contents}</script>`];
} else {
throw Error('No html or js(x) file found');
throw Error('No html, ts or js(x) file found');
}
};
@@ -247,7 +278,8 @@ function challengeFilesToObject(challengeFiles) {
);
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
return { indexHtml, indexJsx, stylesCss, scriptJs };
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs };
}
const parseAndTransform = async function (transform, contents) {

View File

@@ -3,10 +3,7 @@ import frameRunnerData from '../../../../../client/config/browser-scripts/frame-
import jsTestEvaluatorData from '../../../../../client/config/browser-scripts/test-evaluator.json';
import pyTestEvaluatorData from '../../../../../client/config/browser-scripts/python-test-evaluator.json';
import {
ChallengeFile as PropTypesChallengeFile,
ChallengeMeta
} from '../../../redux/prop-types';
import { ChallengeFile, ChallengeMeta } from '../../../redux/prop-types';
import { concatHtml } from '../rechallenge/builders';
import {
getTransformers,
@@ -25,12 +22,6 @@ import {
} from './frame';
import { WorkerExecutor } from './worker-executor';
interface ChallengeFile extends PropTypesChallengeFile {
source: string;
index: string;
editableContents: string;
}
interface BuildChallengeData extends Context {
challengeType: number;
challengeFiles?: ChallengeFile[];

View File

@@ -13,7 +13,7 @@ export interface Source {
index: string;
contents?: string;
editableContents: string;
original: { [key: string]: string };
original: { [key: string]: string | null };
}
export interface Context {

View File

@@ -0,0 +1,44 @@
import typeScriptWorkerData from '../../../../config/browser-scripts/typescript-worker.json';
import { awaitResponse } from './worker-messenger';
const typeScriptWorkerSrc = `/js/${typeScriptWorkerData.filename}.js`;
let worker: Worker | null = null;
function getTypeScriptWorker(): Worker {
if (!worker) {
worker = new Worker(typeScriptWorkerSrc);
}
return worker;
}
export function compileTypeScriptCode(code: string): Promise<string> {
return awaitResponse({
worker: getTypeScriptWorker(),
message: { type: 'compile', code },
onMessage: (data, onSuccess, onFailure) => {
if (data.type === 'compiled') {
if (!data.error) {
onSuccess(data.value);
} else {
onFailure(Error(data.error));
}
} else {
onFailure(Error('unable to compile code'));
}
}
});
}
export function initTypeScriptService(): Promise<boolean> {
return awaitResponse({
worker: getTypeScriptWorker(),
message: { type: 'init' },
onMessage: (data, onSuccess) => {
if (data.type === 'ready') {
onSuccess(true);
}
// otherwise it times out.
}
});
}

View File

@@ -17,7 +17,7 @@
*/
export function awaitResponse<
MessageOut,
MessageIn extends { type: string; value: Value },
MessageIn extends { type: string; value: Value; error: string },
Value
>({
worker,
@@ -28,25 +28,27 @@ export function awaitResponse<
message: MessageOut;
onMessage: (
response: MessageIn,
resolve: (res: Value) => void,
reject: (err: string) => void
onSuccess: (res: Value) => void,
onFailure: (err: Error) => void
) => void;
}): Promise<Value> {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
// TODO: Figure out how to ensure the worker is ready and/or handle when it
// is not.
const id = setTimeout(() => {
channel.port1.close();
reject(Error('No response from worker'));
}, 5000);
return new Promise(
(resolve: (res: Value) => void, reject: (err: Error) => void) => {
const channel = new MessageChannel();
// TODO: Figure out how to ensure the worker is ready and/or handle when it
// is not.
const id = setTimeout(() => {
channel.port1.close();
reject(Error('No response from worker'));
}, 5000);
channel.port1.onmessage = (event: MessageEvent<MessageIn>) => {
clearTimeout(id);
channel.port1.close();
onMessage(event.data, resolve, reject);
};
channel.port1.onmessage = (event: MessageEvent<MessageIn>) => {
clearTimeout(id);
channel.port1.close();
onMessage(event.data, resolve, reject);
};
worker.postMessage(message, [channel.port2]);
});
worker.postMessage(message, [channel.port2]);
}
);
}

23
pnpm-lock.yaml generated
View File

@@ -885,6 +885,9 @@ importers:
shared:
dependencies:
'@types/invariant':
specifier: ^2.2.37
version: 2.2.37
invariant:
specifier: 2.2.4
version: 2.2.4
@@ -1126,6 +1129,9 @@ importers:
'@types/lodash-es':
specifier: 4.17.12
version: 4.17.12
'@typescript/vfs':
specifier: ^1.6.0
version: 1.6.0(typescript@5.4.5)
babel-loader:
specifier: 8.3.0
version: 8.3.0(@babel/core@7.23.7)(webpack@5.90.3)
@@ -4181,6 +4187,9 @@ packages:
'@types/inquirer@8.2.10':
resolution: {integrity: sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA==}
'@types/invariant@2.2.37':
resolution: {integrity: sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==}
'@types/istanbul-lib-coverage@2.0.4':
resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==}
@@ -4596,6 +4605,11 @@ packages:
resolution: {integrity: sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==}
engines: {node: ^16.0.0 || >=18.0.0}
'@typescript/vfs@1.6.0':
resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==}
peerDependencies:
typescript: '*'
'@uiw/react-codemirror@3.2.10':
resolution: {integrity: sha512-sSabPpOQFFRAZwm/JZ6gRRqQg0PBPw9nRAT9YepqUSM3TJHQzavZDRDxKF8B9jL+KU24+aKqM/clLlpPPPF5sQ==}
peerDependencies:
@@ -18389,6 +18403,8 @@ snapshots:
'@types/through': 0.0.33
rxjs: 7.8.1
'@types/invariant@2.2.37': {}
'@types/istanbul-lib-coverage@2.0.4': {}
'@types/istanbul-lib-report@3.0.1':
@@ -18907,6 +18923,13 @@ snapshots:
'@typescript-eslint/types': 7.1.1
eslint-visitor-keys: 3.4.3
'@typescript/vfs@1.6.0(typescript@5.4.5)':
dependencies:
debug: 4.3.4(supports-color@8.1.1)
typescript: 5.4.5
transitivePeerDependencies:
- supports-color
'@uiw/react-codemirror@3.2.10(@babel/runtime@7.23.9)(codemirror@5.65.16)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)':
dependencies:
'@babel/runtime': 7.23.9

View File

@@ -18,6 +18,7 @@
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"dependencies": {
"@types/invariant": "^2.2.37",
"invariant": "2.2.4"
}
}

View File

@@ -1,168 +0,0 @@
// originally based off of https://github.com/gulpjs/vinyl
const invariant = require('invariant');
// interface PolyVinyl {
// source: String,
// contents: String,
// name: String,
// ext: String,
// path: String,
// key: String,
// head: String,
// tail: String,
// history: [...String],
// error: Null|Object|Error
// }
// createPoly({
// name: String,
// ext: String,
// contents: String,
// history?: [...String],
// }) => PolyVinyl, throws
function createPoly({ name, ext, contents, history, ...rest }) {
invariant(typeof name === 'string', 'name must be a string but got %s', name);
invariant(typeof ext === 'string', 'ext must be a string, but was %s', ext);
invariant(
typeof contents === 'string',
'contents must be a string but got %s',
contents
);
return {
...rest,
history: Array.isArray(history) ? history : [name + '.' + ext],
name,
ext,
path: name + '.' + ext,
fileKey: name + ext,
contents,
error: null
};
}
// isPoly(poly: Any) => Boolean
function isPoly(poly) {
return (
poly &&
typeof poly.contents === 'string' &&
typeof poly.name === 'string' &&
typeof poly.ext === 'string' &&
Array.isArray(poly.history)
);
}
// checkPoly(poly: Any) => Void, throws
function checkPoly(poly) {
invariant(
isPoly(poly),
'function should receive a PolyVinyl, but got %s',
poly
);
}
// setContent(contents: String, poly: PolyVinyl) => PolyVinyl
// setContent will loose source if set
function setContent(contents, poly) {
checkPoly(poly);
return {
...poly,
contents,
source: null
};
}
// setExt(ext: String, poly: PolyVinyl) => PolyVinyl
function setExt(ext, poly) {
checkPoly(poly);
const newPoly = {
...poly,
ext,
path: poly.name + '.' + ext,
fileKey: poly.name + ext
};
newPoly.history = [...poly.history, newPoly.path];
return newPoly;
}
// This is currently only used to add back properties that are not stored in the
// database.
function regeneratePathAndHistory(poly) {
const newPath = poly.name + '.' + poly.ext;
const newPoly = {
...poly,
path: newPath,
history: [newPath]
};
checkPoly(newPoly);
return newPoly;
}
// clearHeadTail(poly: PolyVinyl) => PolyVinyl
function clearHeadTail(poly) {
checkPoly(poly);
return {
...poly,
head: '',
tail: ''
};
}
// compileHeadTail(padding: String, poly: PolyVinyl) => PolyVinyl
function compileHeadTail(padding = '', poly) {
return clearHeadTail(
transformContents(
() => [poly.head, poly.contents, poly.tail].join(padding),
poly
)
);
}
// transformContents(
// wrap: (contents: String) => String,
// poly: PolyVinyl
// ) => PolyVinyl
// transformContents will keep a copy of the original
// code in the `source` property. If the original polyvinyl
// already contains a source, this version will continue as
// the source property
function transformContents(wrap, poly) {
const newPoly = setContent(wrap(poly.contents), poly);
// if no source exist, set the original contents as source
newPoly.source = poly.source || poly.contents;
return newPoly;
}
// transformHeadTailAndContents(
// wrap: (source: String) => String,
// poly: PolyVinyl
// ) => PolyVinyl
function transformHeadTailAndContents(wrap, poly) {
return {
...transformContents(wrap, poly),
head: wrap(poly.head),
tail: wrap(poly.tail)
};
}
// createSource(poly: PolyVinyl) => PolyVinyl
function createSource(poly) {
return {
...poly,
source: poly.source || poly.contents
};
}
module.exports = {
createPoly,
isPoly,
setContent,
setExt,
createSource,
compileHeadTail,
regeneratePathAndHistory,
transformContents,
transformHeadTailAndContents
};

View File

@@ -1,4 +1,4 @@
import polyvinyl from './polyvinyl';
import { createPoly, createSource } from './polyvinyl';
const polyData = {
name: 'test',
@@ -9,21 +9,21 @@ const polyData = {
describe('createSource', () => {
it('should return a vinyl object with a source matching the contents', () => {
const original = polyvinyl.createPoly(polyData);
const original = createPoly(polyData);
const updated = polyvinyl.createSource(original);
const updated = createSource(original);
expect(original).not.toHaveProperty('source');
expect(updated).toHaveProperty('source', 'var hello = world;');
expect(updated).toMatchObject(original);
});
it('should not update the source if it already exists', () => {
const original = polyvinyl.createPoly({
const original = createPoly({
...polyData,
source: 'const hello = world;'
});
const updated = polyvinyl.createSource(original);
const updated = createSource(original);
expect(updated).toStrictEqual(original);
});
});

185
shared/utils/polyvinyl.ts Normal file
View File

@@ -0,0 +1,185 @@
// originally based off of https://github.com/gulpjs/vinyl
import invariant from 'invariant';
export type Ext = 'js' | 'html' | 'css' | 'jsx' | 'ts';
export type ChallengeFile = {
fileKey: string;
ext: Ext;
name: string;
editableRegionBoundaries?: number[];
editableContents?: string;
usesMultifileEditor?: boolean;
error?: unknown;
head: string;
tail: string;
seed: string;
contents: string;
source?: string | null;
id: string;
history: string[];
};
type PolyProps = {
name: string;
ext: string;
contents: string;
history?: string[];
};
// The types are a little awkward, but should suffice until we move the
// curriculum to TypeScript.
type AddedProperties = {
path: string;
fileKey: string;
error: null;
};
export function createPoly<Rest>({
name,
ext,
contents,
history,
...rest
}: PolyProps & Rest): PolyProps & AddedProperties & Rest {
invariant(typeof name === 'string', 'name must be a string but got %s', name);
invariant(typeof ext === 'string', 'ext must be a string, but was %s', ext);
invariant(
typeof contents === 'string',
'contents must be a string but got %s',
contents
);
return {
...rest,
history: Array.isArray(history) ? history : [name + '.' + ext],
name,
ext,
path: name + '.' + ext,
fileKey: name + ext,
contents,
error: null
} as PolyProps & AddedProperties & Rest;
}
export function isPoly(poly: unknown): poly is ChallengeFile {
return (
!!poly &&
typeof poly === 'object' &&
'contents' in poly &&
typeof poly.contents === 'string' &&
'name' in poly &&
typeof poly.name === 'string' &&
'ext' in poly &&
typeof poly.ext === 'string' &&
'history' in poly &&
Array.isArray(poly.history)
);
}
function checkPoly(poly: ChallengeFile) {
invariant(
isPoly(poly),
'function should receive a PolyVinyl, but got %s',
poly
);
}
// setContent will lose source if set
export function setContent(
contents: string,
poly: ChallengeFile
): ChallengeFile {
checkPoly(poly);
return {
...poly,
contents,
source: null
};
}
export async function setExt(ext: string, polyP: Promise<ChallengeFile>) {
const poly = await polyP;
checkPoly(poly);
const newPoly = {
...poly,
ext,
path: poly.name + '.' + ext,
fileKey: poly.name + ext
};
newPoly.history = [...poly.history, newPoly.path];
return newPoly;
}
// This is currently only used to add back properties that are not stored in the
// database.
export function regeneratePathAndHistory(poly: ChallengeFile) {
const newPath = poly.name + '.' + poly.ext;
const newPoly = {
...poly,
path: newPath,
history: [newPath]
};
checkPoly(newPoly);
return newPoly;
}
async function clearHeadTail(polyP: Promise<ChallengeFile>) {
const poly = await polyP;
checkPoly(poly);
return {
...poly,
head: '',
tail: ''
};
}
export async function compileHeadTail(padding = '', poly: ChallengeFile) {
return clearHeadTail(
transformContents(
() => [poly.head, poly.contents, poly.tail].join(padding),
poly
)
);
}
type Wrapper = (x: string) => Promise<string> | string;
// transformContents will keep a copy of the original
// code in the `source` property. If the original polyvinyl
// already contains a source, this version will continue as
// the source property
export async function transformContents(
wrap: Wrapper,
polyP: ChallengeFile | Promise<ChallengeFile>
) {
const poly = await polyP;
const newPoly = setContent(await wrap(poly.contents), poly);
// if no source exist, set the original contents as source
newPoly.source = poly.source || poly.contents;
return newPoly;
}
export async function transformHeadTailAndContents(
wrap: Wrapper,
polyP: ChallengeFile | Promise<ChallengeFile>
) {
const poly = await polyP;
const contents = await transformContents(wrap, poly);
const head = await wrap(poly.head);
const tail = await wrap(poly.tail);
return {
...contents,
head,
tail
};
}
// createSource(poly: PolyVinyl) => PolyVinyl
export function createSource(poly: Pick<ChallengeFile, 'contents' | 'source'>) {
return {
...poly,
source: poly.source || poly.contents
};
}

View File

@@ -18,7 +18,7 @@ export interface InitTestFrameArg {
code: {
contents?: string;
editableContents?: string;
original?: { [id: string]: string };
original?: { [id: string]: string | null };
};
getUserInput?: (fileName: string) => string;
loadEnzyme?: () => void;

View File

@@ -35,6 +35,7 @@
"@types/enzyme-adapter-react-16": "1.0.9",
"@types/jquery": "3.5.29",
"@types/lodash-es": "4.17.12",
"@typescript/vfs": "^1.6.0",
"babel-loader": "8.3.0",
"chai": "4.4.1",
"copy-webpack-plugin": "9.1.0",

View File

@@ -0,0 +1,147 @@
import { type VirtualTypeScriptEnvironment } from '@typescript/vfs';
import type { CompilerOptions, CompilerHost } from 'typescript';
// Most of the ts types are only a guideline. This is because we're not bundling
// TS in this worker. The specific TS version is going to be determined by the
// challenge (in general - it will be hardcoded in the MVP). So, the vfs types
// should be correct, but ts may not be.
declare const tsvfs: typeof import('@typescript/vfs');
declare const createDefaultMapFromCDN: typeof import('@typescript/vfs').createDefaultMapFromCDN;
declare const createVirtualCompilerHost: typeof import('@typescript/vfs').createVirtualCompilerHost;
declare const ts: typeof import('typescript');
const ctx: Worker & typeof globalThis = self as unknown as Worker &
typeof globalThis;
let tsEnv: VirtualTypeScriptEnvironment | null = null;
let compilerHost: CompilerHost | null = null;
interface TSCompileEvent extends MessageEvent {
data: {
type: 'compile';
code: string;
};
}
interface TSCompiledMessage {
type: 'compiled';
value: string;
error: string;
}
interface InitRequestEvent extends MessageEvent {
data: {
type: 'init';
};
}
interface CancelEvent extends MessageEvent {
data: {
type: 'cancel';
value: number;
};
}
let cachedVersion: string | null = null;
async function setupTypeScript(version: string) {
// TODO: make sure no racing happens if multiple inits arrive at once.
if (cachedVersion == version) return tsEnv;
importScripts('https://unpkg.com/typescript@' + version);
importScripts('https://unpkg.com/@typescript/vfs@1.6.0/dist/vfs.globals.js');
const compilerOptions: CompilerOptions = {
target: ts.ScriptTarget.ES2015,
skipLibCheck: true // TODO: look into why this is needed. Are we doing something wrong? Could it be that it's not "synced" with this TS version?
// from the docs: "Note: it's possible for this list to get out of
// sync with TypeScript over time. It was last synced with TypeScript
// 3.8.0-rc."
};
const fsMap = await createDefaultMapFromCDN(
compilerOptions,
ts.version,
false, // TODO: cache this. It needs a store that's available to workers and implements https://github.com/microsoft/TypeScript-Website/blob/ac68b8b8e4a621113c4ee45c4051002fd55ede24/packages/typescript-vfs/src/index.ts#L11
ts
);
const system = tsvfs.createSystem(fsMap);
// TODO: if passed an invalid compiler options object (e.g. { module:
// ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext
// }), this will throw. When we allow users to set compiler options, we should
// show them the diagnostics from this function.
const env = tsvfs.createVirtualTypeScriptEnvironment(
system,
[],
ts,
compilerOptions
);
compilerHost = createVirtualCompilerHost(
system,
compilerOptions,
ts
).compilerHost;
tsEnv = env;
// We freeze this to prevent learners from getting the worker into a
// weird state.
Object.freeze(self);
cachedVersion = version;
return env;
}
// TODO: figure out how to start setting up TS in the background, but allow the
// client to wait for it to be ready. Currently the waiting works, but the setup
// is done on demand.
// void setupTypeScript('5');
ctx.onmessage = (e: TSCompileEvent | InitRequestEvent | CancelEvent) => {
const { data, ports } = e;
if (data.type === 'init') {
void handleInitRequest(ports[0]);
} else if (data.type === 'cancel') {
handleCancelRequest(data);
} else {
handleCompileRequest(data, ports[0]);
}
};
// This lets the client know that there is nothing to cancel.
function handleCancelRequest({ value }: { value: number }) {
postMessage({ type: 'is-alive', text: value });
}
async function handleInitRequest(port: MessagePort) {
await setupTypeScript('5');
port.postMessage({ type: 'ready' });
}
function handleCompileRequest(data: TSCompileEvent['data'], port: MessagePort) {
// If we try to update or create an empty file, the environment will become
// permanently unable to interact with that file. The workaround is to create
// a file with a single newline character.
const code = (data.code || '').slice() || '\n';
// TODO: If creating the file fresh each time is too slow, we can try checking
// if the file exists and updating it if it does.
tsEnv?.createFile('index.ts', code);
const program = tsEnv!.languageService.getProgram()!;
const emitOutput = tsEnv!.languageService.getEmitOutput('index.ts');
const compiled = emitOutput.outputFiles[0].text;
const message: TSCompiledMessage = {
type: 'compiled',
value: compiled,
// TODO: stop forcing the non-null assertions here.
error: ts.formatDiagnostics(
ts.getPreEmitDiagnostics(program),
compilerHost!
)
};
port.postMessage(message);
}

View File

@@ -18,7 +18,8 @@ module.exports = (env = {}) => {
'sass-compile': './sass-compile.ts',
'test-evaluator': './test-evaluator.ts',
'python-worker': './python-worker.ts',
'python-test-evaluator': './python-test-evaluator.ts'
'python-test-evaluator': './python-test-evaluator.ts',
'typescript-worker': './typescript-worker.ts'
},
devtool: __DEV__ ? 'inline-source-map' : 'source-map',
output: {