Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
61 changes: 56 additions & 5 deletions http/file_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import { MINUTE } from "@std/datetime/constants";
import { getAvailablePort } from "@std/net/get-available-port";
import { concat } from "@std/bytes/concat";
import { lessThan, parse as parseSemver } from "@std/semver";
import { serveDir as unstableServeDir } from "./unstable_file_server.ts";
import {
serveDir as unstableServeDir,
type ServeDirOptions as UnstableServeDirOptions,
} from "./unstable_file_server.ts";
import { serveFile as unstableServeFile } from "./unstable_file_server.ts";

const moduleDir = dirname(fromFileUrl(import.meta.url));
Expand Down Expand Up @@ -1205,7 +1208,7 @@ Deno.test("serveDir() handles HEAD request for missing file", async () => {
Deno.test("(unstable) serveDir() serves files without the need of html extension when cleanUrls=true", async () => {
const req = new Request("http://localhost/hello");
const res = await unstableServeDir(req, {
...serveDirOptions,
...serveDirOptions as UnstableServeDirOptions,

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.

The as UnstableServeDirOptions cast (repeated at 5 call sites) is forced by Omit<StableServeDirOptions, "headers"> + redefine: the spread carries stable's headers?: string[], which isn't assignable to HeadersInit. You could avoid all the casts by typing the shared serveDirOptions fixture as UnstableServeDirOptions (or using satisfies). Minor, test-only.

cleanUrls: true,
});
const downloadedFile = await res.text();
Expand All @@ -1219,21 +1222,69 @@ Deno.test("(unstable) serveDir() serves files without the need of html extension
Deno.test("(unstable) serveDir() does not shadow existing files and directory if cleanUrls=true", async () => {
const req = new Request("http://localhost/test_clean_urls");
const res = await unstableServeDir(req, {
...serveDirOptions,
...serveDirOptions as UnstableServeDirOptions,
cleanUrls: true,
});

assertEquals(res.status, 301);
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 as UnstableServeDirOptions,
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 as UnstableServeDirOptions,
headers: new Headers({ "x-custom-header": "hi" }),
});
await res.body?.cancel();

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 as UnstableServeDirOptions,
headers: { "x-custom-header": "hi" },
});
await res.body?.cancel();

assertEquals(res.status, 301);
assertEquals(res.headers.has("x-custom-header"), false);
});
41 changes: 28 additions & 13 deletions http/unstable_file_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import {
serveFile as stableServeFile,
type ServeFileOptions as StableServeFileOptions,
} from "./file_server.ts";
import { isRedirectStatus } from "./status.ts";

function appendHeaders(target: Headers, headers: HeadersInit): void {
const normalized = new Headers(headers);
for (const [name, value] of normalized) {
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 +53,42 @@ 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.
*
* @default {[]}
*/
headers?: HeadersInit;
}

/** Interface for serveFile options. */
export interface ServeFileOptions extends StableServeFileOptions {
/** Headers to add to each response
/** Headers to add to each response.
*
* @default {[]}
*/
headers?: string[];
headers?: HeadersInit;
}

/**
Expand All @@ -96,15 +115,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