UNPKG

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
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(); }); }