Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: platform agnostic serveStatic utility #480

Merged
merged 2 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});