@shaferllc/keel 0.59.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -0,0 +1,174 @@
1
+ # Social authentication
2
+
3
+ "Sign in with GitHub / Google / Discord" — OAuth 2.0, without an SDK. Keel owns
4
+ the OAuth handshake only: it hands you a normalized **social user**, and *you*
5
+ find-or-create your own user and log them in (with a [session](./authentication.md),
6
+ [`jwt`](./authentication.md#token-api-authentication), or an
7
+ [access token](./authentication.md#opaque-access-tokens)). It stores nothing.
8
+
9
+ Every driver is `fetch`-based — no dependencies, no native bindings — so it runs
10
+ on Node and the edge alike.
11
+
12
+ ## Configure a provider
13
+
14
+ ```ts
15
+ import { social } from "@shaferllc/keel/core";
16
+
17
+ const github = social.github({
18
+ clientId: config("services.github.id"),
19
+ clientSecret: config("services.github.secret"),
20
+ redirectUri: "https://app.example.com/auth/github/callback",
21
+ });
22
+ ```
23
+
24
+ Presets: `social.github`, `social.google`, `social.discord`. Each defaults to the
25
+ scopes needed for id + email + profile; override with `scopes` in the config or
26
+ per-redirect.
27
+
28
+ ## The two-step flow
29
+
30
+ **1. Redirect** the user to the provider. Generate a `state` for CSRF and stash it
31
+ (in the session) to check on the way back:
32
+
33
+ ```ts
34
+ import { social, session, redirect } from "@shaferllc/keel/core";
35
+
36
+ router.get("/auth/github", () => {
37
+ const state = social.state();
38
+ session().put("oauth_state", state);
39
+ return redirect(github.redirect({ state }));
40
+ });
41
+ ```
42
+
43
+ **2. Handle the callback.** Verify `state`, then exchange the `code` for the user:
44
+
45
+ ```ts
46
+ router.get("/auth/github/callback", async () => {
47
+ if (request.query("state") !== session().pull("oauth_state")) {
48
+ throw new ForbiddenException("Invalid OAuth state");
49
+ }
50
+
51
+ const gh = await github.user(request.query("code")); // exchange + fetch profile
52
+
53
+ const user = await User.query()
54
+ .where("github_id", gh.id)
55
+ .first() ?? await User.create({ github_id: gh.id, email: gh.email, name: gh.name });
56
+
57
+ auth().login(user.id);
58
+ return redirect("/dashboard");
59
+ });
60
+ ```
61
+
62
+ ## The social user
63
+
64
+ `user()` returns a shape that's the same across every provider:
65
+
66
+ ```ts
67
+ {
68
+ id: string; // the provider's stable id (always a string)
69
+ email: string | null;
70
+ name: string | null;
71
+ nickname: string | null; // handle / username
72
+ avatarUrl: string | null;
73
+ token: OAuthToken; // { accessToken, refreshToken?, expiresIn?, … }
74
+ raw: Record<string, unknown>; // the untouched provider payload
75
+ }
76
+ ```
77
+
78
+ Reach `raw` for provider-specific fields not in the normalized shape. Use `token`
79
+ to call the provider's API on the user's behalf.
80
+
81
+ ## Issuing your own credential
82
+
83
+ After you've found-or-created the user, log them in however your app authenticates
84
+ — they're independent of the OAuth token:
85
+
86
+ ```ts
87
+ // server-rendered app → session
88
+ auth().login(user.id);
89
+
90
+ // SPA / mobile → an opaque access token
91
+ const { token } = await createToken(user.id);
92
+ return response.json({ token });
93
+ ```
94
+
95
+ ## Splitting the steps
96
+
97
+ `user(code)` is `exchangeCode(code)` then `userFromToken(token)`. Call them apart
98
+ when you already hold a token (e.g. a native mobile SDK did the OAuth dance):
99
+
100
+ ```ts
101
+ const token = await github.exchangeCode(code); // { accessToken, … }
102
+ const gh = await github.userFromToken(token); // normalized user
103
+ ```
104
+
105
+ A failed exchange or profile fetch throws `OAuthError` (with the `provider` name).
106
+
107
+ ## OAuth 1.0a (Twitter/X)
108
+
109
+ Some providers still speak the older, three-legged **OAuth 1.0a** — every request
110
+ is HMAC-SHA1-signed (done here with Web Crypto, so it stays edge-native). The flow
111
+ has an extra hop: get a temporary *request token*, send the user to authorize,
112
+ then swap the returned `oauth_verifier` for the access token. Stash the request
113
+ token's secret between the two steps.
114
+
115
+ ```ts
116
+ import { social, session, redirect } from "@shaferllc/keel/core";
117
+
118
+ const twitter = social.twitter({
119
+ clientId: config("services.twitter.key"),
120
+ clientSecret: config("services.twitter.secret"),
121
+ redirectUri: "https://app.example.com/auth/twitter/callback",
122
+ });
123
+
124
+ // 1. request token → redirect
125
+ router.get("/auth/twitter", async () => {
126
+ const request = await twitter.requestToken();
127
+ session().put("twitter_secret", request.tokenSecret); // needed on the way back
128
+ return redirect(twitter.redirect(request));
129
+ });
130
+
131
+ // 2. callback → access token → user
132
+ router.get("/auth/twitter/callback", async () => {
133
+ const tw = await twitter.user(
134
+ request.query("oauth_token"),
135
+ request.query("oauth_verifier"),
136
+ session().pull("twitter_secret"),
137
+ );
138
+ // tw is a SocialUser — same shape as OAuth2, but tw.token is an OAuth1Token
139
+ const user = await User.firstOrCreate({ twitter_id: tw.id }, { name: tw.name });
140
+ auth().login(user.id);
141
+ return redirect("/dashboard");
142
+ });
143
+ ```
144
+
145
+ For any other OAuth 1.0a provider, use `social.driver1(spec, config)` with
146
+ `requestTokenUrl` / `authorizeUrl` / `accessTokenUrl` and a `fetchUser` that calls
147
+ `driver.get(url, token)` (a signed request). The low-level `oauth1Signature()` is
148
+ exported too, if you need to sign an arbitrary API call yourself.
149
+
150
+ ## Any other OAuth2 provider
151
+
152
+ Build a driver for anything OAuth2 with `social.driver(spec, config)` — supply the
153
+ `authorizeUrl`, `tokenUrl`, default scopes, and a `fetchUser(token)` that returns a
154
+ `SocialUser`:
155
+
156
+ ```ts
157
+ const gitlab = social.driver(
158
+ {
159
+ name: "gitlab",
160
+ authorizeUrl: "https://gitlab.com/oauth/authorize",
161
+ tokenUrl: "https://gitlab.com/oauth/token",
162
+ defaultScopes: ["read_user"],
163
+ async fetchUser(token) {
164
+ const res = await fetch("https://gitlab.com/api/v4/user", {
165
+ headers: { authorization: `Bearer ${token.accessToken}` },
166
+ });
167
+ const data = await res.json();
168
+ return { id: String(data.id), email: data.email, name: data.name,
169
+ nickname: data.username, avatarUrl: data.avatar_url, token, raw: data };
170
+ },
171
+ },
172
+ { clientId, clientSecret, redirectUri },
173
+ );
174
+ ```
@@ -0,0 +1,211 @@
1
+ # Static Files
2
+
3
+ `serveStatic()` serves files from a directory (default `public/`) **before** your
4
+ routes run. If a file matches the request path it's sent with caching headers;
5
+ otherwise the request falls through to your routes.
6
+
7
+ ## Enable it
8
+
9
+ Add the middleware to your HTTP kernel:
10
+
11
+ ```ts
12
+ import { HttpKernel, serveStatic } from "@shaferllc/keel/core";
13
+
14
+ export class Kernel extends HttpKernel {
15
+ constructor(app: Application) {
16
+ super(app);
17
+ this.use(serveStatic()); // serves ./public
18
+ }
19
+ }
20
+ ```
21
+
22
+ Now `./public/css/style.css` is served at `/css/style.css`, and `./public/index.html`
23
+ at `/`.
24
+
25
+ ## How a request is matched
26
+
27
+ For each request the middleware:
28
+
29
+ 1. **Skips non-reads.** Only `GET` and `HEAD` are handled; any other method falls
30
+ straight through to your routes.
31
+ 2. **Decodes and guards the path.** The URL pathname is `decodeURIComponent`'d,
32
+ then any path containing `..` is rejected (falls through) — so percent-encoded
33
+ traversal (`%2e%2e`) is caught too.
34
+ 3. **Applies the dot-file policy** (see below).
35
+ 4. **Resolves the file.** It looks for `root + urlPath` on disk. If that's a
36
+ directory, it appends `index` (`index.html`) and looks again. If nothing
37
+ resolves to a real file, the request falls through.
38
+ 5. **Sends the file** with `Content-Type`, `Last-Modified`, `ETag`, and — when
39
+ configured — `Cache-Control` headers.
40
+
41
+ Because the middleware calls `next()` (rather than returning a 404) whenever it
42
+ can't serve a file, a missing asset is handled by your routes, not by the static
43
+ server. That's what lets a client-side app fall back to an SPA catch-all route.
44
+
45
+ ## Options
46
+
47
+ ```ts
48
+ serveStatic({
49
+ root: "./public", // directory to serve
50
+ index: "index.html", // directory index file
51
+ dotFiles: "ignore", // "ignore" (404) · "deny" (403) · "allow"
52
+ maxAge: 86400, // Cache-Control: public, max-age=…
53
+ immutable: true, // add the immutable directive (hashed filenames)
54
+ headers: (path) => // extra per-file headers
55
+ path.endsWith(".html") ? { "X-Frame-Options": "DENY" } : undefined,
56
+ });
57
+ ```
58
+
59
+ Every response carries an `ETag` and `Last-Modified`, and a matching
60
+ `If-None-Match` returns a `304`. Dot-files (`.env`, `.git`) are 404'd by default
61
+ so secrets aren't exposed. Path traversal (`..`) is blocked.
62
+
63
+ ## Caching & conditional requests
64
+
65
+ The `ETag` is **weak** — derived from the file's byte size and modified time
66
+ (`W/"<size>-<mtime>"`) — so it changes whenever the file changes without hashing
67
+ its contents. On a repeat request the browser echoes it back in `If-None-Match`;
68
+ if it still matches, the middleware short-circuits with `304 Not Modified` and an
69
+ empty body, saving the read and the transfer.
70
+
71
+ `Cache-Control` is only sent when you set `maxAge`; omit it and the response has
72
+ no `Cache-Control` header at all (the browser falls back to its heuristic
73
+ freshness). Add `immutable: true` for content-hashed filenames so conditional
74
+ revalidation is skipped entirely for the cache lifetime:
75
+
76
+ ```ts
77
+ // Long-lived, fingerprinted build assets: cache hard, never revalidate.
78
+ this.use(serveStatic({ root: "./dist", maxAge: 31536000, immutable: true }));
79
+ ```
80
+
81
+ `HEAD` requests get the full header set with an empty body, so clients can probe
82
+ an asset's `ETag`/`Last-Modified` without downloading it.
83
+
84
+ ## Per-file headers
85
+
86
+ The `headers` callback runs for every file about to be served and merges its
87
+ result into the response. It receives the **resolved filesystem path** (root
88
+ included, e.g. `./public/app.js`), not the URL path — match on the extension or
89
+ suffix rather than a leading slash:
90
+
91
+ ```ts
92
+ serveStatic({
93
+ headers: (path) => {
94
+ if (path.endsWith(".html")) return { "X-Frame-Options": "DENY" };
95
+ if (path.endsWith(".wasm")) return { "Cross-Origin-Embedder-Policy": "require-corp" };
96
+ return undefined; // no extra headers
97
+ },
98
+ });
99
+ ```
100
+
101
+ Returning `undefined` (or an empty object) adds nothing. These headers are set
102
+ after the built-ins, so a `Cache-Control` you return here overrides the one
103
+ derived from `maxAge`.
104
+
105
+ ## Dot-files & traversal
106
+
107
+ Any path segment that starts with `.` — not just the last one — is a "dot
108
+ segment", so `/.git/config` and `/assets/.env` both trip the policy:
109
+
110
+ - `"ignore"` (default) — falls through to your routes, so it reads as a 404.
111
+ - `"deny"` — responds `403 Forbidden`.
112
+ - `"allow"` — serves the file like any other.
113
+
114
+ Separately, any decoded path containing `..` is always rejected regardless of the
115
+ dot-file policy, so `../` traversal can't escape `root`.
116
+
117
+ ## Error behavior
118
+
119
+ The file-resolution block is wrapped in a `try/catch` that swallows errors by
120
+ calling `next()`. A permission error, a mid-request delete, or a malformed path
121
+ never becomes a `500` — it falls through to your routes exactly like a miss. The
122
+ trade-off: genuine filesystem faults are invisible here, so don't rely on this
123
+ middleware to surface disk problems.
124
+
125
+ ## Edge note
126
+
127
+ `serveStatic()` reads from the filesystem (via a dynamically-imported `node:fs`),
128
+ so it's for **Node** apps. On Cloudflare Workers, serve assets through the
129
+ platform's static-assets binding instead — the framework core still imports
130
+ cleanly either way (the `node:fs` import is deferred until the first request the
131
+ middleware actually handles).
132
+
133
+ ## Production
134
+
135
+ For high-traffic sites, prefer a CDN or reverse proxy (Nginx, Caddy, Cloudflare)
136
+ in front of static assets rather than serving them from the Node process.
137
+
138
+ ---
139
+
140
+ ## API reference
141
+
142
+ ### `serveStatic(options?)`
143
+
144
+ `serveStatic(options?: StaticOptions): MiddlewareHandler`
145
+
146
+ Builds a Hono middleware that serves files from `options.root` before the request
147
+ reaches your routes, falling through to `next()` on any miss.
148
+
149
+ ```ts
150
+ import { serveStatic } from "@shaferllc/keel/core";
151
+
152
+ const assets = serveStatic({ root: "./public", maxAge: 86400 });
153
+ this.use(assets);
154
+ ```
155
+
156
+ **Notes:** all options are optional (`serveStatic()` serves `./public`). Only
157
+ `GET`/`HEAD` are handled — other methods pass through untouched. Returns the
158
+ middleware synchronously; `node:fs/promises` is imported lazily on the first
159
+ handled request, so importing this on a non-Node runtime is safe until a request
160
+ hits it. Sends `Content-Type` (via Hono's `getMimeType`, defaulting to
161
+ `application/octet-stream`), `Last-Modified`, and a weak `ETag`; honors
162
+ `If-None-Match` with a `304`. A trailing slash on `root` is stripped. The `..`
163
+ guard is a plain substring check, so a (rare) legitimate filename containing `..`
164
+ is also rejected.
165
+
166
+ ### Interfaces & types
167
+
168
+ #### `StaticOptions`
169
+
170
+ ```ts
171
+ interface StaticOptions {
172
+ root?: string;
173
+ index?: string;
174
+ dotFiles?: "ignore" | "deny" | "allow";
175
+ maxAge?: number;
176
+ immutable?: boolean;
177
+ headers?: (path: string) => Record<string, string> | undefined;
178
+ }
179
+ ```
180
+
181
+ The configuration bag for `serveStatic()`. Pass it to tune the served directory,
182
+ directory index, dot-file policy, and caching. Every field has a default, so an
183
+ empty object (or no argument) is valid.
184
+
185
+ ```ts
186
+ const options: StaticOptions = {
187
+ root: "./dist",
188
+ index: "index.html",
189
+ dotFiles: "deny",
190
+ maxAge: 31536000,
191
+ immutable: true,
192
+ headers: (path) => (path.endsWith(".html") ? { "X-Frame-Options": "DENY" } : undefined),
193
+ };
194
+ serveStatic(options);
195
+ ```
196
+
197
+ **Field defaults & behavior:**
198
+
199
+ - `root` — directory to serve from. Default `"./public"`; trailing slashes are
200
+ stripped.
201
+ - `index` — file served for a directory request. Default `"index.html"`.
202
+ - `dotFiles` — policy for paths with a `.`-prefixed segment: `"ignore"` (fall
203
+ through, reads as 404), `"deny"` (`403 Forbidden`), or `"allow"` (serve).
204
+ Default `"ignore"`.
205
+ - `maxAge` — `Cache-Control: public, max-age=<seconds>`. Omit for no
206
+ `Cache-Control` header at all.
207
+ - `immutable` — appends `, immutable` to `Cache-Control` (only meaningful
208
+ alongside `maxAge`). Default `false`.
209
+ - `headers` — called with the resolved filesystem path (root included) for each
210
+ served file; return extra headers to merge, or `undefined` for none. These are
211
+ applied last, so they can override the built-in headers.