184 lines
5.3 KiB
TypeScript
184 lines
5.3 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,
|
|
decorateReply: 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)
|
|
}
|
|
}
|
|
}
|