htmx-router 2.2.6 → 2.2.8

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/dist/etag.d.ts ADDED
@@ -0,0 +1,218 @@
1
+ type ResourceOptions = {
2
+ etag?: string;
3
+ lastModified?: Date;
4
+ revalidate?: number;
5
+ public?: boolean;
6
+ };
7
+ /**
8
+ * @deprecated use {@link AssertResourceFresh} instead
9
+ */
10
+ export declare function AssertETagStale(request: Request, headers: Headers, etag: string, options?: {
11
+ revalidate?: number;
12
+ public?: boolean;
13
+ }): void;
14
+ /**
15
+ * Asserts the freshness of a resource using ETag or Last-Modified headers to manage conditional HTTP requests.
16
+ * This function acts as a guard: it evaluates the request against the provided resource state and
17
+ * throws a Response to intercept the execution flow if the cache state requires it.
18
+ *
19
+ * @param {Request} request - The incoming HTTP request object
20
+ * @param {Headers} headers - The response headers object to modify
21
+ * @param {Object} options - Configuration for freshness validation
22
+ * @param {string} [options.etag] - The current ETag value for the resource (do not quote)
23
+ * @param {Date} [options.lastModified] - The last modified timestamp of the resource
24
+ * @param {number} [options.revalidate] - Client must revalidate their cache at this interval in seconds
25
+ * @param {boolean} [options.public] - Cache visibility scope:
26
+ * - true: Can be cached by any cache (e.g., Cloudflare)
27
+ * - false: Can only be cached by private caches (e.g., browser)
28
+ *
29
+ * @throws {Response} `304 Not Modified`
30
+ * For `GET` or `HEAD` requests when the client's cache is still valid (stale state).
31
+ *
32
+ * @throws {Response} `412 Precondition Failed`
33
+ * - When the client's conditional headers do not match the current resource state.
34
+ * - For non-idempotent methods (`POST`, `PUT`, `PATCH`, `DELETE`) when the resource state is stale.
35
+ *
36
+ * @returns {void} If the resource is fresh or the request is valid, allows execution to continue.
37
+ *
38
+ * @example
39
+ * Basic usage with ETag
40
+ * ```ts
41
+ * AssertResourceFresh(request, headers, { etag: "v1.2.3" });
42
+ * ```
43
+ *
44
+ * @example
45
+ * Usage with Last-Modified and caching options
46
+ * ```ts
47
+ * AssertResourceFresh(request, headers, {
48
+ * lastModified: new Date("2026-01-01T00:00:00Z"),
49
+ * revalidate: 3600,
50
+ * public: true
51
+ * });
52
+ * ```
53
+ *
54
+ * @example
55
+ * Usage in a handler function
56
+ * ```ts
57
+ * async function handleRequest(request, headers) {
58
+ * const data = await fetchData();
59
+ * const etag = GenerateEtag(data);
60
+ *
61
+ * // Will throw 304 if the client's cache is still valid
62
+ * // Or throw 412 if preconditions fail
63
+ * AssertResourceFresh(request, headers, { etag });
64
+ *
65
+ * // This code only runs if the client needs updated content
66
+ * return new Response(JSON.stringify(data), { headers });
67
+ * }
68
+ * ```
69
+ *
70
+ * @see {@link GetResourceState} For the detailed breakdown of the underlying state logic
71
+ */
72
+ export declare function AssertResourceFresh(request: Request, headers: Headers, options: {
73
+ etag?: string;
74
+ lastModified?: Date;
75
+ revalidate?: number;
76
+ public?: boolean;
77
+ }): void;
78
+ /**
79
+ * A predicate function that checks if the resource is "fresh" (i.e., it has been
80
+ * updated or changed since the client's last cached version).
81
+ *
82
+ * Use this to determine if you need to proceed with generating a full response
83
+ * or if the client can continue using their current cached version.
84
+ *
85
+ * @param {Request} request - The incoming HTTP request object
86
+ * @param {Headers} headers - The response headers object
87
+ * @param {ResourceOptions} options - The current metadata of the resource (ETag, Last-Modified, etc.)
88
+ *
89
+ * @returns {boolean} `true` if the resource is fresh (has changed and needs to be sent),
90
+ * `false` if the resource is stale (client's cache is still valid) or a precondition has failed.
91
+ *
92
+ * @example
93
+ * Check if we need to send new data
94
+ * ```ts
95
+ * if (IsResourceFresh(request, headers, { etag: currentEtag })) {
96
+ * return new Response(JSON.stringify(data), { headers });
97
+ * } else {
98
+ * // The resource is stale (client has it) or preconditions failed
99
+ * // Handle accordingly (e.g., return 304 or 412)
100
+ * }
101
+ * ```
102
+ *
103
+ * @example
104
+ * Using it with more complex options
105
+ * ```ts
106
+ * const options = {
107
+ * etag: "v2",
108
+ * revalidate: 300,
109
+ * public: true
110
+ * };
111
+ *
112
+ * if (IsResourceFresh(request, headers, options)) {
113
+ * // Generate full response...
114
+ * }
115
+ * ```
116
+ *
117
+ * @see {@link GetResourceState} For the detailed breakdown of the underlying state logic.
118
+ */
119
+ export declare function IsResourceFresh(request: Request, headers: Headers, options: ResourceOptions): boolean;
120
+ /**
121
+ * Evaluates the freshness and precondition status of a resource by comparing
122
+ * the incoming request's conditional headers against the current resource metadata.
123
+ *
124
+ * This function performs the core logic to determine if a client's cached version
125
+ * is still valid, needs updating, or if a conditional request (like `If-Match`)
126
+ * has failed.
127
+ *
128
+ * @param {Request} request - The incoming HTTP request object containing conditional headers
129
+ * @param {Headers} headers - The response headers object to be used during evaluation
130
+ * @param {Object} options - The current metadata of the resource being requested
131
+ * @param {string} [options.etag] - The current ETag value of the resource
132
+ * @param {Date} [options.lastModified] - The last modified timestamp of the resource
133
+ * @param {number} [options.revalidate] - The revalidation interval in seconds
134
+ * @param {boolean} [options.public] - Cache visibility scope:
135
+ * - true: Can be cached by any cache (e.g., Cloudflare)
136
+ * - false: Can only be cached by private caches (e.g., browser)
137
+ *
138
+ * @returns {'FRESH' | 'STALE' | 'PRECONDITION FAILED'} The calculated state:
139
+ * - `'FRESH'`: The client's cache is outdated; new content should be sent.
140
+ * - `'STALE'`: The client's cache is valid; no update is required.
141
+ * - `'PRECONDITION FAILED'`: The client's conditional headers (e.g., `If-Match`) did not match the current resource state.
142
+ *
143
+ * @example
144
+ * Manual state checking
145
+ * ```ts
146
+ * const state = GetResourceState(request, responseHeaders, { etag: "v1.2.3" });
147
+ *
148
+ * if (state === 'FRESH') {
149
+ * console.log("Client needs new data");
150
+ * } else if (state === 'PRECONDITION FAILED') {
151
+ * console.log("Client's precondition check failed");
152
+ * }
153
+ * ```
154
+ *
155
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | ETag - MDN Web Docs}
156
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match | If-None-Match - MDN Web Docs}
157
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match | If-Match - MDN Web Docs}
158
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since | If-Modified-Since - MDN Web Docs}
159
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since | If-Unmodified-Since - MDN Web Docs}
160
+ */
161
+ export declare function GetResourceState(request: Request, headers: Headers, options: {
162
+ etag?: string;
163
+ lastModified?: Date;
164
+ revalidate?: number;
165
+ public?: boolean;
166
+ }): 'FRESH' | 'STALE' | 'PRECONDITION FAILED';
167
+ /**
168
+ * Generates a deterministic ETag (Entity Tag) for various data types.
169
+ *
170
+ * The generation strategy varies based on the input type to balance performance and collision resistance:
171
+ * - **Dates & Numbers**: Uses a lightweight base-36 encoding for speed.
172
+ * - **Arrays**: Joins elements with `:` and returns the string if shorter than the hash would; otherwise, hashes the result.
173
+ * - **Strings, Uint8Arrays, & ArrayBuffers**: Produces a SHA-256 hex digest.
174
+ *
175
+ * @param {string | Uint8Array | ArrayBuffer | Date | number | number[]} data - The content to represent.
176
+ *
177
+ * @returns {Promise<string>} A unique string identifier:
178
+ * - A base-36 string (for `Date` and `number`).
179
+ * - A colon-separated string (for short `number[]`).
180
+ * - A hex-encoded SHA-256 hash (for strings, buffers, or long arrays).
181
+ *
182
+ * @example
183
+ * Generating an ETag for a Date (Base-36)
184
+ * ```ts
185
+ * const dateEtag = await GenerateETag(new Date());
186
+ * ```
187
+ *
188
+ * @example
189
+ * Generating an ETag for a number (Base-36)
190
+ * ```ts
191
+ * const numEtag = await GenerateETag(12345);
192
+ * ```
193
+ *
194
+ * @example
195
+ * Generating an ETag for a large string (SHA-256 Hash)
196
+ * ```ts
197
+ * const content = "Large payload...";
198
+ * const stringEtag = await GenerateETag(content);
199
+ * ```
200
+ *
201
+ * @throws {Error} If the cryptographic operation fails.
202
+ */
203
+ export declare function GenerateETag(data: string | Uint8Array | ArrayBuffer | Date | Number | Number[]): Promise<string>;
204
+ /**
205
+ * Sets the `ETag` header on the provided `Headers` object.
206
+ *
207
+ * This utility ensures the ETag is compliant with HTTP standards and safe for transport by:
208
+ * 1. Trimming leading/trailing whitespace.
209
+ * 2. Applying URI encoding to safely handle special characters.
210
+ * 3. Wrapping the resulting value in double quotes as required by RFC standards.
211
+ *
212
+ * @param {Headers} headers - The response headers object to modify.
213
+ * @param {string} etag - The raw ETag value (e.g., a hash or a version string).
214
+ *
215
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | ETag - MDN Web Docs}
216
+ */
217
+ export declare function SetETag(headers: Headers, etag: string): void;
218
+ export {};
package/dist/etag.js ADDED
@@ -0,0 +1,339 @@
1
+ import crypto from 'node:crypto';
2
+ /**
3
+ * @deprecated use {@link AssertResourceFresh} instead
4
+ */
5
+ export function AssertETagStale(request, headers, etag, options) {
6
+ return AssertResourceFresh(request, headers, { etag, ...options });
7
+ }
8
+ /**
9
+ * Asserts the freshness of a resource using ETag or Last-Modified headers to manage conditional HTTP requests.
10
+ * This function acts as a guard: it evaluates the request against the provided resource state and
11
+ * throws a Response to intercept the execution flow if the cache state requires it.
12
+ *
13
+ * @param {Request} request - The incoming HTTP request object
14
+ * @param {Headers} headers - The response headers object to modify
15
+ * @param {Object} options - Configuration for freshness validation
16
+ * @param {string} [options.etag] - The current ETag value for the resource (do not quote)
17
+ * @param {Date} [options.lastModified] - The last modified timestamp of the resource
18
+ * @param {number} [options.revalidate] - Client must revalidate their cache at this interval in seconds
19
+ * @param {boolean} [options.public] - Cache visibility scope:
20
+ * - true: Can be cached by any cache (e.g., Cloudflare)
21
+ * - false: Can only be cached by private caches (e.g., browser)
22
+ *
23
+ * @throws {Response} `304 Not Modified`
24
+ * For `GET` or `HEAD` requests when the client's cache is still valid (stale state).
25
+ *
26
+ * @throws {Response} `412 Precondition Failed`
27
+ * - When the client's conditional headers do not match the current resource state.
28
+ * - For non-idempotent methods (`POST`, `PUT`, `PATCH`, `DELETE`) when the resource state is stale.
29
+ *
30
+ * @returns {void} If the resource is fresh or the request is valid, allows execution to continue.
31
+ *
32
+ * @example
33
+ * Basic usage with ETag
34
+ * ```ts
35
+ * AssertResourceFresh(request, headers, { etag: "v1.2.3" });
36
+ * ```
37
+ *
38
+ * @example
39
+ * Usage with Last-Modified and caching options
40
+ * ```ts
41
+ * AssertResourceFresh(request, headers, {
42
+ * lastModified: new Date("2026-01-01T00:00:00Z"),
43
+ * revalidate: 3600,
44
+ * public: true
45
+ * });
46
+ * ```
47
+ *
48
+ * @example
49
+ * Usage in a handler function
50
+ * ```ts
51
+ * async function handleRequest(request, headers) {
52
+ * const data = await fetchData();
53
+ * const etag = GenerateEtag(data);
54
+ *
55
+ * // Will throw 304 if the client's cache is still valid
56
+ * // Or throw 412 if preconditions fail
57
+ * AssertResourceFresh(request, headers, { etag });
58
+ *
59
+ * // This code only runs if the client needs updated content
60
+ * return new Response(JSON.stringify(data), { headers });
61
+ * }
62
+ * ```
63
+ *
64
+ * @see {@link GetResourceState} For the detailed breakdown of the underlying state logic
65
+ */
66
+ export function AssertResourceFresh(request, headers, options) {
67
+ const state = GetResourceState(request, headers, options);
68
+ function PreconditionFailed() {
69
+ headers.set("X-Caught", "true");
70
+ throw new Response(null, { headers, status: 412, statusText: 'Precondition Failed' });
71
+ }
72
+ function Stale() {
73
+ headers.set("X-Caught", "true");
74
+ throw new Response(null, { headers, status: 304, statusText: 'Not Modified' });
75
+ }
76
+ if (request.method === 'GET' || request.method === 'HEAD') {
77
+ switch (state) {
78
+ case 'PRECONDITION FAILED': return PreconditionFailed();
79
+ case 'STALE': return Stale();
80
+ case 'FRESH': return; // continue with response generation
81
+ }
82
+ }
83
+ else {
84
+ switch (state) {
85
+ case 'PRECONDITION FAILED': return PreconditionFailed();
86
+ case 'STALE': return PreconditionFailed(); // always treat as pre-condition on post/patch/put/delete
87
+ case 'FRESH': return; // continue with response generation
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * A predicate function that checks if the resource is "fresh" (i.e., it has been
93
+ * updated or changed since the client's last cached version).
94
+ *
95
+ * Use this to determine if you need to proceed with generating a full response
96
+ * or if the client can continue using their current cached version.
97
+ *
98
+ * @param {Request} request - The incoming HTTP request object
99
+ * @param {Headers} headers - The response headers object
100
+ * @param {ResourceOptions} options - The current metadata of the resource (ETag, Last-Modified, etc.)
101
+ *
102
+ * @returns {boolean} `true` if the resource is fresh (has changed and needs to be sent),
103
+ * `false` if the resource is stale (client's cache is still valid) or a precondition has failed.
104
+ *
105
+ * @example
106
+ * Check if we need to send new data
107
+ * ```ts
108
+ * if (IsResourceFresh(request, headers, { etag: currentEtag })) {
109
+ * return new Response(JSON.stringify(data), { headers });
110
+ * } else {
111
+ * // The resource is stale (client has it) or preconditions failed
112
+ * // Handle accordingly (e.g., return 304 or 412)
113
+ * }
114
+ * ```
115
+ *
116
+ * @example
117
+ * Using it with more complex options
118
+ * ```ts
119
+ * const options = {
120
+ * etag: "v2",
121
+ * revalidate: 300,
122
+ * public: true
123
+ * };
124
+ *
125
+ * if (IsResourceFresh(request, headers, options)) {
126
+ * // Generate full response...
127
+ * }
128
+ * ```
129
+ *
130
+ * @see {@link GetResourceState} For the detailed breakdown of the underlying state logic.
131
+ */
132
+ export function IsResourceFresh(request, headers, options) {
133
+ const state = GetResourceState(request, headers, options);
134
+ return state === 'FRESH';
135
+ }
136
+ /**
137
+ * Evaluates the freshness and precondition status of a resource by comparing
138
+ * the incoming request's conditional headers against the current resource metadata.
139
+ *
140
+ * This function performs the core logic to determine if a client's cached version
141
+ * is still valid, needs updating, or if a conditional request (like `If-Match`)
142
+ * has failed.
143
+ *
144
+ * @param {Request} request - The incoming HTTP request object containing conditional headers
145
+ * @param {Headers} headers - The response headers object to be used during evaluation
146
+ * @param {Object} options - The current metadata of the resource being requested
147
+ * @param {string} [options.etag] - The current ETag value of the resource
148
+ * @param {Date} [options.lastModified] - The last modified timestamp of the resource
149
+ * @param {number} [options.revalidate] - The revalidation interval in seconds
150
+ * @param {boolean} [options.public] - Cache visibility scope:
151
+ * - true: Can be cached by any cache (e.g., Cloudflare)
152
+ * - false: Can only be cached by private caches (e.g., browser)
153
+ *
154
+ * @returns {'FRESH' | 'STALE' | 'PRECONDITION FAILED'} The calculated state:
155
+ * - `'FRESH'`: The client's cache is outdated; new content should be sent.
156
+ * - `'STALE'`: The client's cache is valid; no update is required.
157
+ * - `'PRECONDITION FAILED'`: The client's conditional headers (e.g., `If-Match`) did not match the current resource state.
158
+ *
159
+ * @example
160
+ * Manual state checking
161
+ * ```ts
162
+ * const state = GetResourceState(request, responseHeaders, { etag: "v1.2.3" });
163
+ *
164
+ * if (state === 'FRESH') {
165
+ * console.log("Client needs new data");
166
+ * } else if (state === 'PRECONDITION FAILED') {
167
+ * console.log("Client's precondition check failed");
168
+ * }
169
+ * ```
170
+ *
171
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | ETag - MDN Web Docs}
172
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match | If-None-Match - MDN Web Docs}
173
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match | If-Match - MDN Web Docs}
174
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since | If-Modified-Since - MDN Web Docs}
175
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since | If-Unmodified-Since - MDN Web Docs}
176
+ */
177
+ export function GetResourceState(request, headers, options) {
178
+ headers.delete("Cache-Control"); // clear any defaults
179
+ // only apply cache control if something was actually provided in the options
180
+ if (options.etag || options.lastModified || options.public !== undefined || options.revalidate !== undefined) {
181
+ // default to private, because it's the slightly less worse of the two potential foot guns
182
+ if (options.public)
183
+ headers.append("Cache-Control", "public");
184
+ else
185
+ headers.append("Cache-Control", "private");
186
+ if (options.revalidate !== undefined)
187
+ headers.append("Cache-Control", `max-age=${options.revalidate}`);
188
+ }
189
+ if (options.etag)
190
+ headers.append("Cache-Control", "must-revalidate");
191
+ if (options?.lastModified)
192
+ headers.append("Last-Modified", options.lastModified.toUTCString());
193
+ if (options.etag) {
194
+ SetETag(headers, options.etag);
195
+ const match = CheckETag(request, options.etag);
196
+ if (match) {
197
+ if (match.some === false)
198
+ return 'PRECONDITION FAILED';
199
+ // match.some === true, no-op still check dates
200
+ if (match.none === false)
201
+ return 'STALE';
202
+ // match.none === true, no-op still check dates
203
+ }
204
+ }
205
+ if (options?.lastModified) {
206
+ // HTTP dates only have second precision
207
+ function StripMs(time) {
208
+ return Math.floor(time / 1000) * 1000;
209
+ }
210
+ const actual = StripMs(options.lastModified.getTime());
211
+ if (isNaN(actual))
212
+ return 'FRESH';
213
+ const modified = request.headers.get('If-Modified-Since');
214
+ if (modified) {
215
+ const expected = StripMs(new Date(modified).getTime());
216
+ if (!isNaN(expected) && actual <= expected)
217
+ return 'STALE';
218
+ }
219
+ const unmodified = request.headers.get('If-Unmodified-Since');
220
+ if (unmodified) {
221
+ const expected = StripMs(new Date(unmodified).getTime());
222
+ if (!isNaN(expected) && actual > expected)
223
+ return 'PRECONDITION FAILED';
224
+ }
225
+ }
226
+ return 'FRESH';
227
+ }
228
+ /**
229
+ * Generates a deterministic ETag (Entity Tag) for various data types.
230
+ *
231
+ * The generation strategy varies based on the input type to balance performance and collision resistance:
232
+ * - **Dates & Numbers**: Uses a lightweight base-36 encoding for speed.
233
+ * - **Arrays**: Joins elements with `:` and returns the string if shorter than the hash would; otherwise, hashes the result.
234
+ * - **Strings, Uint8Arrays, & ArrayBuffers**: Produces a SHA-256 hex digest.
235
+ *
236
+ * @param {string | Uint8Array | ArrayBuffer | Date | number | number[]} data - The content to represent.
237
+ *
238
+ * @returns {Promise<string>} A unique string identifier:
239
+ * - A base-36 string (for `Date` and `number`).
240
+ * - A colon-separated string (for short `number[]`).
241
+ * - A hex-encoded SHA-256 hash (for strings, buffers, or long arrays).
242
+ *
243
+ * @example
244
+ * Generating an ETag for a Date (Base-36)
245
+ * ```ts
246
+ * const dateEtag = await GenerateETag(new Date());
247
+ * ```
248
+ *
249
+ * @example
250
+ * Generating an ETag for a number (Base-36)
251
+ * ```ts
252
+ * const numEtag = await GenerateETag(12345);
253
+ * ```
254
+ *
255
+ * @example
256
+ * Generating an ETag for a large string (SHA-256 Hash)
257
+ * ```ts
258
+ * const content = "Large payload...";
259
+ * const stringEtag = await GenerateETag(content);
260
+ * ```
261
+ *
262
+ * @throws {Error} If the cryptographic operation fails.
263
+ */
264
+ export async function GenerateETag(data) {
265
+ if (data instanceof Date)
266
+ return data.getTime().toString(36);
267
+ if (typeof data === 'number')
268
+ return data.toString(36);
269
+ if (Array.isArray(data)) {
270
+ data = data.join(':');
271
+ if (data.length < 64)
272
+ return data;
273
+ }
274
+ let view;
275
+ if (typeof data === 'string') {
276
+ view = new TextEncoder().encode(data).buffer;
277
+ }
278
+ else if (data instanceof Uint8Array) {
279
+ view = data.buffer;
280
+ }
281
+ else {
282
+ view = data;
283
+ }
284
+ const hash = await crypto.subtle.digest('SHA-256', view);
285
+ const chars = [];
286
+ for (const byte of new Uint8Array(hash))
287
+ chars.push(byte.toString(16).padStart(2, '0'));
288
+ return chars.join('');
289
+ }
290
+ /**
291
+ * Sets the `ETag` header on the provided `Headers` object.
292
+ *
293
+ * This utility ensures the ETag is compliant with HTTP standards and safe for transport by:
294
+ * 1. Trimming leading/trailing whitespace.
295
+ * 2. Applying URI encoding to safely handle special characters.
296
+ * 3. Wrapping the resulting value in double quotes as required by RFC standards.
297
+ *
298
+ * @param {Headers} headers - The response headers object to modify.
299
+ * @param {string} etag - The raw ETag value (e.g., a hash or a version string).
300
+ *
301
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | ETag - MDN Web Docs}
302
+ */
303
+ export function SetETag(headers, etag) {
304
+ etag = encodeURIComponent(etag.trim()); // safely handle any special characters
305
+ headers.set("ETag", `"${etag}"`);
306
+ }
307
+ function GetETagRules(input) {
308
+ const target = input instanceof Request ? input.headers : input;
309
+ const some = target.get("if-match")?.trim();
310
+ const none = target.get("if-none-match")?.trim();
311
+ return { some, none };
312
+ }
313
+ function CheckETag(input, etag) {
314
+ const headers = input instanceof Request ? input.headers : input;
315
+ return CheckRules(GetETagRules(headers), etag);
316
+ }
317
+ function CheckRules(rules, etag) {
318
+ if (!rules)
319
+ return { some: undefined, none: undefined, valid: true };
320
+ const some = rules.some ? MatchRules(rules.some, etag) : undefined;
321
+ const none = rules.none ? !MatchRules(rules.none, etag) : undefined;
322
+ const vSome = some || some === undefined;
323
+ const vNone = none || none === undefined;
324
+ return { some, none, valid: vSome && vNone };
325
+ }
326
+ function MatchRules(header, etag) {
327
+ if (header === "*")
328
+ return true;
329
+ for (const term of header.split(/,\s*/)) {
330
+ let s = term.startsWith('W/') ? 'W/'.length : 0;
331
+ if (term.startsWith('"', s))
332
+ s++;
333
+ const e = term.endsWith('"') ? term.length - 1 : term.length;
334
+ const tag = term.slice(s, e);
335
+ if (etag === tag)
336
+ return true;
337
+ }
338
+ return false;
339
+ }
@@ -34,7 +34,7 @@ export declare class EventSourceSet<JsxEnabled extends boolean = false> extends
34
34
  * Send update to all EventSources, auto closing failed dispatches
35
35
  * @returns number of successful sends
36
36
  */
37
- dispatch(type: string, data: string): number;
37
+ dispatch(type: string, data: JsxEnabled extends true ? (JSX.Element | string) : string): number;
38
38
  /**
39
39
  * Cull all closed connections
40
40
  * @returns number of connections closed
@@ -60,7 +60,7 @@ export declare class EventSourceMap<T, JsxEnabled extends boolean = false> exten
60
60
  * Send update to all EventSources, auto closing failed dispatches
61
61
  * @returns number of successful sends
62
62
  */
63
- dispatch(type: string, data: string): number;
63
+ dispatch(type: string, data: JsxEnabled extends true ? (JSX.Element | string) : string): number;
64
64
  /**
65
65
  * Cull all closed connections
66
66
  * @returns number of connections closed
@@ -1,3 +1,4 @@
1
+ import * as etag from "./etag";
1
2
  import { ResponseInit } from "./util/types";
2
3
  export declare function text(text: BodyInit, init?: ResponseInit): Response;
3
4
  export declare function html(text: BodyInit, init?: ResponseInit): Response;
@@ -15,49 +16,6 @@ export declare function refresh(init?: ResponseInit & {
15
16
  clientOnly?: boolean;
16
17
  }): Response;
17
18
  /**
18
- * Handles Entity-Tag based conditional requests by setting cache headers and controlling execution flow.
19
- * Acts like an assertion that stops execution when the client's ETag matches the current ETag.
20
- *
21
- * @param {Request} request - The incoming HTTP request object
22
- * @param {Headers} headers - The output response headers object to modify
23
- * @param {string} etag - The current ETag value for the resource (do not quote)
24
- * @param {Object} [options] - Optional caching configuration
25
- * @param {number} [options.revalidate] - client must revalidate their etag at this interval in seconds
26
- * @param {boolean} [options.public] - cache visibility scope:
27
- * - "public": Can be cached by any cache (i.e. Cloudflare)
28
- * - "private": Can only be cached by private caches (i.e. browser)
29
- *
30
- * @throws {Response} `304 Not Modified` Response with the configured headers
31
- * when the client's If-None-Match header matches the provided ETag
32
- *
33
- * @returns {void} if client's etag is stale, allows execution to continue to generate new result
34
- *
35
- * @example
36
- * // Basic usage - will return early if client has current version
37
- * GuardEntityTag(request, headers, "v1.2.3");
38
- *
39
- * @example
40
- * // With caching options
41
- * GuardEntityTag(request, headers, "v1.2.3", {
42
- * revalidate: 3600,
43
- * scope: "public"
44
- * });
45
- *
46
- * @example
47
- * // Usage in a handler function
48
- * function handleRequest(request, headers) {
49
- * const currentEtag = generateEtag(data);
50
- *
51
- * // Will throw 304 if client ETag is fresh
52
- * GuardEntityTag(request, headers, currentEtag);
53
- *
54
- * // This code only runs if client needs updated content
55
- * return new Response(generateContent(), { headers });
56
- * }
57
- *
58
- * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | ETag - MDN Web Docs }
19
+ * @deprecated import from `htmx-router/etag` instead
59
20
  */
60
- export declare function AssertETagStale(request: Request, headers: Headers, etag: string, options?: {
61
- revalidate?: number;
62
- public?: boolean;
63
- }): void;
21
+ export declare const AssertETagStale: typeof etag.AssertETagStale;
package/dist/response.js CHANGED
@@ -1,3 +1,4 @@
1
+ import * as etag from "./etag";
1
2
  export function text(text, init) {
2
3
  init = FillResponseInit(200, "Ok", init);
3
4
  const res = new Response(text, init);
@@ -55,83 +56,9 @@ export function refresh(init) {
55
56
  return res;
56
57
  }
57
58
  /**
58
- * Handles Entity-Tag based conditional requests by setting cache headers and controlling execution flow.
59
- * Acts like an assertion that stops execution when the client's ETag matches the current ETag.
60
- *
61
- * @param {Request} request - The incoming HTTP request object
62
- * @param {Headers} headers - The output response headers object to modify
63
- * @param {string} etag - The current ETag value for the resource (do not quote)
64
- * @param {Object} [options] - Optional caching configuration
65
- * @param {number} [options.revalidate] - client must revalidate their etag at this interval in seconds
66
- * @param {boolean} [options.public] - cache visibility scope:
67
- * - "public": Can be cached by any cache (i.e. Cloudflare)
68
- * - "private": Can only be cached by private caches (i.e. browser)
69
- *
70
- * @throws {Response} `304 Not Modified` Response with the configured headers
71
- * when the client's If-None-Match header matches the provided ETag
72
- *
73
- * @returns {void} if client's etag is stale, allows execution to continue to generate new result
74
- *
75
- * @example
76
- * // Basic usage - will return early if client has current version
77
- * GuardEntityTag(request, headers, "v1.2.3");
78
- *
79
- * @example
80
- * // With caching options
81
- * GuardEntityTag(request, headers, "v1.2.3", {
82
- * revalidate: 3600,
83
- * scope: "public"
84
- * });
85
- *
86
- * @example
87
- * // Usage in a handler function
88
- * function handleRequest(request, headers) {
89
- * const currentEtag = generateEtag(data);
90
- *
91
- * // Will throw 304 if client ETag is fresh
92
- * GuardEntityTag(request, headers, currentEtag);
93
- *
94
- * // This code only runs if client needs updated content
95
- * return new Response(generateContent(), { headers });
96
- * }
97
- *
98
- * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | ETag - MDN Web Docs }
59
+ * @deprecated import from `htmx-router/etag` instead
99
60
  */
100
- export function AssertETagStale(request, headers, etag, options) {
101
- headers.delete("Cache-Control"); // clear any defaults
102
- if (options) {
103
- // default to private, because it's the slightly less worse of the two potential foot guns
104
- if (options.public)
105
- headers.append("Cache-Control", "public");
106
- else
107
- headers.append("Cache-Control", "private");
108
- if (options.revalidate !== undefined)
109
- headers.append("Cache-Control", `max-age=${options.revalidate}`);
110
- }
111
- headers.append("Cache-Control", "must-revalidate");
112
- etag = encodeURIComponent(etag.trim()); // safely handle any special characters
113
- headers.set("ETag", `"${etag}"`);
114
- const rules = request.headers.get("if-none-match");
115
- if (!rules || !MatchEtags(rules.trim(), etag))
116
- return;
117
- const res = new Response(null, { headers, status: 304, statusText: "Not Modified" });
118
- res.headers.set("X-Caught", "true");
119
- throw res;
120
- }
121
- function MatchEtags(header, etag) {
122
- if (header === "*")
123
- return true;
124
- for (const term of header.split(/,\s*/)) {
125
- let s = term.startsWith('W/') ? 'W/'.length : 0;
126
- let e = term.endsWith('"') ? term.length - 1 : term.length;
127
- if (term.startsWith('"', s))
128
- s++;
129
- const tag = term.slice(s, e);
130
- if (etag === tag)
131
- return true;
132
- }
133
- return false;
134
- }
61
+ export const AssertETagStale = etag.AssertETagStale;
135
62
  /**
136
63
  * This is to fix issues with deno
137
64
  * When you try and change the statusText on a Response object
package/dist/router.d.ts CHANGED
@@ -27,6 +27,11 @@ export declare class RouteResolver {
27
27
  resolve(): Promise<Response | null>;
28
28
  unwind(e: unknown, offset: number): Promise<Response>;
29
29
  }
30
+ type IngestContext = {
31
+ path: string[];
32
+ route: string;
33
+ params: string[];
34
+ };
30
35
  export declare class RouteTree {
31
36
  private nested;
32
37
  private index;
@@ -34,12 +39,13 @@ export declare class RouteTree {
34
39
  private wild;
35
40
  private wildCard;
36
41
  constructor();
37
- ingest(node: RouteLeaf, path?: string[]): void;
42
+ ingest(node: RouteLeaf, ctx?: IngestContext): void;
38
43
  _applyChain(out: RouteResolver, fragments: string[], offset?: number): void;
39
44
  }
40
45
  declare class RouteLeaf {
41
46
  readonly module: RouteModule<any>;
42
47
  readonly path: string;
43
48
  constructor(module: RouteModule<any>, path: string);
49
+ checkParameters(ctx: IngestContext): void;
44
50
  }
45
51
  export {};
package/dist/router.js CHANGED
@@ -148,19 +148,28 @@ export class RouteTree {
148
148
  this.wild = null;
149
149
  this.slug = null;
150
150
  }
151
- ingest(node, path) {
152
- if (!path)
153
- path = node.path.length === 0 ? [] : node.path.slice(1).split("/");
154
- if (path.length === 0) {
151
+ ingest(node, ctx) {
152
+ if (!ctx)
153
+ ctx = {
154
+ path: node.path.slice(1).split("/").reverse(),
155
+ route: node.path,
156
+ params: [],
157
+ };
158
+ const segment = ctx.path.pop();
159
+ if (!segment) {
160
+ node.checkParameters(ctx);
155
161
  this.index = node;
156
162
  return;
157
163
  }
158
- if (path[0] === "$") {
164
+ if (segment === "$") {
165
+ ctx.params.push('$');
166
+ node.checkParameters(ctx);
159
167
  this.slug = node;
160
168
  return;
161
169
  }
162
- if (path[0][0] === "$") {
163
- const wildCard = path[0].slice(1);
170
+ if (segment[0] === "$") {
171
+ const wildCard = segment.slice(1);
172
+ ctx.params.push(wildCard);
164
173
  // Check wildcard isn't being changed
165
174
  if (!this.wild) {
166
175
  this.wildCard = wildCard;
@@ -169,15 +178,15 @@ export class RouteTree {
169
178
  else if (wildCard !== this.wildCard) {
170
179
  throw new Error(`Redefinition of wild card ${this.wildCard} to ${wildCard}`);
171
180
  }
172
- this.wild.ingest(node, path.slice(1));
181
+ this.wild.ingest(node, ctx);
173
182
  return;
174
183
  }
175
- let next = this.nested.get(path[0]);
184
+ let next = this.nested.get(segment);
176
185
  if (!next) {
177
186
  next = new RouteTree();
178
- this.nested.set(path[0], next);
187
+ this.nested.set(segment, next);
179
188
  }
180
- next.ingest(node, path.slice(1));
189
+ next.ingest(node, ctx);
181
190
  }
182
191
  _applyChain(out, fragments, offset = 0) {
183
192
  if (this.slug) {
@@ -205,4 +214,13 @@ class RouteLeaf {
205
214
  this.module = module;
206
215
  this.path = path;
207
216
  }
217
+ checkParameters(ctx) {
218
+ if (!this.module.parameters)
219
+ return;
220
+ for (const key in this.module.parameters) {
221
+ if (ctx.params.includes(key))
222
+ continue;
223
+ console.warn(`\x1b[33mWarn:\x1b[0m \x1b[36m${ctx.route}\x1b[0m has no parameter \x1b[36m${key}\x1b[0m`);
224
+ }
225
+ }
208
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmx-router",
3
- "version": "2.2.6",
3
+ "version": "2.2.8",
4
4
  "description": "A lightweight SSR framework with server+client islands",
5
5
  "keywords": [ "htmx", "router", "client islands", "ssr", "vite" ],
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "./defer": "./dist/defer.js",
16
16
  "./endpoint": "./dist/endpoint.js",
17
17
  "./event": "./dist/event.js",
18
+ "./etag": "./dist/etag.js",
18
19
  "./event-source": "./dist/event-source.js",
19
20
  "./navigate": "./dist/navigate.js",
20
21
  "./response": "./dist/response.js",