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 +218 -0
- package/dist/etag.js +339 -0
- package/dist/event-source.d.ts +2 -2
- package/dist/response.d.ts +3 -45
- package/dist/response.js +3 -76
- package/dist/router.d.ts +7 -1
- package/dist/router.js +29 -11
- package/package.json +2 -1
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
|
+
}
|
package/dist/event-source.d.ts
CHANGED
|
@@ -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
|
package/dist/response.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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,
|
|
152
|
-
if (!
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
164
|
+
if (segment === "$") {
|
|
165
|
+
ctx.params.push('$');
|
|
166
|
+
node.checkParameters(ctx);
|
|
159
167
|
this.slug = node;
|
|
160
168
|
return;
|
|
161
169
|
}
|
|
162
|
-
if (
|
|
163
|
-
const wildCard =
|
|
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,
|
|
181
|
+
this.wild.ingest(node, ctx);
|
|
173
182
|
return;
|
|
174
183
|
}
|
|
175
|
-
let next = this.nested.get(
|
|
184
|
+
let next = this.nested.get(segment);
|
|
176
185
|
if (!next) {
|
|
177
186
|
next = new RouteTree();
|
|
178
|
-
this.nested.set(
|
|
187
|
+
this.nested.set(segment, next);
|
|
179
188
|
}
|
|
180
|
-
next.ingest(node,
|
|
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.
|
|
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",
|