piral-cli
Version:
The standard CLI for creating and building a Piral instance or a Pilet.
462 lines (391 loc) • 13.2 kB
text/typescript
import { URL } from 'url';
import { IncomingHttpHeaders } from 'http';
import { join, resolve, basename } from 'path';
import { EventEmitter } from 'events';
import { existsSync } from 'fs';
import { readFile, stat, writeFile } from 'fs/promises';
import { KrasInjector, KrasRequest, KrasInjectorConfig, KrasConfiguration, KrasResult } from 'kras';
import { log } from '../common/log';
import { getAxiosOptions } from '../common/http';
import { getPiletSpecMeta } from '../common/spec';
import { config as commonConfig } from '../common/config';
import { axios, mime, jju } from '../external';
import { Bundler } from '../types';
export interface PiletInjectorConfig extends KrasInjectorConfig {
/**
* The pilets to serve.
*/
pilets: Array<Pilet>;
/**
* The base URL for the app shell / portal to be used.
*/
publicUrl: string;
/**
* The base URL for the pilet assets to be used.
*/
assetUrl?: string;
/**
* Defines if properties from the feed (if given) meta response should be taken over to local pilets.
*/
mergeConfig?: boolean;
/**
* The additional metadata file to consider for the pilets.
*/
meta: string;
/**
* The API path - usually somethin like `/$pilet-api`.
*/
api: string;
/**
* The directory of the app (usually Piral instance emulator) to serve.
*/
app?: string;
/**
* The remote feed to merge into.
*/
feed?: string;
/**
* The additional headers to include.
*/
headers?: Record<string, string>;
}
interface Pilet {
bundler: Bundler;
root: string;
dispose(): void;
getMeta(basePath: string): PiletMetadata;
}
interface PiletMetadata {
name?: string;
config?: Record<string, any>;
[key: string]: unknown;
}
async function getMetaOverride(root: string, metaFile: string) {
if (metaFile) {
const metaPath = join(root, metaFile);
const exists = await stat(metaPath).then(
() => true,
() => false,
);
if (exists) {
const metaContent = await readFile(metaPath, 'utf8');
return jju.parse(metaContent);
}
}
return undefined;
}
async function fillPiletMeta(pilet: Pilet, metaFile: string, subPath: string) {
const { root, bundler } = pilet;
const packagePath = join(root, 'package.json');
const jsonContent = await readFile(packagePath, 'utf8');
const def = jju.parse(jsonContent);
const file = bundler.bundle.name.replace(/^[\/\\]/, '');
const target = join(bundler.bundle.dir, file);
const metaOverride = await getMetaOverride(root, metaFile);
pilet.getMeta = (parentPath) => {
const basePath = `${parentPath}${subPath}`;
const url = new URL(file, basePath);
return {
custom: def.custom,
config: def.piletConfig,
...metaOverride,
name: def.name,
version: def.version,
link: `${url.href}?updated=${Date.now()}`,
...getPiletSpecMeta(target, basePath),
};
};
}
type FeedResponse = { items?: Array<PiletMetadata> } | Array<PiletMetadata> | PiletMetadata;
async function loadFeed(feed: string, headers: any) {
try {
const response = await axios.get<FeedResponse>(feed, { headers });
if (Array.isArray(response.data)) {
return response.data;
} else if (Array.isArray(response.data?.items)) {
return response.data.items;
} else {
return [response.data];
}
} catch (e) {
log('generalWarning_0001', `Couldn't load feed at ${feed}.`);
}
}
export default class PiletInjector implements KrasInjector {
public readonly config: PiletInjectorConfig;
private readonly serverConfig: KrasConfiguration;
private readonly indexPath: string;
private readonly cbs: Record<string, { baseUrl: string; notify: (msg: string) => void }> = {};
private proxyInfo?: {
source: string;
files: Array<string>;
date: Date;
};
constructor(config: PiletInjectorConfig, serverConfig: KrasConfiguration, core: EventEmitter) {
this.config = config;
this.serverConfig = serverConfig;
if (this.config.active) {
const { api, app, publicUrl, assetUrl } = config;
this.indexPath = `${publicUrl}index.html`;
// If we end with "/app" or "\app" we might have a proper emulator
if (app && basename(app) === 'app') {
const path = resolve(app, '..', 'package.json');
if (existsSync(path)) {
try {
const packageJson = require(path);
if (typeof packageJson.piralCLI.source === 'string') {
this.proxyInfo = {
source: packageJson.piralCLI.source,
files: packageJson.files,
date: new Date(packageJson.piralCLI.timestamp),
};
}
} catch {
// silently ignore errors - we just continue as-is
}
}
}
core.on('user-connected', (e) => {
const baseUrl = assetUrl || e.req.headers.origin;
if (e.target === '*' && e.url === api.substring(1)) {
this.cbs[e.id] = {
baseUrl,
notify: (msg: string) => e.ws.send(msg),
};
}
});
core.on('user-disconnected', (e) => {
delete this.cbs[e.id];
});
this.setupBundler();
}
}
private setupBundler() {
const { pilets } = this.config;
pilets.forEach((p, i) => {
const handler = async () => {
await fillPiletMeta(p, this.config.meta, `/${i}/`);
for (const id of Object.keys(this.cbs)) {
const { baseUrl, notify } = this.cbs[id];
const meta = this.getPiletMeta(baseUrl, p);
notify(meta);
}
};
p.bundler.on(handler);
p.dispose = () => {
p.bundler.off(handler);
};
});
}
private teardownBundler() {
const { pilets } = this.config;
pilets.forEach((p) => {
p.dispose();
});
}
get active() {
return this.config.active;
}
set active(value) {
this.config.active = value;
}
get name() {
return 'pilet-injector';
}
getOptions() {
return {};
}
setOptions(options: any) {
if ('pilets' in options) {
this.teardownBundler();
this.config.pilets = options.pilets;
this.setupBundler();
}
}
getPiletApi(baseUrl: string) {
const { api } = this.config;
if (/^https?:/.test(api)) {
return api;
} else if (baseUrl) {
return `${baseUrl}${api}`;
} else {
const { ssl, port } = this.serverConfig;
const { host } = commonConfig;
return `${ssl ? 'https' : 'http'}://${host}:${port}${api}`;
}
}
getPiletMeta(baseUrl: string, pilet: Pilet) {
const basePath = this.getPiletApi(baseUrl);
return JSON.stringify(pilet.getMeta(basePath));
}
async getIndexMeta(baseUrl: string, headers: IncomingHttpHeaders) {
const { pilets, feed } = this.config;
const basePath = this.getPiletApi(baseUrl);
const localPilets = pilets.map((pilet) => pilet.getMeta?.(basePath)).filter(Boolean);
const mergedPilets = this.mergePilets(localPilets, await this.loadRemoteFeed(headers, feed));
return JSON.stringify(mergedPilets);
}
async loadRemoteFeed(
rawHeaders: IncomingHttpHeaders,
feed?: string | Array<string>,
): Promise<Array<Array<PiletMetadata>>> {
if (feed) {
const feeds = Array.isArray(feed) ? feed : [feed];
const {
// We skip the standard client headers to focus on the custom ones
host: _0,
['user-agent']: _1,
accept: _2,
['accept-language']: _3,
['accept-encoding']: _4,
referer: _5,
traceparent: _6,
dnt: _7,
connection: _8,
['sec-fetch-fest']: _9,
['sec-fetch-mode']: _10,
['sec-fetch-site']: _11,
...headers
} = rawHeaders;
return await Promise.all(feeds.map((url) => loadFeed(url, headers)));
}
}
mergePilets(localPilets: Array<PiletMetadata>, remoteFeeds: Array<Array<PiletMetadata>>) {
if (!remoteFeeds || !Array.isArray(remoteFeeds)) {
return localPilets;
}
const { mergeConfig = false } = this.config;
const names = localPilets.map((pilet) => pilet.name);
const merged = [...localPilets];
for (const remotePilets of remoteFeeds) {
if (!Array.isArray(remotePilets)) {
continue;
}
const newPilets = remotePilets.filter((pilet) => {
if (!pilet || typeof pilet !== 'object') {
return false;
}
const name = pilet.name;
const isNew = name !== undefined && !names.includes(name);
if (!isNew && mergeConfig) {
const existing = merged.find((m) => m.name === name);
if (existing.config === undefined) {
existing.config = pilet.config;
} else if (pilet.config !== undefined) {
Object.assign(existing.config, pilet.config);
}
}
return isNew;
});
names.push(...newPilets.map((p) => p.name));
merged.push(...newPilets);
}
return merged;
}
sendContent(content: Buffer | string, type: string, url: string): KrasResult {
const { headers } = this.config;
return {
injector: { name: this.name },
headers: {
...headers,
'content-type': type,
'cache-control': 'no-cache, no-store, must-revalidate',
pragma: 'no-cache',
expires: '0',
},
status: { code: 200 },
url,
content,
};
}
async sendFile(target: string, url: string): Promise<KrasResult> {
const content = await readFile(target);
const type = mime.getType(target) ?? 'application/octet-stream';
return this.sendContent(content, type, url);
}
async sendResponse(path: string, req: KrasRequest, baseUrl: string): Promise<KrasResult> {
const { url, headers } = req;
const { pilets } = this.config;
const [index, ...rest] = path.split('/');
const pilet = pilets[+index];
const bundler = pilet?.bundler;
await bundler?.ready();
if (!path) {
const content = await this.getIndexMeta(baseUrl, headers);
return this.sendContent(content, 'application/json', url);
} else if (bundler?.bundle) {
const target = join(bundler.bundle.dir, rest.join('/'));
const info = await stat(target).catch(() => undefined);
if (info && info.isFile()) {
return await this.sendFile(target, url);
}
}
return undefined;
}
async sendIndexFile(target: string, url: string, baseUrl: string): Promise<KrasResult> {
const indexHtml = await readFile(target, 'utf8');
// mechanism to inject server side debug piletApi config into piral emulator
const windowInjectionScript = `window['dbg:pilet-api'] = '${this.getPiletApi(baseUrl)}';`;
const findStr = `<script`;
const replaceStr = `<script>/* Pilet Debugging Emulator Config Injection */${windowInjectionScript}</script><script`;
const content = indexHtml.replace(`${findStr}`, `${replaceStr}`);
return this.sendContent(content, mime.getType(target), url);
}
private download(path: string) {
const manifestUrl = this.proxyInfo.source;
const url = new URL(path, manifestUrl);
const opts = getAxiosOptions(manifestUrl);
return axios.get(url.href, { ...opts, responseType: 'arraybuffer' });
}
private async shouldLoad(target: string, path: string) {
if (this.proxyInfo) {
if (!this.proxyInfo.files.includes(path)) {
return false;
}
const fileInfo = await stat(target).catch(() => undefined);
if (!fileInfo || fileInfo.mtime < this.proxyInfo.date) {
try {
const response = await this.download(path);
await writeFile(target, response.data);
} catch (ex) {
log('generalDebug_0003', `HTTP request for emulator asset retrieval failed: ${ex}`);
log(fileInfo ? 'optionalEmulatorAssetUpdateSkipped_0122' : 'requiredEmulatorAssetDownloadSkipped_0123', path);
return !!fileInfo;
}
}
return true;
} else {
const fileInfo = await stat(target).catch(() => undefined);
return fileInfo && fileInfo.isFile();
}
}
async handle(req: KrasRequest): Promise<KrasResult> {
const { app, api, publicUrl, assetUrl } = this.config;
const baseUrl =
assetUrl || (req.headers.host ? `${req.encrypted ? 'https' : 'http'}://${req.headers.host}` : undefined);
if (!req.target) {
if (req.url.startsWith(publicUrl)) {
const path = req.url.substring(publicUrl.length).split('?').shift();
if (app) {
const target = join(app, path);
if (await this.shouldLoad(target, path)) {
if (req.url === this.indexPath) {
return await this.sendIndexFile(target, req.url, baseUrl);
}
return await this.sendFile(target, req.url);
}
}
if (req.url !== this.indexPath) {
return await this.handle({
...req,
url: this.indexPath,
});
}
}
return undefined;
} else if (req.target === api) {
const path = req.url.substring(1).split('?').shift();
return await this.sendResponse(path, req, baseUrl);
}
}
}