UNPKG

boostr

Version:
332 lines (317 loc) 11.6 kB
import fsExtra from 'fs-extra'; import { join, dirname, basename, extname } from 'path'; import walkSync from 'walk-sync'; import chokidar from 'chokidar'; import escape from 'lodash/escape.js'; import debounce from 'lodash/debounce.js'; import { Subservice } from './sub.js'; import { check } from '../checker.js'; import { build } from '../builder.js'; import { SinglePageApplicationServer } from '../spa-server.js'; import { AWSWebsiteResource } from '../resources/aws/website.js'; import { resolveVariables, generateHashFromFile } from '../utilities.js'; const HTML_TEMPLATE = `<!DOCTYPE html> <html lang="{{language}}"> <head> <title>{{headTitle}}</title> {{headMetas}} {{headLinks}} {{headStyle}} {{headScripts}} </head> <body> <noscript><p>Sorry, this site requires JavaScript to be enabled.</p></noscript> {{bodyScripts}} <div id="root"></div> <script src="{{jsBundleURL}}"></script> </body> </html> `; const BOOTSTRAP_TEMPLATE = ` import React from 'react'; import ReactDOM from 'react-dom'; import {BrowserRootView, BrowserNavigatorView} from '@layr/react-integration'; import rootComponentGetter from '{{entryPoint}}'; async function main() { let content; try { const rootComponent = await rootComponentGetter(); await rootComponent.initialize(); content = React.createElement( BrowserRootView, undefined, React.createElement(BrowserNavigatorView, {rootComponent}) ); } catch (err) { console.error(err); content = React.createElement('pre', undefined, err.stack); } ReactDOM.render(content, document.getElementById('root')); } main().catch((error) => { console.error(error); }); `; const BOOTSTRAP_LOCAL = ` function openWebSocket({reconnectionCount = 0} = {}) { const webSocket = new WebSocket('ws://' + window.location.host); webSocket.addEventListener('open', () => { if (reconnectionCount !== 0) { window.location.reload(); } }); webSocket.addEventListener('message', (event) => { if (event.data === 'restart') { window.location.reload(); } }); webSocket.addEventListener('close', () => { setTimeout(() => { reconnectionCount++; if (reconnectionCount > 30) { console.warn( 'Automatic refresh disabled because the server has not responded for 5 minutes.' ); return; } openWebSocket({reconnectionCount}); }, 10000); // 10 seconds }); } openWebSocket(); `; const PUBLIC_DIRECTORY_NAME = 'public'; const IMMUTABLE_EXTENSION = '.immutable'; export class WebFrontendService extends Subservice { async check() { await super.check(); const serviceDirectory = this.getDirectory(); const serviceName = this.getName(); await check({ serviceDirectory, serviceName }); } async build({ watch = false } = {}) { await super.build(); const serviceDirectory = this.getDirectory(); const serviceName = this.getName(); const stage = this.getStage(); const { environment, platform, rootComponent, build: buildConfig = {}, html: htmlConfig, hooks } = this.getConfig(); if (!rootComponent) { this.throwError(`A 'rootComponent' property is required in the configuration (directory: '${serviceDirectory}')`); } const buildDirectory = join(serviceDirectory, 'build', stage); fsExtra.emptyDirSync(buildDirectory); const isLocal = platform === 'local'; let bootstrapTemplate = BOOTSTRAP_TEMPLATE; if (isLocal) { bootstrapTemplate += BOOTSTRAP_LOCAL; } const { jsBundleFile, cssBundleFile } = await build({ serviceDirectory, entryPoint: rootComponent, buildDirectory, bootstrapTemplate, serviceName, environment, sourceMap: buildConfig.sourceMap ?? isLocal, minify: buildConfig.minify ?? !isLocal, watch, freeze: !isLocal, esbuildOptions: { target: 'es2020', platform: 'browser', mainFields: ['browser', 'module', 'main'], publicPath: '/', define: { global: 'window' } } }); const htmlFile = buildHTMLFile({ buildDirectory, jsBundleFile, cssBundleFile, htmlConfig }); const publicDirectory = join(serviceDirectory, PUBLIC_DIRECTORY_NAME); fsExtra.copySync(publicDirectory, buildDirectory); if (watch) { // TODO: Implement a proper syncing mechanism chokidar.watch(publicDirectory, { ignoreInitial: true }).on('all', debounce(() => { fsExtra.copySync(publicDirectory, buildDirectory); this.logMessage(`Public directory synchronized`); }, 200)); } if (hooks?.afterBuild !== undefined) { // TODO: Handle watch mode await hooks.afterBuild({ serviceDirectory, serviceName, stage, platform, buildDirectory, htmlFile, jsBundleFile, cssBundleFile }); } return { buildDirectory, htmlFile, jsBundleFile, cssBundleFile }; } async start() { await super.start(); const config = this.getConfig(); const serviceName = this.getName(); if (config.platform !== 'local') { return; } const { port } = this.parseConfigURL(); let server; const { buildDirectory } = await this.build({ watch: { afterRebuild() { server.restartClients(); } } }); server = new SinglePageApplicationServer({ directory: buildDirectory, serviceName, port }); await server.start(); } async deploy({ skipServiceNames = [] } = {}) { await super.deploy({ skipServiceNames }); const serviceName = this.getName(); if (skipServiceNames.includes(serviceName)) { return; } const config = this.getConfig(); if (!config.url) { this.logMessage(`The 'url' property is not specified in the configuration. Skipping deployment...`); return; } const { hostname } = this.parseConfigURL(); await this.check(); const { buildDirectory } = await this.build(); const resource = new AWSWebsiteResource({ domainName: hostname, region: config.aws?.region, profile: config.aws?.profile, accessKeyId: config.aws?.accessKeyId, secretAccessKey: config.aws?.secretAccessKey, directory: buildDirectory, cloudFront: { priceClass: config.aws?.cloudFront?.priceClass } }, { serviceName }); await resource.initialize(); await resource.deploy(); } async freeze() { const publicDirectory = join(this.getDirectory(), PUBLIC_DIRECTORY_NAME); const files = walkSync(publicDirectory, { directories: false, includeBasePath: true }); for (const file of files) { const directory = dirname(file); const fileName = basename(file); const extension = extname(fileName); const fileNameWithoutExtension = fileName.slice(0, -extension.length); if (fileName.endsWith(IMMUTABLE_EXTENSION + extension)) { continue; } const hash = generateHashFromFile(file); const newFileName = fileNameWithoutExtension + '-' + hash + IMMUTABLE_EXTENSION + extension; const newFile = join(directory, newFileName); fsExtra.moveSync(file, newFile, { overwrite: true }); this.logMessage(`File frozen ('${fileName}' -> '${newFileName}')`); } } } WebFrontendService.type = 'web-frontend'; WebFrontendService.description = 'A web frontend service providing a user interface for your app.'; WebFrontendService.examples = [ 'boostr {{serviceName}} deploy --skip=backend', 'boostr {{serviceName}} freeze', 'boostr {{serviceName}} exec -- npm install lodash' ]; // === Commands === WebFrontendService.commands = { ...Subservice.commands, freeze: { ...Subservice.commands.freeze, description: 'Freezes all the files that are in your public directory.', examples: ['boostr {{serviceName}} freeze'], async handler() { await this.freeze(); } } }; function buildHTMLFile({ buildDirectory, jsBundleFile, cssBundleFile, htmlConfig = {} }) { const language = escape(htmlConfig.language ?? ''); const headConfig = htmlConfig.head ?? {}; let headConfigLinks = headConfig.links ?? []; headConfigLinks = Array.isArray(headConfigLinks) ? headConfigLinks : [headConfigLinks]; if (cssBundleFile !== undefined) { const cssBundleURL = `/${basename(cssBundleFile)}`; headConfigLinks.push({ rel: 'stylesheet', href: cssBundleURL }); } const headTitle = escape(headConfig.title ?? ''); const headMetas = buildTags('meta', headConfig.metas); const headLinks = buildTags('link', headConfigLinks); const headStyle = headConfig.style !== undefined ? `<style>\n${headConfig.style}\n </style>` : ''; const headScripts = buildTags('script', headConfig.scripts); const bodyConfig = htmlConfig.body ?? {}; const bodyScripts = buildTags('script', bodyConfig.scripts); const jsBundleURL = `/${basename(jsBundleFile)}`; const htmlContent = removeEmptyLines(resolveVariables(HTML_TEMPLATE, { language, headTitle, headMetas, headLinks, headStyle, headScripts, bodyScripts, jsBundleURL })); const htmlFile = join(buildDirectory, 'index.html'); fsExtra.outputFileSync(htmlFile, htmlContent); return htmlFile; } function buildTags(tagName, specOrSpecs = []) { const specs = Array.isArray(specOrSpecs) ? specOrSpecs : [specOrSpecs]; let tags = ''; for (const spec of specs) { let attributes; let content; if (Array.isArray(spec)) { attributes = spec[0] ?? {}; content = spec[1] ?? ''; } else if (typeof spec === 'object') { attributes = spec; content = ''; } else { attributes = {}; content = spec; } const formattedAttributes = []; for (const [name, value] of Object.entries(attributes)) { if (typeof value === 'boolean') { if (value) { formattedAttributes.push(name); } } else { formattedAttributes.push(`${name}="${escape(value)}"`); } } let tag = `<${tagName}`; if (formattedAttributes.length > 0) { tag += ` ${formattedAttributes.join(' ')}`; } if (content !== '' || tagName === 'script') { tag += `>${content !== '' ? `\n${content}\n ` : ''}</${tagName}>`; } else { tag += ' />'; } tags += `${tags === '' ? tag : `\n ${tag}`}`; } return tags; } function removeEmptyLines(text) { return text .split('\n') .filter((line) => line.trim().length > 0) .join('\n'); } //# sourceMappingURL=web-frontend.js.map