Skip to content
Open
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
79 changes: 75 additions & 4 deletions http/file_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
67 changes: 54 additions & 13 deletions http/unstable_file_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .append() means custom headers are added alongside any the file server already set, rather than replacing them — e.g. passing cache-control would yield a comma-joined value rather than overriding. This matches the previous string[] behavior so it's not a regression, but now that the API takes a rich HeadersInit, callers may reasonably expect to override (e.g. set their own Cache-Control). Worth a one-line doc note on the headers option clarifying that values are appended, not replaced. (The cache-control test only passes because the dir-listing response doesn't pre-set that header.)

}
}

/**
* Serves the files under the given directory root (opts.fsRoot).
Expand Down Expand Up @@ -45,31 +63,58 @@ import {
* @param opts Additional options.
* @returns A response for the request.
*/
export function serveDir(
export async function serveDir(
req: Request,
opts: ServeDirOptions = {},
): Promise<Response> {
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<StableServeDirOptions, "headers"> {
/**
* Also serves `.html` files without the need for specifying the extension.
* For example `foo.html` could be accessed through both `/foo` and `/foo.html`.
*
* @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[];
}

/**
Expand All @@ -96,15 +141,11 @@ export async function serveFile(
filePath: string,
options?: ServeFileOptions,
): Promise<Response> {
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;
Expand Down
Loading