etagparse
v0.1.0
Published
Zero-dependency HTTP ETag and cache-validation header parser, builder, and comparator for Node.js
Maintainers
Readme
etagparse
Zero-dependency HTTP ETag and cache-validation header parser, builder, and comparator for Node.js
"No more
startsWith('W/"')hacks — parse, build, and compare RFC 9110 ETag values, If-Match / If-None-Match / If-Range lists, and Cache-Control directives with a single library."
Why etagparse?
The Node.js ecosystem has the etag package (jshttp/etag) for generating ETags from bodies, and fresh (jshttp/fresh) for HTTP freshness testing, but no zero-dependency npm package exists for parsing ETag values. Developers resort to manual string manipulation (startsWith('W/"'), slice(2, -1)) which misses edge cases: weak validators, wildcard *, RFC 9110 comparison semantics, and empty values.
etagparse is the missing primitive — the standalone parser that the Node.js stdlib does not provide.
| Edge case | etagparse | naive startsWith('W/"') |
|---|---|---|
| "abc" (strong) | ✅ | ✅ |
| W/"abc" (weak) | ✅ | ⚠️ (needs slice(2, -1)) |
| w/"abc" (lowercase) | ✅ | ❌ |
| * (wildcard) | ✅ | ❌ |
| "" (empty) | ✅ | ❌ |
| W/ (malformed) | ✅ returns null | ❌ throws or false-positive |
| "unterminated | ✅ returns null | ❌ |
| RFC 9110 §13 strong vs weak comparison | ✅ | ❌ (commonly equal-by-value) |
Quick Start
npm install etagparseconst { parseETag, buildETag, etagEquals, parseIfMatch, parseCacheDirectives } = require('etagparse');
// Parse any ETag value
const weak = parseETag('W/"abc123"');
// → { strong: false, value: 'abc123', raw: 'W/"abc123"' }
const strong = parseETag('"abc123"');
// → { strong: true, value: 'abc123', raw: '"abc123"' }
// Build an ETag
buildETag('hash', { weak: false }); // → '"hash"'
buildETag('hash', { weak: true }); // → 'W/"hash"'
// Compare per RFC 9110
etagEquals('"abc"', '"abc"'); // → true (strong comparison by default)
etagEquals('W/"abc"', '"abc"', { weak: true }); // → true (weak comparison: value-only)
etagEquals('W/"abc"', '"abc"', { weak: false }); // → false (strong: weak ≠ strong)
// Parse If-Match / If-None-Match lists
parseIfMatch('"a", "b", W/"c"'); // → [ {strong,value:'a'}, {strong,value:'b'}, {strong:false,value:'c'} ]
parseIfMatch('*'); // → { wildcard: true }
parseIfNoneMatch('"abc"'); // → [ {strong,value:'abc'} ]
// Parse If-Range (ETag OR HTTP-date)
parseIfRange('"abc"'); // → { type:'etag', etag:{...} }
parseIfRange('Wed, 21 Oct 2015 07:28:00 GMT');// → { type:'date', date:Date(...) }
parseIfRange('not-a-date'); // → { type:'invalid' }
// Parse Cache-Control directives
parseCacheDirectives('max-age=3600, no-cache');
// → { 'max-age': 3600, 'no-cache': true }
// Check freshness (mirrors [email protected] algorithm)
checkFreshness(
{ etag: '"abc"', lastModified: new Date('2015-10-21') },
{ 'if-none-match': '"abc"', 'if-modified-since': 'Wed, 21 Oct 2015 07:28:00 GMT' }
);
// → 'fresh'API Reference
parseETag(input: string): ParsedETag | WildcardETag | null
Parses a single ETag header value. Trims surrounding whitespace. Returns:
{ strong: true, value, raw }for"..."{ strong: false, value, raw }forW/"..."orw/"..."{ wildcard: true }for*nullfor empty or malformed input
parseETag('"abc"') // → { strong:true, value:'abc', raw:'"abc"' }
parseETag('W/"abc"') // → { strong:false, value:'abc', raw:'W/"abc"' }
parseETag('*') // → { wildcard:true }
parseETag('') // → null (does not throw)
parseETag('W/') // → null (malformed)
parseETag('"unterminated') // → null (malformed)buildETag(value: string, opts?: { weak?: boolean }): string
Builds an ETag header value. Default is weak (W/"..."); pass { weak: false } for strong.
buildETag('hash') // → 'W/"hash"'
buildETag('hash', { weak: false }) // → '"hash"'
buildETag('hash', { weak: true }) // → 'W/"hash"'
buildETag('a"b') // → 'W/"a\\"b"' (embedded quotes escaped)Throws TypeError if value is not a string.
buildETagFromBody(body: string | Buffer): string
Builds a weak ETag by MD5-hashing the body — matches [email protected]'s weak default.
buildETagFromBody('hello') // → 'W/"5d41402abc4b2a76b9719d911017c592"'
buildETagFromBody(Buffer.from('hi')) // → 'W/"49f68a5c8493ec2c0bf489821c21fc3b"'etagEquals(a, b, opts?: { weak?: boolean }): boolean
Compares two ETag values per RFC 9110 §13:
- Default (
weak: true): weak comparison — opaque-tag values match case-sensitively, weak flag ignored.W/"v"≡"v"(same value). - Strong (
weak: false): byte-exact match of the full header value, including weak flag.W/"v"≢"v". *always matches.
etagEquals('"abc"', '"abc"') // → true
etagEquals('W/"abc"', 'W/"abc"') // → true (weak match)
etagEquals('W/"abc"', '"abc"', { weak: false })// → false (strong)
etagEquals('*', '"anything"') // → true (wildcard)
etagEquals('ABC', 'abc') // → false (case-sensitive)parseIfMatch(header: string): ParsedETag[] | { wildcard: true } | null
Parses a comma-separated If-Match list. Handles commas inside quoted strings. Returns null for malformed.
parseIfMatch('"a", "b", W/"c"') // → 3-element array
parseIfMatch('*') // → { wildcard:true }
parseIfMatch('') // → nullparseIfNoneMatch(header: string): ParsedETag[] | { wildcard: true } | null
Same shape as parseIfMatch. The header field name is the only difference.
parseIfRange(header: string): IfRangeResult | null
Detects ETag vs HTTP-date form:
parseIfRange('"abc"') // → { type:'etag', etag:{...} }
parseIfRange('W/"abc"') // → { type:'etag', etag:{...} }
parseIfRange('Wed, 21 Oct 2015 07:28:00 GMT')// → { type:'date', date:Date }
parseIfRange('not-a-date') // → { type:'invalid' }parseCacheDirectives(header: string): Record<string, number | true | string>
Parses a Cache-Control header value into a directives object. Integer-valued directives (max-age=3600) become numbers; flag directives (no-cache) become true. Quoted values are unquoted and parsed if numeric.
parseCacheDirectives('max-age=3600, s-maxage=7200, no-cache, no-store')
// → { 'max-age':3600, 's-maxage':7200, 'no-cache':true, 'no-store':true }
parseCacheDirectives('private="cookie, auth"')
// → { 'private': 'cookie, auth' }
parseCacheDirectives('') // → {}checkFreshness(response, reqHeaders): 'fresh' | 'stale' | undefined
Mirrors the algorithm in jshttp/[email protected]:
- If response has an etag and request has
If-None-Match: compare (weak by default).*always → fresh. Match → fresh; mismatch → stale. - Else, if response has
lastModifiedand request hasIf-Modified-Since: compare at 1-second resolution.lm <= ims→ fresh; otherwise stale. - Returns
undefinedwhen neither check applies.
checkFreshness(
{ etag: '"a"', lastModified: new Date('2015-10-21') },
{ 'if-none-match': '"a"' }
);
// → 'fresh'
checkFreshness(
{ etag: '"x"' },
{ 'if-none-match': '"a", "b"' }
);
// → 'stale'Install
npm install etagparseOr from source:
git clone <repo>
cd etagparse
npm install # no deps to install — there are none
npm testESM / CJS
etagparse is shipped as CommonJS. Both require('etagparse') and dynamic import('etagparse') work.
TypeScript
Type definitions are bundled at src/index.d.ts (referenced from package.json#types). All public functions and result types are exported.
Limitations
- parseIfRange date parsing uses the JavaScript
Dateconstructor, which is lenient. RFC 9110 mandates strict IMF-fixdate orrfc850-dateorasctime-dateparsing.etagparseaccepts any inputDate.parserecognizes. - opaque-tag validation only checks RFC 9110 §5.3 quoted-string syntax (
"..."with\escapes); it does NOT validate the opaque-tag payload (e.g., it cannot tell a valid base64 etag from a garbage one — by design, ETag values are opaque). - checkFreshness does not implement the full RFC 9110 §13 cache algorithm — it covers the ETag + If-None-Match + Last-Modified + If-Modified-Since subset. Age/Expires/max-age-based freshness is out of scope.
- No signature verification.
etagparseparses — it does not authenticate or authorize.
Non-goals
- HTTP caching policy implementation (age, expiry, max-stale, etc.)
- HTTP Range request handling beyond
If-Rangeparsing - ETag signature verification
- Generating ETags from file metadata (mtime + size); only
buildETagFromBody(MD5 of body) is included
Tests
npm testThe test suite has 123 tests covering: every acceptance criterion, RFC 9110 strong/weak comparison, wildcard semantics, all 5 header forms, Cache-Control directive parsing (integer / flag / quoted / unquoted), empty / malformed / whitespace / unicode / very-long inputs, ESM/CJS dual export, TypeScript declarations, freshness algorithm parity, idempotent build round-trip.
License
MIT
