diff --git a/http/file_server_test.ts b/http/file_server_test.ts index eba6a99cdfcd..cb9ea452c343 100644 --- a/http/file_server_test.ts +++ b/http/file_server_test.ts @@ -28,13 +28,13 @@ import { serveFile as unstableServeFile } from "./unstable_file_server.ts"; const moduleDir = dirname(fromFileUrl(import.meta.url)); const testdataDir = resolve(moduleDir, "testdata"); -const serveDirOptions: ServeDirOptions = { +const serveDirOptions = { quiet: true, fsRoot: testdataDir, showDirListing: true, showDotfiles: true, enableCors: true, -}; +} satisfies ServeDirOptions; const denoVersion = parseSemver(Deno.version.deno); const isCanary = denoVersion.build ? denoVersion.build.length > 0 : false; // FileInfo.mode is not available on Windows before Deno 2.1.0 @@ -1227,13 +1227,84 @@ Deno.test("(unstable) serveDir() does not shadow existing files and directory if assertEquals(res.headers.has("location"), true); }); -Deno.test("(unstable) serveFile() sends custom headers", async () => { +Deno.test("(unstable) serveFile() sends custom headers from a record", async () => { const req = new Request("http://localhost/testdata/test_file.txt"); const res = await unstableServeFile(req, TEST_FILE_PATH, { - headers: ["X-Extra: extra header"], + headers: { "X-Extra": "extra header" }, }); assertEquals(res.status, 200); assertEquals(res.headers.get("X-Extra"), "extra header"); assertEquals(await res.text(), TEST_FILE_TEXT); }); + +Deno.test("(unstable) serveFile() sends custom headers from a Headers instance", async () => { + const req = new Request("http://localhost/testdata/test_file.txt"); + const res = await unstableServeFile(req, TEST_FILE_PATH, { + headers: new Headers([["X-Extra", "extra header"]]), + }); + + assertEquals(res.status, 200); + assertEquals(res.headers.get("x-extra"), "extra header"); + assertEquals(await res.text(), TEST_FILE_TEXT); +}); + +Deno.test("(unstable) serveDir() sets headers from a record", async () => { + const req = new Request("http://localhost/test_file.txt"); + const res = await unstableServeDir(req, { + ...serveDirOptions, + headers: { "cache-control": "max-age=100", "x-custom-header": "hi" }, + }); + await res.body?.cancel(); + + assertEquals(res.headers.get("cache-control"), "max-age=100"); + assertEquals(res.headers.get("x-custom-header"), "hi"); +}); + +Deno.test("(unstable) serveDir() sets headers from a Headers instance", async () => { + const req = new Request("http://localhost/test_file.txt"); + const res = await unstableServeDir(req, { + ...serveDirOptions, + headers: new Headers({ "x-custom-header": "hi" }), + }); + await res.body?.cancel(); + + assertEquals(res.headers.get("x-custom-header"), "hi"); +}); + +Deno.test("(unstable) serveFile() sends custom headers from a legacy string array", async () => { + const req = new Request("http://localhost/testdata/test_file.txt"); + const res = await unstableServeFile(req, TEST_FILE_PATH, { + headers: ["X-Extra: extra header"], + }); + + assertEquals(res.status, 200); + assertEquals(res.headers.get("x-extra"), "extra header"); + assertEquals(await res.text(), TEST_FILE_TEXT); +}); + +Deno.test("(unstable) serveDir() sets headers from a legacy string array", async () => { + const req = new Request("http://localhost/test_file.txt"); + const res = await unstableServeDir(req, { + ...serveDirOptions, + headers: ["cache-control: max-age=100", "x-custom-header: hi"], + }); + await res.body?.cancel(); + + assertEquals(res.headers.get("cache-control"), "max-age=100"); + assertEquals(res.headers.get("x-custom-header"), "hi"); +}); + +Deno.test("(unstable) serveDir() does not set headers on redirect responses", async () => { + const req = new Request( + "http://localhost/%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..", + ); + const res = await unstableServeDir(req, { + ...serveDirOptions, + headers: { "x-custom-header": "hi" }, + }); + await res.body?.cancel(); + + assertEquals(res.status, 301); + assertEquals(res.headers.has("x-custom-header"), false); +}); diff --git a/http/unstable_file_server.ts b/http/unstable_file_server.ts index c0a4ecbdd8de..a350945b4a9a 100644 --- a/http/unstable_file_server.ts +++ b/http/unstable_file_server.ts @@ -6,6 +6,24 @@ import { serveFile as stableServeFile, type ServeFileOptions as StableServeFileOptions, } from "./file_server.ts"; +import { isRedirectStatus } from "./status.ts"; + +function appendHeaders( + target: Headers, + headers: HeadersInit | string[], +): void { + // Legacy form: a flat array of `"name: value"` strings. + if (Array.isArray(headers) && typeof headers[0] === "string") { + for (const header of headers as string[]) { + const i = header.indexOf(":"); + target.append(header.slice(0, i), header.slice(i + 1).trimStart()); + } + return; + } + for (const [name, value] of new Headers(headers as HeadersInit)) { + target.append(name, value); + } +} /** * Serves the files under the given directory root (opts.fsRoot). @@ -45,15 +63,21 @@ import { * @param opts Additional options. * @returns A response for the request. */ -export function serveDir( +export async function serveDir( req: Request, opts: ServeDirOptions = {}, ): Promise { - return stableServeDir(req, opts); + const { headers, ...rest } = opts; + const response = await stableServeDir(req, rest); + if (headers && !isRedirectStatus(response.status)) { + appendHeaders(response.headers, headers); + } + return response; } /** Interface for serveDir options. */ -export interface ServeDirOptions extends StableServeDirOptions { +export interface ServeDirOptions + extends Omit { /** * Also serves `.html` files without the need for specifying the extension. * For example `foo.html` could be accessed through both `/foo` and `/foo.html`. @@ -61,15 +85,36 @@ export interface ServeDirOptions extends StableServeDirOptions { * @default {false} */ cleanUrls?: boolean; + /** Headers to add to each response. + * + * Accepts any {@linkcode HeadersInit}. The legacy flat array of + * `"name: value"` strings (e.g. `["X-Extra: extra header"]`) is also + * still accepted. + * + * Values are appended to the response, not replaced, so passing a header + * the file server already sets (e.g. `cache-control`) yields a + * comma-joined value rather than overriding it. + * + * @default {[]} + */ + headers?: HeadersInit | string[]; } /** Interface for serveFile options. */ export interface ServeFileOptions extends StableServeFileOptions { - /** Headers to add to each response + /** Headers to add to each response. + * + * Accepts any {@linkcode HeadersInit}. The legacy flat array of + * `"name: value"` strings (e.g. `["X-Extra: extra header"]`) is also + * still accepted. + * + * Values are appended to the response, not replaced, so passing a header + * the file server already sets (e.g. `cache-control`) yields a + * comma-joined value rather than overriding it. * * @default {[]} */ - headers?: string[]; + headers?: HeadersInit | string[]; } /** @@ -96,15 +141,11 @@ export async function serveFile( filePath: string, options?: ServeFileOptions, ): Promise { - const response = await stableServeFile(req, filePath, options); + const { headers, ...rest } = options ?? {}; + const response = await stableServeFile(req, filePath, rest); - if (options?.headers) { - for (const header of options.headers) { - const headerSplit = header.split(":"); - const name = headerSplit[0]!; - const value = headerSplit.slice(1).join(":"); - response.headers.append(name, value); - } + if (headers) { + appendHeaders(response.headers, headers); } return response;