write Deno web servers with Vite
README.md

@mary/dromi #

JSR | source code

write Deno web servers with Vite.

deno add jsr:@mary/dromi npm:@deno/vite-plugin npm:vite

usage #

setting up Vite #

add the plugin to your Vite config, optionally configuring deno() first so it can resolve your Deno imports:

// vite.config.ts
import { defineConfig } from 'vite';

import deno from '@deno/vite-plugin';
import { dromi } from '@mary/dromi';

export default defineConfig({
	plugins: [
		deno(),
		dromi({ entry: './server.ts' }),
	],
});

then add these tasks to your deno.json:

// deno.json
{
	"tasks": {
		"dev": "vite --configLoader native",
		"build": "vite build --configLoader native",
		"start": "deno serve -A dist/server/index.mjs"
	}
}

handling requests #

export an object with a fetch method, just as you would for deno serve:

// server.ts
export default {
	fetch(request) {
		const url = new URL(request.url);

		switch (url.pathname) {
			case '/': {
				return new Response(`hello from Deno!`);
			}
			case '/api/version': {
				return Response.json({ deno: Deno.version.deno });
			}
		}

		return new Response(`not found`, { status: 404 });
	},
} satisfies Deno.ServeDefaultExport;

building for production #

deno task build
deno task start

the build outputs static assets to dist/client/ and the server to dist/server/index.mjs.

environment variables #

in development, all .env variables are available in Deno.env, following Vite's env file rules. for production, load them at runtime:

deno serve -A --env-file=.env --env-file=.env.production dist/server/index.mjs

later --env-file arguments take precedence; existing environment variables override the files.

type checking #

there are two approaches you could take to get Deno to type-check both server-side and client-side code correctly, either by using tsconfig references or Deno's workspaces functionality:

tsconfig references #

Deno can read tsconfig.json project references.

// tsconfig.json
{
	"files": [],
	"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.server.json" }]
}
// tsconfig.app.json
{
	"compilerOptions": {
		"lib": ["dom", "esnext"],
		"types": ["vite/client"]
	},
	"include": ["src"]
}
// tsconfig.server.json
{
	"compilerOptions": {
		"lib": ["deno.window"],
		"types": ["vite/client"]
	},
	"include": ["server.ts"]
}

be sure to omit compilerOptions from deno.json as even an empty object makes Deno skip tsconfig.json.

workspace member #

put client code in a separate folder with a deno.json. list the folder in workspace so Deno reads its config:

// deno.json
{
	"workspace": ["./client"],
	"compilerOptions": {
		"types": ["vite/client"]
	}
}
// client/deno.json
{
	"compilerOptions": {
		"lib": ["dom", "esnext"],
		"types": ["vite/client"]
	}
}

static assets and client apps #

files in Vite's public/ directory are served as-is. to add a client app, put an index.html in the Vite root and configure its client plugins.

matching assets are served before your handler; unmatched requests go to your handler. /about/ serves /about/index.html.

running the server before assets #

use runServerFirst when your handler needs to see a request even if an asset exists at that path:

dromi({
	entry: './server.ts',
	runServerFirst: ['/api/*', '!/api/public/*'],
});

here, /api/* goes straight to the server, while /api/public/* is served from assets first. set runServerFirst: true to send every request to the server first.

fetching assets from a handler #

fetchAsset() serves an asset from within your handler, useful for falling back to assets or modifying an asset response:

// server.ts, with runServerFirst: true
import { fetchAsset } from '@mary/dromi/server';

export default {
	async fetch(request) {
		const response = await fetchAsset(request);
		const headers = new Headers(response.headers);
		headers.set('x-served-by', 'my-server');

		return new Response(response.body, {
			status: response.status,
			headers,
		});
	},
} satisfies Deno.ServeDefaultExport;

when no asset matches, it uses notFoundHandling, which defaults to a plain 404.

handling unmatched requests #

use notFoundHandling to serve a client app or custom 404 page when no asset matches.

SPA fallback #

with 'single-page-application', page navigations that match no asset are served /index.html without running your handler:

dromi({
	entry: './server.ts',
	notFoundHandling: 'single-page-application',
});

page navigations have Sec-Fetch-Mode: navigate. other requests, such as fetch('/api/users'), still go to your handler. for unmatched GET and HEAD requests, fetchAsset() serves /index.html.

requests matching runServerFirst always go to your handler.

custom 404 pages #

with '404-page', fetchAsset() serves the nearest 404.html with a 404 status for unmatched GET and HEAD requests. /blog/missing looks for /blog/404.html, then /404.html.

dromi({
	entry: './server.ts',
	notFoundHandling: '404-page',
});

unmatched requests still go to your handler. call fetchAsset() after your routes to serve the 404 page:

// server.ts
import { fetchAsset } from '@mary/dromi/server';

export default {
	fetch(request) {
		// ...your routes

		return fetchAsset(request);
	},
} satisfies Deno.ServeDefaultExport;

server-side rendering #

add client scripts to environments.client.build.rolldownOptions.input:

// vite.config.ts
export default defineConfig({
	environments: {
		client: {
			build: {
				rolldownOptions: {
					input: { main: './client/main.ts' },
				},
			},
		},
	},
	plugins: [
		deno(),
		dromi({ entry: './server.ts' }),
	],
});

use resolveClientEntry('main') to load that entry in server-rendered HTML:

// server.ts
import { resolveClientEntry } from '@mary/dromi/server';

export default {
	fetch(request) {
		const { entry, js, css } = resolveClientEntry('main');

		const html = `<!doctype html>
<html>
	<head>
		${css.map((href) => `<link rel="stylesheet" href="${href}">`).join('')}
		${js.map((href) => `<link rel="modulepreload" href="${href}">`).join('')}
		<script type="module" src="${entry}"></script>
	</head>
	<body>...</body>
</html>`;

		return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
	},
} satisfies Deno.ServeDefaultExport;

in development, Vite loads CSS through JavaScript, so pages may briefly render unstyled.

examples #

  • server-only — public assets, Deno imports, module-relative files, and 404 pages
  • client-app — a multi-page Vite client with server API routes and SPA fallback
  • server-first — route exclusions and fetchAsset()
  • server-rendering — server-rendered pages with resolveClientEntry()
  • external-deps — native Deno imports without @deno/vite-plugin