From e5cae6909c5825f70a290ffead2aa05c112f937c Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 26 Jan 2026 13:21:20 +0100 Subject: [PATCH] feat(tools): modularize browser-scripts (#65399) --- client/.gitignore | 1 - client/config/browser-scripts/.gitkeep | 0 client/package.json | 3 ++- .../Challenges/rechallenge/transformers.js | 9 ++++--- .../Challenges/utils/python-worker-handler.ts | 6 ++--- .../utils/typescript-worker-handler.ts | 5 ++-- client/tools/copy-browser-scripts.ts | 19 ++++++++++++++ curriculum/.gitignore | 1 + curriculum/package.json | 1 + curriculum/src/test/vitest-global-setup.mjs | 26 ++++++++++++++----- pnpm-lock.yaml | 6 +++++ .../client-plugins/browser-scripts/.gitignore | 1 + .../browser-scripts/package.json | 7 +++++ .../browser-scripts/sass-compile.ts | 3 ++- .../browser-scripts/webpack.config.cjs | 24 ++++------------- 15 files changed, 74 insertions(+), 38 deletions(-) delete mode 100644 client/config/browser-scripts/.gitkeep create mode 100644 client/tools/copy-browser-scripts.ts create mode 100644 tools/client-plugins/browser-scripts/.gitignore diff --git a/client/.gitignore b/client/.gitignore index b1a21ca72a7..f5f41512776 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -12,7 +12,6 @@ yarn-error.log static/curriculum-data # Generated config -config/browser-scripts/*.json i18n/locales/**/trending.json i18n/locales/**/search-bar.json diff --git a/client/config/browser-scripts/.gitkeep b/client/config/browser-scripts/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/client/package.json b/client/package.json index c5b71c4db1e..164d1b51436 100644 --- a/client/package.json +++ b/client/package.json @@ -21,7 +21,7 @@ "scripts": { "prebuild": "pnpm run common-setup && pnpm run build:scripts --env production", "build": "NODE_OPTIONS=\"--max-old-space-size=7168 --no-deprecation\" gatsby build --prefix-paths", - "build:scripts": "pnpm run -F=browser-scripts compile", + "build:scripts": "pnpm run -F=browser-scripts compile && tsx ./tools/copy-browser-scripts.ts", "build:external-curriculum": "tsx ./tools/external-curriculum/build", "clean": "gatsby clean", "common-setup": "pnpm -w turbo compile && pnpm run create:env && pnpm run create:trending && pnpm run create:search-placeholder", @@ -144,6 +144,7 @@ "@freecodecamp/eslint-config": "workspace:*", "@freecodecamp/shared": "workspace:*", "@freecodecamp/curriculum": "workspace:*", + "@freecodecamp/browser-scripts": "workspace:*", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", diff --git a/client/src/templates/Challenges/rechallenge/transformers.js b/client/src/templates/Challenges/rechallenge/transformers.js index 94dd8a80f4e..7a2f440175b 100644 --- a/client/src/templates/Challenges/rechallenge/transformers.js +++ b/client/src/templates/Challenges/rechallenge/transformers.js @@ -9,21 +9,20 @@ import { stubTrue } from 'lodash-es'; -import sassData from '../../../../config/browser-scripts/sass-compile.json'; import { transformContents, transformHeadTailAndContents, compileHeadTail, createSource } from '@freecodecamp/shared/utils/polyvinyl'; +import { version } from '@freecodecamp/browser-scripts/package.json'; + import { WorkerExecutor } from '../utils/worker-executor'; import { compileTypeScriptCode, checkTSServiceIsReady } from '../utils/typescript-worker-handler'; -const { filename: sassCompile } = sassData; - const protectTimeout = 100; const testProtectTimeout = 1500; const loopsPerTimeoutCheck = 100; @@ -209,7 +208,9 @@ function getBabelOptions( return presets; } -const sassWorkerExecutor = new WorkerExecutor(sassCompile); +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. diff --git a/client/src/templates/Challenges/utils/python-worker-handler.ts b/client/src/templates/Challenges/utils/python-worker-handler.ts index 0137484e105..8c7aa2aa1a8 100644 --- a/client/src/templates/Challenges/utils/python-worker-handler.ts +++ b/client/src/templates/Challenges/utils/python-worker-handler.ts @@ -1,7 +1,7 @@ -// TODO: This might be cleaner as a class. -import pythonWorkerData from '../../../../config/browser-scripts/python-worker.json'; +import { version } from '@freecodecamp/browser-scripts/package.json'; -const pythonWorkerSrc = `/js/${pythonWorkerData.filename}.js`; +// TODO: This might be cleaner as a class. +const pythonWorkerSrc = `/js/workers/${version}/python-worker.js`; let worker: Worker | null = null; let listener: ((event: MessageEvent) => void) | null = null; diff --git a/client/src/templates/Challenges/utils/typescript-worker-handler.ts b/client/src/templates/Challenges/utils/typescript-worker-handler.ts index 8c550f31c73..09752b36a45 100644 --- a/client/src/templates/Challenges/utils/typescript-worker-handler.ts +++ b/client/src/templates/Challenges/utils/typescript-worker-handler.ts @@ -1,7 +1,8 @@ -import typeScriptWorkerData from '../../../../config/browser-scripts/typescript-worker.json'; +import { version } from '@freecodecamp/browser-scripts/package.json'; + import { awaitResponse } from './awaitable-messenger'; -const typeScriptWorkerSrc = `/js/${typeScriptWorkerData.filename}.js`; +const typeScriptWorkerSrc = `/js/workers/${version}/typescript-worker.js`; let worker: Worker | null = null; diff --git a/client/tools/copy-browser-scripts.ts b/client/tools/copy-browser-scripts.ts new file mode 100644 index 00000000000..aadf261a712 --- /dev/null +++ b/client/tools/copy-browser-scripts.ts @@ -0,0 +1,19 @@ +import { cpSync, mkdirSync, rmSync } from 'node:fs'; + +import { resolve } from 'node:path'; + +const browserScriptDist = resolve( + __dirname, + '../../tools/client-plugins/browser-scripts/dist' +); + +const destJsDir = resolve(__dirname, '../static/js'); + +// Everything is done synchronously to keep the script simple. There's no +// performance benefit to doing this asynchronously since it's already so fast. +rmSync(destJsDir, { recursive: true, force: true }); +mkdirSync(destJsDir, { recursive: true }); + +cpSync(resolve(browserScriptDist, 'artifacts'), destJsDir, { + recursive: true +}); diff --git a/curriculum/.gitignore b/curriculum/.gitignore index 86d4c2dd380..9134943f301 100644 --- a/curriculum/.gitignore +++ b/curriculum/.gitignore @@ -1 +1,2 @@ generated +src/test/stubs/js diff --git a/curriculum/package.json b/curriculum/package.json index 8fe4604b37e..0951504e0aa 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -50,6 +50,7 @@ "@babel/register": "7.23.7", "@freecodecamp/eslint-config": "workspace:*", "@freecodecamp/shared": "workspace:*", + "@freecodecamp/browser-scripts": "workspace:*", "@total-typescript/ts-reset": "^0.6.1", "@types/debug": "^4.1.12", "@types/js-yaml": "4.0.5", diff --git a/curriculum/src/test/vitest-global-setup.mjs b/curriculum/src/test/vitest-global-setup.mjs index adec32c4ea1..16a68bbb27e 100644 --- a/curriculum/src/test/vitest-global-setup.mjs +++ b/curriculum/src/test/vitest-global-setup.mjs @@ -3,10 +3,8 @@ import path from 'node:path'; import sirv from 'sirv'; import polka from 'polka'; import puppeteer from 'puppeteer'; - -import { helperVersion } from '../../../client/src/templates/Challenges/utils/frame'; - -const clientPath = path.resolve(__dirname, '../../../client'); +import { cpSync, mkdirSync, rmSync } from 'node:fs'; +import { version } from '@freecodecamp/browser-scripts/test-runner'; async function createBrowser() { return puppeteer.launch({ @@ -21,6 +19,20 @@ async function createBrowser() { let browser, server; +function setupStubs() { + const browserScriptDist = path.resolve( + __dirname, + '../../../tools/client-plugins/browser-scripts/dist' + ); + const destArtifactsDir = path.resolve(__dirname, 'stubs/js'); + + rmSync(destArtifactsDir, { recursive: true, force: true }); + mkdirSync(destArtifactsDir, { recursive: true }); + cpSync(path.resolve(browserScriptDist, 'artifacts'), destArtifactsDir, { + recursive: true + }); +} + async function startServer() { const host = '127.0.0.1'; const port = 8080; @@ -29,16 +41,16 @@ async function startServer() { // Mount static files used by the tests app.use( - '/dist', - sirv(path.join(clientPath, `static/js/test-runner/${helperVersion}`)) + '/dist', // the runner is mounted at dist so we don't need to specify the asset path when initializing + sirv(path.resolve(__dirname, `stubs/js/test-runner/${version}`)) ); - app.use('/js', sirv(path.join(clientPath, 'static/js'))); app.use('/', sirv(path.resolve(__dirname, 'stubs'))); app.listen(port, host); return app.server; } export async function setup() { + setupStubs(); server = await startServer(); browser = await createBrowser(); // Sharing the Websocket endpoint so that setup files can connect. This allows diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ec29fe9afa..f82c1179dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,6 +550,9 @@ importers: '@babel/plugin-syntax-dynamic-import': specifier: 7.8.3 version: 7.8.3(@babel/core@7.28.5) + '@freecodecamp/browser-scripts': + specifier: workspace:* + version: link:../tools/client-plugins/browser-scripts '@freecodecamp/curriculum': specifier: workspace:* version: link:../curriculum @@ -710,6 +713,9 @@ importers: '@babel/register': specifier: 7.23.7 version: 7.23.7(@babel/core@7.23.7) + '@freecodecamp/browser-scripts': + specifier: workspace:* + version: link:../tools/client-plugins/browser-scripts '@freecodecamp/eslint-config': specifier: workspace:* version: link:../packages/eslint-config diff --git a/tools/client-plugins/browser-scripts/.gitignore b/tools/client-plugins/browser-scripts/.gitignore new file mode 100644 index 00000000000..1521c8b7652 --- /dev/null +++ b/tools/client-plugins/browser-scripts/.gitignore @@ -0,0 +1 @@ +dist diff --git a/tools/client-plugins/browser-scripts/package.json b/tools/client-plugins/browser-scripts/package.json index 1d84d3e1e37..052d11fc8fc 100644 --- a/tools/client-plugins/browser-scripts/package.json +++ b/tools/client-plugins/browser-scripts/package.json @@ -8,6 +8,13 @@ "node": ">=24", "pnpm": ">=10" }, + "files": [ + "dist" + ], + "exports": { + "./test-runner": "./test-runner.ts", + "./package.json": "./package.json" + }, "repository": { "type": "git", "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" diff --git a/tools/client-plugins/browser-scripts/sass-compile.ts b/tools/client-plugins/browser-scripts/sass-compile.ts index d6a017941aa..f910e9f6894 100644 --- a/tools/client-plugins/browser-scripts/sass-compile.ts +++ b/tools/client-plugins/browser-scripts/sass-compile.ts @@ -1,3 +1,4 @@ +import { version } from '@freecodecamp/browser-scripts/package.json'; // work around for SASS error in Edge // https://github.com/medialize/sass.js/issues/96#issuecomment-424386171 interface WorkerWithSass extends Worker { @@ -20,7 +21,7 @@ if (!ctx.crypto) { }; } -ctx.importScripts('/js/sass.sync.js'); +ctx.importScripts(`/js/workers/${version}/sass.sync.js`); ctx.onmessage = e => { const data: unknown = e.data; diff --git a/tools/client-plugins/browser-scripts/webpack.config.cjs b/tools/client-plugins/browser-scripts/webpack.config.cjs index c08b45b29f3..09217df9606 100644 --- a/tools/client-plugins/browser-scripts/webpack.config.cjs +++ b/tools/client-plugins/browser-scripts/webpack.config.cjs @@ -1,18 +1,14 @@ -const { writeFileSync } = require('fs'); const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const webpack = require('webpack'); const { version: helperVersion } = require('@freecodecamp/curriculum-helpers/package.json'); +const { version } = require('./package.json'); module.exports = (env = {}) => { const __DEV__ = env.production !== true; - const staticPath = path.join(__dirname, '../../../client/static/js'); - const configPath = path.join( - __dirname, - '../../../client/config/browser-scripts/' - ); + return { cache: __DEV__ ? { type: 'filesystem' } : false, mode: __DEV__ ? 'development' : 'production', @@ -23,19 +19,9 @@ module.exports = (env = {}) => { }, devtool: __DEV__ ? 'inline-source-map' : 'source-map', output: { - publicPath: '/js/', - filename: chunkData => { - // construct and output the filename here, so the client can use the - // json to find the file. - const filename = `${chunkData.chunk.name}-${chunkData.chunk.contentHash.javascript}`; - writeFileSync( - path.join(configPath, `${chunkData.chunk.name}.json`), - `{"filename": "${filename}"}` - ); - return filename + '.js'; - }, chunkFilename: '[name]-[contenthash].js', - path: staticPath + path: path.resolve(__dirname, `dist/artifacts/workers/${version}`), + clean: true }, stats: { // Display bailout reasons @@ -74,7 +60,7 @@ module.exports = (env = {}) => { './node_modules/xterm/css/xterm.css', { from: './node_modules/@freecodecamp/curriculum-helpers/dist/test-runner', - to: `test-runner/${helperVersion}/` + to: `../../test-runner/${helperVersion}/` } ] }),