// eslint-disable-next-line @typescript-eslint/triple-slash-reference
///
import type { Server } from 'bun';
import Negotiator from 'negotiator';
import path from 'node:path';
import type { MochiCompileErrorLog } from '../events';
import type { BunRouteValue } from '../types';
// Wraps `negotiator` to pin the call shape, so our own tests can guard the q-value behaviour.
export function negotiate(accept: string, types: string[]): string | undefined {
return new Negotiator({ headers: { accept } }).mediaType(types);
}
/**
* Convert a native filesystem path to forward-slash form. Windows backslashes corrupt when embedded in generated module
* source, where they're eaten as JS string escapes, and Bun's importer resolves `C:\dir` and `C:/dir` alike — so every
* path spliced into a generated `import`/`export` specifier goes through this.
*/
export function toPosixPath(p: string): string {
return p.replace(/\\/g, '/');
}
/** Cwd-relative path for logs, error messages, and reports, always forward-slash so user-facing output and the tests asserting on it match across platforms. */
export function relForDisplay(p: string): string {
return toPosixPath(path.relative(process.cwd(), p));
}
export type CompressionMethod = 'gzip' | 'brotli';
export const COMPRESSION_TOKEN: Record = { gzip: 'gzip', brotli: 'br' };
// `methods` is the server's allowlist (and tiebreak order for `*`); the client's
// header preference (order + q-values) decides among configured methods.
export function negotiateEncoding(acceptEncoding: string, methods: CompressionMethod[]): CompressionMethod | null {
const tokens = methods.map((m) => COMPRESSION_TOKEN[m]);
const best = new Negotiator({ headers: { 'accept-encoding': acceptEncoding } }).encodings(tokens)[0];
return methods.find((m) => COMPRESSION_TOKEN[m] === best) ?? null;
}
/** Create a JSON response. */
export function json(
data: unknown,
init?: {
status?: number;
statusText?: string;
headers?: Record;
},
): Response {
const body = JSON.stringify(data);
const headers: Record = {
...init?.headers,
'Content-Type': 'application/json',
};
return new Response(body, {
status: init?.status,
statusText: init?.statusText,
headers,
});
}
/** Throws an error the framework catches and turns into a JSON error response. */
export function error(status: number, message: string): never {
throw new MochiHttpError(status, message);
}
export class MochiHttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
/** Build a JSON error `Response` with the canonical Mochi envelope `{ error: { message, status } }`, for returning a typed error from `Mochi.api()` without throwing. */
export function apiError(status: number, message: string): Response {
return new Response(JSON.stringify({ error: { message, status } }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
// `getSetCookie()` keeps multiple `Set-Cookie` headers as separate entries rather than comma-merging them.
export function collectHeaderPairs(headers: Headers): Array<[string, string]> {
const out: Array<[string, string]> = [];
for (const [k, v] of headers) {
if (k.toLowerCase() === 'set-cookie') {
continue;
}
out.push([k, v]);
}
for (const c of headers.getSetCookie()) {
out.push(['set-cookie', c]);
}
return out;
}
export function isHtmlResponse(response: Response): boolean {
return response.headers.get('Content-Type')?.startsWith('text/html') ?? false;
}
export function appendVary(headers: Headers, value: string): void {
const existing = headers.get('Vary');
if (!existing) {
headers.set('Vary', value);
return;
}
if (existing.trim() === '*') {
return;
}
const tokens = existing.split(',').map((t) => t.trim().toLowerCase());
if (tokens.includes(value.toLowerCase())) {
return;
}
headers.set('Vary', `${existing}, ${value}`);
}
/** Extract Bun's route params from a Request object. */
export function extractParams(req: Request): Record {
return ((req as unknown as Record).params as Record) ?? {};
}
export const DEFAULT_ASSET_PREFIX = '/_mochi';
// Returns the default when undefined and throws on a missing leading slash, root `/`, trailing slash, whitespace, or `..` segment.
export function normalizeAssetPrefix(input: string | undefined): string {
if (input === undefined) {
return DEFAULT_ASSET_PREFIX;
}
if (typeof input !== 'string' || input.length === 0) {
throw new Error(`[mochi] assetPrefix must be a non-empty string, got ${JSON.stringify(input)}`);
}
if (!input.startsWith('/')) {
throw new Error(`[mochi] assetPrefix must start with "/", got ${JSON.stringify(input)}`);
}
if (input === '/') {
throw new Error(`[mochi] assetPrefix must not be the root "/" — pick a sub-path like "/_mochi"`);
}
if (input.endsWith('/')) {
throw new Error(`[mochi] assetPrefix must not end with "/", got ${JSON.stringify(input)}`);
}
if (/\s/.test(input)) {
throw new Error(`[mochi] assetPrefix must not contain whitespace, got ${JSON.stringify(input)}`);
}
if (input.split('/').includes('..')) {
throw new Error(`[mochi] assetPrefix must not contain ".." segments, got ${JSON.stringify(input)}`);
}
return input;
}
// Centralized so a future CSP `nonce`/`crossorigin` attribute lands in one place; the URL is an internal hashed asset
// path rather than user input, so it goes in unescaped.
export function cssLinkTag(url: string): string {
return ``;
}
/**
* Test whether an HTML comment's text is a Svelte SSR hydration marker. `text` must be the comment's inner bytes, with
* the surrounding `` already stripped by the caller, since the patterns below assume it.
*/
export function isSvelteMarker(text: string): boolean {
// Svelte emits:
// , block open/close (HYDRATION_START / HYDRATION_END)
// pending boundary (HYDRATION_START_ELSE)
// failed boundary (HYDRATION_START_FAILED)
// , , ... {#if} branch index (0 = consequent,
// N = nth :else if, -1 = final :else)
// , empty / component hash
return text === '[' || text === ']' || text === '[!' || text.startsWith('[?') || /^\[-?\d+$/.test(text) || /^\w*$/.test(text);
}
/**
* Collapse the doubled-marker pattern Svelte SSR emits for some component shapes (`$state` arrays + `{@attach}` — see
* `/demos/reload-form-data/`), where the inner `` writes two ``/`` pairs at the wrapper
* edges instead of one and the client `hydrate()` walker advances past only the first, then hits `HierarchyRequestError`.
*
* The doubled open and doubled close match as one unit so the regex fires only on the bug case: `{#if}`/`{:else}` blocks
* legitimately close with ``, but their second open marker is a branch index like ``.
*/
export function normalizeIslandHydrationMarkers(html: string): string {
return html.replace(/(]*>)(.*?)(<\/mochi-hydratable-island>)/gs, '$1$2$3');
}
/**
* Strip Svelte SSR hydration markers from HTML while preserving them inside `` and
* `` blocks, parsing through Bun's HTMLRewriter so element nesting is tracked properly.
*
* NOTE(bun<1.4.0): the obvious `el.onEndTag(() => islandDepth--)` depth counter leaks the request's `AsyncLocalStorage`
* frame — and with it the whole request — for the life of the process on Bun 1.3.x. Island-internal comments are flagged
* through an element-scoped `comments` handler instead, which lol-html invokes immediately before the document handler
* for the same comment. Revert to the depth counter once the minimum supported Bun is >= 1.4.0.
*/
export function stripHydrationMarkers(html: string): string {
let insideIsland = false;
const markInside = {
comments() {
insideIsland = true;
},
};
return new HTMLRewriter()
.on('mochi-hydratable-island', markInside)
.on('mochi-server-island', markInside)
.onDocument({
comments(comment) {
if (insideIsland) {
insideIsland = false;
return;
}
if (isSvelteMarker(comment.text)) {
comment.remove();
}
},
})
.transform(html);
}
// Position fields are elided when missing, keeping the `compile:error` payload minimal for handlers that only want `{ file, message }`.
export function toCompileErrorLogs(
logs: ReadonlyArray<{
message: string;
position?: { file: string; line: number; column: number } | null;
}>,
): MochiCompileErrorLog[] {
return logs.map((l) => {
const entry: MochiCompileErrorLog = { message: l.message };
const pos = l.position;
if (pos?.file) {
entry.file = pos.file;
}
if (typeof pos?.line === 'number') {
entry.line = pos.line;
}
if (typeof pos?.column === 'number') {
entry.column = pos.column;
}
return entry;
});
}
/**
* Body-less clone of a `Response` for answering HEAD. Finite bodies are buffered once so `Content-Length` matches what
* the equivalent GET would have sent; a streaming body (`text/event-stream`) would never end, so its length is left unset.
*/
export async function headResponse(res: Response): Promise {
const headers = new Headers(res.headers);
const isStream = (headers.get('content-type') ?? '').includes('text/event-stream');
if (!isStream && res.body) {
const buf = await res.arrayBuffer();
headers.set('Content-Length', String(buf.byteLength));
}
return new Response(null, { status: res.status, statusText: res.statusText, headers });
}
type RouteFn = (req: Request, server: Server) => Response | Promise;
/**
* Wrap a page/api `BunRouteValue` so HEAD reuses the GET handler's logic and returns no body. A bare function runs for
* every method with its result stripped only on HEAD; a method-keyed object gains a `HEAD` entry running `GET`, since Bun
* 405s an unlisted HEAD. `Response` and `BunFile` pass through untouched, as Bun serves their HEAD itself.
*/
export function withHead(value: BunRouteValue): BunRouteValue {
if (typeof value === 'function') {
const fn = value as RouteFn;
return async (req, server) => {
const res = await fn(req, server);
return req.method === 'HEAD' ? headResponse(res) : res;
};
}
if (value && typeof value === 'object' && !(value instanceof Response) && !(value instanceof Blob)) {
const rec = value as Record;
const get = rec.GET;
if (get && !rec.HEAD) {
return { ...rec, HEAD: async (req, server) => headResponse(await get(req, server)) };
}
}
return value;
}