one
Version:
One is a new React Framework that makes Vite serve both native and web.
284 lines (259 loc) • 10 kB
text/typescript
import { join, resolve } from 'node:path'
import FSExtra from 'fs-extra'
import type { RollupOutput } from 'rollup'
import { isMatching, P } from 'ts-pattern'
import type { One } from '../../vite/types'
import { vercelBuildOutputConfigBase } from './config/vc-build-output-config-base'
import { serverlessVercelNodeJsConfig } from './config/vc-config-base'
import { serverlessVercelPackageJson } from './config/vc-package-base'
import { createApiServerlessFunction } from './generate/createApiServerlessFunction'
import { createSsrServerlessFunction } from './generate/createSsrServerlessFunction'
import { getPathFromRoute } from './getPathFromRoute'
const { copy, ensureDir, existsSync, writeJSON } = FSExtra
async function moveAllFiles(src: string, dest: string) {
try {
await copy(src, dest, { overwrite: true, errorOnExist: false })
} catch (err) {
console.error('Error moving files:', err)
}
}
function getMiddlewaresByNamedRegex(buildInfoForWriting: One.BuildInfo) {
return buildInfoForWriting.manifest.allRoutes
.filter((r) => r.middlewares && r.middlewares.length > 0)
.map((r) => [
r.namedRegex,
r.middlewares!.map((m) =>
m.contextKey.startsWith('dist/middlewares/')
? m.contextKey.substring('dist/middlewares/'.length)
: m.contextKey
),
])
.sort((a, b) => b[0].length - a[0].length)
}
export const buildVercelOutputDirectory = async ({
apiOutput,
buildInfoForWriting,
clientDir,
oneOptionsRoot,
postBuildLogs,
}: {
apiOutput: RollupOutput | null
buildInfoForWriting: One.BuildInfo
clientDir: string
oneOptionsRoot: string
postBuildLogs: string[]
}) => {
const { routeToBuildInfo } = buildInfoForWriting
if (apiOutput) {
const compiltedApiRoutes = (apiOutput?.output ?? []).filter((o) =>
isMatching({ code: P.string, facadeModuleId: P.string }, o)
)
for (const route of buildInfoForWriting.manifest.apiRoutes) {
const compiledRoute = compiltedApiRoutes.find((compiled) => {
const flag = compiled.facadeModuleId.includes(route.file.replace('./', ''))
return flag
})
if (compiledRoute) {
postBuildLogs.push(
`[one.build][vercel] generating serverless function for apiRoute ${route.page}`
)
await createApiServerlessFunction(
route,
compiledRoute.code,
oneOptionsRoot,
postBuildLogs
)
} else {
console.warn(
'\n 🔨[one.build][vercel] apiRoute missing code compilation for',
route.file
)
}
}
}
const vercelOutputFunctionsDir = join(oneOptionsRoot, '.vercel/output/functions')
await ensureDir(vercelOutputFunctionsDir)
for (const route of buildInfoForWriting.manifest.pageRoutes) {
switch (route.type) {
case 'ssr': {
// Server Side Rendered
const builtPageRoute = routeToBuildInfo[route.file]
if (builtPageRoute) {
postBuildLogs.push(
`[one.build][vercel] generate serverless function for ${route.page} with ${route.type}`
)
await createSsrServerlessFunction(
route,
buildInfoForWriting,
oneOptionsRoot,
postBuildLogs
)
}
break
}
default:
// no-op, these will be copied from built dist/client into .vercel/output/static
// postBuildLogs.push(`[one.build][vercel] pageRoute will be copied to .vercel/output/static for ${route.page} with ${route.type}`)
break
}
}
const distMiddlewareDir = resolve(join(oneOptionsRoot, 'dist', 'middlewares'))
if (existsSync(distMiddlewareDir)) {
const vercelMiddlewareDir = resolve(
join(oneOptionsRoot, '.vercel/output/functions/_middleware.func')
)
await ensureDir(vercelMiddlewareDir)
postBuildLogs.push(
`[one.build][vercel] copying middlewares from ${distMiddlewareDir} to ${vercelMiddlewareDir}`
)
await moveAllFiles(
resolve(join(oneOptionsRoot, 'dist', 'middlewares')),
vercelMiddlewareDir
)
const vercelMiddlewarePackageJsonFilePath = resolve(
join(vercelMiddlewareDir, 'package.json')
)
postBuildLogs.push(
`[one.build][vercel] writing package.json to ${vercelMiddlewarePackageJsonFilePath}`
)
await writeJSON(vercelMiddlewarePackageJsonFilePath, serverlessVercelPackageJson)
const wrappedMiddlewareEntryPointFilename = '_wrapped_middleware.js'
const wrappedMiddlewareEntryPointPath = resolve(
join(vercelMiddlewareDir, wrappedMiddlewareEntryPointFilename)
)
const middlewaresByNamedRegex = getMiddlewaresByNamedRegex(buildInfoForWriting)
const middlewaresToVariableNameMap = middlewaresByNamedRegex.reduce(
(acc, [namedRegex, middlewares]) => {
;(Array.isArray(middlewares) ? middlewares : [middlewares]).forEach(
(middleware) => {
const middlewareVariableName = middleware
.replace(/\.[a-z]+$/, '')
.replaceAll('/', '_')
acc[middleware] = middlewareVariableName
}
)
return acc
},
{}
)
await FSExtra.writeFile(
wrappedMiddlewareEntryPointPath,
`
const middlewaresByNamedRegex = ${JSON.stringify(middlewaresByNamedRegex)}
${Object.entries(middlewaresToVariableNameMap)
.map(([path, variableName]) => `import ${variableName} from './${path}'`)
.join('\n')}
function getMiddleware(path) {
switch (path){
${Object.entries(middlewaresToVariableNameMap)
.map(([path, variableName]) => `case '${path}': return ${variableName}`)
.join('\n')}
default: return null
}
}
const next = (e) => {
const t = new Headers(null == e ? void 0 : e.headers)
t.set('x-middleware-next', '1')
return new Response(null, { ...e, headers: t })
}
const wrappedMiddlewareFunction = (request, event) => {
const url = new URL(request.url)
const pathname = url.pathname
// Find matching middlewares for this request
const matchingMiddlewares = middlewaresByNamedRegex
.filter(([namedRegex]) => new RegExp(namedRegex).test(pathname))
.reduce((prev, current) => prev.length > current[1]?.length ? prev : current[1], []);
// Import and execute the middleware function
const boundNext = () => {
if (matchingMiddlewares.length === 0) {
return next(request)
}
const middleware = getMiddleware(matchingMiddlewares.shift())
return middleware ? middleware({request, event, next: boundNext}) : next(request)
};
return boundNext()
}
export { wrappedMiddlewareFunction as default }
`
)
const middlewareVercelConfigFilePath = resolve(
join(vercelMiddlewareDir, '.vc-config.json')
)
postBuildLogs.push(
`[one.build][vercel] writing .vc-config.json to ${middlewareVercelConfigFilePath}`
)
await writeJSON(middlewareVercelConfigFilePath, {
runtime: 'edge', // Seems that middlewares only work with edge runtime
entrypoint: wrappedMiddlewareEntryPointFilename,
})
}
const vercelOutputStaticDir = resolve(join(oneOptionsRoot, '.vercel/output/static'))
await ensureDir(vercelOutputStaticDir)
postBuildLogs.push(
`[one.build][vercel] copying static files from ${clientDir} to ${vercelOutputStaticDir}`
)
await moveAllFiles(clientDir, vercelOutputStaticDir)
// Documentation - Vercel Build Output v3 config.json
// https://vercel.com/docs/build-output-api/v3/configuration#config.json-supported-properties
// Generate loader routes for SSR pages
// These intercept /assets/*_vxrn_loader.js requests and route to the SSR function
const ssrLoaderRoutes = buildInfoForWriting.manifest.pageRoutes
.filter((r) => r.type === 'ssr')
.map((r) => {
const pagePath = getPathFromRoute(r) || '/'
// Convert page path to loader asset pattern
// /ssr-page -> ssr-page
// /dynamic/:id -> dynamic_:id (getPathFromRoute converts [id] to :id)
const cleanPath = pagePath.slice(1).replace(/\//g, '_')
// Build regex pattern for the loader asset
// Replace :param with capture groups for dynamic segments
// The loader URL pattern uses the actual param value, not :param
// e.g., /dynamic/123 -> /assets/dynamic_123_12345_vxrn_loader.js
const loaderPattern = cleanPath.replace(/:([^_]+)/g, '(?<$1>[^_]+)')
// Match the loader file pattern: {path}_{cacheKey}_vxrn_loader.js
// Also handle _refetch_ pattern for cache busting
const src = `^/assets/${loaderPattern}(?:_refetch_\\d+)?_\\d+_vxrn_loader\\.js$`
// Build destination with captured params
let dest = `${pagePath}?__loader=1`
const paramMatches = cleanPath.match(/:([^_]+)/g)
if (paramMatches) {
for (const match of paramMatches) {
const paramName = match.slice(1) // remove leading :
dest += `&${paramName}=$${paramName}`
}
}
return { src, dest }
})
const vercelConfigFilePath = resolve(
join(oneOptionsRoot, '.vercel/output', 'config.json')
)
await writeJSON(vercelConfigFilePath, {
...vercelBuildOutputConfigBase,
routes: [
...vercelBuildOutputConfigBase.routes,
...(existsSync(distMiddlewareDir)
? [
{
src: '/(.*)',
middlewarePath: '_middleware',
continue: true,
},
]
: []),
{
handle: 'rewrite',
},
// SSR loader routes must come before dynamic page routes
...ssrLoaderRoutes,
...buildInfoForWriting.manifest.allRoutes
.filter((r) => r.routeKeys && Object.keys(r.routeKeys).length > 0)
.map((r) => ({
src: r.namedRegex,
dest: `${getPathFromRoute(r) || '/'}?${Object.entries(r.routeKeys)
.map(([k, v]) => `${k}=$${v}`)
.join('&')}`,
})),
],
})
postBuildLogs.push(`[one.build] wrote vercel config to: ${vercelConfigFilePath}`)
}