This commit is contained in:
2026-01-04 22:42:19 +00:00
parent abed903b06
commit febf480b60
22 changed files with 2665 additions and 321 deletions

View File

@@ -1,12 +1,18 @@
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';
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;
npmRegistryUrl?: string;
allowedPackages?: string[];
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;
@@ -17,7 +23,6 @@ export interface IPublicServerOptions {
*/
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;
@@ -29,12 +34,38 @@ export class UiPublicServer {
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 = {
@@ -51,24 +82,12 @@ export class UiPublicServer {
});
}
/**
* initializes the readme content
*/
private async initReadme(): Promise<void> {
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();
@@ -84,38 +103,83 @@ export class UiPublicServer {
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.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]);
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' });
}
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();
// 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}`);
@@ -143,6 +207,52 @@ export class UiPublicServer {
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;
}
@@ -171,25 +281,14 @@ export class UiPublicServer {
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.<br/>`,
`Running in mode: <b>${this.options.mode}</b><br/><br/>`,
];
if (this.options.mode === 'dev') {
textArray.push(
`<b>Wondering what we serve?</b> <a href="/peek/">Lets take a peek!</a><br/>`
);
textArray.push(`<b>Documentation:</b> <a href="/readme/">Read the docs</a>`);
}
// Handle root path - already served by express routes above
if (pathArray.length < 3 || !pathArray[1].startsWith('@')) {
return {
headers: {
'content-type': 'text/html',
},
status: 201,
body: await plugins.litNtml.html`
${ntml.getBody(this, textArray)}
`,
status: 404,
body: this.getErrorPage('Not Found', 'The requested path was not found.'),
};
}
@@ -198,11 +297,11 @@ export class UiPublicServer {
if (!npmOrg.startsWith('@')) {
console.log('malformed npmorg');
return {
status: 500,
status: 400,
headers: {
'content-type': 'text/html',
},
body: `npmorg "${npmOrg}" must start with @`,
body: this.getErrorPage('Bad Request', `npm org "${npmOrg}" must start with @`),
};
}
@@ -219,15 +318,17 @@ export class UiPublicServer {
headers: {
'content-type': 'text/html',
},
status: 503,
body: await plugins.litNtml.html`
the requested package is not allowlisted for public access
`,
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 = this.options.packageBaseDirectory;
let filePath = baseDirectory;
let first = true;
for (let i = 3; i < pathArray.length; i++) {
if (first) {
@@ -253,7 +354,7 @@ export class UiPublicServer {
smartfile = await this.requestMap[requestDescriptor].promise;
} else {
this.requestMap[requestDescriptor] = plugins.smartpromise.defer();
smartfile = await this.npmRegistry
smartfile = await registry
.getFileFromPackage(packageName, filePath, {
version,
distTag,
@@ -272,9 +373,9 @@ export class UiPublicServer {
headers: {
'content-type': 'text/html',
},
body: await ntml.getBody(
this,
`${packageName}@${version} does not have a file at "${filePath}"`
body: this.getErrorPage(
'Not Found',
`${packageName}@${version || 'latest'} does not have a file at "${filePath}"`
),
};
}
@@ -298,121 +399,78 @@ export class UiPublicServer {
};
};
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, [
`<a href="../">../ -> Go back</a><br/>`,
`<b>allowlisted packages:</b><br/>`,
...this.options.allowedPackages.map((packageArg) => {
return `
<div>"${packageArg}" -> mapping to "${this.options.packageBaseDirectory}" inside the package <a href="/peek/${packageArg}">Peek...</a></div>
`;
}),
])}
`,
};
/**
* 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;
}
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, [
`<a href="/peek/">/peek/ -> Go to peek overview</a><br/>`,
`<b>Not found: package ${npmPackageName}@${
version || 'latest'
} is not available on the supplied registry</b><br/>`,
])}
`,
};
.card {
background: #09090b;
border: 1px solid #27272a;
border-radius: 8px;
padding: 48px;
max-width: 500px;
text-align: center;
}
const packageInfo = await this.npmRegistry.getPackageInfo(npmPackageName);
return {
status: 200,
headers: {
'content-type': 'text/html',
},
body: `
${await ntml.getBody(this, [
`<a href="/peek/">/peek/ -> Go to peek overview</a><br/><br/>`,
`<h1>${npmPackageName}@${version || distTag || '<i>inferred</i> latest'}</h1>`,
`<b>Versions:</b> ${packageInfo.allVersions
.map(
(versionArg) =>
`<a href="./${npmPackage}?version=${versionArg.version}">${versionArg.version}</a> | `
)
.join('')}<br/><br/>`,
`<b>DistTags:</b> ${
packageInfo.allDistTags
.map(
(distTagArg) =>
`<a href="./${npmPackage}?disttag=${distTagArg.name}">${distTagArg.name} (${distTagArg.targetVersion})</a> | `
)
.join('') || 'no dist tags found!'
}<br/><br/>`,
`<b>File Overview at version and path:</b><br/>`,
`"<b>${npmPackageName}@${
version || distTag || '<i>inferred</i> latest'
}</b>" under internal path "<b>${this.options.packageBaseDirectory}</b>"<br/></br>`,
...result.map((smartfile) => {
const displayPath = smartfile.path.replace(
plugins.path.join('package', this.options.packageBaseDirectory),
''
);
return `<a href="/${npmPackageName}/${displayPath}?version=${version || ''}&disttag=${
distTag || ''
}">${displayPath}</a><br/>`;
}),
])}
`,
};
};
private renderReadme: interfaces.IRenderFunction = async (req) => {
return {
status: 200,
headers: {
'content-type': 'text/html',
},
body: await ntml.getBody(this, [
`<style>
pre {
display: block;
background: #fafafa;
border: 1px dotted #CCC;
padding: 20px;
}
</style>`,
this.readme,
]),
};
};
.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>`;
}
}