license-kit
Version:
Aggregate license notes of OSS libraries used in your Node.js project, analyze & visualize OSS licenses with AI-turbocharged tooling
244 lines (188 loc) • 6.95 kB
text/typescript
import { watch } from 'node:fs';
import { type Server } from 'node:http';
import path from 'node:path';
import { parse } from 'node:url';
import { type Command, InvalidArgumentError } from 'commander';
import express from 'express';
import helmet from 'helmet';
import next from 'next';
import open from 'open';
import { Signale } from 'signale';
import { type LicensesMappingResult, generateLicensesMapping } from '../logic/generateLicensesMapping';
import { curryCommonScanOptions, validateCommonScanOptions } from '../utils/commandUtils';
import { getLockfilePath, getPackageLockChecksum } from '../utils/projectUtils';
const isDev = !!process.env.NODE_ENV && process.env.NODE_ENV !== 'production';
const visualizerSignale = new Signale({ scope: 'visualize' });
const sseSignale = new Signale({ scope: 'SSE' });
const apiSignale = new Signale({ scope: 'API' });
const reportSignale = new Signale({ scope: 'report' });
export default function visualizeCommandSetup(program: Command): Command {
return curryCommonScanOptions(
program
.command('visualize')
.description(
'Launches a local server providing a web license graph visualizer & analyzer app: summarizes the dependency graph state, shows an interactive graph of licenses with possibility to select a subgraph, provides browser built-in AI-turbocharged summary of the dependency graph.',
)
.option(
'--port [port]',
'Port on which to launch the app',
(value) => {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
throw new InvalidArgumentError('Not a number.');
}
return parsedValue;
},
8094,
)
.option('--h, --host [host]', 'Host on which to launch the app', 'localhost')
.option(
'--a, --auto-open [open]',
'Host on which to launch the app',
(value) => value === 'true' || value === '1',
true,
)
.option('--root [path]', 'Path to the root of your project', '.'),
).action(async (options) => {
validateCommonScanOptions(options);
const expressApp = express();
expressApp.use(
helmet({
contentSecurityPolicy: false,
}),
);
const eventClients: Set<express.Response> = new Set();
/** Logic - begin */
let lastScanPackageJsonChecksum: string | null = null;
let lastScanResult: LicensesMappingResult | null = null;
function updateLastScanResultIfNeeded() {
let result: LicensesMappingResult;
const currentPackageJsonChecksum = getPackageLockChecksum(options);
if (lastScanResult && lastScanPackageJsonChecksum === currentPackageJsonChecksum) {
result = lastScanResult;
return false;
} else {
if (lastScanResult) {
reportSignale.log(
`Project's root ${path.basename(
getLockfilePath(options),
)} changed (new checksum: ${currentPackageJsonChecksum}), re-generating report...`,
);
} else {
reportSignale.log('Generating report for the first time, please stand by...');
}
result = generateLicensesMapping(options);
reportSignale.log('Report generated');
lastScanResult = result;
lastScanPackageJsonChecksum = currentPackageJsonChecksum;
return true;
}
}
/** Logic - end */
/** API routes - begin */
expressApp.get('/api/report', (req, res) => {
if (!lastScanResult) {
updateLastScanResultIfNeeded();
}
const { licenses, projectName } = lastScanResult!;
res.json({
report: licenses,
projectName,
});
});
expressApp.get('/api/events', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();
eventClients.add(res);
req.on('close', () => {
res.end();
});
});
/** API routes - end */
/** Lockfile watcher - begin */
const lockfilePath = getLockfilePath(options);
visualizerSignale.log(`Watching '${lockfilePath}' file for changes`);
const packageJsonWatcher = watch(lockfilePath, (event) => {
if (event === 'change') {
const didChange = updateLastScanResultIfNeeded();
if (didChange) {
sseSignale.log('Sending report updates to clients over SSE');
eventClients.forEach((client) => {
client.write(
`event: message\ndata: ${JSON.stringify({
type: 'UPDATE',
report: lastScanResult!.licenses,
projectName: lastScanResult!.projectName,
})}\n\n`,
);
});
}
}
});
/** Lockfile watcher - end */
let server: Server;
await new Promise<void>((resolve) => {
server = expressApp.listen(options.port as number, options.host as string, async () => {
apiSignale.log('Preparing GUI server...');
// Next app
const visualizerNextAppDir = path.join(__dirname, '..', '..', ...(isDev ? ['..', 'visualizer'] : []));
const visualizerNextApp = next({
dev: isDev,
dir: visualizerNextAppDir,
customServer: true,
httpServer: server,
hostname: options.host as string,
port: options.port as number,
});
await visualizerNextApp.prepare();
const visualizerReqHandler = visualizerNextApp.getRequestHandler();
expressApp.use((req, res) => {
visualizerReqHandler(req, res, parse(req.url!, true));
});
apiSignale.log(`Server running at http://${options.host}:${options.port}\n`);
// open the browser automatically
if (options.autoOpen) {
await open(`http://${options.host}:${options.port}`);
}
resolve();
});
});
const threadBlocker = (() => {
let resolver!: () => void;
const promise = new Promise<void>((resolve) => {
resolver = resolve;
});
return {
resolve: resolver,
promise,
};
})();
function shutdown() {
eventClients.forEach((client) => client.end());
server?.close();
packageJsonWatcher.close();
process.exit(0);
}
process.on('SIGINT', () => {
threadBlocker.resolve();
shutdown();
});
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (inputBuff) => {
const input = inputBuff.toString().trim().toLowerCase();
if (input === 'q' || input === '\u0003') {
threadBlocker.resolve();
}
});
visualizerSignale.log('Press "q" or ctrl+c to stop the server');
await threadBlocker.promise;
visualizerSignale.log('Stopping the server...');
shutdown();
});
}