UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

353 lines (309 loc) 9.99 kB
import { createRequire } from "node:module"; import { isAbsolute, join } from "node:path"; import { $inject, Alepha, AlephaError } from "alepha"; import { $logger } from "alepha/logger"; import { FileSystemProvider } from "alepha/system"; import type * as vite from "vite"; import type { UserConfig } from "vite"; import { analyzer as viteAnalyzer } from "vite-bundle-analyzer"; import { ViteUtils } from "../services/ViteUtils.ts"; import { BuildTask, type BuildTaskContext } from "./BuildTask.ts"; /** * Build server-side SSR bundle with Vite. * * Compiles the server code for production, generates the externals * package.json, and creates the dist/index.js entry wrapper. */ export class BuildServerTask extends BuildTask { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly fs = $inject(FileSystemProvider); protected readonly viteUtils = $inject(ViteUtils); async run(ctx: BuildTaskContext): Promise<void> { if (ctx.flags?.prebuilt) { return; } const distDir = ctx.options.output?.dist ?? "dist"; const publicDir = ctx.options.output?.public ?? "public"; const stats = ctx.options.stats ?? false; const isCI = this.alepha.isCI(); const clientIndexPath = this.fs.join( ctx.root, distDir, publicDir, "index.html", ); const clientBuilt = await this.fs.exists(clientIndexPath); const conditions: string[] = []; if (ctx.options.runtime === "bun") { conditions.push("bun"); } else if (ctx.options.runtime === "workerd") { conditions.push("workerd"); } await ctx.run({ name: "build server", handler: async () => { await this.buildServer({ root: ctx.root, entry: ctx.entry.server, distDir, clientDir: clientBuilt ? publicDir : undefined, stats, silent: !isCI, conditions, alepha: ctx.alepha, }); // Server will handle index.html if both client & server are built if (clientBuilt) { await this.fs.rm(clientIndexPath); } }, }); } protected async buildServer(opts: { root: string; entry: string; distDir: string; clientDir?: string; stats?: boolean | "json"; silent?: boolean; conditions?: string[]; alepha: Alepha; }): Promise<void> { const { build: viteBuild, resolveConfig } = await this.viteUtils.importVite(); const plugins: any[] = []; const viteReact = await this.viteUtils.importViteReact(); if (viteReact && opts.clientDir) { plugins.push(viteReact()); } plugins.push(this.viteUtils.createTsconfigPathsPlugin()); plugins.push(this.viteUtils.createSsrPreloadPlugin()); if (opts.stats) { plugins.push( viteAnalyzer({ analyzerMode: opts.stats === "json" ? "json" : "static", }), ); } const logger = opts.silent ? this.viteUtils.createBufferedLogger() : undefined; const conditions = ["node", "import", "module", "default"]; if (opts.conditions) { conditions.unshift(...opts.conditions); } const viteBuildServerConfig: UserConfig = { mode: "production", logLevel: opts.silent ? "silent" : undefined, define: { "process.env.NODE_ENV": '"production"', }, resolve: { dedupe: [ "react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", ], }, publicDir: false, ssr: { noExternal: true, resolve: { conditions }, }, build: { ssr: opts.entry, minify: true, sourcemap: true, chunkSizeWarningLimit: 10000, outDir: `${opts.distDir}/server`, rolldownOptions: { external: [/^bun(:|$)/, /^cloudflare:/], output: { entryFileNames: "[hash].js", chunkFileNames: "[hash].js", assetFileNames: "[hash][extname]", format: "esm", codeSplitting: { groups: [ { name: "react", test: /node_modules\/react(\/|-dom\/)/, }, ], }, // Rolldown/Oxc minifier: preserve class and function names minify: { mangle: { keepNames: true }, compress: { keepNames: { function: true, class: true }, }, }, }, }, }, customLogger: logger, plugins, }; let result: vite.Rollup.RollupOutput | vite.Rollup.RollupOutput[]; try { result = (await viteBuild(viteBuildServerConfig)) as | vite.Rollup.RollupOutput | vite.Rollup.RollupOutput[]; } catch (error) { logger?.flush(); throw error; } const resolvedConfig = await resolveConfig(viteBuildServerConfig, "build"); const externals: string[] = []; if (Array.isArray(resolvedConfig?.ssr?.external)) { externals.push(...resolvedConfig.ssr.external); } await this.generateExternals(opts.distDir, externals); const entryFile = this.extractEntryFromBundle( opts.root, opts.entry, result, ); let manifest = ""; let manifestData: | { base?: string; client?: Record<string, any>; preload?: Record<string, string>; favicon?: string; } | undefined; if (opts.clientDir) { const viteDir = `${opts.distDir}/${opts.clientDir}/.vite`; const clientManifest = await this.loadJsonFile( `${viteDir}/manifest.json`, ); const preloadManifest = await this.loadJsonFile( `${viteDir}/preload-manifest.json`, ); const strippedClientManifest = this.stripClientManifest(clientManifest); let base = resolvedConfig.base || "/"; if (!base.startsWith("/")) { base = `/${base}`; } if (base.length > 1 && base.endsWith("/")) { base = base.slice(0, -1); } const favicon = await this.detectFavicon( `${opts.distDir}/${opts.clientDir}`, ); manifestData = { base: base !== "/" ? base : undefined, client: strippedClientManifest, preload: preloadManifest, favicon, }; manifest = `__alepha.set("alepha.react.ssr.manifest", ${JSON.stringify(manifestData, null, " ")});\n`; opts.alepha.store.set("alepha.react.ssr.manifest" as any, manifestData); await this.fs.rm(viteDir, { recursive: true }); } const warning = "// This file was automatically generated. DO NOT MODIFY." + "\n" + "// Changes to this file will be lost when the code is regenerated.\n"; await this.fs.writeFile( `${opts.distDir}/index.js`, `${warning}\nimport './server/${entryFile}';\n\n${manifest}`.trim(), ); } /** * Detect a favicon file in the given directory. * Returns "mimeType:/path" if found, undefined otherwise. */ protected async detectFavicon( publicDir: string, ): Promise<string | undefined> { const candidates: [string, string][] = [ ["favicon.svg", "image/svg+xml"], ["favicon.png", "image/png"], ["favicon.ico", "image/x-icon"], ]; for (const [file, mime] of candidates) { if (await this.fs.exists(join(publicDir, file))) { return `${mime}:/${file}`; } } return undefined; } protected async generateExternals( distDir: string, externals: string[], ): Promise<void> { const require = createRequire(import.meta.filename); const deps: Record<string, string> = {}; for (const dep of externals) { try { const requirePath = require.resolve(dep); const pkgPath = `${requirePath.split(`node_modules/${dep}`)[0]}node_modules/${dep}/package.json`; const pkg = JSON.parse((await this.fs.readFile(pkgPath)).toString()); deps[dep] = `^${pkg.version}`; } catch { this.log.warn(`Cannot find '${dep}' in node_modules`); } } const minimalPkg = { type: "module", main: "index.js", dependencies: deps, }; await this.fs.mkdir(distDir); await this.fs.writeFile( join(distDir, "package.json"), JSON.stringify(minimalPkg, null, 2), ); } protected async loadJsonFile(path: string): Promise<any> { try { const content = (await this.fs.readFile(path)).toString(); return JSON.parse(content); } catch { return undefined; } } protected stripClientManifest( manifest: Record<string, any> | undefined, ): Record<string, any> | undefined { if (!manifest) return undefined; const stripped: Record<string, any> = {}; for (const [key, entry] of Object.entries(manifest)) { stripped[key] = { file: entry.file, ...(entry.isEntry && { isEntry: entry.isEntry }), ...(entry.imports?.length && { imports: entry.imports }), ...(entry.css?.length && { css: entry.css }), }; } return stripped; } protected extractEntryFromBundle( root: string, entry: string, result: | vite.Rollup.RollupOutput | vite.Rollup.RollupOutput[] | vite.Rollup.RollupWatcher, ): string { const entryFilePath = isAbsolute(entry) ? entry : join(root, entry); const normalizedEntryPath = entryFilePath.replace(/\\/g, "/"); const rollupOutput = ( Array.isArray(result) ? result[0] : result ) as vite.Rollup.RollupOutput; const entryFile = rollupOutput.output.find( (it) => "facadeModuleId" in it && it.facadeModuleId === normalizedEntryPath, )?.fileName; if (!entryFile) { throw new AlephaError( `Could not find the entry file "${entryFilePath}" in the build output. Please check your entry file and try again.`, ); } return entryFile; } }