From 23844c67af69a3fd9ef8c66de0ea5d646466840e Mon Sep 17 00:00:00 2001 From: Brian Smith Date: Fri, 30 Jan 2026 10:52:06 -0500 Subject: [PATCH] feat: add `autoinstall` --- .gitignore | 1 + .nvmrc | 1 + README.md | 8 +- package-lock.json | 47 ++++++++ package.json | 24 ++++ tools/autoinstall/README.md | 25 ++++ .../autoinstall-frontend-base-tarballs.mjs | 112 ++++++++++++++++++ tools/autoinstall/dev-with-autoinstall.mjs | 110 +++++++++++++++++ tools/autoinstall/env.mjs | 21 ++++ 9 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tools/autoinstall/README.md create mode 100644 tools/autoinstall/autoinstall-frontend-base-tarballs.mjs create mode 100755 tools/autoinstall/dev-with-autoinstall.mjs create mode 100644 tools/autoinstall/env.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 68b4416..5e7f1a4 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# frontend-dev-utils \ No newline at end of file +# frontend-dev-utils + +This package includes development utilities for use with [`frontend-base`](https://github.com/openedx/frontend-base/). + +## Tools + +* [`autoinstall`](./tools/autoinstall/): Tool to watch for/install packed `frontend-base` `.tgz` files when running an app's developement server. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8293a7f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@openedx/frontend-dev-utils", + "version": "1.0.0-prealpha", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openedx/frontend-dev-utils", + "version": "1.0.0-prealpha", + "license": "AGPL-3.0", + "dependencies": { + "chokidar": "^5.0.0" + }, + "bin": { + "devutils-dev-with-autoinstall": "tools/autoinstall/dev-with-autoinstall.mjs" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cfe48d5 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openedx/frontend-dev-utils", + "version": "1.0.0-prealpha", + "description": "Development utilities for use with @openedx/frontend-base", + "homepage": "https://github.com/openedx/frontend-dev-utils#readme", + "bugs": { + "url": "https://github.com/openedx/frontend-dev-utils/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openedx/frontend-dev-utils.git" + }, + "license": "AGPL-3.0", + "author": "Open edX Community", + "bin": { + "devutils-dev-with-autoinstall": "./tools/autoinstall/dev-with-autoinstall.mjs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "chokidar": "^5.0.0" + } +} diff --git a/tools/autoinstall/README.md b/tools/autoinstall/README.md new file mode 100644 index 0000000..12936e2 --- /dev/null +++ b/tools/autoinstall/README.md @@ -0,0 +1,25 @@ +# Developing an app and `frontend-base` concurrently + +## Why this workflow exists + +When developing an application alongside `frontend-base`, the usual approaches for working on a dependency locally, such as `file:` dependencies, `npm link`, and webpack module configuration, proved unreliable. + +In multiple cases: + +* the consuming application would surface **TypeScript errors inside `frontend-base` source files** +* those same errors would **disappear entirely** when `frontend-base` was installed from a packaged tarball +* this behavior persisted even when extracting the tarball and linking the extracted directory directly + +Changing *how* `frontend-base` was introduced into the app (without changing any code) eliminated the TypeScript errors. + +This made it clear that symlink and source-based workflows were interacting poorly with TypeScript and build tooling, even when the underlying code was identical. + +## What this workflow does instead + +The tooling in this directory treats `frontend-base` as a packaged dependency during local development. + +* `frontend-base` is built using `npm pack` +* the consuming application installs the resulting `.tgz` +* changes trigger a reinstall and dev server restart + +This mirrors how `frontend-base` is actually consumed from npm, and avoids the TypeScript and tooling issues encountered with linking approaches. diff --git a/tools/autoinstall/autoinstall-frontend-base-tarballs.mjs b/tools/autoinstall/autoinstall-frontend-base-tarballs.mjs new file mode 100644 index 0000000..126a1ec --- /dev/null +++ b/tools/autoinstall/autoinstall-frontend-base-tarballs.mjs @@ -0,0 +1,112 @@ +import chokidar from 'chokidar'; +import fs from 'fs'; +import { spawn } from 'child_process'; +import { APP_ROOT, INSTALL_DEBOUNCE_MS, TGZ_FILENAME, TGZ_PATH } from './env.mjs'; + +const install = () => { + return new Promise((resolve, reject) => { + const installProcess = spawn( + 'npm', + ['i', '--no-save', TGZ_PATH], + { stdio: 'inherit', shell: false, cwd: APP_ROOT } + ); + + installProcess.once('error', reject); + + installProcess.once('exit', (code) => ( + code === 0 + ? resolve() + : reject(new Error(`npm exited ${code}`)) + ) + ); + }); +}; + +const tryInstall = async (trigger) => { + console.log(`\n[install] ${TGZ_FILENAME} (${trigger})`); + + if (!fs.existsSync(TGZ_PATH)) { + throw new Error(`${TGZ_FILENAME} not found at ${TGZ_PATH}`); + } + + await install(); +}; + +/** + * Creates an autoinstaller that watches the frontend-base tgz + * generated by npm pack and installs it. + * + * Usage: + * const installer = createInstaller({ onInstall: () => {...} }); + * await installer.start(); // initial install + start watching + * await installer.stop(); // stop watching + clear timers + */ +export const createInstaller = ({ + onInstall, +}) => { + let installing = false; + let timer = null; + let watcher; + + const runInstall = async (trigger) => { + if (installing) { + return; + } + + installing = true; + try { + await tryInstall(trigger); + await onInstall?.(trigger); + } catch (e) { + console.error('\n[error]', e.message || e); + } finally { + installing = false; + } + }; + + const scheduleInstall = (trigger) => { + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(() => runInstall(trigger), INSTALL_DEBOUNCE_MS); + }; + + const start = async () => { + if (watcher) { + console.warn('[install:watch] watcher already exists; start() called twice?'); + return; + } + + console.log(`[install:watch] ${TGZ_PATH}`); + + watcher = chokidar + .watch(TGZ_PATH, { ignoreInitial: true, awaitWriteFinish: true }) + .on('add', () => scheduleInstall('tgz:add')) + .on('change', () => scheduleInstall('tgz:change')); + + // Explicit initial install (not relying on watcher) + await tryInstall('initial'); + }; + + const stop = async () => { + if (timer) { + clearTimeout(timer); + } + timer = null; + + await watcher?.close(); + watcher = null; + }; + + const installNow = async () => { + if (timer) { + clearTimeout(timer); + } + timer = null; + + await runInstall('manual'); + }; + + return { start, stop, installNow }; +}; diff --git a/tools/autoinstall/dev-with-autoinstall.mjs b/tools/autoinstall/dev-with-autoinstall.mjs new file mode 100755 index 0000000..2bce4a3 --- /dev/null +++ b/tools/autoinstall/dev-with-autoinstall.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +import net from 'net'; +import { spawn } from 'child_process'; +import { createInstaller } from './autoinstall-frontend-base-tarballs.mjs'; +import { APP_ROOT, HOST, PORT } from './env.mjs'; + +const portInUse = () => { + return new Promise((resolve) => { + const portProbe = net.createServer(); + portProbe.once('error', () => resolve(true)); + portProbe.once('listening', () => portProbe.close(() => resolve(false))); + portProbe.listen(PORT, HOST); + }); +}; + +const waitForPortFree = async (timeoutMs = 8000) => { + const start = Date.now(); + while (await portInUse()) { + if ((Date.now() - start) > timeoutMs) { + throw new Error(`Port ${PORT} still in use after ${timeoutMs}ms`); + } + + await new Promise((r) => setTimeout(r, 150)); + } +}; + +let devServerProcess = null; + +const startDev = () => { + console.log('\n[dev] start: npm run dev\n'); + devServerProcess = spawn('npm', ['run', 'dev'], { + cwd: APP_ROOT, + stdio: 'inherit', + shell: false, + detached: true, + env: process.env, + }); + devServerProcess?.once('error', (e) => console.error('[dev] spawn failed:', e)); + devServerProcess?.unref(); +}; + +const WAIT_BETWEEN_TERM_AND_KILL_MS = 1200; +const stopDev = async () => { + if (!devServerProcess) { + return; + } + + console.log('\n[dev] stop\n'); + + try { + process.kill(-devServerProcess.pid, 'SIGTERM'); + } catch (e) { + if (e.code !== 'ESRCH') { + throw e; + } + } + + await new Promise((r) => setTimeout(r, WAIT_BETWEEN_TERM_AND_KILL_MS)); + + if (await portInUse()) { + try { + process.kill(-devServerProcess.pid, 'SIGKILL'); + } catch (e) { + if (e.code !== 'ESRCH') { + throw e; + } + } + } + + await waitForPortFree(); + devServerProcess = null; +}; + +let shuttingDown = false; + +const installer = createInstaller({ + onInstall: async (trigger) => { + if (shuttingDown) { + return; + } + + console.log(`\n[watch] restart (${trigger})`); + await stopDev(); + if (!shuttingDown) { + startDev(); + } + }, +}); + +const shutdown = async () => { + if (shuttingDown) { + return; + } + shuttingDown = true; + + console.log('\n[exit]'); + try { + await installer.stop(); + await stopDev(); + } finally { + process.exit(0); + } +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +await installer.start(); +startDev(); diff --git a/tools/autoinstall/env.mjs b/tools/autoinstall/env.mjs new file mode 100644 index 0000000..1e0ef91 --- /dev/null +++ b/tools/autoinstall/env.mjs @@ -0,0 +1,21 @@ +import path from 'path'; + +export const APP_ROOT = process.cwd(); + +export const FRONTEND_BASE_DIR = process.env.FRONTEND_BASE_DIR + ? path.resolve(process.env.FRONTEND_BASE_DIR) + : path.resolve('../frontend-base'); + +export const PACK_DIR = path.resolve(FRONTEND_BASE_DIR, 'pack'); +export const TGZ_FILENAME = 'openedx-frontend-base.tgz'; +export const TGZ_PATH = path.join(PACK_DIR, TGZ_FILENAME); +export const HOST = '127.0.0.1'; + +if (!process.env.PORT) { + throw new Error( + '[frontend-dev-utils] PORT is required. Example: PORT=1234 devutils-dev-with-autoinstall' + ); +} +export const PORT = Number(process.env.PORT); + +export const INSTALL_DEBOUNCE_MS = 350;