Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
8 changes: 7 additions & 1 deletion README.md
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.
47 changes: 47 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions package.json
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"
Copy link
Contributor Author

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 a cli.mjs then apps could have frontend-dev-utils dev-with-autoinstall instead.

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.

},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"chokidar": "^5.0.0"
}
}
25 changes: 25 additions & 0 deletions tools/autoinstall/README.md
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.
112 changes: 112 additions & 0 deletions tools/autoinstall/autoinstall-frontend-base-tarballs.mjs
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 };
};
110 changes: 110 additions & 0 deletions tools/autoinstall/dev-with-autoinstall.mjs
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();
21 changes: 21 additions & 0 deletions tools/autoinstall/env.mjs
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has changed from the version in test-site which was hardcoded to point to frontend-base in its parent directory.


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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was hardcoded in the test-site version, now it needs to be set explicitly.


export const INSTALL_DEBOUNCE_MS = 350;