Something went wrong. Try again.
write Deno web servers with Vite
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469/** * internal runtime for generated server builds. * * @module */
import type { AssetEntry, AssetManifest, ClientEntries, ClientEntry } from './manifest.ts';import { decodePathname, matchRouteRules, type RouteRules } from './routing.ts';
/** * static assets in the client build output * * @ignore */export interface AssetDirectory { /** assets by URL pathname */ entries: Map<string, AssetEntry>; /** client build output directory */ root: string;}
/** * serves a fallback for a GET or HEAD request that matches no asset. * * @param request unmatched request * @param pathname decoded URL pathname * @returns fallback response, or `undefined` if no fallback matches * @ignore */export type AssetFallback = (request: Request, pathname: string) => Promise<Response> | undefined;
/** * options for {@link serveAssetsFirst} * * @ignore */export interface ServeAssetsFirstOptions { /** fallback for page navigations that match no asset, served without running the handler */ navigationFallback?: AssetFallback; /** checks whether a decoded URL pathname should go to the server before static assets */ runServerFirst?: (pathname: string) => boolean;}
/** byte range of an asset, both ends inclusive */interface ByteRange { start: number; end: number;}
// #region public
/** * creates a matcher for server-first pathnames. * * @param rules compiled `runServerFirst` rules * @returns matcher accepting a decoded URL pathname * @ignore */export const createRouteMatcher = (rules: RouteRules): (pathname: string) => boolean => { return (pathname) => matchRouteRules(rules, pathname);};
/** * creates an asset directory from a build manifest. * * @param root client build output directory * @param manifest assets by URL pathname * @returns asset directory * @ignore */export const createAssetDirectory = (root: string, manifest: AssetManifest): AssetDirectory => { return { entries: new Map(Object.entries(manifest)), root };};
/** * creates an SPA index fallback for pathnames under `prefix`. * * @param assets asset directory * @param prefix Vite's asset pathname prefix, ending with `/` * @returns fallback, or `undefined` if the index page doesn't exist * @ignore */export const createSinglePageFallback = ( assets: AssetDirectory, prefix: string,): AssetFallback | undefined => { const entry = assets.entries.get(prefix + 'index.html'); if (!entry) { return undefined; }
return (request, pathname) => { return pathname.startsWith(prefix) ? serveFile(assets, request, entry) : undefined; };};
/** * creates a fallback that serves the nearest `404.html` with a 404 status. * * @param assets asset directory * @returns fallback, or `undefined` if there are no 404 pages * @ignore */export const createNotFoundPageFallback = (assets: AssetDirectory): AssetFallback | undefined => { const pages: { dir: string; entry: AssetEntry }[] = []; for (const [pathname, entry] of assets.entries) { if (pathname.endsWith('/404.html')) { pages.push({ dir: pathname.slice(0, -'404.html'.length), entry }); } }
if (pages.length === 0) { return undefined; }
// longest prefix first so lookup finds the nearest 404 page pages.sort((a, b) => b.dir.length - a.dir.length);
return (request, pathname) => { const page = pages.find(({ dir }) => pathname.startsWith(dir)); return page && serveNotFoundPage(assets, request, page.entry); };};
/** * creates an asset handler for the client build output. * * @param assets asset directory * @param fallback fallback for GET and HEAD requests that match no asset * @returns asset handler, returning 404 when no asset or fallback matches * @ignore */export const createFetchAsset = ( assets: AssetDirectory, fallback: AssetFallback | undefined,): (request: Request) => Promise<Response> => { return async (request) => { const url = new URL(request.url); const pathname = decodePathname(url); if (pathname === undefined) { return notFound(); }
if (!isGetOrHead(request)) { if (!findAsset(assets, pathname)) { return notFound(); }
return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } }); }
let response = serveAsset(assets, request, url, pathname); if (!response && acceptsHtml(request)) { response = fallback?.(request, pathname); }
return await response ?? notFound(); };};
/** * creates an asset proxy that preserves paths, queries, and redirects. * * @param origin asset server origin * @returns asset request handler * @ignore */export const createAssetProxy = (origin: string): (request: Request) => Promise<Response> => { return (request) => { const url = new URL(request.url);
const headers = new Headers(request.headers); // let fetch set the local host expected by Vite's host check headers.delete('host');
// concatenation keeps //path from being resolved as another host return fetch(origin + url.pathname + url.search, { method: request.method, headers, body: request.body, redirect: 'manual', }); };};
/** * creates a client entry lookup. * * @param entries client entries by name * @returns lookup that throws for unknown names * @ignore */export const createClientEntryResolver = (entries: ClientEntries): (name: string) => ClientEntry => { return (name) => { if (!Object.hasOwn(entries, name)) { throw new Error(`unknown client entry ${JSON.stringify(name)}`); }
return entries[name]; };};
/** * serves static assets ahead of a server entrypoint. * * @param handler server entrypoint's default export * @param assets asset directory * @param options routing options * @returns handler to export for `deno serve` * @ignore */export const serveAssetsFirst = ( handler: Deno.ServeDefaultExport, assets: AssetDirectory, { navigationFallback, runServerFirst }: ServeAssetsFirstOptions,): Deno.ServeDefaultExport => { return { ...handler, fetch(request, info) { if (isGetOrHead(request)) { const url = new URL(request.url); const pathname = decodePathname(url);
if (pathname !== undefined && !runServerFirst?.(pathname)) { const response = serveAsset(assets, request, url, pathname) ?? (navigationFallback && isNavigation(request) ? navigationFallback(request, pathname) : undefined); if (response) { return response; } } }
return handler.fetch(request, info); }, };};
// #endregion
// #region serving
/** returns the asset at a decoded pathname, resolving directory indexes */const findAsset = (assets: AssetDirectory, pathname: string): AssetEntry | undefined => { return assets.entries.get(pathname.endsWith('/') ? `${pathname}index.html` : pathname);};
/** serves the asset at a GET or HEAD request's decoded pathname, if any */const serveAsset = ( assets: AssetDirectory, request: Request, url: URL, pathname: string,): Promise<Response> | undefined => { const entry = findAsset(assets, pathname); if (entry) { return serveFile(assets, request, entry); }
// directory indexes need a trailing slash for relative URLs to resolve correctly if (!pathname.endsWith('/') && findAsset(assets, `${pathname}/`)) { const location = `${url.pathname}/${url.search}`; return Promise.resolve(new Response(null, { status: 307, headers: { location } })); }
return undefined;};
const serveFile = async (assets: AssetDirectory, request: Request, entry: AssetEntry): Promise<Response> => { const headers = createAssetHeaders(entry); headers.set('accept-ranges', 'bytes'); headers.set('etag', entry.etag);
if (matchesEtag(request.headers.get('if-none-match'), entry.etag)) { return new Response(null, { status: 304, headers }); }
// only GET supports ranges; stale If-Range requests receive the full asset let range: ByteRange | null | undefined; if (request.method === 'GET' && isRangeFresh(request.headers.get('if-range'), entry.etag)) { range = parseRange(request.headers.get('range'), entry.size); }
if (range === null) { headers.set('content-range', `bytes */${entry.size}`); return new Response(null, { status: 416, headers }); }
const status = range ? 206 : 200; const length = range ? range.end - range.start + 1 : entry.size;
headers.set('content-length', String(length)); if (range) { headers.set('content-range', `bytes ${range.start}-${range.end}/${entry.size}`); }
if (request.method === 'HEAD') { return new Response(null, { status, headers }); }
const file = await openFile(assets, entry); if (!file) { return notFound(); }
let body = file.readable; if (range) { if (range.start > 0) { try { await file.seek(range.start, Deno.SeekMode.Start); } catch (error) { file.close();
throw error; } }
if (range.end < entry.size - 1) { body = body.pipeThrough(limitBytes(length)); } }
return new Response(body, { status, headers });};
const serveNotFoundPage = async ( assets: AssetDirectory, request: Request, entry: AssetEntry,): Promise<Response> => { // preserve the 404 status even for conditional and range requests const headers = createAssetHeaders(entry); headers.set('content-length', String(entry.size));
if (request.method === 'HEAD') { return new Response(null, { status: 404, headers }); }
const file = await openFile(assets, entry); if (!file) { return notFound(); }
return new Response(file.readable, { status: 404, headers });};
/** opens an asset, or returns `undefined` if it was removed after the build */const openFile = async (assets: AssetDirectory, entry: AssetEntry): Promise<Deno.FsFile | undefined> => { try { return await Deno.open(`${assets.root}/${entry.file}`); } catch (error) { if (error instanceof Deno.errors.NotFound) { return undefined; }
throw error; }};
// #endregion
// #region helpers
const isGetOrHead = (request: Request): boolean => { return request.method === 'GET' || request.method === 'HEAD';};
const createAssetHeaders = (entry: AssetEntry): Headers => { return new Headers({ 'cache-control': entry.immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate', 'content-type': entry.contentType, });};
const acceptsHtml = (request: Request): boolean => { // match Vite: never return HTML for script requests return request.headers.get('sec-fetch-dest') !== 'script';};
const isNavigation = (request: Request): boolean => { return request.headers.get('sec-fetch-mode') === 'navigate';};
const notFound = (): Response => { return new Response('not found', { status: 404 });};
const matchesEtag = (ifNoneMatch: string | null, etag: string): boolean => { if (ifNoneMatch === null) { return false; } if (ifNoneMatch.trim() === '*') { return true; }
// If-None-Match uses weak comparison return ifNoneMatch.split(',').some((tag) => tag.trim().replace(/^W\//, '') === etag);};
/** checks whether a `Range` header may be honored, given the request's `If-Range` */const isRangeFresh = (ifRange: string | null, etag: string): boolean => { // If-Range requires a strong match; dates cannot match without Last-Modified return ifRange === null || ifRange.trim() === etag;};
/** * parses a `Range` header. * * @param header `Range` header value * @param size asset size in bytes * @returns the requested byte range, `undefined` when the whole asset should be served instead, or `null` if * the range can't be satisfied */const parseRange = (header: string | null, size: number): ByteRange | null | undefined => { // ignore malformed and multi-range headers; multipart responses are not supported const match = header?.match(/^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i); if (!match) { return undefined; }
const [, first, last] = match;
if (first === '') { if (last === '') { return undefined; }
// suffix range, the last N bytes const suffix = Number(last); if (suffix === 0 || size === 0) { return null; }
return { start: Math.max(size - suffix, 0), end: size - 1 }; }
const start = Number(first); const end = last === '' ? size - 1 : Math.min(Number(last), size - 1); if (last !== '' && Number(last) < start) { return undefined; } if (start >= size) { return null; }
return { start, end };};
/** creates a stream that passes the first `length` bytes through, then cancels its source */const limitBytes = (length: number): TransformStream<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>> => { let remaining = length;
return new TransformStream({ transform(chunk, controller) { if (chunk.byteLength < remaining) { remaining -= chunk.byteLength; controller.enqueue(chunk); return; }
controller.enqueue(chunk.subarray(0, remaining)); controller.terminate(); }, });};
// #endregion