import * as interfaces from './interfaces.js'; import * as plugins from './plugins.js'; import * as paths from './paths.js'; import * as ntml from './ntml/index.js'; export interface IPublicServerOptions { packageBaseDirectory?: string; npmRegistryUrl?: string; allowedPackages?: string[]; port?: number; mode: 'dev' | 'prod'; log?: boolean; } /** * the main public server instance */ export class UiPublicServer { public projectinfo = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir); public readme: string; 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: [], port: 8080, mode: 'dev', log: true, }; public options: IPublicServerOptions; 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, }); } /** * initializes the readme content */ private async initReadme(): Promise { const readmePath = plugins.path.join(paths.packageDir, 'readme.md'); const readmeContent = await plugins.smartfile.fs.toStringSync(readmePath); this.readme = await plugins.smartmarkdown.SmartMarkdown.easyMarkdownToHtml(readmeContent); } /** * starts the server */ public async startServer() { console.log('starting the uipublicserver'); // Initialize readme await this.initReadme(); 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 })); if (this.options.mode === 'dev') { expressApplication.use('/peek/', 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.renderPeek({ headers: req.headers, parsedUrl, }); for (const header of Object.keys(simpleResponse.headers)) { res.setHeader(header, simpleResponse.headers[header]); } res.status(simpleResponse.status); res.write(simpleResponse.body); res.end(); }); expressApplication.use('/readme/', 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.renderReadme({ headers: req.headers, parsedUrl, }); for (const header of Object.keys(simpleResponse.headers)) { res.setHeader(header, simpleResponse.headers[header]); } res.status(simpleResponse.status); res.write(simpleResponse.body); res.end(); }); } 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; } 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('/'); if (pathArray.length < 3) { const textArray = [ `serving javascript to the web for Lossless GmbH.
`, `Running in mode: ${this.options.mode}

`, ]; if (this.options.mode === 'dev') { textArray.push( `Wondering what we serve? Lets take a peek!
` ); textArray.push(`Documentation: Read the docs`); } return { headers: { 'content-type': 'text/html', }, status: 201, body: await plugins.litNtml.html` ${ntml.getBody(this, textArray)} `, }; } // lets care about the npm organization const npmOrg = pathArray[1]; if (!npmOrg.startsWith('@')) { console.log('malformed npmorg'); return { status: 500, headers: { 'content-type': 'text/html', }, body: `npmorg "${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: 503, body: await plugins.litNtml.html` the requested package is not allowlisted for public access `, }; } // lets care about the inner package path let filePath = this.options.packageBaseDirectory; 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 this.npmRegistry .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: await ntml.getBody( this, `${packageName}@${version} 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, }; }; private renderPeek: interfaces.IRenderFunction = async (req) => { const pathArray = req.parsedUrl.pathname.split('/'); const npmOrg = pathArray[1]; const npmPackage = pathArray[2]; const npmPackageName = `${npmOrg}/${npmPackage}`; const distTag = req.parsedUrl.searchParams.get('disttag'); const version = req.parsedUrl.searchParams.get('version'); // lets care about cors if (!npmOrg || !npmPackage || !this.options.allowedPackages.includes(npmPackageName)) { return { status: 200, headers: { 'content-type': 'text/html', }, body: ` ${await ntml.getBody(this, [ `../ -> Go back
`, `allowlisted packages:
`, ...this.options.allowedPackages.map((packageArg) => { return `
"${packageArg}" -> mapping to "${this.options.packageBaseDirectory}" inside the package Peek...
`; }), ])} `, }; } const result = await this.npmRegistry .getFilesFromPackage(npmPackageName, plugins.path.join(this.options.packageBaseDirectory), { version: version, distTag: distTag, }) .catch((err) => { return; }); if (!result) { return { status: 404, headers: { 'content-type': 'text/html', }, body: ` ${await ntml.getBody(this, [ `/peek/ -> Go to peek overview
`, `Not found: package ${npmPackageName}@${ version || 'latest' } is not available on the supplied registry
`, ])} `, }; } const packageInfo = await this.npmRegistry.getPackageInfo(npmPackageName); return { status: 200, headers: { 'content-type': 'text/html', }, body: ` ${await ntml.getBody(this, [ `/peek/ -> Go to peek overview

`, `

${npmPackageName}@${version || distTag || 'inferred latest'}

`, `Versions: ${packageInfo.allVersions .map( (versionArg) => `${versionArg.version} | ` ) .join('')}

`, `DistTags: ${ packageInfo.allDistTags .map( (distTagArg) => `${distTagArg.name} (${distTagArg.targetVersion}) | ` ) .join('') || 'no dist tags found!' }

`, `File Overview at version and path:
`, `"${npmPackageName}@${ version || distTag || 'inferred latest' }" under internal path "${this.options.packageBaseDirectory}"

`, ...result.map((smartfile) => { const displayPath = smartfile.path.replace( plugins.path.join('package', this.options.packageBaseDirectory), '' ); return `${displayPath}
`; }), ])} `, }; }; private renderReadme: interfaces.IRenderFunction = async (req) => { return { status: 200, headers: { 'content-type': 'text/html', }, body: await ntml.getBody(this, [ ``, this.readme, ]), }; }; }