UNPKG

html-render-webpack-plugin

Version:

webpack plugin for rendering static HTML in a multi-config webpack build

340 lines (301 loc) 10.4 kB
import { validate as validateOptions } from "schema-utils"; import schema from "./schemas/HtmlRenderWebpackPlugin.json"; import RenderError from "./RenderError"; import renderRoutes from "./renderRoutes"; import { log, logError } from "./logging"; import MultiStats from "webpack/lib/MultiStats"; import createDevRouter from "./createDevRouter"; import { BaseRoute, ExtraGlobals, FileSystem, MapStatsToParams, RenderConcurrency, Renderer, Render, RouteInput, OnRendererReady, TransformExpressPath, TransformPath, WebpackStats, GetRouteFromRequest, } from "./common-types"; import { Compiler, Compilation } from "webpack"; import createRenderer from "./createRenderer"; import { Router } from "express"; const timeSince = (startTime: number) => `${(Date.now() - startTime) / 1000}s`; const defaultMapStats = ({ webpackStats }: { webpackStats: WebpackStats }) => webpackStats ? { webpackStats: webpackStats.toJson() } : {}; const defaultTransform: TransformPath = <Route extends BaseRoute>( route: Route ) => route.route; interface Options<Route extends BaseRoute = BaseRoute> { skipAssets?: boolean; getRouteFromRequest?: GetRouteFromRequest<Route>; routes?: RouteInput<Route>[]; mapStatsToParams?: MapStatsToParams; renderDirectory?: string; renderConcurrency?: RenderConcurrency; transformFilePath?: TransformPath<Route>; transformExpressPath?: TransformExpressPath<Route>; renderEntry?: string; extraGlobals?: ExtraGlobals; } interface CompilationStatus { compilation: Compilation | null; isReady: boolean; } export default class HtmlRenderPlugin<Route extends BaseRoute = BaseRoute> { constructor(options: Options<Route> = {}) { validateOptions(schema as any, options || {}, { name: "HTML Render Webpack Plugin", }); const pluginName = "HtmlRenderPlugin"; const { extraGlobals = {}, skipAssets = false, mapStatsToParams = defaultMapStats, renderEntry = "main", getRouteFromRequest, transformFilePath = defaultTransform, transformExpressPath = defaultTransform, renderConcurrency = "serial", } = options; const routes: Route[] = (options.routes || [""]).map((route) => typeof route === "string" ? ({ route } as Route) : route ); const renderDirectory = options.renderDirectory || "dist"; const clientCompilations: CompilationStatus[] = []; let rendererCompilation: CompilationStatus; let renderer: Renderer; let lastClientStats: WebpackStats | null = null; const isBuildReady = () => rendererCompilation && rendererCompilation.isReady && clientCompilations.every( (compilationStatus) => compilationStatus.isReady ); const isRendererReady = () => isBuildReady() && Boolean(renderer); const renderQueue: Array<() => void> = []; const flushRenderQueue = async () => { if (isRendererReady() && renderQueue.length > 0) { await renderQueue.shift()!(); flushRenderQueue(); } }; const render: Render<Route> = async (route: Route) => { const startRenderTime = Date.now(); log(`Starting render`, route); const webpackStats = getClientStats(); const renderParams = { ...route, ...mapStatsToParams({ ...route, webpackStats, }), }; try { const result = await renderer(renderParams); log( `Successfully rendered ${route.route} (${timeSince(startRenderTime)})` ); return result; } catch (error: any) { log(`Error rendering ${route.route}`); if (error) { error.route = route.route; error.webpackStats = webpackStats; } throw error; } }; const onRenderAll = async (currentCompilation: Compilation) => { log(`Starting routes render`); if (!rendererCompilation.compilation) { const errorMessage = `Unable to find render compilation. Something may have gone wrong during render build.`; logError(errorMessage); throw new Error(errorMessage); } try { await renderRoutes({ render, renderConcurrency, renderCompilation: rendererCompilation.compilation, renderDirectory, renderEntry, routes, transformFilePath, }); } catch (error) { logError("An error occurred rendering HTML", error); // @ts-expect-error Allow passing errors to compilation currentCompilation.errors.push(new RenderError(error)); } log(`Ending routes render`); }; const getRenderEntry = (compilation: Compilation) => { const renderStats = compilation.getStats().toJson(); const asset = renderStats.assetsByChunkName![renderEntry]; if (!asset) { throw new Error( `Unable to find renderEntry "${renderEntry}" in assets. Possible entries are: ${Object.keys( renderStats.assetsByChunkName! ).join(", ")}.` ); } let renderFile: any = asset; if (Array.isArray(renderFile)) { renderFile = renderFile[0]; } if (renderFile && typeof renderFile === "object") { renderFile = renderFile.name; } return renderFile as string; }; const renderCallbacks: any[] = []; const getClientStats = () => { if (lastClientStats) { return lastClientStats; } const clientStats = clientCompilations.length === 1 ? clientCompilations[0].compilation!.getStats() : new MultiStats( clientCompilations .map((compilationStatus) => compilationStatus.compilation) .filter(Boolean) .map((compilation) => compilation!.getStats()) ); lastClientStats = clientStats; return clientStats; }; const flushQueuedRenders = () => { if (isRendererReady() && renderCallbacks.length > 0) { renderCallbacks.shift()(renderer, getClientStats()); flushQueuedRenders(); } }; const onRendererReady: OnRendererReady<Route> = (cb) => { if (isRendererReady()) { cb(render); } else { renderCallbacks.push(cb); } }; const createRendererIfReady = async (currentCompilation: Compilation) => { if (!isBuildReady()) { return; } const renderCompilation = rendererCompilation.compilation!; const renderEntry = getRenderEntry(renderCompilation); log("Render route:", { renderEntry }); const rootDir = renderCompilation.compiler.outputPath; renderer = createRenderer({ fileSystem: renderCompilation.compiler.outputFileSystem as FileSystem, fileName: renderEntry, rootDir, extraGlobals, }); log("Created renderer"); if (typeof renderer !== "function") { log( `Unable to find render function. File "${renderEntry}". Received ${typeof renderer}.` ); throw new Error( `Unable to find render function. File "${renderEntry}". Received ${typeof renderer}.` ); } flushQueuedRenders(); flushRenderQueue(); log("Queued and flushed"); if (!skipAssets) { await onRenderAll(currentCompilation); log("onRenderAll complete"); } }; const apply = (compiler: Compiler, isRenderer: boolean) => { const compilerName = compiler.name || compiler.options.name; const compilationStatus: CompilationStatus = { compilation: null, isReady: false, }; if (isRenderer) { log(`Received render compiler: ${compilerName}`); } else { log(`Received compiler: ${compilerName}`); } if (isRenderer) { if (rendererCompilation) { throw new Error("Error. Unable to apply a second renderer"); } rendererCompilation = compilationStatus; } else { clientCompilations.push(compilationStatus); } compiler.hooks.watchRun.tap(pluginName, () => { log(`Build started for for ${compilerName}.`); compilationStatus.isReady = false; }); compiler.hooks.afterEmit.tapPromise(pluginName, async (compilation) => { log(`Assets emitted for ${compilerName}.`); compilationStatus.compilation = compilation; lastClientStats = null; compilationStatus.isReady = true; try { await createRendererIfReady(compilation); } catch (error) { compilation.errors.push(error); } }); }; this.statsCollectorPlugin = (compiler: Compiler) => apply(compiler, false); this.rendererPlugin = (compiler?: Compiler) => { // Support legacy behaviour of calling '.render()' until next breaking change if (!compiler) { console.warn( "Warning. Calling render no longer required. Change htmlRenderPlugin.render() to htmlRenderPlugin.render" ); return (compiler: Compiler) => apply(compiler, true); } return apply(compiler, true); }; this.apply = (compiler: Compiler) => { console.warn( "Warning. Attempted to apply directly to webpack. Use htmlRenderPlugin.statsCollectorPlugin instead." ); this.statsCollectorPlugin(compiler); }; this.render = () => this.rendererPlugin; this.createDevRouter = () => createDevRouter<Route>({ transformExpressPath, getRouteFromRequest, onRendererReady, getClientStats, routes, }); this.renderWhenReady = (route: Route) => new Promise((resolve, reject) => { const onRender = () => { log("Rendering renderWhenReady onRender"); try { resolve(render(route)); } catch (error) { reject(error); } }; if (isRendererReady()) { onRender(); } else { renderQueue.push(onRender); } }); } renderWhenReady: (route: Route) => Promise<string>; statsCollectorPlugin: (compiler: Compiler) => void; rendererPlugin: (compiler: Compiler) => void; render: () => (compiler: Compiler) => void; apply: (compiler: Compiler) => void; createDevRouter: () => Router; } export { HtmlRenderPlugin };