import { cpSync, existsSync, rmSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { defaultExternalConditions, defaultServerConditions, normalizePath, type Plugin, type ResolvedConfig, } from 'vite'; import { createAssetManifest, getAssetPrefix } from './build/asset-manifest.ts'; import { createClientEntries } from './build/client-entries.ts'; import { importMetaPlugin } from './build/import-meta.ts'; import { denoSpecifiersPlugin } from './deno-specifiers.ts'; import { createDevClientEntries, getClientInputs } from './dev/client-entries.ts'; import { DenoDevEnvironment } from './dev/environment.ts'; import { createAssetBypassMiddleware, createDirectoryIndexMiddleware, createDispatchMiddleware, createHtmlMiddleware, createNavigationFallbackMiddleware, createNotFoundMiddleware, createServerFirstMiddleware, createUpgradeListener, } from './dev/middlewares.ts'; import { parseRouteRules, serializeRouteRules } from './route-rules.ts'; import type { AssetManifest, ClientEntries } from './runtime/manifest.ts'; import type { NotFoundHandling } from './runtime/routing.ts'; export type { NotFoundHandling }; const ENVIRONMENT_NAME = 'ssr'; const ENTRY_ID = 'virtual:deno-server/entry'; const RESOLVED_ENTRY_ID = '\0' + ENTRY_ID; // shared by the entry and `fetchAsset()` const ASSETS_ID = 'virtual:deno-server/assets'; const RESOLVED_ASSETS_ID = '\0' + ASSETS_ID; const SERVER_ID_RE = /^(?:jsr:)?@mary\/dromi(?:@[^/]+)?\/server$/; const RESOLVED_SERVER_ID = '\0virtual:deno-server/server'; const INTERNAL_SERVER_ID = '@mary/dromi/internal/server'; // chunk-relative paths aren't known until renderChunk const CLIENT_DIR_PLACEHOLDER = '__DENO_SERVER_CLIENT_DIR__'; /** options for {@link dromi} */ export interface DromiOptions { /** server entrypoint relative to the Vite root */ entry: string; /** * flags passed to `deno run` for the dev server process. * * @default ['-A'] */ args?: string[]; /** * fallback for GET and HEAD requests that match no asset: * * - `'none'`: requests go to the server entrypoint, and `fetchAsset()` returns a plain 404 * - `'single-page-application'`: page navigations (`Sec-Fetch-Mode: navigate`) are served `/index.html` * without running the server entrypoint, and `fetchAsset()` returns `/index.html` * - `'404-page'`: requests go to the server entrypoint; `fetchAsset()` serves the nearest `404.html` * with a 404 status * * requests matching `runServerFirst` always go to the server entrypoint. * * @default 'none' */ notFoundHandling?: NotFoundHandling; /** * pathname globs to send to the server before static assets, or `true` for every request. prefix a glob * with `!` to exclude it. * * @default false */ runServerFirst?: boolean | string[]; } /** * runs a server entrypoint in Deno during development and builds it into a module for `deno serve`. * * @param options plugin options * @returns Vite plugins * @throws when `runServerFirst` is invalid */ export const dromi = (options: DromiOptions): Plugin[] => { return [ ...createServerPlugins(options), importMetaPlugin(ENVIRONMENT_NAME), ...denoSpecifiersPlugin(ENVIRONMENT_NAME), ]; }; const createServerPlugins = (options: DromiOptions): Plugin[] => { const { args = ['-A'], notFoundHandling = 'none', runServerFirst = false } = options; // reject invalid rules during config loading const rules = parseRouteRules(runServerFirst); // files emitted under assetsDir are assumed to have content-hashed names const hashed = new Set(); let clientEntries: ClientEntries = {}; let devClientEntries: ClientEntries = {}; let devEntryFiles = new Set(); // avoid rescanning client output for each virtual module let manifest: AssetManifest | undefined; const getManifest = (config: ResolvedConfig): AssetManifest => { return manifest ??= createAssetManifest({ dir: getClientDir(config), base: config.base, hashed }); }; const serverPlugin: Plugin = { name: 'deno-server', // share the client build's hashed filenames with the server build sharedDuringBuild: true, config(userConfig) { // config hook results override user config; preserve explicit output directories const outDir = userConfig.build?.outDir ?? 'dist'; return { // leave unmatched requests to the server entrypoint appType: 'custom', // enable buildApp for `vite build` builder: {}, environments: { client: { build: { outDir: userConfig.environments?.client?.build?.outDir ?? `${outDir}/client`, }, }, [ENVIRONMENT_NAME]: { resolve: { conditions: ['deno', ...defaultServerConditions], externalConditions: ['deno', ...defaultExternalConditions], }, build: { outDir: userConfig.environments?.[ENVIRONMENT_NAME]?.build?.outDir ?? `${outDir}/server`, copyPublicDir: false, rolldownOptions: { input: { index: ENTRY_ID }, }, }, dev: { createEnvironment(name, config) { return new DenoDevEnvironment(name, config, { entry: resolve(config.root, options.entry), args, }); }, }, }, }, }; }, configEnvironment(name, config, env) { if (name !== ENVIRONMENT_NAME || env.command !== 'build') { return; } const define: Record = { // bundled imports must not inherit the entry's import.meta.main 'import.meta.main': 'false', }; // inline NODE_ENV for bundled dependencies: neither Vite's server build nor `deno serve` sets it // preserve an explicit define, since hook results override user config if (config.define?.['process.env.NODE_ENV'] === undefined) { define['process.env.NODE_ENV'] = JSON.stringify(process.env.NODE_ENV || env.mode); } return { define, resolve: { // bundle dependencies instead of requiring a colocated node_modules noExternal: true, }, }; }, configureServer(server) { const environment = server.environments[ENVIRONMENT_NAME]; if (!(environment instanceof DenoDevEnvironment)) { return; } const inputs = getClientInputs(server.config); devClientEntries = createDevClientEntries(inputs, server.config); devEntryFiles = new Set(inputs.values()); environment.connect(server.middlewares); // upgrade requests bypass the middleware stack server.httpServer?.on('upgrade', createUpgradeListener(server, environment)); if (rules) { server.middlewares.use(createServerFirstMiddleware(rules, environment)); } server.middlewares.use(createAssetBypassMiddleware(server, environment)); server.middlewares.use(createDirectoryIndexMiddleware(server)); return () => { server.middlewares.use(createHtmlMiddleware(server)); if (notFoundHandling === 'single-page-application') { server.middlewares.use(createNavigationFallbackMiddleware(server)); } server.middlewares.use(createDispatchMiddleware(environment)); if (notFoundHandling !== 'none') { server.middlewares.use(createNotFoundMiddleware(server, notFoundHandling)); } }; }, resolveId: { // ahead of resolvers that reject ids they don't recognize, like `@deno/vite-plugin` order: 'pre', filter: { id: [ new RegExp(`^${ENTRY_ID}$`), new RegExp(`^${ASSETS_ID}$`), new RegExp(`^${INTERNAL_SERVER_ID}$`), SERVER_ID_RE, ], }, async handler(id, importer) { switch (id) { case ENTRY_ID: { return RESOLVED_ENTRY_ID; } case ASSETS_ID: { return RESOLVED_ASSETS_ID; } case INTERNAL_SERVER_ID: { const url = import.meta.resolve('./runtime/server.ts'); if (url.startsWith('file:')) { return fileURLToPath(url); } // JSR installations need a remote-module resolver; otherwise Deno loads it at runtime const resolved = await this.resolve(id, importer, { skipSelf: true }); return resolved ?? { id: url, external: true }; } } // other environments keep the placeholder if (this.environment.name === ENVIRONMENT_NAME) { return RESOLVED_SERVER_ID; } }, }, load: { filter: { id: [ new RegExp(`^${RESOLVED_ENTRY_ID}$`), new RegExp(`^${RESOLVED_ASSETS_ID}$`), new RegExp(`^${RESOLVED_SERVER_ID}$`), ], }, async handler(id) { switch (id) { case RESOLVED_ENTRY_ID: { const entry = JSON.stringify(resolve(this.environment.config.root, options.entry)); // omit asset routing when all requests go straight to the handler if (runServerFirst === true || Object.keys(getManifest(this.environment.config)).length === 0) { return `export { default } from ${entry};\n`; } const routing = rules ? `navigationFallback, runServerFirst: createRouteMatcher(${serializeRouteRules(rules)})` : 'navigationFallback'; const imports = rules ? 'createRouteMatcher, serveAssetsFirst' : 'serveAssetsFirst'; return ` import { ${imports} } from ${JSON.stringify(INTERNAL_SERVER_ID)}; import { assets, navigationFallback } from ${JSON.stringify(ASSETS_ID)}; import handler from ${entry}; export default serveAssetsFirst(handler, assets, { ${routing} }); `; } case RESOLVED_ASSETS_ID: { const { config } = this.environment; // only SPA fallbacks bypass the handler for page navigations let fallback = 'undefined'; let navigationFallback = 'undefined'; switch (notFoundHandling) { case '404-page': { fallback = '/* @__PURE__ */ internal.createNotFoundPageFallback(assets)'; break; } case 'single-page-application': { const prefix = JSON.stringify(getAssetPrefix(config.base)); fallback = `/* @__PURE__ */ internal.createSinglePageFallback(assets, ${prefix})`; navigationFallback = 'fallback'; break; } } return ` import * as internal from ${JSON.stringify(INTERNAL_SERVER_ID)}; export const assets = /* @__PURE__ */ internal.createAssetDirectory( import.meta.dirname + '/' + ${JSON.stringify(CLIENT_DIR_PLACEHOLDER)}, ${JSON.stringify(getManifest(config))}, ); export const fallback = ${fallback}; export const navigationFallback = ${navigationFallback}; `; } case RESOLVED_SERVER_ID: { const environment = this.environment; if (environment.mode === 'build') { return ` import { createClientEntryResolver, createFetchAsset } from ${JSON.stringify(INTERNAL_SERVER_ID)}; import { assets, fallback } from ${JSON.stringify(ASSETS_ID)}; export const fetchAsset = /* @__PURE__ */ createFetchAsset(assets, fallback); export const resolveClientEntry = /* @__PURE__ */ createClientEntryResolver(${serializeJson(clientEntries)}); `; } if (!(environment instanceof DenoDevEnvironment)) { this.error(`@mary/dromi/server requires the ${ENVIRONMENT_NAME} environment to run in Deno`); } return ` import { createAssetProxy, createClientEntryResolver } from ${JSON.stringify(INTERNAL_SERVER_ID)}; export const fetchAsset = createAssetProxy(${JSON.stringify(await environment.getAssetsOrigin())}); export const resolveClientEntry = createClientEntryResolver(${serializeJson(devClientEntries)}); `; } } }, }, renderChunk(code, chunk) { if (this.environment.name !== ENVIRONMENT_NAME || !code.includes(CLIENT_DIR_PLACEHOLDER)) { return null; } const { config } = this.environment; const chunkDir = dirname(resolve(config.root, config.build.outDir, chunk.fileName)); const clientDir = normalizePath(relative(chunkDir, getClientDir(config))); // escape the path for the surrounding string literal return code.replaceAll(CLIENT_DIR_PLACEHOLDER, JSON.stringify(clientDir || '.').slice(1, -1)); }, buildStart() { switch (this.environment.name) { case 'client': { hashed.clear(); clientEntries = {}; break; } case ENVIRONMENT_NAME: { manifest = undefined; break; } } }, writeBundle(_options, bundle) { if (this.environment.name !== 'client') { return; } clientEntries = createClientEntries(bundle, this.environment.config.base); const { assetsDir } = this.environment.config.build; for (const fileName in bundle) { if (fileName.startsWith(`${assetsDir}/`)) { hashed.add(fileName); } } }, async buildApp(builder) { const client = builder.environments.client; if (hasClientInput(client.config)) { // build the client first so the server can inline its entry URLs await builder.build(client); } else { // server-only apps still need public assets const { build, publicDir, root } = client.config; const outDir = resolve(root, build.outDir); // match Vite's default: only empty output directories inside the root if (build.emptyOutDir ?? normalizePath(outDir).startsWith(`${root}/`)) { rmSync(outDir, { recursive: true, force: true }); } if (build.copyPublicDir && publicDir && existsSync(publicDir)) { // dereference symlinks so the asset manifest can index their targets cpSync(publicDir, outDir, { recursive: true, dereference: true }); } } await builder.build(builder.environments[ENVIRONMENT_NAME]); }, }; const clientEntryPlugin: Plugin = { name: 'deno-server:client-entries', apply: 'serve', applyToEnvironment(env) { return env.name === 'client'; }, transform: { filter: { id: { exclude: /^\0/ } }, handler(code, id) { if (!devEntryFiles.has(id)) { return; } // SSR bypasses Vite's HTML injection; import its HMR client here. // append to preserve source-map positions return { code: `${code}\nimport '/@vite/client';\n`, map: null }; }, }, }; return [serverPlugin, clientEntryPlugin]; }; // computed keys preserve `__proto__` as data instead of setting an object literal's prototype const serializeJson = (value: unknown): string => { return JSON.stringify(value).replace(/([,{])"__proto__":/g, '$1["__proto__"]:'); }; const getClientDir = ({ environments, root }: ResolvedConfig): string => { return resolve(root, environments.client.build.outDir); }; const hasClientInput = (config: { root: string; build: { rolldownOptions: { input?: unknown } } }) => { return config.build.rolldownOptions.input !== undefined || existsSync(join(config.root, 'index.html')); };