brf/server/plugins/vite/production.ts
2025-12-13 21:12:08 +01:00

183 lines
5.2 KiB
TypeScript

import path from 'node:path'
import fs from 'node:fs'
import { type Readable } from 'node:stream'
import { resolveConfig, type ResolvedConfig as ViteResolvedConfig, type Manifest as ViteManifest } from 'vite'
import type { FastifyInstance, RouteHandler } from 'fastify'
import fstatic from '@fastify/static'
import StatusError from '../../lib/status_error.ts'
import type { Route } from '../../../shared/types.ts'
import type { Entry, ParsedConfig, Renderer, RenderCache, RenderFunction, ViteEntryFile } from './types.ts'
export default async function viteProduction(fastify: FastifyInstance, config: ParsedConfig) {
const viteConfig = await resolveConfig({}, 'build', 'production')
fastify.register(fstatic, {
root: viteConfig.build.outDir,
wildcard: false,
})
fastify.decorateReply('ctx', null)
for (const entry of Object.values(config.entries)) {
fastify.register((fastify, _, done) => setupEntry(fastify, entry, config, viteConfig).then(() => done()), {
prefix: entry.path,
})
}
}
async function setupEntry(
fastify: FastifyInstance,
entry: Entry,
_config: ParsedConfig,
viteConfig: ViteResolvedConfig,
) {
const viteEntryFilePath = path.join(viteConfig.build.outDir, entry.name + '.js')
const viteEntryFile: ViteEntryFile = fs.existsSync(viteEntryFilePath)
? await import(viteEntryFilePath)
: await import(path.join(viteConfig.root, entry.name, 'server.ts'))
const manifestPath = path.join(viteConfig.build.outDir, `manifest.${entry.name}.json`)
const manifest: ViteManifest = fs.existsSync(manifestPath)
? (await import(manifestPath, { with: { type: 'json' } })).default
: null
const routes = entry.routes || viteEntryFile.routes
if (!routes) throw new Error('No routes found')
const cache = new Map()
const renderer = createRenderer(fastify, entry, viteEntryFile.render, manifest)
const cachedHandler = createCachedHandler(cache)
const handler = createHandler(renderer)
if (entry.preHandler || entry.createPreHandler) {
fastify.addHook('onRequest', (_request, reply, done) => {
reply.ctx = Object.create(null)
done()
})
}
if (entry.preHandler) {
fastify.addHook('preHandler', entry.preHandler)
}
for (const route of routes.flatMap((route) => route.routes || route)) {
const preHandler = entry.createPreHandler?.(route, entry)
if (route.locales?.length) {
const locales = [{ hostname: entry.hostname, path: route.path }, ...route.locales]
for (const locale of locales) {
fastify.route({
method: 'GET',
url: locale.path,
onRequest(request, reply, done) {
if (request.hostname !== locale.hostname) {
// TODO should probably redirect to correct path on request.hostname, not to locale.hostname
return reply.redirect('http://' + locale.hostname + request.url)
}
done()
},
handler: route.cache ? cachedHandler : handler,
preHandler,
})
}
} else {
fastify.route({
method: 'GET',
url: route.path,
handler: route.cache ? cachedHandler : handler,
preHandler,
})
}
}
entry.setupRebuildCache?.(() => buildCache(entry, routes, renderer, cache))
fastify.setNotFoundHandler(() => {
throw new StatusError(404)
})
if (entry.createErrorHandler) {
fastify.setErrorHandler(
entry.createErrorHandler(async (error, request, reply) => {
reply.type('text/html').status((error as StatusError).status || 500)
return renderer(
request.url,
Object.assign(
{
error,
},
reply.ctx,
),
)
}),
)
}
}
function createRenderer(
_fastify: FastifyInstance,
entry: Entry,
render: RenderFunction | null | undefined,
manifest: ViteManifest,
): Renderer {
const files = manifest[`${entry.name}/client.ts`]
const bundle = path.join('/', files.file)
const preload = files.imports?.map((name) => path.join('/', manifest[name].file))
const css = files.css?.map((name) => path.join('/', name))
return (url, ctx) => {
ctx = Object.assign({ url }, entry.ctx, ctx)
const renderPromise = render?.(ctx)
return entry
.template({
env: 'production',
script: bundle,
preload,
css,
content: renderPromise?.then((result) => result.html),
head: renderPromise?.then((result) => result.head),
state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx),
})
.stream(true)
}
}
function createHandler(renderer: Renderer): RouteHandler {
return async function handler(request, reply) {
reply.type('text/html')
return renderer(request.url, reply.ctx)
}
}
function createCachedHandler(cache: RenderCache): RouteHandler {
return function cachedHandler(request, reply) {
reply.type('text/html')
return cache.get(request.url)
}
}
async function buildCache(_entry: Entry, routes: Route[], renderer: Renderer, cache: RenderCache) {
for (const route of routes) {
if (route.cache) {
let html = ''
// TODO run preHandlers
for await (const chunk of renderer(route.path) as Readable) {
html += chunk
}
cache.set(route.path, html)
}
}
}