@benpsnyder/analogjs-esm-vite-plugin-nitro
Version:
A Vite plugin for adding a nitro API server
467 lines (458 loc) • 21.8 kB
JavaScript
import { build, createDevServer, createNitro } from 'nitropack';
import { toNodeListener } from 'h3';
import { mergeConfig, normalizePath } from 'vite';
import { dirname, join, relative, resolve } from 'node:path';
import { platform } from 'node:os';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { buildServer } from './build-server.js';
import { buildSSRApp } from './build-ssr.js';
import { pageEndpointsPlugin } from './plugins/page-endpoints.js';
import { getPageHandlers } from './utils/get-page-handlers.js';
import { buildSitemap } from './build-sitemap.js';
import { devServerPlugin } from './plugins/dev-server-plugin.js';
import { getMatchingContentFilesWithFrontMatter } from './utils/get-content-files.js';
const isWindows = platform() === 'win32';
const filePrefix = isWindows ? 'file:///' : '';
let clientOutputPath = '';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export function nitro(options, nitroOptions) {
const workspaceRoot = options?.workspaceRoot ?? process.cwd();
const sourceRoot = options?.sourceRoot ?? 'src';
let isTest = process.env['NODE_ENV'] === 'test' || !!process.env['VITEST'];
const baseURL = process.env['NITRO_APP_BASE_URL'] || '';
const prefix = baseURL ? baseURL.substring(0, baseURL.length - 1) : '';
const apiPrefix = `/${options?.apiPrefix || 'api'}`;
const useAPIMiddleware = typeof options?.useAPIMiddleware !== 'undefined'
? options?.useAPIMiddleware
: true;
let isBuild = false;
let isServe = false;
let ssrBuild = false;
let config;
let nitroConfig;
let environmentBuild = false;
let hasAPIDir = false;
let routeSitemaps = {};
return [
(options?.ssr
? devServerPlugin({
entryServer: options?.entryServer,
index: options?.index,
routeRules: nitroOptions?.routeRules,
})
: false),
{
name: '@benpsnyder/analogjs-esm-vite-plugin-nitro',
async config(userConfig, { mode, command }) {
isServe = command === 'serve';
isBuild = command === 'build';
ssrBuild = userConfig.build?.ssr === true;
config = userConfig;
isTest = isTest ? isTest : mode === 'test';
const rootDir = relative(workspaceRoot, config.root || '.') || '.';
hasAPIDir = existsSync(resolve(workspaceRoot, rootDir, `${sourceRoot}/server/routes/${options?.apiPrefix || 'api'}`));
const buildPreset = process.env['BUILD_PRESET'] ??
nitroOptions?.preset;
const pageHandlers = getPageHandlers({
workspaceRoot,
sourceRoot,
rootDir,
additionalPagesDirs: options?.additionalPagesDirs,
hasAPIDir,
});
const ssrEntryPath = resolve(options?.ssrBuildDir ||
resolve(workspaceRoot, 'dist', rootDir, `ssr`), `main.server${filePrefix ? '.js' : ''}`);
const ssrEntry = normalizePath(filePrefix + ssrEntryPath);
const rendererEntry = filePrefix +
normalizePath(join(__dirname, `runtime/renderer${!options?.ssr ? '-client' : ''}${filePrefix ? '.mjs' : ''}`));
nitroConfig = {
rootDir,
preset: buildPreset,
compatibilityDate: '2024-11-19',
logLevel: nitroOptions?.logLevel || 0,
srcDir: normalizePath(`${sourceRoot}/server`),
scanDirs: [
normalizePath(`${rootDir}/${sourceRoot}/server`),
...(options?.additionalAPIDirs || []).map((dir) => normalizePath(`${workspaceRoot}${dir}`)),
],
output: {
dir: normalizePath(resolve(workspaceRoot, 'dist', rootDir, 'analog')),
publicDir: normalizePath(resolve(workspaceRoot, 'dist', rootDir, 'analog/public')),
},
buildDir: normalizePath(resolve(workspaceRoot, 'dist', rootDir, '.nitro')),
typescript: {
generateTsConfig: false,
},
runtimeConfig: {
apiPrefix: apiPrefix.substring(1),
prefix,
},
// Fixes support for Rolldown
imports: {
autoImport: false,
},
rollupConfig: {
onwarn(warning) {
if (warning.message.includes('empty chunk') &&
warning.message.endsWith('.server')) {
return;
}
},
plugins: [pageEndpointsPlugin()],
},
handlers: [
...(hasAPIDir
? []
: useAPIMiddleware
? [
{
handler: '#ANALOG_API_MIDDLEWARE',
middleware: true,
},
]
: []),
...pageHandlers,
],
routeRules: hasAPIDir
? undefined
: useAPIMiddleware
? undefined
: {
[`${prefix}${apiPrefix}/**`]: {
proxy: { to: '/**' },
},
},
virtual: hasAPIDir
? undefined
: {
'#ANALOG_API_MIDDLEWARE': `
import { eventHandler, proxyRequest } from 'h3';
import { useRuntimeConfig } from '#imports';
export default eventHandler(async (event) => {
const prefix = useRuntimeConfig().prefix;
const apiPrefix = \`\${prefix}/\${useRuntimeConfig().apiPrefix}\`;
if (event.node.req.url?.startsWith(apiPrefix)) {
const reqUrl = event.node.req.url?.replace(apiPrefix, '');
if (
event.node.req.method === 'GET' &&
// in the case of XML routes, we want to proxy the request so that nitro gets the correct headers
// and can render the XML correctly as a static asset
!event.node.req.url?.endsWith('.xml')
) {
return $fetch(reqUrl, { headers: event.node.req.headers });
}
return proxyRequest(event, reqUrl, {
// @ts-ignore
fetch: $fetch.native,
});
}
});`,
},
};
if (isVercelPreset(buildPreset)) {
nitroConfig = withVercelOutputAPI(nitroConfig, workspaceRoot);
}
if (isCloudflarePreset(buildPreset)) {
nitroConfig = withCloudflareOutput(nitroConfig);
}
if (isFirebaseAppHosting()) {
nitroConfig = withAppHostingOutput(nitroConfig);
}
if (!ssrBuild && !isTest) {
// store the client output path for the SSR build config
clientOutputPath = resolve(workspaceRoot, rootDir, config.build?.outDir || 'dist/client');
}
const indexEntry = normalizePath(resolve(clientOutputPath, 'index.html'));
nitroConfig.alias = {
'#analog/ssr': ssrEntry,
'#analog/index': indexEntry,
};
if (isBuild) {
nitroConfig.publicAssets = [{ dir: clientOutputPath }];
nitroConfig.renderer = rendererEntry;
if (isEmptyPrerenderRoutes(options)) {
nitroConfig.prerender = {};
nitroConfig.prerender.routes = ['/'];
}
if (options?.prerender) {
nitroConfig.prerender = nitroConfig.prerender ?? {};
nitroConfig.prerender.crawlLinks = options?.prerender?.discover;
let routes = [];
const prerenderRoutes = options?.prerender?.routes;
if (isArrayWithElements(prerenderRoutes)) {
routes = prerenderRoutes;
}
else if (typeof prerenderRoutes === 'function') {
routes = await prerenderRoutes();
}
nitroConfig.prerender.routes = routes.reduce((prev, current) => {
if (!current) {
return prev;
}
if (typeof current === 'string') {
prev.push(current);
return prev;
}
if ('route' in current) {
if (current.sitemap) {
routeSitemaps[current.route] = current.sitemap;
}
prev.push(current.route);
// Add the server-side data fetching endpoint URL
if ('staticData' in current) {
prev.push(`${apiPrefix}/_analog/pages/${current.route}`);
}
return prev;
}
const affectedFiles = getMatchingContentFilesWithFrontMatter(workspaceRoot, rootDir, current.contentDir);
affectedFiles.forEach((f) => {
const result = current.transform(f);
if (result) {
if (current.sitemap) {
routeSitemaps[result] =
current.sitemap && typeof current.sitemap === 'function'
? current.sitemap?.(f)
: current.sitemap;
}
prev.push(result);
// Add the server-side data fetching endpoint URL
if ('staticData' in current) {
prev.push(`${apiPrefix}/_analog/pages/${result}`);
}
}
});
return prev;
}, []);
}
if (ssrBuild) {
if (isWindows) {
const indexContents = readFileSync(normalizePath(join(clientOutputPath, 'index.html')), 'utf-8');
// Write out the renderer manually because
// Windows doesn't resolve the aliases
// correctly in its native environment
writeFileSync(normalizePath(rendererEntry.replace(filePrefix, '')), `
/**
* This file is shipped as ESM for Windows support,
* as it won't resolve the renderer.ts file correctly in node.
*/
import { eventHandler, getResponseHeader } from 'h3';
// @ts-ignore
import renderer from '${ssrEntry}';
// @ts-ignore
const template = \`${indexContents}\`;
export default eventHandler(async (event) => {
const noSSR = getResponseHeader(event, 'x-analog-no-ssr');
if (noSSR === 'true') {
return template;
}
const html = await renderer(event.node.req.url, template, {
req: event.node.req,
res: event.node.res,
});
return html;
});
`);
nitroConfig.externals = {
inline: ['std-env'],
};
}
nitroConfig = {
...nitroConfig,
externals: {
...nitroConfig.externals,
external: ['rxjs', 'node-fetch-native/dist/polyfill'],
},
moduleSideEffects: ['zone.js/node', 'zone.js/fesm2015/zone-node'],
handlers: [
...(hasAPIDir
? []
: useAPIMiddleware
? [
{
handler: '#ANALOG_API_MIDDLEWARE',
middleware: true,
},
]
: []),
...pageHandlers,
],
};
}
}
nitroConfig = mergeConfig(nitroConfig, nitroOptions);
return {
environments: {
client: {
build: {
outDir: config?.build?.outDir ||
resolve(workspaceRoot, 'dist', rootDir, 'client'),
},
},
ssr: {
build: {
ssr: true,
rollupOptions: {
input: options?.entryServer ||
resolve(workspaceRoot, rootDir, `${sourceRoot}/main.server.ts`),
},
outDir: options?.ssrBuildDir ||
resolve(workspaceRoot, 'dist', rootDir, 'ssr'),
},
},
},
builder: {
sharedPlugins: true,
buildApp: async (builder) => {
environmentBuild = true;
const builds = [builder.build(builder.environments['client'])];
if (options?.ssr || nitroConfig.prerender?.routes?.length) {
builds.push(builder.build(builder.environments['ssr']));
}
await Promise.all(builds);
await buildServer(options, nitroConfig);
if (nitroConfig.prerender?.routes?.length &&
options?.prerender?.sitemap) {
console.log('Building Sitemap...');
// sitemap needs to be built after all directories are built
await buildSitemap(config, options.prerender.sitemap, nitroConfig.prerender.routes, nitroConfig.output?.publicDir, routeSitemaps);
}
console.log(`\n\nThe '@benpsnyder/analogjs-esm-platform' server has been successfully built.`);
},
},
};
},
async configureServer(viteServer) {
if (isServe && !isTest) {
const nitro = await createNitro({
dev: true,
...nitroConfig,
});
const server = createDevServer(nitro);
await build(nitro);
const apiHandler = toNodeListener(server.app);
if (hasAPIDir) {
viteServer.middlewares.use((req, res, next) => {
if (req.url?.startsWith(`${prefix}${apiPrefix}`)) {
apiHandler(req, res);
return;
}
next();
});
}
else {
viteServer.middlewares.use(apiPrefix, apiHandler);
}
viteServer.httpServer?.once('listening', () => {
process.env['ANALOG_HOST'] = !viteServer.config.server.host
? 'localhost'
: viteServer.config.server.host;
process.env['ANALOG_PORT'] = `${viteServer.config.server.port}`;
});
// handle upgrades if websockets are enabled
if (nitroOptions?.experimental?.websocket) {
viteServer.httpServer?.on('upgrade', server.upgrade);
}
console.log(`\n\nThe server endpoints are accessible under the "${prefix}${apiPrefix}" path.`);
}
},
async closeBundle() {
// Skip when build is triggered by the Environment API
if (environmentBuild) {
return;
}
if (ssrBuild) {
return;
}
if (isBuild) {
if (options?.ssr) {
console.log('Building SSR application...');
await buildSSRApp(config, options);
}
if (nitroConfig.prerender?.routes?.length &&
options?.prerender?.sitemap) {
console.log('Building Sitemap...');
// sitemap needs to be built after all directories are built
await buildSitemap(config, options.prerender.sitemap, nitroConfig.prerender.routes, clientOutputPath, routeSitemaps);
}
await buildServer(options, nitroConfig);
console.log(`\n\nThe '@benpsnyder/analogjs-esm-platform' server has been successfully built.`);
}
},
},
{
name: '@benpsnyder/analogjs-esm-vite-plugin-nitro-api-prefix',
config() {
return {
define: {
ANALOG_API_PREFIX: `"${baseURL.substring(1)}${apiPrefix.substring(1)}"`,
},
};
},
},
];
}
function isEmptyPrerenderRoutes(options) {
if (!options || isArrayWithElements(options?.prerender?.routes)) {
return false;
}
return !options.prerender?.routes;
}
function isArrayWithElements(arr) {
return !!(Array.isArray(arr) && arr.length);
}
const isVercelPreset = (buildPreset) => process.env['VERCEL'] ||
(buildPreset && buildPreset.toLowerCase().includes('vercel'));
const withVercelOutputAPI = (nitroConfig, workspaceRoot) => ({
...nitroConfig,
output: {
...nitroConfig?.output,
dir: normalizePath(resolve(workspaceRoot, '.vercel', 'output')),
publicDir: normalizePath(resolve(workspaceRoot, '.vercel', 'output/static')),
},
});
const isCloudflarePreset = (buildPreset) => process.env['CF_PAGES'] ||
(buildPreset && buildPreset.toLowerCase().includes('cloudflare-pages'));
const withCloudflareOutput = (nitroConfig) => ({
...nitroConfig,
output: {
...nitroConfig?.output,
serverDir: '{{ output.publicDir }}/_worker.js',
},
});
const isFirebaseAppHosting = () => !!process.env['NG_BUILD_LOGS_JSON'];
const withAppHostingOutput = (nitroConfig) => {
let hasOutput = false;
return {
...nitroConfig,
serveStatic: true,
rollupConfig: {
...nitroConfig.rollupConfig,
output: {
...nitroConfig.rollupConfig?.output,
entryFileNames: 'server.mjs',
},
},
hooks: {
...nitroConfig.hooks,
compiled: () => {
if (!hasOutput) {
const buildOutput = {
errors: [],
warnings: [],
outputPaths: {
root: pathToFileURL(`${nitroConfig.output?.dir}`),
browser: pathToFileURL(`${nitroConfig.output?.publicDir}`),
server: pathToFileURL(`${nitroConfig.output?.dir}/server`),
},
};
// Log the build output for Firebase App Hosting to pick up
console.log(JSON.stringify(buildOutput, null, 2));
hasOutput = true;
}
},
},
};
};
//# sourceMappingURL=vite-plugin-nitro.js.map