-
Notifications
You must be signed in to change notification settings - Fork 223
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: platform agnostic
serveStatic
utility (#480)
Co-Authored-By: Daniel Roe <[email protected]>
- Loading branch information
Showing
3 changed files
with
307 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |