UNPKG

@platform/react.ssr

Version:

A lightweight SSR (server-side-rendering) system for react apps bundled with ParcelJS and hosted on S3.

283 lines (246 loc) 7.36 kB
import { defaultValue, fs, http, t, time, util } from '../common'; import { Site } from './Site'; type IPullResonse = { ok: boolean; status: number; manifest?: Manifest; error?: Error; }; type IManifestArgs = { baseUrl: string; def: t.IManifest; status?: number; }; type IManifestCache = { time: number; manifest: Manifest }; let URL_CACHE: { [key: string]: IManifestCache } = {}; export class Manifest { /** * [Static] */ /** * Reset the cache. */ public static reset() { URL_CACHE = {}; } public static async fromFile(args: { path: string; baseUrl: string; loadBundleManifest?: boolean; }) { const { baseUrl, loadBundleManifest } = args; const path = fs.resolve(args.path); if (!(await fs.pathExists(path))) { throw new Error(`Manifest file does not exist: '${args.path}'`); } const yaml = await fs.readFile(path, 'utf-8'); const def = await Manifest.parse({ yaml, baseUrl, loadBundleManifest }); return Manifest.create({ def, baseUrl }); } /** * Pulls the manifest from the given url end-point. */ public static async fromUrl(args: { manifestUrl: string; baseUrl?: string; loadBundleManifest?: boolean; }): Promise<IPullResonse> { const { manifestUrl, loadBundleManifest } = args; const errorResponse = (status: number, error: string): IPullResonse => { return { ok: false, status, error: new Error(error) }; }; try { // Retrieve manifiest from network. const res = await http.get(args.manifestUrl); if (!res.ok) { const error = res.status === 403 ? `The manifest YAML has not been made "public" on the internet.` : `Failed while pulling manifest YAML from cloud.`; return errorResponse(403, error); } // Attempt to parse the yaml. const baseUrl = args.baseUrl || manifestUrl; const manifest = await Manifest.fromYaml({ yaml: res.text, baseUrl, loadBundleManifest }); // Finish up. return { ok: true, status: 200, manifest, }; } catch (error) { return errorResponse(500, error); } } public static async fromYaml(args: { yaml: string; baseUrl: string; loadBundleManifest?: boolean; }) { const { yaml, baseUrl, loadBundleManifest } = args; const def = await Manifest.parse({ yaml, baseUrl, loadBundleManifest }); return Manifest.create({ def, baseUrl }); } /** * Pulls the manifest at the given url end-point. */ public static async parse(args: { yaml: string; baseUrl: string; loadBundleManifest?: boolean }) { const { loadBundleManifest } = args; const baseUrl = args.baseUrl.replace(/\/manifest.yml$/, ''); // Attempt to parse the yaml. const yaml = util.parseYaml(args.yaml); if (!yaml.ok || !yaml.data) { const error = `Failed to parse manifest YAML. ${yaml.error.message}`; throw new Error(error); } // Process the set of sites. let sites: t.ISiteManifest[] = []; const input = (yaml.data as Record<string, unknown>).sites; sites = await Site.formatMany({ input, baseUrl, loadBundleManifest }); // Finish up. const manifest: t.IManifest = { sites }; return manifest; } /** * Gets the manifest (from cache if already pulled). */ public static async get(args: { manifestUrl: string; // URL to the manifest.yml (NB: don't use a caching CDN). baseUrl: string; // If different from `url` (use this to pass in the Edge/CDN alternative URL). force?: boolean; loadBundleManifest?: boolean; ttl?: number; // msecs }) { const { ttl } = args; const key = `${args.manifestUrl}:${args.loadBundleManifest || 'false'}`; // Check the cache. let cached = URL_CACHE[key]; if (!args.force && cached && !isCacheExpired({ key, ttl })) { return cached.manifest; } // Retrieve from S3. const res = await Manifest.fromUrl(args); if (res.manifest) { const manifest = res.manifest; cached = { manifest, time: time.now.timestamp }; URL_CACHE[key] = cached; } // Finish up. return URL_CACHE[key].manifest; } /** * [Lifecycle] */ public static create = (args: IManifestArgs) => new Manifest(args); private constructor(args: IManifestArgs) { this.def = args.def; this.baseUrl = util.stripSlashes(args.baseUrl); this.status = args.status || 200; } /** * [Fields] */ public readonly status: number; public readonly baseUrl: string; private readonly def: t.IManifest; private _sites: Site[]; /** * [Properties] */ public get ok() { return this.status.toString().startsWith('2'); } public get sites() { if (!this._sites) { const manifest = this.def; this._sites = this.def.sites.map((def, index) => Site.create({ index, manifest })); } return this._sites; } /** * Retrieve the site definition for the domain (hostname). */ public get site() { return { byName: (name?: string) => { name = (name || '').trim(); return this.sites.find((site) => site.name.trim() === name); }, byHost: (domain?: string) => { domain = util.stripHttp(domain || ''); return this.sites.find((site) => site.isMatch(domain || '')); }, }; } /** * Methods for changing and saving values. */ public get change() { return { site: (id: string | Site) => { const name = typeof id === 'string' ? id : id.name; return { bundle: async (args: { value: string; saveTo?: string }) => { // Find the site. const site = this.site.byName(name); if (!site) { return undefined; } // Update the bundle version. const bundle = args.value; const def = { ...this.def }; def.sites[site.index].bundle = bundle; // Clone of manifest with updated def. const manifest = Manifest.create({ def, baseUrl: this.baseUrl }); // Save to local file-system. if (args.saveTo) { await manifest.save(args.saveTo); } // Finish up. return manifest; }, }; }, }; } /** * [Methods] */ /** * Object representation of the Manifest. */ public toObject() { return { status: this.status, ...this.def, }; } public async save(path: string, options: { minimal?: boolean } = {}) { // Prepare content. const def = { ...this.def }; const fields: (keyof t.ISiteManifest)[] = ['files', 'entries', 'baseUrl', 'size', 'bytes']; if (defaultValue(options.minimal, true)) { def.sites.forEach((site) => { fields.forEach((field) => { delete site[field]; }); }); } // Save to file-system. path = fs.resolve(path); await fs.ensureDir(fs.dirname(path)); await fs.file.stringifyAndSave(path, def); } } /** * [Helpers] */ function isCacheExpired(args: { key: string; ttl?: number }) { const { key, ttl } = args; const cached = URL_CACHE[key]; if (!cached || typeof ttl !== 'number') { return false; } const age = time.now.timestamp - cached.time; return age > ttl; }