Something went wrong. Try again.
write Deno web servers with Vite
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453import { 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 renderChunkconst 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<string>(); let clientEntries: ClientEntries = {}; let devClientEntries: ClientEntries = {}; let devEntryFiles = new Set<string>();
// 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<string, string> = { // 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 prototypeconst 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'));};