From d97e92180e076528e8f3fa270d2a6e4981aa717b Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Tue, 1 Aug 2023 20:53:38 +0200 Subject: [PATCH] feat: platform agnostic `serveStatic` utility (#480) Co-Authored-By: Daniel Roe --- src/utils/index.ts | 5 +- src/utils/static.ts | 194 ++++++++++++++++++++++++++++++++++++++++++++ test/static.test.ts | 110 +++++++++++++++++++++++++ 3 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/utils/static.ts create mode 100644 test/static.test.ts diff --git a/src/utils/index.ts b/src/utils/index.ts index 35d6efbc..4404d426 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,10 +2,11 @@ export * from "./route"; export * from "./body"; export * from "./cache"; export * from "./consts"; +export * from "./cors"; export * from "./cookie"; export * from "./proxy"; export * from "./request"; export * from "./response"; -export * from "./session"; -export * from "./cors"; export * from "./sanitize"; +export * from "./session"; +export * from "./static"; diff --git a/src/utils/static.ts b/src/utils/static.ts new file mode 100644 index 00000000..ad2c6dbf --- /dev/null +++ b/src/utils/static.ts @@ -0,0 +1,194 @@ +import { + decodePath, + parseURL, + withLeadingSlash, + withoutTrailingSlash, +} from "ufo"; +import { H3Event } from "../event"; +import { createError } from "../error"; +import { getRequestHeader } from "./request"; +import { + getResponseHeader, + setResponseHeader, + setResponseStatus, + send, + isStream, + sendStream, +} from "./response"; + +export interface StaticAssetMeta { + type?: string; + etag?: string; + mtime?: number | string | Date; + path?: string; + size?: number; + encoding?: string; +} + +export interface ServeStaticOptions { + /** + * This function should resolve asset meta + */ + getMeta: ( + id: string + ) => StaticAssetMeta | undefined | Promise; + + /** + * This function should resolve asset content + */ + getContents: (id: string) => unknown | Promise; + + /** + * Map of supported encodings (compressions) and their file extensions. + * + * Each extension will be appended to the asset path to find the compressed version of the asset. + * + * @example { gzip: ".gz", br: ".br" } + */ + encodings?: Record; + + /** + * Default index file to serve when the path is a directory + * + * @default ["/index.html"] + */ + indexNames?: string[]; + + /** + * When set to true, the function will not throw 404 error when the asset meta is not found or meta validation failed + */ + fallthrough?: boolean; +} + +export async function serveStatic( + event: H3Event, + options: ServeStaticOptions +): Promise { + if (event.method !== "GET" && event.method !== "HEAD") { + if (!options.fallthrough) { + throw createError({ + statusMessage: "Method Not Allowed", + statusCode: 405, + }); + } + return false; + } + + const originalId = decodePath( + withLeadingSlash(withoutTrailingSlash(parseURL(event.path).pathname)) + ); + + const acceptEncodings = parseAcceptEncoding( + getRequestHeader(event, "accept-encoding"), + options.encodings + ); + + if (acceptEncodings.length > 1) { + setResponseHeader(event, "vary", "accept-encoding"); + } + + let id = originalId; + let meta: StaticAssetMeta | undefined; + + const _ids = idSearchPaths( + originalId, + acceptEncodings, + options.indexNames || ["/index.html"] + ); + + for (const _id of _ids) { + const _meta = await options.getMeta(_id); + if (_meta) { + meta = _meta; + id = _id; + break; + } + } + + if (!meta) { + if (!options.fallthrough) { + throw createError({ + statusMessage: "Cannot find static asset " + id, + statusCode: 404, + }); + } + return false; + } + + const ifNotMatch = + meta.etag && getRequestHeader(event, "if-none-match") === meta.etag; + if (ifNotMatch) { + setResponseStatus(event, 304, "Not Modified"); + return send(event, ""); + } + + if (meta.mtime) { + const mtimeDate = new Date(meta.mtime); + + const ifModifiedSinceH = getRequestHeader(event, "if-modified-since"); + if (ifModifiedSinceH && new Date(ifModifiedSinceH) >= mtimeDate) { + setResponseStatus(event, 304, "Not Modified"); + return send(event, null); + } + + if (!getResponseHeader(event, "last-modified")) { + setResponseHeader(event, "last-modified", mtimeDate.toUTCString()); + } + } + + if (meta.type && !getResponseHeader(event, "content-type")) { + setResponseHeader(event, "content-type", meta.type); + } + + if (meta.etag && !getResponseHeader(event, "etag")) { + setResponseHeader(event, "etag", meta.etag); + } + + if (meta.encoding && !getResponseHeader(event, "content-encoding")) { + setResponseHeader(event, "content-encoding", meta.encoding); + } + + if ( + meta.size !== undefined && + meta.size > 0 && + !getResponseHeader(event, "content-length") + ) { + setResponseHeader(event, "content-length", meta.size); + } + + if (event.method === "HEAD") { + return send(event, null); + } + + const contents = await options.getContents(id); + return isStream(contents) + ? sendStream(event, contents) + : send(event, contents); +} + +// --- Internal Utils --- + +function parseAcceptEncoding( + header?: string, + encodingMap?: Record +): string[] { + if (!encodingMap || !header) { + return []; + } + return String(header || "") + .split(",") + .map((e) => encodingMap[e.trim()]) + .filter(Boolean); +} + +function idSearchPaths(id: string, encodings: string[], indexNames: string[]) { + const ids = []; + + for (const suffix of ["", ...indexNames]) { + for (const encoding of [...encodings, ""]) { + ids.push(`${id}${suffix}${encoding}`); + } + } + + return ids; +} diff --git a/test/static.test.ts b/test/static.test.ts new file mode 100644 index 00000000..f1b66fc8 --- /dev/null +++ b/test/static.test.ts @@ -0,0 +1,110 @@ +import supertest, { SuperTest, Test } from "supertest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + App, + createApp, + toNodeListener, + eventHandler, + serveStatic, +} from "../src"; + +describe("Serve Static", () => { + let app: App; + let request: SuperTest; + + const serveStaticOptions = { + getContents: vi.fn((id) => + id.includes("404") ? undefined : `asset:${id}` + ), + getMeta: vi.fn((id) => + id.includes("404") + ? undefined + : { + type: "text/plain", + encoding: "utf8", + etag: "w/123", + mtime: 1_700_000_000_000, + path: id, + size: `asset:${id}`.length, + } + ), + indexNames: ["/index.html"], + encodings: { gzip: ".gz", br: ".br" }, + }; + + beforeEach(() => { + app = createApp({ debug: true }); + app.use( + "/", + eventHandler((event) => { + return serveStatic(event, serveStaticOptions); + }) + ); + request = supertest(toNodeListener(app)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const expectedHeaders = { + "content-type": "text/plain", + etag: "w/123", + "content-encoding": "utf8", + "last-modified": new Date(1_700_000_000_000).toUTCString(), + vary: "accept-encoding", + }; + + it("Can serve asset (GET)", async () => { + const res = await request + .get("/test.png") + .set("if-none-match", "w/456") + .set("if-modified-since", new Date(1_700_000_000_000 - 1).toUTCString()) + .set("accept-encoding", "gzip, br"); + + expect(res.status).toEqual(200); + expect(res.text).toBe("asset:/test.png.gz"); + expect(res.headers).toMatchObject(expectedHeaders); + expect(res.headers["content-length"]).toBe("18"); + }); + + it("Can serve asset (HEAD)", async () => { + const headRes = await request + .head("/test.png") + .set("if-none-match", "w/456") + .set("if-modified-since", new Date(1_700_000_000_000 - 1).toUTCString()) + .set("accept-encoding", "gzip, br"); + + expect(headRes.status).toEqual(200); + expect(headRes.text).toBeUndefined(); + expect(headRes.headers).toMatchObject(expectedHeaders); + expect(headRes.headers["content-length"]).toBe("18"); + }); + + it("Handles cache (if-none-match)", async () => { + const res = await request.get("/test.png").set("if-none-match", "w/123"); + expect(res.status).toEqual(304); + expect(res.text).toBe(""); + }); + + it("Handles cache (if-modified-since)", async () => { + const res = await request + .get("/test.png") + .set("if-modified-since", new Date(1_700_000_000_001).toUTCString()); + expect(res.status).toEqual(304); + expect(res.text).toBe(""); + }); + + it("Returns 404 if not found", async () => { + const res = await request.get("/404/test.png"); + expect(res.status).toEqual(404); + + const headRes = await request.head("/404/test.png"); + expect(headRes.status).toEqual(404); + }); + + it("Returns 405 if other methods used", async () => { + const res = await request.post("/test.png"); + expect(res.status).toEqual(405); + }); +});