Skip to content

Commit

Permalink
feat: platform agnostic serveStatic utility (#480)
Browse files Browse the repository at this point in the history
Co-Authored-By: Daniel Roe <[email protected]>
  • Loading branch information
pi0 and danielroe authored Aug 1, 2023
1 parent 5ec4b30 commit d97e921
Show file tree
Hide file tree
Showing 3 changed files with 307 additions and 2 deletions.
5 changes: 3 additions & 2 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
194 changes: 194 additions & 0 deletions src/utils/static.ts
Original file line number Diff line number Diff line change
@@ -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<StaticAssetMeta | undefined>;

/**
* This function should resolve asset content
*/
getContents: (id: string) => unknown | Promise<unknown>;

/**
* 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<string, string>;

/**
* 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<void | false> {
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, string>
): 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;
}
110 changes: 110 additions & 0 deletions test/static.test.ts
Original file line number Diff line number Diff line change
@@ -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<Test>;

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);
});
});

0 comments on commit d97e921

Please sign in to comment.