Skip to content
This repository was archived by the owner on Jul 25, 2025. It is now read-only.

Commit 5a6e1a3

Browse files
committed
Add If-None-Match header
Fixes #21
1 parent 4e41b18 commit 5a6e1a3

File tree

8 files changed

+265
-19
lines changed

8 files changed

+265
-19
lines changed

packages/headers/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@ let cookieNames = Array.from(headers.cookie.names());
1818
let cookieNames = headers.cookie.names;
1919
```
2020

21+
Additionally, this release adds support for the `If-None-Match` header. This is useful for conditional GET requests where you want to return a response with content only if the ETag has changed.
22+
23+
```ts
24+
import { SuperHeaders } from '@mjackson/headers';
25+
26+
function requestHandler(request: Request): Promise<Response> {
27+
let response = await callDownstreamService(request);
28+
29+
if (request.method === 'GET' && response.headers.has('ETag')) {
30+
let headers = new SuperHeaders(request.headers);
31+
if (headers.ifNoneMatch.matches(response.headers.get('ETag'))) {
32+
return new Response(null, { status: 304 });
33+
}
34+
}
35+
36+
return response;
37+
}
38+
```
39+
2140
## v0.9.0 (2024-12-20)
2241

2342
This release tightens up the type safety and brings `SuperHeaders` more in line with the built-in `Headers` interface.

packages/headers/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ headers.get('Cookie'); // 'session_id=abc123; user_id=12345; theme=dark'
100100
// Host
101101
headers.host = 'example.com';
102102

103+
// If-None-Match
104+
headers.ifNoneMatch = ['67ab43', '54ed21'];
105+
headers.get('If-None-Match'); // '"67ab43", "54ed21"'
106+
103107
// Last-Modified
104108
headers.lastModified = new Date();
105109
// or headers.lastModified = new Date().getTime();
@@ -366,6 +370,25 @@ let header = new Cookie([
366370
]);
367371
```
368372

373+
### If-None-Match
374+
375+
```ts
376+
import { IfNoneMatch } from '@mjackson/headers';
377+
378+
let header = new IfNoneMatch('"67ab43", "54ed21"');
379+
380+
header.has('67ab43'); // true
381+
header.has('21ba69'); // false
382+
383+
header.matches('"67ab43"'); // true
384+
385+
// Alternative init style
386+
let header = new IfNoneMatch(['67ab43', '54ed21']);
387+
let header = new IfNoneMatch({
388+
tags: ['67ab43', '54ed21'],
389+
});
390+
```
391+
369392
### Set-Cookie
370393

371394
```ts

packages/headers/src/headers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { type CacheControlInit, CacheControl } from './lib/cache-control.ts';
55
export { type ContentDispositionInit, ContentDisposition } from './lib/content-disposition.ts';
66
export { type ContentTypeInit, ContentType } from './lib/content-type.ts';
77
export { type CookieInit, Cookie } from './lib/cookie.ts';
8+
export { type IfNoneMatchInit, IfNoneMatch } from './lib/if-none-match.ts';
89
export { type SetCookieInit, SetCookie } from './lib/set-cookie.ts';
910

1011
export {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { IfNoneMatch } from './if-none-match.ts';
5+
6+
describe('IfNoneMatch', () => {
7+
it('initializes with an empty string', () => {
8+
let header = new IfNoneMatch('');
9+
assert.deepEqual(header.tags, []);
10+
});
11+
12+
it('initializes with a string with a single tag', () => {
13+
let header = new IfNoneMatch('67ab43');
14+
assert.deepEqual(header.tags, ['"67ab43"']);
15+
16+
let header2 = new IfNoneMatch('"67ab43"');
17+
assert.deepEqual(header2.tags, ['"67ab43"']);
18+
19+
let header3 = new IfNoneMatch('W/"67ab43"');
20+
assert.deepEqual(header3.tags, ['W/"67ab43"']);
21+
});
22+
23+
it('initializes with a string with multiple tags', () => {
24+
let header = new IfNoneMatch('67ab43, 54ed21');
25+
assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']);
26+
27+
let header2 = new IfNoneMatch('"67ab43", "54ed21"');
28+
assert.deepEqual(header2.tags, ['"67ab43"', '"54ed21"']);
29+
30+
let header3 = new IfNoneMatch('W/"67ab43", "54ed21"');
31+
assert.deepEqual(header3.tags, ['W/"67ab43"', '"54ed21"']);
32+
});
33+
34+
it('initializes with an array of tags', () => {
35+
let header = new IfNoneMatch(['67ab43', '54ed21']);
36+
assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']);
37+
38+
let header2 = new IfNoneMatch(['"67ab43"', '"54ed21"']);
39+
assert.deepEqual(header2.tags, ['"67ab43"', '"54ed21"']);
40+
41+
let header3 = new IfNoneMatch(['W/"67ab43"', '"54ed21"']);
42+
assert.deepEqual(header3.tags, ['W/"67ab43"', '"54ed21"']);
43+
});
44+
45+
it('initializes with an object', () => {
46+
let header = new IfNoneMatch({ tags: ['67ab43', '54ed21'] });
47+
assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']);
48+
49+
let header2 = new IfNoneMatch({ tags: ['"67ab43"', '"54ed21"'] });
50+
assert.deepEqual(header2.tags, ['"67ab43"', '"54ed21"']);
51+
52+
let header3 = new IfNoneMatch({ tags: ['W/"67ab43"', '"54ed21"'] });
53+
assert.deepEqual(header3.tags, ['W/"67ab43"', '"54ed21"']);
54+
});
55+
56+
it('initializes with another IfNoneMatch', () => {
57+
let header = new IfNoneMatch(new IfNoneMatch('67ab43, 54ed21'));
58+
assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']);
59+
});
60+
61+
it('checks if a tag is present', () => {
62+
let header = new IfNoneMatch('67ab43, 54ed21');
63+
assert.ok(header.has('"67ab43"'));
64+
assert.ok(header.has('"54ed21"'));
65+
assert.ok(!header.has('"7892dd"'));
66+
assert.ok(!header.has('*'));
67+
68+
let header2 = new IfNoneMatch('W/"67ab43", "54ed21"');
69+
assert.ok(header2.has('W/"67ab43"'));
70+
assert.ok(header2.has('"54ed21"'));
71+
assert.ok(!header2.has('"7892dd"'));
72+
});
73+
74+
it('checks if a tag matches', () => {
75+
let header = new IfNoneMatch('67ab43, 54ed21');
76+
assert.ok(header.matches('"67ab43"'));
77+
assert.ok(header.matches('"54ed21"'));
78+
assert.ok(!header.matches('"7892dd"'));
79+
80+
let header2 = new IfNoneMatch('W/"67ab43", "54ed21"');
81+
assert.ok(header2.matches('W/"67ab43"'));
82+
assert.ok(header2.matches('"54ed21"'));
83+
assert.ok(!header2.matches('"7892dd"'));
84+
85+
let header3 = new IfNoneMatch('*');
86+
assert.ok(header3.matches('"67ab43"'));
87+
assert.ok(header3.matches('"54ed21"'));
88+
});
89+
90+
it('converts to a string', () => {
91+
let header = new IfNoneMatch('W/"67ab43", "54ed21"');
92+
assert.equal(header.toString(), 'W/"67ab43", "54ed21"');
93+
});
94+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type HeaderValue } from './header-value.ts';
2+
import { quoteEtag } from './utils.ts';
3+
4+
export interface IfNoneMatchInit {
5+
/**
6+
* The entity tags to compare against the current entity.
7+
*/
8+
tags: string[];
9+
}
10+
11+
/**
12+
* The value of an `If-None-Match` HTTP header.
13+
*
14+
* [MDN `If-None-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
15+
*
16+
* [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.2)
17+
*/
18+
export class IfNoneMatch implements HeaderValue, IfNoneMatchInit {
19+
tags: string[] = [];
20+
21+
constructor(init?: string | string[] | IfNoneMatchInit) {
22+
if (init) {
23+
if (typeof init === 'string') {
24+
this.tags.push(...init.split(/\s*,\s*/).map(quoteEtag));
25+
} else if (Array.isArray(init)) {
26+
this.tags.push(...init.map(quoteEtag));
27+
} else {
28+
this.tags.push(...init.tags.map(quoteEtag));
29+
}
30+
}
31+
}
32+
33+
/**
34+
* Checks if the header contains the given entity tag.
35+
*
36+
* Note: This method checks only for exact matches and does not consider wildcards.
37+
*
38+
* @param tag The entity tag to check for.
39+
* @returns `true` if the tag is present in the header, `false` otherwise.
40+
*/
41+
has(tag: string): boolean {
42+
return this.tags.includes(quoteEtag(tag));
43+
}
44+
45+
/**
46+
* Checks if this header matches the given entity tag.
47+
*
48+
* @param tag The entity tag to check for.
49+
* @returns `true` if the tag is present in the header (or the header contains a wildcard), `false` otherwise.
50+
*/
51+
matches(tag: string): boolean {
52+
return this.has(tag) || this.tags.includes('*');
53+
}
54+
55+
toString() {
56+
return this.tags.join(', ');
57+
}
58+
}

packages/headers/src/lib/super-headers.test.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ContentDisposition } from './content-disposition.ts';
99
import { ContentType } from './content-type.ts';
1010
import { Cookie } from './cookie.ts';
1111
import { SuperHeaders } from './super-headers.ts';
12+
import { IfNoneMatch } from './if-none-match.ts';
1213

1314
describe('SuperHeaders', () => {
1415
it('is an instance of Headers', () => {
@@ -77,7 +78,11 @@ describe('SuperHeaders', () => {
7778
it('checks if a header exists', () => {
7879
let headers = new SuperHeaders({ 'X-Custom': 'value' });
7980
assert.equal(headers.has('X-Custom'), true);
80-
assert.equal(headers.has('Nonexistent'), false);
81+
assert.equal(headers.has('Content-Type'), false);
82+
83+
// Accessing this property should not change the result of has()
84+
let _ = headers.contentType;
85+
assert.equal(headers.has('Content-Type'), false);
8186
});
8287

8388
it('iterates over entries', () => {
@@ -233,14 +238,14 @@ describe('SuperHeaders', () => {
233238
});
234239

235240
it('handles the etag property', () => {
236-
let headers = new SuperHeaders({ etag: '"abc"' });
237-
assert.equal(headers.get('ETag'), '"abc"');
241+
let headers = new SuperHeaders({ etag: '"67ab43"' });
242+
assert.equal(headers.get('ETag'), '"67ab43"');
238243

239-
let headers2 = new SuperHeaders({ etag: 'abc' });
240-
assert.equal(headers2.get('ETag'), '"abc"');
244+
let headers2 = new SuperHeaders({ etag: '67ab43' });
245+
assert.equal(headers2.get('ETag'), '"67ab43"');
241246

242-
let headers3 = new SuperHeaders({ etag: 'W/"abc"' });
243-
assert.equal(headers3.get('ETag'), 'W/"abc"');
247+
let headers3 = new SuperHeaders({ etag: 'W/"67ab43"' });
248+
assert.equal(headers3.get('ETag'), 'W/"67ab43"');
244249
});
245250

246251
it('handles the expires property', () => {
@@ -258,6 +263,11 @@ describe('SuperHeaders', () => {
258263
assert.equal(headers.get('If-Modified-Since'), 'Fri, 01 Jan 2021 00:00:00 GMT');
259264
});
260265

266+
it('handles the ifNoneMatch property', () => {
267+
let headers = new SuperHeaders({ ifNoneMatch: ['67ab43', '54ed21'] });
268+
assert.equal(headers.get('If-None-Match'), '"67ab43", "54ed21"');
269+
});
270+
261271
it('handles the ifUnmodifiedSince property', () => {
262272
let headers = new SuperHeaders({ ifUnmodifiedSince: new Date('2021-01-01T00:00:00Z') });
263273
assert.equal(headers.get('If-Unmodified-Since'), 'Fri, 01 Jan 2021 00:00:00 GMT');
@@ -542,14 +552,14 @@ describe('SuperHeaders', () => {
542552

543553
assert.equal(headers.etag, null);
544554

545-
headers.etag = '"abc"';
546-
assert.equal(headers.etag, '"abc"');
555+
headers.etag = '"67ab43"';
556+
assert.equal(headers.etag, '"67ab43"');
547557

548-
headers.etag = 'abc';
549-
assert.equal(headers.etag, '"abc"');
558+
headers.etag = '67ab43';
559+
assert.equal(headers.etag, '"67ab43"');
550560

551-
headers.etag = 'W/"abc"';
552-
assert.equal(headers.etag, 'W/"abc"');
561+
headers.etag = 'W/"67ab43"';
562+
assert.equal(headers.etag, 'W/"67ab43"');
553563

554564
headers.etag = '';
555565
assert.equal(headers.etag, '""');
@@ -599,6 +609,25 @@ describe('SuperHeaders', () => {
599609
assert.equal(headers.ifModifiedSince, null);
600610
});
601611

612+
it('supports the ifNoneMatch property', () => {
613+
let headers = new SuperHeaders();
614+
615+
assert.ok(headers.ifNoneMatch instanceof IfNoneMatch);
616+
617+
headers.ifNoneMatch = '"67ab43", "54ed21"';
618+
assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']);
619+
620+
headers.ifNoneMatch = ['67ab43', '54ed21'];
621+
assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']);
622+
623+
headers.ifNoneMatch = { tags: ['67ab43', '54ed21'] };
624+
assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']);
625+
626+
headers.ifNoneMatch = null;
627+
assert.ok(headers.ifNoneMatch instanceof IfNoneMatch);
628+
assert.equal(headers.ifNoneMatch.toString(), '');
629+
});
630+
602631
it('supports the ifUnmodifiedSince property', () => {
603632
let headers = new SuperHeaders();
604633

packages/headers/src/lib/super-headers.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { type ContentTypeInit, ContentType } from './content-type.ts';
77
import { type CookieInit, Cookie } from './cookie.ts';
88
import { canonicalHeaderName } from './header-names.ts';
99
import { type HeaderValue } from './header-value.ts';
10+
import { type IfNoneMatchInit, IfNoneMatch } from './if-none-match.ts';
1011
import { type SetCookieInit, SetCookie } from './set-cookie.ts';
11-
import { isIterable } from './utils.ts';
12+
import { isIterable, quoteEtag } from './utils.ts';
1213

1314
type DateInit = number | Date;
1415

@@ -85,6 +86,10 @@ interface SuperHeadersPropertyInit {
8586
* The [`If-Modified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) header value.
8687
*/
8788
ifModifiedSince?: string | DateInit;
89+
/**
90+
* The [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) header value.
91+
*/
92+
ifNoneMatch?: string | string[] | IfNoneMatchInit;
8893
/**
8994
* The [`If-Unmodified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since) header value.
9095
*/
@@ -131,6 +136,7 @@ const ETagKey = 'etag';
131136
const ExpiresKey = 'expires';
132137
const HostKey = 'host';
133138
const IfModifiedSinceKey = 'if-modified-since';
139+
const IfNoneMatchKey = 'if-none-match';
134140
const IfUnmodifiedSinceKey = 'if-unmodified-since';
135141
const LastModifiedKey = 'last-modified';
136142
const LocationKey = 'location';
@@ -251,7 +257,7 @@ export class SuperHeaders extends Headers {
251257
*/
252258
has(name: string): boolean {
253259
let key = name.toLowerCase();
254-
return key === SetCookieKey ? this.#setCookies.length > 0 : this.#map.has(key);
260+
return key === SetCookieKey ? this.#setCookies.length > 0 : this.get(key) != null;
255261
}
256262

257263
/**
@@ -569,10 +575,7 @@ export class SuperHeaders extends Headers {
569575
}
570576

571577
set etag(value: string | undefined | null) {
572-
this.#setStringValue(
573-
ETagKey,
574-
typeof value === 'string' && !/^(W\/)?".*"$/.test(value) ? `"${value}"` : value,
575-
);
578+
this.#setStringValue(ETagKey, typeof value === 'string' ? quoteEtag(value) : value);
576579
}
577580

578581
/**
@@ -621,6 +624,21 @@ export class SuperHeaders extends Headers {
621624
this.#setDateValue(IfModifiedSinceKey, value);
622625
}
623626

627+
/**
628+
* The `If-None-Match` header makes a request conditional on the absence of a matching ETag.
629+
*
630+
* [MDN `If-None-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
631+
*
632+
* [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.2)
633+
*/
634+
get ifNoneMatch(): IfNoneMatch {
635+
return this.#getHeaderValue(IfNoneMatchKey, IfNoneMatch);
636+
}
637+
638+
set ifNoneMatch(value: string | string[] | IfNoneMatchInit | undefined | null) {
639+
this.#setHeaderValue(IfNoneMatchKey, IfNoneMatch, value);
640+
}
641+
624642
/**
625643
* The `If-Unmodified-Since` header makes a request conditional on the last modification date of the
626644
* requested resource.

0 commit comments

Comments
 (0)