@zvndev/darkmown 2.11.0 → 2.12.1

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.
package/src/statics.js CHANGED
@@ -67,36 +67,72 @@ function routeEntry(distRoot, route) {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * The conditional-request headers of an incoming request, forwarded by the
72
+ * caller. Passing this object turns on validator-based caching for the
73
+ * response: every served file (200s and the built 404) carries `Last-Modified`
74
+ * (the file's mtime) and a strong `ETag` (`"<size>-<mtimeMs>"`), plus a
75
+ * `Cache-Control` by file class — `no-cache` for HTML (always revalidate, but
76
+ * revalidations 304), `public, max-age=3600` for framework assets under
77
+ * `/__wd/` and media/image/font files (they change only across builds, and a
78
+ * rebuild also changes the validators), and `no-cache` for everything else. A
79
+ * matching `If-None-Match` (which takes precedence) or `If-Modified-Since`
80
+ * gets a bare 304: the validators again, no body, no Content-Encoding.
81
+ * Callers that omit it (the dev server) get no validators at all.
82
+ * @typedef {object} ConditionalRequest
83
+ * @property {string | string[] | undefined} [ifNoneMatch]
84
+ * @property {string | string[] | undefined} [ifModifiedSince]
85
+ */
86
+
70
87
  /**
71
88
  * Serve a built file for a request URL out of `distRoot`, or a 404 page.
72
89
  *
73
90
  * When `options.acceptEncoding` carries the request's Accept-Encoding header,
74
91
  * a compressible body (HTML, CSS, JS, JSON, SVG, …) is negotiated and sent
75
92
  * brotli- or gzip-compressed with `Content-Encoding` + `Vary: Accept-Encoding`.
76
- * Callers that omit the option (the dev server) get the exact pre-compression
77
- * behavior: 200 bodies stream, nothing is re-encoded.
93
+ * When `options.validators` carries the request's conditional headers,
94
+ * validator-based caching (ETag/Last-Modified/Cache-Control, 304s) is on —
95
+ * see {@link ConditionalRequest}; the validators describe the selected
96
+ * variant, so they compose with the encoding negotiation (`Vary` already
97
+ * covers Accept-Encoding, and a 304 to a compressible type repeats it).
98
+ * Callers that omit both options (the dev server) get the exact pre-2.12
99
+ * behavior: 200 bodies stream, nothing is re-encoded, no caching headers.
78
100
  * @param {string} distRoot Absolute path to the build output directory.
79
101
  * @param {string} url Request URL (may include a query string).
80
102
  * @param {http.ServerResponse} res Response to write to.
81
- * @param {{ acceptEncoding?: string | string[] }} [options]
103
+ * @param {{ acceptEncoding?: string | string[], validators?: ConditionalRequest }} [options]
82
104
  * @returns {void}
83
105
  */
84
106
  export function serve(distRoot, url, res, options = {}) {
85
107
  const encoding = negotiateEncoding(options.acceptEncoding);
86
108
  const file = resolvePublicFile(distRoot, url || "/");
87
- if (!file || !isServableFile(file)) {
109
+ const stat = file ? fs.statSync(file, { throwIfNoEntry: false }) : undefined;
110
+ if (!file || !stat?.isFile()) {
88
111
  const notFound = path.join(distRoot, "404.html");
89
- const body = fs.existsSync(notFound)
112
+ const notFoundStat = fs.statSync(notFound, { throwIfNoEntry: false });
113
+ const built = Boolean(notFoundStat?.isFile());
114
+ const body = built
90
115
  ? fs.readFileSync(notFound)
91
116
  : "<h1>Not found</h1><p>This route is hidden or has not been created.</p>";
92
- // The 404 body is always text/html, always compressible.
93
- sendBuffered(
94
- res,
95
- 404,
96
- { "content-type": "text/html; charset=utf-8", ...htmlSecurityHeaders(distRoot, url) },
97
- body,
98
- encoding
99
- );
117
+ // The 404 body is always text/html, always compressible. The BUILT 404 is
118
+ // a real file, so it carries validators like any other HTML response; the
119
+ // inline fallback has no file to validate and stays header-minimal.
120
+ /** @type {Record<string, string>} */
121
+ const headers = {
122
+ "content-type": "text/html; charset=utf-8",
123
+ ...htmlSecurityHeaders(distRoot, url)
124
+ };
125
+ if (options.validators && built) {
126
+ const etag = strongEtag(/** @type {fs.Stats} */ (notFoundStat));
127
+ headers.etag = etag;
128
+ headers["last-modified"] = /** @type {fs.Stats} */ (notFoundStat).mtime.toUTCString();
129
+ headers["cache-control"] = "no-cache";
130
+ if (isFresh(options.validators, etag, /** @type {fs.Stats} */ (notFoundStat))) {
131
+ sendNotModified(res, headers, true);
132
+ return;
133
+ }
134
+ }
135
+ sendBuffered(res, 404, headers, body, encoding);
100
136
  return;
101
137
  }
102
138
  const type = contentType(file);
@@ -104,11 +140,24 @@ export function serve(distRoot, url, res, options = {}) {
104
140
  const headers = type.startsWith("text/html")
105
141
  ? { "content-type": type, ...htmlSecurityHeaders(distRoot, url) }
106
142
  : { "content-type": type };
107
- if (!encoding || !isCompressibleType(type)) {
143
+ const compressible = isCompressibleType(type);
144
+ if (options.validators) {
145
+ const etag = strongEtag(stat);
146
+ headers.etag = etag;
147
+ headers["last-modified"] = stat.mtime.toUTCString();
148
+ headers["cache-control"] = cacheControlFor(url || "/", type);
149
+ // The validators describe the variant the request would select, so the
150
+ // freshness check runs before any compression work.
151
+ if (isFresh(options.validators, etag, stat)) {
152
+ sendNotModified(res, headers, compressible);
153
+ return;
154
+ }
155
+ }
156
+ if (!encoding || !compressible) {
108
157
  // Identity path (or a non-compressible asset): stream, exactly as before.
109
158
  // A compressible type still varies on Accept-Encoding even when this
110
159
  // response is identity, so caches key correctly.
111
- if (isCompressibleType(type)) headers.vary = appendVary(headers.vary, "Accept-Encoding");
160
+ if (compressible) headers.vary = appendVary(headers.vary, "Accept-Encoding");
112
161
  res.writeHead(200, headers);
113
162
  pipeFile(file, res);
114
163
  return;
@@ -126,6 +175,113 @@ export function serve(distRoot, url, res, options = {}) {
126
175
  sendBuffered(res, 200, headers, body, encoding);
127
176
  }
128
177
 
178
+ /** Image and font extensions that earn the long-cache policy outside `/__wd/`. */
179
+ const LONG_CACHE_EXTENSIONS = new Set([
180
+ ".png",
181
+ ".jpg",
182
+ ".jpeg",
183
+ ".gif",
184
+ ".webp",
185
+ ".avif",
186
+ ".ico",
187
+ ".svg",
188
+ ".woff",
189
+ ".woff2",
190
+ ".ttf",
191
+ ".otf"
192
+ ]);
193
+
194
+ /**
195
+ * The Cache-Control for a served file: `no-cache` for HTML (every load
196
+ * revalidates, and the revalidation is a cheap 304), `public, max-age=3600`
197
+ * for framework assets under `/__wd/` and for media/image/font files anywhere
198
+ * (unchanged between builds, and any rebuild moves the validators), and
199
+ * `no-cache` for everything else — a deliberately unopinionated default that
200
+ * keeps correctness for files with no caching contract (routes.json, feeds).
201
+ * @param {string} url Request URL (may include a query string).
202
+ * @param {string} type The resolved content-type.
203
+ * @returns {string}
204
+ */
205
+ function cacheControlFor(url, type) {
206
+ if (type.startsWith("text/html")) return "no-cache";
207
+ const pathname = url.split("?")[0];
208
+ if (pathname.startsWith("/__wd/")) return "public, max-age=3600";
209
+ if (LONG_CACHE_EXTENSIONS.has(path.extname(pathname).toLowerCase())) {
210
+ return "public, max-age=3600";
211
+ }
212
+ return "no-cache";
213
+ }
214
+
215
+ /**
216
+ * A strong ETag for a served file: `"<size>-<mtimeMs>"`. Byte-exact static
217
+ * files change exactly when size or mtime does, so no weak prefix.
218
+ * @param {fs.Stats} stat
219
+ * @returns {string}
220
+ */
221
+ function strongEtag(stat) {
222
+ return `"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
223
+ }
224
+
225
+ /**
226
+ * Whether the request's conditional headers match the file's current
227
+ * validators. `If-None-Match` wins when present (RFC 7232 precedence: an
228
+ * `If-Modified-Since` riding alongside is ignored), and uses weak comparison
229
+ * semantics — a `*` matches anything, otherwise the client token must be the
230
+ * ETag itself (browsers echo back exactly what they received).
231
+ * `If-Modified-Since` compares at second precision, the granularity the
232
+ * Last-Modified header itself carries.
233
+ * @param {ConditionalRequest} request
234
+ * @param {string} etag The file's current ETag.
235
+ * @param {fs.Stats} stat
236
+ * @returns {boolean}
237
+ */
238
+ function isFresh(request, etag, stat) {
239
+ const inm = headerValue(request.ifNoneMatch);
240
+ if (inm !== undefined) {
241
+ return inm
242
+ .split(",")
243
+ .some((candidate) => candidate.trim() === "*" || candidate.trim() === etag);
244
+ }
245
+ const ims = headerValue(request.ifModifiedSince);
246
+ if (ims !== undefined) {
247
+ const time = Date.parse(ims);
248
+ if (Number.isNaN(time)) return false;
249
+ return Math.floor(stat.mtimeMs / 1000) <= Math.floor(time / 1000);
250
+ }
251
+ return false;
252
+ }
253
+
254
+ /**
255
+ * Join a possibly-arrayed header value into one string, or undefined.
256
+ * @param {string | string[] | undefined} value
257
+ * @returns {string | undefined}
258
+ */
259
+ function headerValue(value) {
260
+ return Array.isArray(value) ? value.join(",") : value;
261
+ }
262
+
263
+ /**
264
+ * Answer 304: the validators and Cache-Control again (so the cached entry's
265
+ * freshness state is refreshed), `Vary: Accept-Encoding` for compressible
266
+ * types (the 304 revalidates one variant of several), and no body or body
267
+ * headers — no Content-Type, no Content-Encoding.
268
+ * @param {http.ServerResponse} res
269
+ * @param {Record<string, string>} headers The 200 response's headers.
270
+ * @param {boolean} compressible
271
+ * @returns {void}
272
+ */
273
+ function sendNotModified(res, headers, compressible) {
274
+ /** @type {Record<string, string>} */
275
+ const out = {
276
+ etag: /** @type {string} */ (headers.etag),
277
+ "last-modified": /** @type {string} */ (headers["last-modified"]),
278
+ "cache-control": /** @type {string} */ (headers["cache-control"])
279
+ };
280
+ if (compressible) out.vary = "Accept-Encoding";
281
+ res.writeHead(304, out);
282
+ res.end();
283
+ }
284
+
129
285
  /**
130
286
  * Write a buffered response body for a COMPRESSIBLE content-type, compressing
131
287
  * when an encoding was negotiated. Always appends `Vary: Accept-Encoding` so
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Subset every project-local font referenced by the emitted stylesheets and
3
- * rewrite the dist CSS to lead with the subset woff2.
3
+ * rewrite the dist CSS to lead with the subset woff2. Then inject one font
4
+ * `<link rel="preload" as="font" crossorigin>` per project font into every
5
+ * emitted page that loads it through its own linked stylesheets.
4
6
  *
5
7
  * Skips, none of them errors: remote/protocol-relative/data/relative URLs
6
- * (silently), already-woff2 sources (silently), URLs that escape or miss the
7
- * project (silently — a broken link is an authoring bug, not a subsetting one).
8
- * An unreadable font file, and one the toolchain fails on, get a hint naming
9
- * the file and ship as-is. With no `@font-face` in any emitted stylesheet the
10
- * pass is a no-op and never even scans the HTML.
8
+ * (silently), already-woff2 sources (silently — not subsetted, but preloaded
9
+ * as-is), URLs that escape or miss the project (silently — a broken link is an
10
+ * authoring bug, not a subsetting one). An unreadable font file, and one the
11
+ * toolchain fails on, get a hint naming the file and ship as-is. With no
12
+ * project-font reference in any emitted stylesheet the pass is a no-op and
13
+ * never even scans the HTML.
11
14
  * @param {Paths} paths
12
15
  * @param {{ cacheDir?: string, quiet?: boolean,
13
16
  * runJobs?: (payload: { charset: number[], jobs: SubsetJob[] }) => SubsetResult }} [options]
@@ -68,6 +71,19 @@ export function charsetFromTexts(texts: string[]): number[];
68
71
  * @returns {string}
69
72
  */
70
73
  export function rewriteFontFaceCss(css: string, subsetByUrl: Map<string, string>): string;
74
+ /**
75
+ * Inject one `<link rel="preload" href="…" as="font" type="…" crossorigin>`
76
+ * per font, immediately after the LAST stylesheet link in the head (after the
77
+ * favicon link when the page has no stylesheet link, and not at all when it
78
+ * has neither). `crossorigin` is mandatory: font fetches are always CORS-mode,
79
+ * even same-origin, so a preload without it downloads the font twice.
80
+ * Idempotent — a preload already present (an earlier pass over the same dist)
81
+ * is never duplicated.
82
+ * @param {string} html The emitted page.
83
+ * @param {PreloadFont[]} preloads The fonts to preload, in order.
84
+ * @returns {string} The page, unchanged when there is nothing new to inject.
85
+ */
86
+ export function injectPreloadLinks(html: string, preloads: PreloadFont[]): string;
71
87
  /** The base charset, sorted ascending. */
72
88
  export const BASE_CODEPOINTS: number[];
73
89
  export type Paths = import("./config.js").Paths;
@@ -142,3 +158,35 @@ export type FontRef = {
142
158
  */
143
159
  cacheFile?: string | undefined;
144
160
  };
161
+ /**
162
+ * A project-local font an emitted stylesheet can preload. For a subsettable
163
+ * font this is the FontRef itself; an already-woff2 original carries a null
164
+ * `subsetUrl` (there is nothing to subset — it preloads as-is).
165
+ */
166
+ export type PreloadRef = {
167
+ /**
168
+ * The url() target exactly as written in the CSS.
169
+ */
170
+ url: string;
171
+ /**
172
+ * Absolute path of the font file it resolves to.
173
+ */
174
+ source: string;
175
+ /**
176
+ * Served URL of the subset, when subsettable.
177
+ */
178
+ subsetUrl: string | null;
179
+ };
180
+ /**
181
+ * One preload link to inject into a page.
182
+ */
183
+ export type PreloadFont = {
184
+ /**
185
+ * The URL the page's CSS leads with (subset or original).
186
+ */
187
+ href: string;
188
+ /**
189
+ * The MIME type for the href's extension.
190
+ */
191
+ type: string;
192
+ };
@@ -1,19 +1,41 @@
1
+ /**
2
+ * The conditional-request headers of an incoming request, forwarded by the
3
+ * caller. Passing this object turns on validator-based caching for the
4
+ * response: every served file (200s and the built 404) carries `Last-Modified`
5
+ * (the file's mtime) and a strong `ETag` (`"<size>-<mtimeMs>"`), plus a
6
+ * `Cache-Control` by file class — `no-cache` for HTML (always revalidate, but
7
+ * revalidations 304), `public, max-age=3600` for framework assets under
8
+ * `/__wd/` and media/image/font files (they change only across builds, and a
9
+ * rebuild also changes the validators), and `no-cache` for everything else. A
10
+ * matching `If-None-Match` (which takes precedence) or `If-Modified-Since`
11
+ * gets a bare 304: the validators again, no body, no Content-Encoding.
12
+ * Callers that omit it (the dev server) get no validators at all.
13
+ * @typedef {object} ConditionalRequest
14
+ * @property {string | string[] | undefined} [ifNoneMatch]
15
+ * @property {string | string[] | undefined} [ifModifiedSince]
16
+ */
1
17
  /**
2
18
  * Serve a built file for a request URL out of `distRoot`, or a 404 page.
3
19
  *
4
20
  * When `options.acceptEncoding` carries the request's Accept-Encoding header,
5
21
  * a compressible body (HTML, CSS, JS, JSON, SVG, …) is negotiated and sent
6
22
  * brotli- or gzip-compressed with `Content-Encoding` + `Vary: Accept-Encoding`.
7
- * Callers that omit the option (the dev server) get the exact pre-compression
8
- * behavior: 200 bodies stream, nothing is re-encoded.
23
+ * When `options.validators` carries the request's conditional headers,
24
+ * validator-based caching (ETag/Last-Modified/Cache-Control, 304s) is on —
25
+ * see {@link ConditionalRequest}; the validators describe the selected
26
+ * variant, so they compose with the encoding negotiation (`Vary` already
27
+ * covers Accept-Encoding, and a 304 to a compressible type repeats it).
28
+ * Callers that omit both options (the dev server) get the exact pre-2.12
29
+ * behavior: 200 bodies stream, nothing is re-encoded, no caching headers.
9
30
  * @param {string} distRoot Absolute path to the build output directory.
10
31
  * @param {string} url Request URL (may include a query string).
11
32
  * @param {http.ServerResponse} res Response to write to.
12
- * @param {{ acceptEncoding?: string | string[] }} [options]
33
+ * @param {{ acceptEncoding?: string | string[], validators?: ConditionalRequest }} [options]
13
34
  * @returns {void}
14
35
  */
15
36
  export function serve(distRoot: string, url: string, res: http.ServerResponse, options?: {
16
37
  acceptEncoding?: string | string[];
38
+ validators?: ConditionalRequest;
17
39
  }): void;
18
40
  /**
19
41
  * Pick the response encoding from an Accept-Encoding header value: brotli when
@@ -85,4 +107,21 @@ export function resolvePublicFile(distRoot: string, url: string): string | null;
85
107
  * @returns {string}
86
108
  */
87
109
  export function contentType(file: string): string;
110
+ /**
111
+ * The conditional-request headers of an incoming request, forwarded by the
112
+ * caller. Passing this object turns on validator-based caching for the
113
+ * response: every served file (200s and the built 404) carries `Last-Modified`
114
+ * (the file's mtime) and a strong `ETag` (`"<size>-<mtimeMs>"`), plus a
115
+ * `Cache-Control` by file class — `no-cache` for HTML (always revalidate, but
116
+ * revalidations 304), `public, max-age=3600` for framework assets under
117
+ * `/__wd/` and media/image/font files (they change only across builds, and a
118
+ * rebuild also changes the validators), and `no-cache` for everything else. A
119
+ * matching `If-None-Match` (which takes precedence) or `If-Modified-Since`
120
+ * gets a bare 304: the validators again, no body, no Content-Encoding.
121
+ * Callers that omit it (the dev server) get no validators at all.
122
+ */
123
+ export type ConditionalRequest = {
124
+ ifNoneMatch?: string | string[] | undefined;
125
+ ifModifiedSince?: string | string[] | undefined;
126
+ };
88
127
  import http from "node:http";