diff --git a/.gitignore b/.gitignore index b8450316fdd..6dcea9464cc 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index ddf77dac79f..23f5f5a922d 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -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; @@ -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 { diff --git a/client/src/templates/Challenges/rechallenge/transformers.js b/client/src/templates/Challenges/rechallenge/transformers.js index 14d04689613..18a18d94d2e 100644 --- a/client/src/templates/Challenges/rechallenge/transformers.js +++ b/client/src/templates/Challenges/rechallenge/transformers.js @@ -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, ``]; } else if (scriptJs) { return [challengeFiles, ``]; + } else if (indexTs) { + return [challengeFiles, ``]; } 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) { diff --git a/client/src/templates/Challenges/utils/build.ts b/client/src/templates/Challenges/utils/build.ts index e18b447b32b..dc1954cf1ef 100644 --- a/client/src/templates/Challenges/utils/build.ts +++ b/client/src/templates/Challenges/utils/build.ts @@ -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[]; diff --git a/client/src/templates/Challenges/utils/frame.ts b/client/src/templates/Challenges/utils/frame.ts index 6955fb7e2b4..a28d4a29a2b 100644 --- a/client/src/templates/Challenges/utils/frame.ts +++ b/client/src/templates/Challenges/utils/frame.ts @@ -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 { diff --git a/client/src/templates/Challenges/utils/typescript-worker-handler.ts b/client/src/templates/Challenges/utils/typescript-worker-handler.ts new file mode 100644 index 00000000000..81188a3299d --- /dev/null +++ b/client/src/templates/Challenges/utils/typescript-worker-handler.ts @@ -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 { + 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 { + return awaitResponse({ + worker: getTypeScriptWorker(), + message: { type: 'init' }, + onMessage: (data, onSuccess) => { + if (data.type === 'ready') { + onSuccess(true); + } + // otherwise it times out. + } + }); +} diff --git a/client/src/templates/Challenges/utils/worker-messenger.ts b/client/src/templates/Challenges/utils/worker-messenger.ts index 551af4942d2..faa4900590e 100644 --- a/client/src/templates/Challenges/utils/worker-messenger.ts +++ b/client/src/templates/Challenges/utils/worker-messenger.ts @@ -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 { - 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) => { - clearTimeout(id); - channel.port1.close(); - onMessage(event.data, resolve, reject); - }; + channel.port1.onmessage = (event: MessageEvent) => { + clearTimeout(id); + channel.port1.close(); + onMessage(event.data, resolve, reject); + }; - worker.postMessage(message, [channel.port2]); - }); + worker.postMessage(message, [channel.port2]); + } + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1463a6b4c30..e99602d15d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/shared/package.json b/shared/package.json index e2b32686d39..823e4ad92f8 100644 --- a/shared/package.json +++ b/shared/package.json @@ -18,6 +18,7 @@ }, "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", "dependencies": { + "@types/invariant": "^2.2.37", "invariant": "2.2.4" } } diff --git a/shared/utils/polyvinyl.js b/shared/utils/polyvinyl.js deleted file mode 100644 index 25a74dc8ded..00000000000 --- a/shared/utils/polyvinyl.js +++ /dev/null @@ -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 -}; diff --git a/shared/utils/polyvinyl.test.js b/shared/utils/polyvinyl.test.ts similarity index 70% rename from shared/utils/polyvinyl.test.js rename to shared/utils/polyvinyl.test.ts index 8263e033146..a040223b939 100644 --- a/shared/utils/polyvinyl.test.js +++ b/shared/utils/polyvinyl.test.ts @@ -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); }); }); diff --git a/shared/utils/polyvinyl.ts b/shared/utils/polyvinyl.ts new file mode 100644 index 00000000000..d1fd07a402c --- /dev/null +++ b/shared/utils/polyvinyl.ts @@ -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({ + 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) { + 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) { + 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; +// 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 +) { + 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 +) { + 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) { + return { + ...poly, + source: poly.source || poly.contents + }; +} diff --git a/tools/client-plugins/browser-scripts/index.d.ts b/tools/client-plugins/browser-scripts/index.d.ts index 10969a45a82..e76967c006a 100644 --- a/tools/client-plugins/browser-scripts/index.d.ts +++ b/tools/client-plugins/browser-scripts/index.d.ts @@ -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; diff --git a/tools/client-plugins/browser-scripts/package.json b/tools/client-plugins/browser-scripts/package.json index 6193206e48e..0d6d2a87feb 100644 --- a/tools/client-plugins/browser-scripts/package.json +++ b/tools/client-plugins/browser-scripts/package.json @@ -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", diff --git a/tools/client-plugins/browser-scripts/typescript-worker.ts b/tools/client-plugins/browser-scripts/typescript-worker.ts new file mode 100644 index 00000000000..d34ca7f01d3 --- /dev/null +++ b/tools/client-plugins/browser-scripts/typescript-worker.ts @@ -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); +} diff --git a/tools/client-plugins/browser-scripts/webpack.config.js b/tools/client-plugins/browser-scripts/webpack.config.js index 447ba57259b..7e67e1c1312 100644 --- a/tools/client-plugins/browser-scripts/webpack.config.js +++ b/tools/client-plugins/browser-scripts/webpack.config.js @@ -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: {