477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
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<string, IPackageConfig>; // 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 (dev mode)
|
|
if (this.options.mode === 'dev') {
|
|
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 </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);
|
|
}
|
|
|
|
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('/');
|
|
|
|
// 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 `<!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;
|
|
}
|
|
.card {
|
|
background: #09090b;
|
|
border: 1px solid #27272a;
|
|
border-radius: 8px;
|
|
padding: 48px;
|
|
max-width: 500px;
|
|
text-align: center;
|
|
}
|
|
.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>`;
|
|
}
|
|
}
|