import * as interfaces from './interfaces.js'; import * as plugins from './plugins.js'; import * as paths from './paths.js'; import { getEmbeddedFile, hasEmbeddedFile, listEmbeddedFiles } from './embedded-ui.generated.js'; export interface IPackageConfig { baseDirectory?: string; // Override base directory for this package registry?: string; // Override registry URL for this package } export interface IPublicServerOptions { packageBaseDirectory?: string; // Default base directory (default: './') npmRegistryUrl?: string; // Default registry URL allowedPackages?: string[]; // List of allowed package names packageConfigs?: Record; // Per-package configuration port?: number; mode: 'dev' | 'prod'; log?: boolean; } /** * the main public server instance */ export class UiPublicServer { public projectinfo = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir); public startedAt: string; private server: plugins.http.Server; private npmRegistry: plugins.smartnpm.NpmRegistry; private corsDev = '*'; private corsProd = ''; // TODO: Define allowed URLs public defaultOptions: IPublicServerOptions = { packageBaseDirectory: './', npmRegistryUrl: 'https://registry.npmjs.org', allowedPackages: [], packageConfigs: {}, port: 8080, mode: 'dev', log: true, }; public options: IPublicServerOptions; /** * Get the base directory for a specific package */ private getPackageBaseDirectory(packageName: string): string { const packageConfig = this.options.packageConfigs?.[packageName]; let baseDir = packageConfig?.baseDirectory || this.options.packageBaseDirectory; if (!baseDir.endsWith('/')) { baseDir += '/'; } return baseDir; } /** * Get the registry for a specific package */ private getPackageRegistry(packageName: string): plugins.smartnpm.NpmRegistry { const packageConfig = this.options.packageConfigs?.[packageName]; if (packageConfig?.registry && packageConfig.registry !== this.options.npmRegistryUrl) { return new plugins.smartnpm.NpmRegistry({ npmRegistryUrl: packageConfig.registry, }); } return this.npmRegistry; } constructor(optionsArg?: IPublicServerOptions) { // lets create an npm instance for the registry that we are talking to this.options = { ...this.defaultOptions, ...optionsArg, }; if (!this.options.packageBaseDirectory.endsWith('/')) { this.options.packageBaseDirectory += '/'; } this.npmRegistry = new plugins.smartnpm.NpmRegistry({ npmRegistryUrl: this.options.npmRegistryUrl, }); } /** * starts the server */ public async startServer() { console.log('starting the uipublicserver'); const done = plugins.smartpromise.defer(); const expressApplication = plugins.express(); const shouldCompress = (req: plugins.express.Request, res: plugins.express.Response) => { if (req.headers['x-no-compression']) { // don't compress responses with this request header return false; } // fallback to standard filter function return plugins.compression.filter(req, res); }; expressApplication.use(plugins.compression({ filter: shouldCompress })); // Serve embedded UI files (index.html, bundle.js, etc.) expressApplication.get('/', async (req, res) => { await this.serveIndexHtml(req, res); }); expressApplication.get('/index.html', async (req, res) => { await this.serveIndexHtml(req, res); }); // Serve other embedded static files (bundle.js, css, etc.) expressApplication.get('/bundle.js', async (req, res) => { res.setHeader('cache-control', 'no-cache, no-store, must-revalidate'); await this.serveEmbeddedFile('/bundle.js', res); }); expressApplication.get('/bundle.js.map', async (req, res) => { res.setHeader('cache-control', 'no-cache, no-store, must-revalidate'); await this.serveEmbeddedFile('/bundle.js.map', res); }); // Serve any other embedded files expressApplication.use('/assets', async (req, res, next) => { const filePath = '/assets' + req.path; if (hasEmbeddedFile(filePath)) { await this.serveEmbeddedFile(filePath, res); } else { next(); } }); // API endpoint for file listing expressApplication.get('/api/files/:org/:package', async (req, res) => { const packageName = `${req.params.org}/${req.params.package}`; const version = (req.query.version as string) || ''; const distTag = (req.query.disttag as string) || ''; if (!this.options.allowedPackages.includes(packageName)) { res.status(403).json({ error: 'Package not allowed' }); return; } const baseDirectory = this.getPackageBaseDirectory(packageName); const registry = this.getPackageRegistry(packageName); try { const result = await registry.getFilesFromPackage( packageName, plugins.path.join(baseDirectory), { version, distTag } ); const files = result.map(smartfile => { return smartfile.path.replace(plugins.path.join('package', baseDirectory), ''); }); // Get package info for versions const packageInfo = await registry.getPackageInfo(packageName).catch(() => null); const versions = packageInfo?.allVersions?.slice(-20).reverse().map(v => v.version) || []; res.json({ files, versions }); } catch (err) { res.status(404).json({ error: 'Package not found' }); } }); // Peek route serves the SPA expressApplication.get('/peek/*', async (req, res) => { await this.serveIndexHtml(req, res); }); expressApplication.get('/peek', async (req, res) => { await this.serveIndexHtml(req, res); }); // Main package serving route expressApplication.use('/', async (req, res) => { const host = req.headers.host || 'localhost'; const parsedUrl = new plugins.url.URL(`http://${host}${req.url}`); const simpleResponse: interfaces.ISimpleResponse = await this.render({ headers: req.headers, parsedUrl, }); for (const header of Object.keys(simpleResponse.headers)) { res.setHeader(header, simpleResponse.headers[header]); } // CORS res.setHeader('ui-public-server-version', this.projectinfo.version); res.setHeader('access-control-allow-origin', true ? this.corsDev : this.corsProd); // TODO: replace true with check for env res.status(simpleResponse.status); res.write(simpleResponse.body); res.end(); }); // note: We assume that ssl termination is done in reverse proxy in kubernetes. so simple http should suffice here. this.server = expressApplication.listen(this.options.port, () => { console.log(`listening on port ${this.options.port}`); this.startedAt = new Date().toISOString(); done.resolve(); }); await done.promise; } /** * Serve the index.html with configuration injected */ private async serveIndexHtml(req: plugins.express.Request, res: plugins.express.Response) { const embeddedFile = getEmbeddedFile('/index.html'); if (!embeddedFile) { res.status(500).send('UI not bundled. Run pnpm bundleUI first.'); return; } // Inject configuration into the HTML let html = embeddedFile.data.toString('utf-8'); const config = { version: this.projectinfo.version, mode: this.options.mode, allowedPackages: this.options.allowedPackages, }; // Inject config script before the closing tag const configScript = ``; html = html.replace('', `${configScript}\n`); // Add version query parameter to bundle.js for cache busting html = html.replace('/bundle.js', `/bundle.js?v=${this.projectinfo.version}`); res.setHeader('content-type', embeddedFile.contentType); res.setHeader('cache-control', 'no-cache'); res.status(200).send(html); } /** * Serve an embedded file */ private async serveEmbeddedFile(filePath: string, res: plugins.express.Response) { const embeddedFile = getEmbeddedFile(filePath); if (!embeddedFile) { res.status(404).send('File not found'); return; } res.setHeader('content-type', embeddedFile.contentType); res.setHeader('content-length', embeddedFile.data.length.toString()); // Only set cache-control if not already set if (!res.getHeader('cache-control')) { res.setHeader('cache-control', 'public, max-age=31536000, immutable'); } res.status(200).send(embeddedFile.data); } public disableLogging() { this.options.log = false; } /** * stops the server */ public async stopServer() { const done = plugins.smartpromise.defer(); if (!this.server) { console.log('server does not seem to be started, so no need to stop it'); return; } this.server.close(() => { done.resolve(); }); await done.promise; } public requestMap: { [key: string]: plugins.smartpromise.Deferred } = {}; /** * renders a response using interfaces.ISimpleRequest and interfaces.ISimpleResponse * @param req */ private render: interfaces.IRenderFunction = async (req) => { const pathArray = req.parsedUrl.pathname.split('/'); // Handle root path - already served by express routes above if (pathArray.length < 3 || !pathArray[1].startsWith('@')) { return { headers: { 'content-type': 'text/html', }, status: 404, body: this.getErrorPage('Not Found', 'The requested path was not found.'), }; } // lets care about the npm organization const npmOrg = pathArray[1]; if (!npmOrg.startsWith('@')) { console.log('malformed npmorg'); return { status: 400, headers: { 'content-type': 'text/html', }, body: this.getErrorPage('Bad Request', `npm org "${npmOrg}" must start with @`), }; } // lets care about the packageName const npmPackage = pathArray[2]; const packageName = `${npmOrg}/${npmPackage}`; if (this.options.log) { console.log( `got a request for package ${packageName} on registry ${this.npmRegistry.options.npmRegistryUrl}` ); } if (!this.options.allowedPackages.includes(packageName)) { return { headers: { 'content-type': 'text/html', }, status: 403, body: this.getErrorPage('Forbidden', 'The requested package is not allowlisted for public access.'), }; } // Get per-package configuration const baseDirectory = this.getPackageBaseDirectory(packageName); const registry = this.getPackageRegistry(packageName); // lets care about the inner package path let filePath = baseDirectory; let first = true; for (let i = 3; i < pathArray.length; i++) { if (first) { filePath += './'; first = false; } else { filePath += '/'; } filePath += pathArray[i]; } // lets care about version and disttag const version = req.parsedUrl.searchParams.get('version') || ''; const distTag = req.parsedUrl.searchParams.get('disttag') || ''; const requestDescriptor = `${packageName}/${filePath}/${distTag}/${version}`; let smartfile: plugins.smartfile.SmartFile; // protect against parallel requests if (this.requestMap[requestDescriptor]) { smartfile = await this.requestMap[requestDescriptor].promise; } else { this.requestMap[requestDescriptor] = plugins.smartpromise.defer(); smartfile = await registry .getFileFromPackage(packageName, filePath, { version, distTag, }) .catch((err) => { console.log(err); return null; }); this.requestMap[requestDescriptor].resolve(smartfile); delete this.requestMap[requestDescriptor]; } if (!smartfile) { return { status: 404, headers: { 'content-type': 'text/html', }, body: this.getErrorPage( 'Not Found', `${packageName}@${version || 'latest'} does not have a file at "${filePath}"` ), }; } // Detect mime type from buffer const mimeResult = await plugins.smartmime.detectMimeType({ buffer: smartfile.contentBuffer }); const contentType = mimeResult?.mime || 'application/octet-stream'; return { headers: { 'cache-control': `max-age=${ (version ? plugins.smarttime.getMilliSecondsFromUnits({ months: 1 }) : plugins.smarttime.getMilliSecondsFromUnits({ days: 1 })) / 1000 }`, 'content-length': smartfile.contentBuffer.length.toString(), 'content-type': contentType, }, status: 200, body: smartfile.contentBuffer, }; }; /** * Generate a simple error page */ private getErrorPage(title: string, message: string): string { return ` ${title} - opencdn
📦

${title}

${message}

← Back to Home
`; } }