refactor: move challenge build outside client (#65513)

This commit is contained in:
Oliver Eyton-Williams
2026-01-27 05:53:51 +01:00
committed by GitHub
parent c8b21dfc4a
commit 5ff971687c
20 changed files with 766 additions and 329 deletions
+1
View File
@@ -0,0 +1 @@
dist
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
@@ -0,0 +1,18 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
const baseLanguageOptions = {
globals: {
...globals.browser,
...globals.node // TODO: necessary?
}
};
export default defineConfig({
extends: [configTypeChecked],
languageOptions: {
...baseLanguageOptions
}
});
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@freecodecamp/challenge-builder",
"version": "0.0.1",
"author": "freeCodeCamp <team@freecodecamp.org>",
"license": "BSD-3-Clause",
"description": "Builds challenges for testing and rendering",
"private": false,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"exports": {
"./build": "./dist/build.js",
"./transformers": "./dist/transformers.js",
"./typescript-worker-handler": "./dist/typescript-worker-handler.js",
"./builders": "./dist/builders.js"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"compile": "tsc",
"lint": "eslint --max-warnings 0"
},
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@types/lodash-es": "4.17.12",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@babel/preset-env": "7.23.7",
"@babel/preset-react": "7.28.5",
"@babel/standalone": "7.23.7",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/loop-protect": "3.0.0",
"@freecodecamp/shared": "workspace:*",
"lodash-es": "4.17.23"
}
}
@@ -0,0 +1,61 @@
export type Messenger<Message> = {
postMessage: (message: Message, options: WindowPostMessageOptions) => void;
};
/**
* Sends a message via a messenger (an object containing postMessage) and awaits a response.
*
* @template MessageOut - The type of the message being sent.
* @template MessageIn - The type of the message expected to be returned. Must include a `type` and `value` property.
* @template Value - The type of the value expected in the response message.
*
* @param {Object} params - The parameters for the function.
* @param {Message} params.messenger - An object cabable of sending messages via postMessage.
* @param {MessageOut} params.message - The message to send .
* @param {Function} params.onMessage - A callback function to handle the response.
* @param {MessageIn} params.onMessage.response - The response message.
* @param {Function} params.onMessage.resolve - A function which, when called, resolves the promise with its argument.
* @param {Function} params.onMessage.reject - A function which, when called, rejects the promise with its argument.
*
* @returns {Promise<Value>} A promise that resolves with the response value or rejects with an error message.
*/
export function awaitResponse<
MessageOut,
MessageIn extends { type: string; value: Value; error: string },
Value
>({
messenger,
message,
onMessage
}: {
messenger: Messenger<MessageOut>;
message: MessageOut;
onMessage: (
response: MessageIn,
onSuccess: (res: Value) => void,
onFailure: (err: Error) => void
) => void;
}): Promise<Value> {
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);
};
messenger.postMessage(message, {
targetOrigin: '*',
transfer: [channel.port2]
});
}
);
}
+269
View File
@@ -0,0 +1,269 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import { concatHtml } from './builders.js';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers,
getMultifileJSXTransformers
} from './transformers.js';
interface Source {
index: string;
contents?: string;
editableContents: string;
}
interface BuildChallengeData {
challengeType: number;
challengeFiles?: ChallengeFile[];
required: { src?: string }[];
template: string;
url: string;
}
interface BuildOptions {
preview: boolean;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
usesTestRunner?: boolean;
}
type ApplyFunctionProps = (
file: ChallengeFile
) => Promise<ChallengeFile> | ChallengeFile;
const applyFunction =
(fn: ApplyFunctionProps) => async (file: ChallengeFile) => {
try {
if (file.error) {
return file;
}
const newFile = await fn.call(this, file);
if (typeof newFile !== 'undefined') {
return newFile;
}
return file;
} catch (error) {
return { ...file, error };
}
};
const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
// TODO: rename sources.index to sources.contents.
const source: Source | undefined = challengeFiles?.reduce(
(sources, challengeFile) => {
sources.index += challengeFile.source || '';
sources.contents = sources.index;
sources.editableContents += challengeFile.editableContents || '';
return sources;
},
{
index: '',
editableContents: ''
} as Source
);
return source;
}
export const buildFunctions = {
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.html]: buildDOMChallenge,
[challengeTypes.modern]: buildDOMChallenge,
[challengeTypes.backend]: buildBackendChallenge,
[challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge,
[challengeTypes.multifilePythonCertProject]: buildPythonChallenge,
[challengeTypes.lab]: buildDOMChallenge,
[challengeTypes.jsLab]: buildJSChallenge,
[challengeTypes.pyLab]: buildPythonChallenge,
[challengeTypes.dailyChallengeJs]: buildJSChallenge,
[challengeTypes.dailyChallengePy]: buildPythonChallenge
};
export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
const { challengeType } = challengeData;
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
}
export async function buildChallenge(
challengeData: BuildChallengeData,
options: BuildOptions
) {
const { challengeType } = challengeData;
const build = buildFunctions[challengeType];
if (build) {
return build(challengeData, options);
}
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
> = {
[challengeTypes.html]: 'dom',
[challengeTypes.js]: 'javascript',
[challengeTypes.backend]: 'dom',
[challengeTypes.zipline]: 'dom',
[challengeTypes.frontEndProject]: 'dom',
[challengeTypes.backEndProject]: 'dom',
[challengeTypes.pythonProject]: 'python',
[challengeTypes.jsProject]: 'javascript',
[challengeTypes.modern]: 'dom',
[challengeTypes.step]: 'dom',
[challengeTypes.quiz]: 'dom',
[challengeTypes.invalid]: 'dom',
[challengeTypes.video]: 'dom',
[challengeTypes.codeAllyPractice]: 'dom',
[challengeTypes.codeAllyCert]: 'dom',
[challengeTypes.multifileCertProject]: 'dom',
[challengeTypes.theOdinProject]: 'dom',
[challengeTypes.colab]: 'dom',
[challengeTypes.exam]: 'dom',
[challengeTypes.msTrophy]: 'dom',
[challengeTypes.multipleChoice]: 'dom',
[challengeTypes.python]: 'python',
[challengeTypes.dialogue]: 'dom',
[challengeTypes.fillInTheBlank]: 'dom',
[challengeTypes.multifilePythonCertProject]: 'python',
[challengeTypes.generic]: 'dom',
[challengeTypes.lab]: 'dom',
[challengeTypes.jsLab]: 'javascript',
[challengeTypes.pyLab]: 'python',
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python',
[challengeTypes.review]: 'dom'
};
type BuildResult = {
challengeType: number;
build?: string;
sources: Source | undefined;
loadEnzyme?: boolean;
error?: unknown;
};
// TODO: All the buildXChallenge files have a similar structure, so make that
// abstraction (function, class, whatever) and then create the various functions
// out of it.
export async function buildDOMChallenge(
{
challengeFiles,
required = [],
template = '',
challengeType
}: BuildChallengeData,
options?: BuildOptions
): Promise<BuildResult> {
// TODO: make this required in the schema.
if (!challengeFiles) throw Error('No challenge files provided');
const hasJsx = challengeFiles.some(
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
);
const isMultifile = challengeFiles.length > 1;
const requiresReact16 = required.some(({ src }) =>
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
);
// I'm reasonably sure this is fine, but we need to migrate transformers to
// TypeScript to be sure.
const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx
? getMultifileJSXTransformers(options)
: getTransformers(options)) as unknown as ApplyFunctionProps[];
const pipeLine = composeFunctions(...transformers);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const contents = (await embedFilesInHtml(finalFiles)) as string;
// if there is an error, we just build the test runner so that it can be
// used to run tests against the code without actually running the code.
const toBuild = error
? {}
: {
required,
template,
contents
};
return {
challengeType,
build: concatHtml(toBuild),
sources: buildSourceMap(finalFiles),
loadEnzyme: requiresReact16,
error
};
}
export async function buildJSChallenge(
{
challengeFiles,
challengeType
}: { challengeFiles?: ChallengeFile[]; challengeType: number },
options: BuildOptions
): Promise<BuildResult> {
if (!challengeFiles) throw Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getTransformers(options) as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles?.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const toBuild = error ? [] : finalFiles;
return {
challengeType,
build: toBuild
.reduce(
(body, challengeFile) => [
...body,
challengeFile.head,
challengeFile.contents,
challengeFile.tail
],
[] as string[]
)
.join('\n'),
sources: buildSourceMap(finalFiles),
error
};
}
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
return {
challengeType,
build: '',
sources: { contents: url }
};
}
export async function buildPythonChallenge({
challengeFiles,
challengeType
}: BuildChallengeData): Promise<BuildResult> {
if (!challengeFiles) throw new Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getPythonTransformers() as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const sources = buildSourceMap(finalFiles);
return {
challengeType,
sources,
build: sources?.contents,
error
};
}
@@ -0,0 +1,44 @@
import { template as _template } from 'lodash-es';
interface ConcatHTMLOptions {
required?: { src?: string; link?: string }[];
template?: string;
contents?: string;
testRunner?: string;
}
export function concatHtml({
required = [],
template,
contents
}: ConcatHTMLOptions): string {
const embedSource = template
? _template(template)
: ({ source }: { source: ConcatHTMLOptions['contents'] }) => source;
const head = required
.map(({ link, src }) => {
if (link && src) {
throw new Error(`
A required file can not have both a src and a link: src = ${src}, link = ${link}
`);
}
if (src) {
return `<script src='${src}' type='text/javascript'></script>`;
}
if (link) {
return `<link href='${link}' rel='stylesheet' />`;
}
return '';
})
.join('\n');
return `<head>${head}</head>${embedSource({ source: contents }) || ''}`;
}
export function createPythonTerminal(pythonRunnerSrc: string): string {
const head =
'<head><style>#terminal { margin-top: 10px; width: 100%; height: 350px; background-color: #000; color: #00ff00; padding: 5px; overflow: auto; border: 1px solid #ccc; border-radius: 3px; }</style></head>';
const body = `<body><div id='terminal'></div><script src='${pythonRunnerSrc}' type='text/javascript'></script></body>`;
return `<html>${head}${body}</html>`;
}
@@ -0,0 +1,407 @@
import protect from '@freecodecamp/loop-protect';
import {
cond,
flow,
identity,
matchesProperty,
overSome,
partial,
stubTrue
} from 'lodash-es';
import {
transformContents,
transformHeadTailAndContents,
compileHeadTail,
createSource
} from '@freecodecamp/shared/utils/polyvinyl';
import { version } from '@freecodecamp/browser-scripts/package.json';
import { WorkerExecutor } from './worker-executor';
import {
compileTypeScriptCode,
checkTSServiceIsReady
} from './typescript-worker-handler';
const protectTimeout = 100;
const testProtectTimeout = 1500;
const loopsPerTimeoutCheck = 100;
const testLoopsPerTimeoutCheck = 2000;
const MODULE_TRANSFORM_PLUGIN = 'transform-modules-umd';
function loopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may fail if this is not changed.`
);
}
function testLoopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may be failing because of this.`
);
}
// hold Babel and presets so we don't try to import them multiple times
let Babel;
let presetEnv, presetReact;
let presetsJS, presetsJSX;
async function loadBabel() {
if (Babel) return;
Babel = await import(
/* webpackChunkName: "@babel/standalone" */ '@babel/standalone'
);
Babel.registerPlugin(
'loopProtection',
protect(protectTimeout, loopProtectCB, loopsPerTimeoutCheck)
);
Babel.registerPlugin(
'testLoopProtection',
protect(testProtectTimeout, testLoopProtectCB, testLoopsPerTimeoutCheck)
);
}
async function loadPresetEnv() {
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJS = {
presets: [presetEnv]
};
}
async function loadPresetReact() {
if (!presetReact)
presetReact = await import(
/* webpackChunkName: "@babel/preset-react" */ '@babel/preset-react'
);
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJSX = {
presets: [presetEnv, presetReact]
};
}
const babelTransformCode = options => code =>
Babel.transform(code, options).code;
const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx');
const testTSX = matchesProperty('ext', 'tsx');
const testTypeScript = matchesProperty('ext', 'ts');
const testHTML = matchesProperty('ext', 'html');
const testHTML$JS$JSX$TS$TSX = overSome(
testHTML,
testJS,
testJSX,
testTypeScript,
testTSX
);
const replaceNBSP = cond([
[
testHTML$JS$JSX$TS$TSX,
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
],
[stubTrue, identity]
]);
const getJSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetEnv();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return transformHeadTailAndContents(
babelTransformCode(babelOptions),
challengeFile
);
};
const getJSXTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
return transformHeadTailAndContents(
babelTransformCode(babelOptions),
challengeFile
);
};
const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getTSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await checkTSServiceIsReady();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return flow(
partial(transformHeadTailAndContents, compileTypeScriptCode),
partial(transformHeadTailAndContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
await checkTSServiceIsReady();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return flow(
partial(transformHeadTailAndContents, compileTypeScriptCode),
partial(transformHeadTailAndContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const createTranspiler = loopProtectOptions => {
return cond([
[testJS, getJSTranspiler(loopProtectOptions)],
[testJSX, getJSXTranspiler(loopProtectOptions)],
[testTypeScript, getTSTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: false })],
[stubTrue, identity]
]);
};
const createModuleTransformer = loopProtectOptions => {
return cond([
[testJSX, getJSXModuleTranspiler(loopProtectOptions)],
[testTSX, getTSXModuleTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: true })],
[stubTrue, identity]
]);
};
function getBabelOptions(
presets,
{ preview, disableLoopProtectTests, disableLoopProtectPreview } = {
preview: false,
disableLoopProtectTests: false,
disableLoopProtectPreview: false
}
) {
// we protect the preview unless specifically disabled, since it evaluates as
// the user types and they may briefly have infinite looping code accidentally
if (preview && !disableLoopProtectPreview)
return { ...presets, plugins: ['loopProtection'] };
if (!disableLoopProtectTests)
return { ...presets, plugins: ['testLoopProtection'] };
return presets;
}
const sassWorkerExecutor = new WorkerExecutor(
`workers/${version}/sass-compile`
);
async function transformSASS(documentElement) {
// we only teach scss syntax, not sass. Also the compiler does not seem to be
// able to deal with sass.
const styleTags = documentElement.querySelectorAll(
'style[type~="text/scss"]'
);
await Promise.all(
[].map.call(styleTags, async style => {
style.type = 'text/css';
style.innerHTML = await sassWorkerExecutor.execute(style.innerHTML, 5000)
.done;
})
);
}
async function transformScript(documentElement, { useModules }) {
await loadBabel();
await loadPresetEnv();
await loadPresetReact();
const scriptTags = documentElement.querySelectorAll('script');
scriptTags.forEach(script => {
const isBabel = script.type === 'text/babel';
const hasSource = !!script.src;
// TODO: make the use of JSX conditional on more than just the script type.
// It should only be used for React challenges since it would be confusing
// for learners to see the results of a transformation they didn't ask for.
const baseOptions = isBabel ? presetsJSX : presetsJS;
const options = {
...baseOptions,
...(useModules && { plugins: [MODULE_TRANSFORM_PLUGIN] })
};
// The type has to be removed, otherwise the browser will ignore the script.
// However, if we're importing modules, the type will be removed when the
// scripts are embedded in the HTML.
if (isBabel && !useModules) script.removeAttribute('type');
// We could use babel standalone to transform inline code in the preview,
// but that generates a warning that's shown to learner. By removing the
// type attribute and transforming the code we can avoid that warning.
if (isBabel && !hasSource) {
script.removeAttribute('type');
script.setAttribute('data-type', 'text/babel');
}
// Skip unnecessary transformations
script.innerHTML = script.innerHTML
? babelTransformCode(options)(script.innerHTML)
: '';
});
}
// 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, indexTs, indexTsx } =
challengeFilesToObject(challengeFiles);
const embedStylesAndScript = contentDocument => {
const documentElement = contentDocument.documentElement;
const link =
documentElement.querySelector('link[href="styles.css"]') ??
documentElement.querySelector('link[href="./styles.css"]');
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"]');
const jsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.jsx"]`
) ??
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.jsx"]`
);
const tsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.tsx"]`
) ??
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.tsx"]`
);
if (link) {
const style = contentDocument.createElement('style');
style.classList.add('fcc-injected-styles');
style.innerHTML = stylesCss?.contents;
link.parentNode.appendChild(style);
link.removeAttribute('href');
link.dataset.href = 'styles.css';
}
if (script) {
script.innerHTML = scriptJs?.contents;
script.removeAttribute('src');
script.setAttribute('data-src', 'script.js');
}
if (tsScript) {
tsScript.innerHTML = indexTs?.contents;
tsScript.removeAttribute('src');
tsScript.setAttribute('data-src', 'index.ts');
}
if (jsxScript) {
jsxScript.innerHTML = indexJsx?.contents;
jsxScript.removeAttribute('src');
jsxScript.removeAttribute('type');
jsxScript.setAttribute('data-src', 'index.jsx');
jsxScript.setAttribute('data-type', 'text/babel');
}
if (tsxScript) {
tsxScript.innerHTML = indexTsx?.contents;
tsxScript.removeAttribute('src');
tsxScript.removeAttribute('type');
tsxScript.setAttribute('data-src', 'index.tsx');
tsxScript.setAttribute('data-type', 'text/babel');
}
return documentElement.innerHTML;
};
if (indexHtml) {
const contents = await parseAndTransform(
embedStylesAndScript,
indexHtml.contents
);
return contents;
} else if (indexJsx) {
return `<script>${indexJsx.contents}</script>`;
} else if (scriptJs) {
return `<script>${scriptJs.contents}</script>`;
} else if (indexTs) {
return `<script>${indexTs.contents}</script>`;
} else if (indexTsx) {
return `<script>${indexTsx.contents}</script>`;
} else {
throw Error('No html, ts(x) or js(x) file found');
}
};
function challengeFilesToObject(challengeFiles) {
const indexHtml = challengeFiles.find(file => file.fileKey === 'indexhtml');
const indexJsx = challengeFiles.find(file => file.fileKey === 'indexjsx');
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs, indexTsx };
}
const parseAndTransform = async function (transform, contents) {
const parser = new DOMParser();
const newDoc = parser.parseFromString(contents, 'text/html');
return await transform(newDoc);
};
const getHtmlTranspiler = scriptOptions =>
async function (file) {
const transform = async contentDocument => {
const documentElement = contentDocument.documentElement;
await Promise.all([
transformSASS(documentElement),
transformScript(documentElement, scriptOptions)
]);
return documentElement.innerHTML;
};
const contents = await parseAndTransform(transform, file.contents);
return transformContents(() => contents, file);
};
export const getTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createTranspiler(loopProtectOptions),
partial(compileHeadTail, '')
];
export const getMultifileJSXTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createModuleTransformer(loopProtectOptions)
];
export const getPythonTransformers = () => [
createSource,
replaceNBSP,
partial(compileHeadTail, '')
];
@@ -0,0 +1,45 @@
import browserScripts from '@freecodecamp/browser-scripts/package.json';
import { awaitResponse } from './awaitable-messenger.js';
const typeScriptWorkerSrc = `/js/workers/${browserScripts.version}/typescript-worker.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({
messenger: 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 checkTSServiceIsReady(): Promise<boolean> {
return awaitResponse({
messenger: getTypeScriptWorker(),
message: { type: 'check-is-ready' },
onMessage: (data, onSuccess) => {
if (data.type === 'ready') {
onSuccess(true);
}
// otherwise it times out.
}
});
}
@@ -0,0 +1,149 @@
export class WorkerExecutor {
constructor(
workerName,
{ location = '/js/', maxWorkers = 2, terminateWorker = false } = {}
) {
this._workerPool = [];
this._taskQueue = [];
this._workersInUse = 0;
this._maxWorkers = maxWorkers;
this._terminateWorker = terminateWorker;
this._scriptURL = `${location}${workerName}.js`;
this._getWorker = this._getWorker.bind(this);
}
async _getWorker() {
return this._workerPool.length
? this._workerPool.shift()
: this._createWorker();
}
_createWorker() {
return new Promise((resolve, reject) => {
const newWorker = new Worker(this._scriptURL);
newWorker.onmessage = e => {
if (e.data?.type === 'contentLoaded') {
resolve(newWorker);
}
};
newWorker.onerror = err => reject(err);
});
}
_handleTaskEnd(task) {
return () => {
this._workersInUse--;
const worker = task._worker;
if (worker) {
if (this._terminateWorker) {
worker.terminate();
} else {
worker.onmessage = null;
worker.onerror = null;
this._workerPool.push(worker);
}
}
this._processQueue();
};
}
_processQueue() {
while (this._workersInUse < this._maxWorkers && this._taskQueue.length) {
const task = this._taskQueue.shift();
const handleTaskEnd = this._handleTaskEnd(task);
task._execute(this._getWorker).done.then(handleTaskEnd, handleTaskEnd);
this._workersInUse++;
}
}
execute(data, timeout = 1000) {
const task = eventify({});
task._execute = function (getWorker) {
getWorker().then(
worker => {
task._worker = worker;
const timeoutId = setTimeout(() => {
task._worker.terminate();
task._worker = null;
this.emit('error', { message: 'timeout' });
}, timeout);
worker.onmessage = e => {
clearTimeout(timeoutId);
// data.type is undefined when the message has been processed
// successfully and defined when something else has happened (e.g.
// an error occurred)
if (e.data?.type) {
this.emit(e.data.type, e.data.data);
} else {
this.emit('done', e.data);
}
};
worker.onerror = e => {
clearTimeout(timeoutId);
this.emit('error', { message: e.message });
};
worker.postMessage(data);
},
err => this.emit('error', err)
);
return this;
};
task.done = new Promise((resolve, reject) => {
task
.once('done', data => resolve(data))
.once('error', err => reject(err.message));
});
this._taskQueue.push(task);
this._processQueue();
return task;
}
}
// Error and completion handling
const eventify = self => {
self._events = {};
self.on = (event, listener) => {
if (typeof self._events[event] === 'undefined') {
self._events[event] = [];
}
self._events[event].push(listener);
return self;
};
self.removeListener = (event, listener) => {
if (typeof self._events[event] !== 'undefined') {
const index = self._events[event].indexOf(listener);
if (index !== -1) {
self._events[event].splice(index, 1);
}
}
return self;
};
self.emit = (event, ...args) => {
if (typeof self._events[event] !== 'undefined') {
const listeners = self._events[event].slice();
for (let listener of listeners) {
listener.apply(self, args);
}
}
return self;
};
self.once = (event, listener) => {
self.on(event, function handler(...args) {
self.removeListener(event, handler);
listener.apply(self, args);
});
return self;
};
return self;
};
@@ -0,0 +1,249 @@
/**
* @vitest-environment node
*/
import { it, expect, afterEach, vi } from 'vitest';
import { WorkerExecutor } from './worker-executor';
function mockWorker({ init, postMessage, terminate } = {}) {
global.Worker = vi.fn(function () {
setImmediate(
(init && init(this)) ||
(() =>
this.onmessage && this.onmessage({ data: { type: 'contentLoaded' } }))
);
this.onmessage = null;
this.postMessage =
postMessage ||
function (data) {
setImmediate(
() => this.onmessage && this.onmessage({ data: `${data} processed` })
);
};
this.terminate = terminate || (() => {});
return this;
});
}
afterEach(() => {
delete global.Worker;
});
it('Worker executor should successfully execute one task', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test');
expect(testWorker).not.toBeUndefined();
const task = testWorker.execute('test');
expect(task).not.toBeUndefined();
expect(task.done).not.toBeUndefined();
const handler = vi.fn();
task.on('done', handler);
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).resolves.toBe('test processed');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('test processed');
expect(errorHandler).not.toHaveBeenCalled();
expect(terminateHandler).not.toHaveBeenCalled();
expect(global.Worker).toHaveBeenCalledTimes(1);
expect(global.Worker).toHaveBeenCalledWith('/js/test.js');
});
it('Worker executor should successfully execute two tasks in parallel', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test');
const task1 = testWorker.execute('test1');
const handler1 = vi.fn();
task1.on('done', handler1);
const errorHandler1 = vi.fn();
task1.on('error', errorHandler1);
const task2 = testWorker.execute('test2');
const handler2 = vi.fn();
task2.on('done', handler2);
const errorHandler2 = vi.fn();
task2.on('error', errorHandler2);
await expect(Promise.all([task1.done, task2.done])).resolves.toEqual([
'test1 processed',
'test2 processed'
]);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith('test1 processed');
expect(errorHandler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith('test2 processed');
expect(errorHandler2).not.toHaveBeenCalled();
expect(terminateHandler).not.toHaveBeenCalled();
expect(global.Worker).toHaveBeenCalledTimes(2);
});
it('Worker executor should successfully execute 3 tasks in parallel and use two workers', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test');
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(2);
});
it('Worker executor should successfully execute 3 tasks, use 3 workers and terminate each worker', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test', { terminateWorker: true });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(terminateHandler).toHaveBeenCalledTimes(3);
expect(global.Worker).toHaveBeenCalledTimes(3);
});
it('Worker executor should successfully execute 3 tasks in parallel and use 3 workers', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', { maxWorkers: 3 });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(3);
});
it('Worker executor should successfully execute 3 tasks and use 1 worker', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', { maxWorkers: 1 });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(1);
});
it('Worker executor should reject task', async () => {
const error = { message: 'Error on init worker' };
mockWorker({
init: () => {
throw error;
}
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).rejects.toBe(error.message);
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorHandler).toHaveBeenCalledWith(error);
});
it('Worker executor should emit LOG events', async () => {
mockWorker({
postMessage: function (data) {
setImmediate(() => {
for (let i = 0; i < 3; i++) {
// eslint-disable-next-line no-unused-expressions
this.onmessage && this.onmessage({ data: { type: 'LOG', data: i } });
}
// eslint-disable-next-line no-unused-expressions
this.onmessage && this.onmessage({ data: `${data} processed` });
setImmediate(
() =>
this.onmessage && this.onmessage({ data: { type: 'LOG', data: 3 } })
);
});
}
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const handler = vi.fn();
task.on('done', handler);
const errorHandler = vi.fn();
task.on('error', errorHandler);
const logHandler = vi.fn();
task.on('LOG', logHandler);
await expect(task.done).resolves.toBe('test processed');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('test processed');
expect(errorHandler).not.toHaveBeenCalled();
expect(logHandler).toHaveBeenCalledTimes(3);
for (let i = 0; i < 3; i++) {
expect(logHandler.mock.calls[i][0]).toBe(i);
}
});
it('Worker executor should reject task on timeout', async () => {
const terminateHandler = vi.fn();
mockWorker({
postMessage: () => {},
terminate: terminateHandler
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test', 0);
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).rejects.toBe('timeout');
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorHandler.mock.calls[0][0]).toEqual({ message: 'timeout' });
expect(terminateHandler).toHaveBeenCalledTimes(1);
});
it('Worker executor should get worker from specified location', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', {
location: '/other/location/'
});
const task = testWorker.execute('test');
await expect(task.done).resolves.toBe('test processed');
expect(global.Worker).toHaveBeenCalledTimes(1);
expect(global.Worker).toHaveBeenCalledWith('/other/location/test.js');
});
it('Task should only emit handler once', () => {
mockWorker();
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const handler = vi.fn();
task.once('testOnce', handler);
task.emit('testOnce', handler);
task.emit('testOnce', handler);
expect(handler).toHaveBeenCalledTimes(1);
});
+12
View File
@@ -0,0 +1,12 @@
{
"include": ["src"],
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"module": "es2020",
"moduleResolution": "bundler",
"outDir": "dist",
"noEmit": false
}
}