-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add autoinstall
#3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| node_modules |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 24 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,7 @@ | ||
| # frontend-dev-utils | ||
| # 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. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
|
Comment on lines
+5
to
+7
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has changed from the version in |
||
|
|
||
| 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); | ||
|
Comment on lines
+14
to
+19
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was hardcoded in the |
||
|
|
||
| export const INSTALL_DEBOUNCE_MS = 350; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't love this name. If we want to get fancy with it I could move this away from directly providing the script in
bin. If we had acli.mjsthen apps could havefrontend-dev-utils dev-with-autoinstallinstead.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's fine as-is. It's going to be abstracted away by an npm run invocation, anyway.