Files
npmcdn/ts/npm-publicserver.classes.uipublicserver.ts

477 lines
14 KiB
TypeScript
Raw Normal View History

2026-01-04 20:47:43 +00:00
import * as interfaces from './interfaces.js';
import * as plugins from './plugins.js';
import * as paths from './paths.js';
2026-01-04 22:42:19 +00:00
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
}
2022-01-06 01:20:03 +01:00
export interface IPublicServerOptions {
2026-01-04 22:42:19 +00:00
packageBaseDirectory?: string; // Default base directory (default: './')
npmRegistryUrl?: string; // Default registry URL
allowedPackages?: string[]; // List of allowed package names
packageConfigs?: Record<string, IPackageConfig>; // Per-package configuration
2022-01-06 01:20:03 +01:00
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: [],
2026-01-04 22:42:19 +00:00
packageConfigs: {},
2022-01-06 01:20:03 +01:00
port: 8080,
mode: 'dev',
log: true,
};
public options: IPublicServerOptions;
2026-01-04 22:42:19 +00:00
/**
* 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;
}
2022-01-06 01:20:03 +01:00
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');
2026-01-04 20:47:43 +00:00
2022-01-06 01:20:03 +01:00
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 }));
2026-01-04 22:42:19 +00:00
// 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 (dev mode)
2022-01-06 01:20:03 +01:00
if (this.options.mode === 'dev') {
2026-01-04 22:42:19 +00:00
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;
2022-01-06 01:20:03 +01:00
}
2026-01-04 22:42:19 +00:00
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' });
2022-01-06 01:20:03 +01:00
}
2026-01-04 22:42:19 +00:00
});
// 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);
2022-01-06 01:20:03 +01:00
});
}
2026-01-04 22:42:19 +00:00
// Main package serving route
2022-01-06 01:20:03 +01:00
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;
}
2026-01-04 22:42:19 +00:00
/**
* 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 </head> tag
const configScript = `<script>window.__OPENCDN_CONFIG__ = ${JSON.stringify(config)};</script>`;
html = html.replace('</head>', `${configScript}\n</head>`);
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);
}
2022-01-06 01:20:03 +01:00
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<any> } = {};
/**
* renders a response using interfaces.ISimpleRequest and interfaces.ISimpleResponse
* @param req
*/
private render: interfaces.IRenderFunction = async (req) => {
const pathArray = req.parsedUrl.pathname.split('/');
2026-01-04 22:42:19 +00:00
// Handle root path - already served by express routes above
if (pathArray.length < 3 || !pathArray[1].startsWith('@')) {
2022-01-06 01:20:03 +01:00
return {
headers: {
'content-type': 'text/html',
},
2026-01-04 22:42:19 +00:00
status: 404,
body: this.getErrorPage('Not Found', 'The requested path was not found.'),
2022-01-06 01:20:03 +01:00
};
}
// lets care about the npm organization
const npmOrg = pathArray[1];
if (!npmOrg.startsWith('@')) {
console.log('malformed npmorg');
return {
2026-01-04 22:42:19 +00:00
status: 400,
2022-01-06 01:20:03 +01:00
headers: {
'content-type': 'text/html',
},
2026-01-04 22:42:19 +00:00
body: this.getErrorPage('Bad Request', `npm org "${npmOrg}" must start with @`),
2022-01-06 01:20:03 +01:00
};
}
// 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',
},
2026-01-04 22:42:19 +00:00
status: 403,
body: this.getErrorPage('Forbidden', 'The requested package is not allowlisted for public access.'),
2022-01-06 01:20:03 +01:00
};
}
2026-01-04 22:42:19 +00:00
// Get per-package configuration
const baseDirectory = this.getPackageBaseDirectory(packageName);
const registry = this.getPackageRegistry(packageName);
2022-01-06 01:20:03 +01:00
// lets care about the inner package path
2026-01-04 22:42:19 +00:00
let filePath = baseDirectory;
2022-01-06 01:20:03 +01:00
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}`;
2026-01-04 20:47:43 +00:00
let smartfile: plugins.smartfile.SmartFile;
2022-01-06 01:20:03 +01:00
// protect against parallel requests
if (this.requestMap[requestDescriptor]) {
smartfile = await this.requestMap[requestDescriptor].promise;
} else {
this.requestMap[requestDescriptor] = plugins.smartpromise.defer();
2026-01-04 22:42:19 +00:00
smartfile = await registry
2022-01-06 01:20:03 +01:00
.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',
},
2026-01-04 22:42:19 +00:00
body: this.getErrorPage(
'Not Found',
`${packageName}@${version || 'latest'} does not have a file at "${filePath}"`
2022-01-06 01:20:03 +01:00
),
};
}
2026-01-04 20:47:43 +00:00
// Detect mime type from buffer
const mimeResult = await plugins.smartmime.detectMimeType({ buffer: smartfile.contentBuffer });
const contentType = mimeResult?.mime || 'application/octet-stream';
2022-01-06 01:20:03 +01:00
return {
headers: {
'cache-control': `max-age=${
(version
? plugins.smarttime.getMilliSecondsFromUnits({ months: 1 })
: plugins.smarttime.getMilliSecondsFromUnits({ days: 1 })) / 1000
}`,
'content-length': smartfile.contentBuffer.length.toString(),
2026-01-04 20:47:43 +00:00
'content-type': contentType,
2022-01-06 01:20:03 +01:00
},
status: 200,
body: smartfile.contentBuffer,
};
};
2026-01-04 22:42:19 +00:00
/**
* Generate a simple error page
*/
private getErrorPage(title: string, message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - opencdn</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
background: #09090b;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: #fafafa;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
-webkit-font-smoothing: antialiased;
2022-01-06 01:20:03 +01:00
}
2026-01-04 22:42:19 +00:00
.card {
background: #09090b;
border: 1px solid #27272a;
border-radius: 8px;
padding: 48px;
max-width: 500px;
text-align: center;
2022-01-06 01:20:03 +01:00
}
2026-01-04 22:42:19 +00:00
.icon {
font-size: 64px;
margin-bottom: 24px;
opacity: 0.8;
}
h2 {
font-size: 24px;
font-weight: 600;
color: #fafafa;
margin-bottom: 12px;
}
p {
color: #a1a1aa;
margin-bottom: 32px;
line-height: 1.5;
}
a {
display: inline-block;
padding: 10px 20px;
background: #27272a;
border: 1px solid #27272a;
border-radius: 6px;
color: #fafafa;
text-decoration: none;
font-weight: 500;
font-size: 14px;
transition: all 0.15s;
}
a:hover {
border-color: #d4d4d8;
}
</style>
</head>
<body>
<div class="card">
<div class="icon">📦</div>
<h2>${title}</h2>
<p>${message}</p>
<a href="/"> Back to Home</a>
</div>
</body>
</html>`;
}
2022-01-06 01:20:03 +01:00
}