Skip to content

Commit

Permalink
feat(server): re-add etag support/handlers
Browse files Browse the repository at this point in the history
- add `StaticOpts.etag`
- update `staticFiles()` HEAD/GET handlers
- add `Server.unmodified()`
- add `etagFileTimeModified()`
- add `etagFileHash()`
- add `isUnmodified()` helper
- remove "content-length" header from HEAD handler
- update tests
  • Loading branch information
postspectacular committed Jan 29, 2025
1 parent 4eff206 commit 13fb75e
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 14 deletions.
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ export * from "./interceptors/x-origin-opener.js";
export * from "./interceptors/x-origin-resource.js";

export * from "./utils/cookies.js";
export * from "./utils/cache.js";
export * from "./utils/formdata.js";
export * from "./utils/multipart.js";
4 changes: 4 additions & 0 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ export class Server {
res.writeHead(403, "Forbidden").end();
}

unmodified(res: http.ServerResponse) {
res.writeHead(304, "Not modified").end();
}

missing(res: http.ServerResponse) {
res.writeHead(404, "Not found").end();
}
Expand Down
92 changes: 79 additions & 13 deletions packages/server/src/static.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { Predicate } from "@thi.ng/api";
import type { Fn, MaybePromise, Predicate } from "@thi.ng/api";
import { fileHash as $fileHash, type HashAlgo } from "@thi.ng/file-io";
import { preferredTypeForPath } from "@thi.ng/mime";
import { existsSync, statSync } from "node:fs";
import type { OutgoingHttpHeaders } from "node:http";
import { join } from "node:path";
import type { Interceptor, ServerRoute } from "./api.js";
import type { Interceptor, RequestCtx, ServerRoute } from "./api.js";
import { isUnmodified } from "./utils/cache.js";

/**
* Static file configuration options.
Expand Down Expand Up @@ -35,14 +38,19 @@ export interface StaticOpts {
/**
* Additional common headers (e.g. cache control) for all static files
*/
headers: Record<string, string | string[]>;
headers: OutgoingHttpHeaders;
/**
* If true (default: false), files will be served with brotli, gzip or deflate
* compression (if the client supports it).
*
* @defaultValue false
*/
compress: boolean;
/**
* User defined function to compute an Etag value for given file path. The
* file is guaranteed to exist when this function is called.
*/
etag: Fn<string, MaybePromise<string>>;
}

/**
Expand All @@ -58,31 +66,89 @@ export const staticFiles = ({
intercept = [],
filter = () => true,
compress = false,
etag,
headers,
}: Partial<StaticOpts> = {}): ServerRoute => ({
id: "__static",
match: [prefix, "+"],
handlers: {
head: {
fn: async ({ server, match, res }) => {
const path = join(rootDir, ...match.rest!);
if (!(existsSync(path) && filter(path)))
return server.missing(res);
res.writeHead(200, {
fn: async (ctx) => {
const path = join(rootDir, ...ctx.match.rest!);
const $headers = await __fileHeaders(
path,
ctx,
filter,
etag,
headers
);
if (!$headers) return;
ctx.res.writeHead(200, {
"content-type": preferredTypeForPath(path),
"content-length": String(statSync(path).size),
...headers,
...$headers,
});
},
intercept,
},
get: {
fn: (ctx) => {
fn: async (ctx) => {
const path = join(rootDir, ...ctx.match.rest!);
if (!filter(path)) return ctx.server.missing(ctx.res);
return ctx.server.sendFile(ctx, path, headers, compress);
const $headers = await __fileHeaders(
path,
ctx,
filter,
etag,
headers
);
if (!$headers) return;
return ctx.server.sendFile(ctx, path, $headers, compress);
},
intercept,
},
},
});

const __fileHeaders = async (
path: string,
ctx: RequestCtx,
filter: StaticOpts["filter"],
etag?: StaticOpts["etag"],
headers?: OutgoingHttpHeaders
) => {
if (!(existsSync(path) && filter(path))) {
return ctx.server.missing(ctx.res);
}
if (etag) {
const etagValue = await etag(path);
return isUnmodified(etagValue, ctx.req.headers["if-none-match"])
? ctx.server.unmodified(ctx.res)
: { ...headers, etag: etagValue };
}
return { ...headers };
};

/**
* Etag header value function for {@link StaticOpts.etag}. Computes Etag based
* on file modified date.
*
* @remarks
* Also see {@link etagFileHash}.
*
* @param path
*/
export const etagFileTimeModified = (path: string) =>
String(statSync(path).mtimeMs);

/**
* Higher-order Etag header value function for {@link StaticOpts.etag}. Computes
* Etag value by computing the hash digest of a given file. Uses MD5 by default.
*
* @remarks
* Also see {@link etagFileTimeModified}.
*
* @param algo
*/
export const etagFileHash =
(algo: HashAlgo = "md5") =>
(path: string) =>
$fileHash(path, undefined, algo);
6 changes: 6 additions & 0 deletions packages/server/src/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const isUnmodified = (etag: string, header?: string) =>
header
? header.includes(",")
? header.split(/,\s+/g).some((x) => x === etag)
: header === etag
: false;
4 changes: 3 additions & 1 deletion packages/server/test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
cacheControl,
crossOriginOpenerPolicy,
crossOriginResourcePolicy,
etagFileHash,
injectHeaders,
logRequest,
logResponse,
Expand All @@ -24,6 +25,7 @@ test("server", async (done) => {
routes: [
staticFiles({
rootDir: join(resolve(__dirname), "fixtures"),
etag: etagFileHash(),
compress: true,
}),
{
Expand Down Expand Up @@ -108,7 +110,7 @@ test("server", async (done) => {
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/json");
expect(res.headers.get("content-length")).toBe("19");
expect(res.headers.get("etag")).toBe("d06f04fccf68d0b228a5923187ce1afd");

res = await fetch("http://localhost:8080/static/foo/bar.json");
expect(res.status).toBe(200);
Expand Down

0 comments on commit 13fb75e

Please sign in to comment.