Skip to content
41 changes: 41 additions & 0 deletions src/utils/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@ import type { H3Event } from "../event.ts";
import { HTTPError } from "../error.ts";
import { withLeadingSlash, withoutTrailingSlash } from "./internal/path.ts";

const COMMON_MIME_TYPES: Record<string, string> = {
Comment thread
outslept marked this conversation as resolved.
Outdated
".html": "text/html",
".htm": "text/html",
".css": "text/css",
".js": "text/javascript",
".json": "application/json",
".txt": "text/plain",
".xml": "application/xml",

".gif": "image/gif",
".ico": "image/vnd.microsoft.icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",

".woff": "font/woff",
".woff2": "font/woff2",

".mp4": "video/mp4",
".webm": "video/webm",

".zip": "application/zip",

".pdf": "application/pdf",
};

function getMimeType(path: string): string {
const ext = path.slice(Math.max(0, path.lastIndexOf("."))).toLowerCase();
Comment thread
outslept marked this conversation as resolved.
Outdated
return COMMON_MIME_TYPES[ext] || "application/octet-stream";
Comment thread
outslept marked this conversation as resolved.
Outdated
}

export interface StaticAssetMeta {
type?: string;
etag?: string;
Expand Down Expand Up @@ -51,6 +84,11 @@ export interface ServeStaticOptions {
* When set to true, the function will not throw 404 error when the asset meta is not found or meta validation failed
*/
fallthrough?: boolean;

/**
* Custom MIME type resolver function
*/
getMimeType?: (path: string) => string | undefined;
Comment thread
outslept marked this conversation as resolved.
Outdated
}

/**
Expand Down Expand Up @@ -146,6 +184,9 @@ export async function serveStatic(

if (meta.type && !event.res.headers.get("content-type")) {
event.res.headers.set("content-type", meta.type);
} else if (!event.res.headers.get("content-type")) {
const mimeType = options.getMimeType?.(id) || getMimeType(id);
event.res.headers.set("content-type", mimeType);
}

if (meta.encoding && !event.res.headers.get("content-encoding")) {
Expand Down
51 changes: 51 additions & 0 deletions test/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,54 @@ describeMatrix("serve static with fallthrough", (t, { it, expect }) => {
expect(await res.json()).toEqual({ fallthroughTest: "passing" });
});
});

describeMatrix("serve static MIME types", (t, { it, expect }) => {
beforeEach(() => {
const serveStaticOptions: ServeStaticOptions = {
getContents: vi.fn((id) => `content for ${id}`),
getMeta: vi.fn((id) => ({
size: 10,
path: id,
})),
};

t.app.all("/**", (event) => {
return serveStatic(event, serveStaticOptions);
});
});

it("Sets correct MIME type for CSS", async () => {
const res = await t.fetch("/styles.css");
expect(res.headers.get("content-type")).toBe("text/css");
});

it("Sets correct MIME type for JavaScript", async () => {
const res = await t.fetch("/script.js");
expect(res.headers.get("content-type")).toBe("text/javascript");
});

it("Sets correct MIME type for images", async () => {
const res = await t.fetch("/image.png");
expect(res.headers.get("content-type")).toBe("image/png");
});

it("Uses custom getMimeType function", async () => {
const customOptions: ServeStaticOptions = {
getContents: vi.fn(() => "content"),
getMeta: vi.fn(() => ({ size: 10 })),
getMimeType: vi.fn(() => "application/custom"),
};

t.app.all("/custom/**", (event) => {
return serveStatic(event, customOptions);
});

const res = await t.fetch("/custom/file.xyz");
expect(res.headers.get("content-type")).toBe("application/custom");
});

it("Falls back to octet-stream for unknown extensions", async () => {
const res = await t.fetch("/unknown.xyz");
expect(res.headers.get("content-type")).toBe("application/octet-stream");
});
});