/** * 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; /** 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 | 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 => { 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 => { 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 | 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 => { 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 => { // 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 => { 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> => { 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