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

404 lines
12 KiB
TypeScript
Raw Normal View History

2022-01-06 01:20:03 +01:00
import * as interfaces from './interfaces';
import * as plugins from './plugins';
import * as paths from './paths';
import * as ntml from './ntml';
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 = new plugins.smartmarkdown.SmartMarkdown().markdownToHtml(
plugins.smartfile.fs.toStringSync(plugins.path.join(paths.packageDir, 'readme.md'))
);
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,
});
}
/**
* 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 }));
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<any> } = {};
/**
* 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.<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>`);
}
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}"`
),
};
}
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': plugins.smartmime.detectMimeType(smartfile.path),
},
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, [
`<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>
`;
}),
])}
`,
};
}
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/>`,
])}
`,
};
}
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,
]),
};
};
}