From f50e9cfa8f3894ca3a98f31519476d19dd476e6c Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:26:03 +1100 Subject: [PATCH 01/15] Remove `SuperHeaders` class, add new parsing utils --- .../.changes/minor.plain-headers.md | 20 + packages/fetch-router/package.json | 1 - .../src/lib/request-context.test.ts | 11 +- .../fetch-router/src/lib/request-context.ts | 12 +- .../.changes/minor.remove-super-headers.md | 65 ++ packages/headers/README.md | 786 ++++++-------- packages/headers/src/index.ts | 37 +- packages/headers/src/lib/accept-encoding.ts | 10 + packages/headers/src/lib/accept-language.ts | 10 + packages/headers/src/lib/accept.ts | 10 + packages/headers/src/lib/cache-control.ts | 10 + .../headers/src/lib/content-disposition.ts | 10 + packages/headers/src/lib/content-range.ts | 10 + packages/headers/src/lib/content-type.ts | 10 + packages/headers/src/lib/cookie.ts | 10 + packages/headers/src/lib/if-match.ts | 10 + packages/headers/src/lib/if-none-match.ts | 10 + packages/headers/src/lib/if-range.ts | 12 + packages/headers/src/lib/parse.test.ts | 183 ++++ packages/headers/src/lib/range.ts | 10 + packages/headers/src/lib/raw-headers.test.ts | 84 ++ packages/headers/src/lib/raw-headers.ts | 46 + packages/headers/src/lib/set-cookie.ts | 10 + .../headers/src/lib/super-headers.test.ts | 861 ---------------- packages/headers/src/lib/super-headers.ts | 967 ------------------ packages/headers/src/lib/vary.ts | 10 + .../patch.use-headers-parse-functions.md | 1 + .../multipart-parser/src/lib/multipart.ts | 11 +- packages/multipart-parser/test/utils.ts | 31 +- .../patch.use-headers-parse-functions.md | 1 + packages/response/src/lib/compress.test.ts | 18 +- packages/response/src/lib/compress.ts | 50 +- packages/response/src/lib/file.ts | 162 +-- 33 files changed, 1057 insertions(+), 2432 deletions(-) create mode 100644 packages/fetch-router/.changes/minor.plain-headers.md create mode 100644 packages/headers/.changes/minor.remove-super-headers.md create mode 100644 packages/headers/src/lib/parse.test.ts create mode 100644 packages/headers/src/lib/raw-headers.test.ts create mode 100644 packages/headers/src/lib/raw-headers.ts delete mode 100644 packages/headers/src/lib/super-headers.test.ts delete mode 100644 packages/headers/src/lib/super-headers.ts create mode 100644 packages/multipart-parser/.changes/patch.use-headers-parse-functions.md create mode 100644 packages/response/.changes/patch.use-headers-parse-functions.md diff --git a/packages/fetch-router/.changes/minor.plain-headers.md b/packages/fetch-router/.changes/minor.plain-headers.md new file mode 100644 index 00000000000..92654af6e2d --- /dev/null +++ b/packages/fetch-router/.changes/minor.plain-headers.md @@ -0,0 +1,20 @@ +BREAKING CHANGE: `RequestContext.headers` now returns a standard `Headers` instance instead of the `SuperHeaders`/`Headers` subclass from `@remix-run/headers`. As a result, the `@remix-run/headers` peer dependency has now been removed. + +If you were relying on the type-safe property accessors on `RequestContext.headers`, you should use the new parse functions from `@remix-run/headers` instead: + +```ts +import { parseAccept } from '@remix-run/headers' + +// Before: +router.get('/api/users', (context) => { + let acceptsJson = context.headers.accept.accepts('application/json') + // ... +}) + +// After: +router.get('/api/users', (context) => { + let accept = parseAccept(context.headers.get('accept')) + let acceptsJson = accept.accepts('application/json') + // ... +}) +``` diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index b922263e9fa..0bbbc2af1fa 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -38,7 +38,6 @@ "@typescript/native-preview": "catalog:" }, "peerDependencies": { - "@remix-run/headers": "workspace:^", "@remix-run/route-pattern": "workspace:^", "@remix-run/session": "workspace:^" }, diff --git a/packages/fetch-router/src/lib/request-context.test.ts b/packages/fetch-router/src/lib/request-context.test.ts index 11c854f9c3c..0dda4cb6ba2 100644 --- a/packages/fetch-router/src/lib/request-context.test.ts +++ b/packages/fetch-router/src/lib/request-context.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { RequestContext } from './request-context.ts' describe('new RequestContext()', () => { - it('has a header object that is SuperHeaders', () => { + it('provides access to request headers', () => { let req = new Request('https://remix.run/test', { headers: { 'Content-Type': 'application/json', @@ -11,13 +11,8 @@ describe('new RequestContext()', () => { }) let context = new RequestContext(req) - assert.equal('contentType' in context.headers, true) - assert.equal('contentType' in context.request.headers, false) - assert.equal(context.headers.contentType.toString(), 'application/json') - assert.equal( - context.headers.contentType.toString(), - context.request.headers.get('content-type'), - ) + assert.equal(context.headers.get('content-type'), 'application/json') + assert.equal(context.headers, context.request.headers) }) it('does not provide formData on GET requests', () => { diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index 7f24d581ebc..8b84f41638e 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -1,4 +1,3 @@ -import SuperHeaders from '@remix-run/headers' import { createSession, type Session } from '@remix-run/session' import { AppStorage } from './app-storage.ts' @@ -20,6 +19,7 @@ export class RequestContext< * @param request The incoming request */ constructor(request: Request) { + this.headers = request.headers this.method = request.method.toUpperCase() as RequestMethod this.params = {} as params this.request = request @@ -71,15 +71,7 @@ export class RequestContext< /** * The headers of the request. */ - get headers(): SuperHeaders { - if (this.#headers == null) { - this.#headers = new SuperHeaders(this.request.headers) - } - - return this.#headers - } - - #headers?: SuperHeaders + headers: Headers /** * The request method. This may differ from `request.method` when using the `methodOverride` diff --git a/packages/headers/.changes/minor.remove-super-headers.md b/packages/headers/.changes/minor.remove-super-headers.md new file mode 100644 index 00000000000..cf5eb15c53f --- /dev/null +++ b/packages/headers/.changes/minor.remove-super-headers.md @@ -0,0 +1,65 @@ +BREAKING CHANGE: Removed `Headers`/`SuperHeaders` class and default export. Use the native `Headers` class with the parse functions instead. + +New individual header parsing utilities added: + +- `parseAccept()` +- `parseAcceptEncoding()` +- `parseAcceptLanguage()` +- `parseCacheControl()` +- `parseContentDisposition()` +- `parseContentRange()` +- `parseContentType()` +- `parseCookie()` +- `parseIfMatch()` +- `parseIfNoneMatch()` +- `parseIfRange()` +- `parseRange()` +- `parseSetCookie()` +- `parseVary()` + +New raw header utilities added: + +- `parseRawHeaders()` +- `stringifyRawHeaders()` + +Migration example: + +```ts +// Before: +import SuperHeaders from '@remix-run/headers' +let headers = new SuperHeaders(request.headers) +let mediaType = headers.contentType.mediaType + +// After: +import { parseContentType } from '@remix-run/headers' +let contentType = parseContentType(request.headers.get('content-type')) +let mediaType = contentType.mediaType +``` + +If you were using the `Headers` constructor to parse raw HTTP header strings, use `parseRawHeaders()` instead: + +```ts +// Before: +import SuperHeaders from '@remix-run/headers' +let headers = new SuperHeaders('Content-Type: text/html\r\nCache-Control: no-cache') + +// After: +import { parseRawHeaders } from '@remix-run/headers' +let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') +``` + +If you were using `headers.toString()` to convert headers to raw format, use `stringifyRawHeaders()` instead: + +```ts +// Before: +import SuperHeaders from '@remix-run/headers' +let headers = new SuperHeaders() +headers.set('Content-Type', 'text/html') +let raw = headers.toString() + +// After: +import { stringifyRawHeaders } from '@remix-run/headers' +let headers = new Headers() +headers.set('Content-Type', 'text/html') +let raw = stringifyRawHeaders(headers) +``` diff --git a/packages/headers/README.md b/packages/headers/README.md index 934dbb7c1a8..6e7fedc1c0f 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -1,18 +1,8 @@ # headers -Tired of manually parsing and stringifying HTTP header values in JavaScript? `headers` supercharges the standard `Headers` interface, providing a robust toolkit for effortless and type-safe header manipulation. +Utilities for parsing, manipulating and stringifying HTTP header values. -HTTP headers are packed with critical information—from content negotiation and caching directives to authentication tokens and file metadata. While the native `Headers` API provides a basic string-based interface, it leaves the complexities of parsing specific header formats (like `Accept`, `Content-Type`, or `Set-Cookie`) entirely up to you. - -## Features - -- **Type-Safe Accessors:** Interact with complex header values (e.g., media types, quality factors, cookie attributes) through strongly-typed properties and methods, eliminating guesswork and manual parsing. -- **Automatic Parsing & Stringification:** The library intelligently handles the parsing of raw header strings into structured objects and stringifies your structured data back into spec-compliant header values. -- **Fluent Interface:** Enjoy a more expressive and developer-friendly API for reading and writing header information. -- **Drop-in Enhancement:** As a subclass of the standard `Headers` object, it can be used anywhere a `Headers` object is expected, providing progressive enhancement to your existing code. -- **Individual Header Utilities:** For fine-grained control, use standalone utility classes for specific headers, perfect for scenarios outside of a full `Headers` object. - -Unlock a more powerful and elegant way to work with HTTP headers in your JavaScript and TypeScript projects! +HTTP headers contain critical information—from content negotiation and caching directives to authentication tokens and file metadata. While the native `Headers` API provides a basic string-based interface, it leaves the complexities of parsing specific header formats entirely up to you. ## Installation @@ -20,564 +10,452 @@ Unlock a more powerful and elegant way to work with HTTP headers in your JavaScr npm install @remix-run/headers ``` -## Overview - -The following should give you a sense of what kinds of things you can do with this library: - -```ts -import Headers from '@remix-run/headers' - -let headers = new Headers() - -// Accept -headers.accept = 'text/html, text/*;q=0.9' - -headers.accept.mediaTypes // [ 'text/html', 'text/*' ] -Object.fromEntries(headers.accept.entries()) // { 'text/html': 1, 'text/*': 0.9 } - -headers.accept.accepts('text/html') // true -headers.accept.accepts('text/plain') // true -headers.accept.accepts('image/jpeg') // false - -headers.accept.getPreferred(['text/plain', 'text/html']) // 'text/html' - -headers.accept.set('text/plain', 0.9) -headers.accept.set('text/*', 0.8) - -headers.get('Accept') // 'text/html,text/plain;q=0.9,text/*;q=0.8' - -// Accept-Encoding -headers.acceptEncoding = 'gzip, deflate;q=0.8' - -headers.acceptEncoding.encodings // [ 'gzip', 'deflate' ] -Object.fromEntries(headers.acceptEncoding.entries()) // { 'gzip': 1, 'deflate': 0.8 } - -headers.acceptEncoding.accepts('gzip') // true -headers.acceptEncoding.accepts('br') // false - -headers.acceptEncoding.getPreferred(['gzip', 'deflate']) // 'gzip' - -// Accept-Language -headers.acceptLanguage = 'en-US, en;q=0.9' +## Individual Header Utilities -headers.acceptLanguage.languages // [ 'en-us', 'en' ] -Object.fromEntries(headers.acceptLanguage.entries()) // { 'en-us': 1, en: 0.9 } +Each supported header has a parse function and a class that represents the header value. Each class has a `toString()` method that returns the header value as a string, which you can either call manually, or will be called automatically when the header class is used in a context that expects a string. -headers.acceptLanguage.accepts('en') // true -headers.acceptLanguage.accepts('ja') // false +The following headers are currently supported: -headers.acceptLanguage.getPreferred(['en-US', 'en-GB']) // 'en-US' -headers.acceptLanguage.getPreferred(['en', 'fr']) // 'en' +- [Accept](./README.md#accept) +- [Accept-Encoding](./README.md#accept-encoding) +- [Accept-Language](./README.md#accept-language) +- [Cache-Control](./README.md#cache-control) +- [Content-Disposition](./README.md#content-disposition) +- [Content-Range](./README.md#content-range) +- [Content-Type](./README.md#content-type) +- [Cookie](./README.md#cookie) +- [If-Match](./README.md#if-match) +- [If-None-Match](./README.md#if-none-match) +- [If-Range](./README.md#if-range) +- [Range](./README.md#range) +- [Set-Cookie](./README.md#set-cookie) +- [Vary](./README.md#vary) -// Accept-Ranges -headers.acceptRanges = 'bytes' - -// Allow -headers.allow = ['GET', 'POST', 'PUT'] -headers.get('Allow') // 'GET, POST, PUT' - -// Connection -headers.connection = 'close' - -// Content-Type -headers.contentType = 'application/json; charset=utf-8' +### Accept -headers.contentType.mediaType // "application/json" -headers.contentType.charset // "utf-8" +Parse, manipulate and stringify [`Accept` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept). -headers.contentType.charset = 'iso-8859-1' +Implements `Map`. -headers.get('Content-Type') // "application/json; charset=iso-8859-1" +```ts +import { parseAccept, Accept } from '@remix-run/headers' -// Content-Disposition -headers.contentDisposition = - 'attachment; filename="example.pdf"; filename*=UTF-8\'\'%E4%BE%8B%E5%AD%90.pdf' +// Parse from headers +let accept = parseAccept(request.headers.get('accept')) -headers.contentDisposition.type // 'attachment' -headers.contentDisposition.filename // 'example.pdf' -headers.contentDisposition.filenameSplat // 'UTF-8\'\'%E4%BE%8B%E5%AD%90.pdf' -headers.contentDisposition.preferredFilename // '例子.pdf' +accept.mediaTypes // ['text/html', 'text/*'] +accept.weights // [1, 0.9] +accept.accepts('text/html') // true +accept.accepts('text/plain') // true (matches text/*) +accept.accepts('image/jpeg') // false +accept.getWeight('text/plain') // 1 (matches text/*) +accept.getPreferred(['text/html', 'text/plain']) // 'text/html' -// Cookie -headers.cookie = 'session_id=abc123; user_id=12345' +// Iterate +for (let [mediaType, quality] of accept) { + // ... +} -headers.cookie.get('session_id') // 'abc123' -headers.cookie.get('user_id') // '12345' +// Modify and set header +accept.set('application/json', 0.8) +accept.delete('text/*') +headers.set('Accept', accept) -headers.cookie.set('theme', 'dark') -headers.get('Cookie') // 'session_id=abc123; user_id=12345; theme=dark' +// Construct directly +new Accept('text/html, text/*;q=0.9') +new Accept({ 'text/html': 1, 'text/*': 0.9 }) +new Accept(['text/html', ['text/*', 0.9]]) +``` -// Host -headers.host = 'example.com' +### Accept-Encoding -// If-Match -headers.ifMatch = ['67ab43', '54ed21'] -headers.get('If-Match') // '"67ab43", "54ed21"' +Parse, manipulate and stringify [`Accept-Encoding` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding). -headers.ifMatch.matches('67ab43') // true -headers.ifMatch.matches('abc123') // false +Implements `Map`. -// If-None-Match -headers.ifNoneMatch = ['67ab43', '54ed21'] -headers.get('If-None-Match') // '"67ab43", "54ed21"' +```ts +import { parseAcceptEncoding, AcceptEncoding } from '@remix-run/headers' + +// Parse from headers +let acceptEncoding = parseAcceptEncoding(request.headers.get('accept-encoding')) + +acceptEncoding.encodings // ['gzip', 'deflate'] +acceptEncoding.weights // [1, 0.8] +acceptEncoding.accepts('gzip') // true +acceptEncoding.accepts('br') // false +acceptEncoding.getWeight('gzip') // 1 +acceptEncoding.getPreferred(['gzip', 'deflate', 'br']) // 'gzip' + +// Modify and set header +acceptEncoding.set('br', 1) +acceptEncoding.delete('deflate') +headers.set('Accept-Encoding', acceptEncoding) + +// Construct directly +new AcceptEncoding('gzip, deflate;q=0.8') +new AcceptEncoding({ gzip: 1, deflate: 0.8 }) +``` -headers.ifNoneMatch.matches('67ab43') // true -headers.ifNoneMatch.matches('abc123') // false +### Accept-Language -// If-Range -headers.ifRange = new Date('2021-01-01T00:00:00Z') -headers.get('If-Range') // 'Fri, 01 Jan 2021 00:00:00 GMT' +Parse, manipulate and stringify [`Accept-Language` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). -headers.ifRange.matches({ lastModified: 1609459200000 }) // true (timestamp) -headers.ifRange.matches({ lastModified: new Date('2021-01-01T00:00:00Z') }) // true (Date) +Implements `Map`. -// Last-Modified -headers.lastModified = new Date('2021-01-01T00:00:00Z') -// or headers.lastModified = new Date('2021-01-01T00:00:00Z').getTime(); -headers.get('Last-Modified') // 'Fri, 01 Jan 2021 00:00:00 GMT' +```ts +import { parseAcceptLanguage, AcceptLanguage } from '@remix-run/headers' + +// Parse from headers +let acceptLanguage = parseAcceptLanguage(request.headers.get('accept-language')) + +acceptLanguage.languages // ['en-us', 'en'] +acceptLanguage.weights // [1, 0.9] +acceptLanguage.accepts('en-US') // true +acceptLanguage.accepts('en-GB') // true (matches en) +acceptLanguage.getWeight('en-GB') // 1 (matches en) +acceptLanguage.getPreferred(['en-US', 'en-GB', 'fr']) // 'en-US' + +// Modify and set header +acceptLanguage.set('fr', 0.5) +acceptLanguage.delete('en') +headers.set('Accept-Language', acceptLanguage) + +// Construct directly +new AcceptLanguage('en-US, en;q=0.9') +new AcceptLanguage({ 'en-US': 1, en: 0.9 }) +``` -// Location -headers.location = 'https://example.com' +### Cache-Control -// Range -headers.range = 'bytes=200-1000' +Parse, manipulate and stringify [`Cache-Control` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). -headers.range.unit // "bytes" -headers.range.ranges // [{ start: 200, end: 1000 }] -headers.range.canSatisfy(2000) // true +```ts +import { parseCacheControl, CacheControl } from '@remix-run/headers' + +// Parse from headers +let cacheControl = parseCacheControl(response.headers.get('cache-control')) + +cacheControl.public // true +cacheControl.maxAge // 3600 +cacheControl.sMaxage // 7200 +cacheControl.noCache // undefined +cacheControl.noStore // undefined +cacheControl.noTransform // undefined +cacheControl.mustRevalidate // undefined +cacheControl.immutable // undefined + +// Modify and set header +cacheControl.maxAge = 7200 +cacheControl.immutable = true +headers.set('Cache-Control', cacheControl) + +// Construct directly +new CacheControl('public, max-age=3600') +new CacheControl({ public: true, maxAge: 3600 }) +``` -// Referer -headers.referer = 'https://example.com/' +### Content-Disposition -// Set-Cookie -headers.setCookie = ['session_id=abc123; Path=/; HttpOnly'] +Parse, manipulate and stringify [`Content-Disposition` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition). -headers.setCookie[0].name // 'session_id' -headers.setCookie[0].value // 'abc123' -headers.setCookie[0].path // '/' -headers.setCookie[0].httpOnly // true +```ts +import { parseContentDisposition, ContentDisposition } from '@remix-run/headers' -// Modifying Set-Cookie attributes -headers.setCookie[0].maxAge = 3600 -headers.setCookie[0].secure = true +// Parse from headers +let contentDisposition = parseContentDisposition(response.headers.get('content-disposition')) -headers.get('Set-Cookie') // 'session_id=abc123; Path=/; HttpOnly; Max-Age=3600; Secure' +contentDisposition.type // 'attachment' +contentDisposition.filename // 'example.pdf' +contentDisposition.filenameSplat // "UTF-8''%E4%BE%8B%E5%AD%90.pdf" +contentDisposition.preferredFilename // '例子.pdf' (decoded from filename*) -// Setting multiple cookies is easy, it's just an array -headers.setCookie.push('user_id=12345; Path=/api; Secure') -// or headers.setCookie = [...headers.setCookie, '...'] +// Modify and set header +contentDisposition.filename = 'download.pdf' +headers.set('Content-Disposition', contentDisposition) -// Accessing multiple Set-Cookie headers -for (let cookie of headers.getSetCookie()) { - console.log(cookie) -} -// session_id=abc123; Path=/; HttpOnly; Max-Age=3600; Secure -// user_id=12345; Path=/api; Secure +// Construct directly +new ContentDisposition('attachment; filename="example.pdf"') +new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }) ``` -`Headers` can be initialized with an object config: - -```ts -let headers = new Headers({ - contentType: { - mediaType: 'text/html', - charset: 'utf-8', - }, - setCookie: [ - { name: 'session', value: 'abc', path: '/' }, - { name: 'theme', value: 'dark', expires: new Date('2021-12-31T23:59:59Z') }, - ], -}) - -console.log(`${headers}`) -// Content-Type: text/html; charset=utf-8 -// Set-Cookie: session=abc; Path=/ -// Set-Cookie: theme=dark; Expires=Fri, 31 Dec 2021 23:59:59 GMT -``` +### Content-Range -`Headers` works just like [DOM's `Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) (it's a subclass) so you can use them anywhere you need a `Headers`. +Parse, manipulate and stringify [`Content-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range). ```ts -import Headers from '@remix-run/headers' +import { parseContentRange, ContentRange } from '@remix-run/headers' -// Use in a fetch() -let response = await fetch('https://example.com', { - headers: new Headers(), -}) +// Parse from headers +let contentRange = parseContentRange(response.headers.get('content-range')) -// Convert from DOM Headers -let headers = new Headers(response.headers) +contentRange.unit // "bytes" +contentRange.start // 200 +contentRange.end // 1000 +contentRange.size // 67589 -headers.set('Content-Type', 'text/html') -headers.get('Content-Type') // "text/html" +// Unsatisfied range +let unsatisfied = parseContentRange('bytes */67589') +unsatisfied.start // null +unsatisfied.end // null +unsatisfied.size // 67589 + +// Construct and set +let newRange = new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }) +headers.set('Content-Range', newRange) ``` -If you're familiar with using DOM `Headers`, everything works as you'd expect. +### Content-Type -`Headers` are iterable: +Parse, manipulate and stringify [`Content-Type` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type). ```ts -let headers = new Headers({ - 'Content-Type': 'application/json', - 'X-API-Key': 'secret-key', - 'Accept-Language': 'en-US,en;q=0.9', -}) +import { parseContentType, ContentType } from '@remix-run/headers' -for (let [name, value] of headers) { - console.log(`${name}: ${value}`) -} -// Content-Type: application/json -// X-Api-Key: secret-key -// Accept-Language: en-US,en;q=0.9 -``` +// Parse from headers +let contentType = parseContentType(request.headers.get('content-type')) -If you're assembling HTTP messages, you can easily convert to a multiline string suitable for using as a Request/Response header block: +contentType.mediaType // "text/html" +contentType.charset // "utf-8" +contentType.boundary // undefined (or boundary string for multipart) -```ts -let headers = new Headers({ - 'Content-Type': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', -}) +// Modify and set header +contentType.charset = 'iso-8859-1' +headers.set('Content-Type', contentType) -console.log(`${headers}`) -// Content-Type: application/json -// Accept-Language: en-US,en;q=0.9 +// Construct directly +new ContentType('text/html; charset=utf-8') +new ContentType({ mediaType: 'text/html', charset: 'utf-8' }) ``` -## Individual Header Utility Classes +### Cookie -In addition to the high-level `Headers` API, `headers` also provides a rich set of primitives you can use to work with just about any complex HTTP header value. Each header class includes a spec-compliant parser (the constructor), stringifier (`toString`), and getters/setters for all relevant attributes. Classes for headers that contain a list of fields, like `Cookie`, are iterable. +Parse, manipulate and stringify [`Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie). -If you need support for a header that isn't listed here, please [send a PR](https://github.com/remix-run/remix/pulls)! The goal is to have first-class support for all common HTTP headers. - -### Accept +Implements `Map`. ```ts -import { Accept } from '@remix-run/headers' - -let header = new Accept('text/html;text/*;q=0.9') - -header.has('text/html') // true -header.has('text/plain') // false +import { parseCookie, Cookie } from '@remix-run/headers' -header.accepts('text/html') // true -header.accepts('text/plain') // true -header.accepts('text/*') // true -header.accepts('image/jpeg') // false +// Parse from headers +let cookie = parseCookie(request.headers.get('cookie')) -header.getPreferred(['text/html', 'text/plain']) // 'text/html' +cookie.get('session_id') // 'abc123' +cookie.get('theme') // 'dark' +cookie.has('session_id') // true +cookie.size // 2 -for (let [mediaType, quality] of header) { +// Iterate +for (let [name, value] of cookie) { // ... } -// Alternative init styles -let header = new Accept({ 'text/html': 1, 'text/*': 0.9 }) -let header = new Accept(['text/html', ['text/*', 0.9]]) -``` - -### Accept-Encoding - -```ts -import { AcceptEncoding } from '@remix-run/headers' - -let header = new AcceptEncoding('gzip,deflate;q=0.9') +// Modify and set header +cookie.set('theme', 'light') +cookie.delete('session_id') +headers.set('Cookie', cookie) -header.has('gzip') // true -header.has('br') // false - -header.accepts('gzip') // true -header.accepts('deflate') // true -header.accepts('identity') // true -header.accepts('br') // true +// Construct directly +new Cookie('session_id=abc123; theme=dark') +new Cookie({ session_id: 'abc123', theme: 'dark' }) +new Cookie([ + ['session_id', 'abc123'], + ['theme', 'dark'], +]) +``` -header.getPreferred(['gzip', 'deflate']) // 'gzip' +### If-Match -for (let [encoding, weight] of header) { - // ... -} +Parse, manipulate and stringify [`If-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match). -// Alternative init styles -let header = new AcceptEncoding({ gzip: 1, deflate: 0.9 }) -let header = new AcceptEncoding(['gzip', ['deflate', 0.9]]) -``` - -### Accept-Language +Implements `Set`. ```ts -import { AcceptLanguage } from '@remix-run/headers' +import { parseIfMatch, IfMatch } from '@remix-run/headers' -let header = new AcceptLanguage('en-US,en;q=0.9') +// Parse from headers +let ifMatch = parseIfMatch(request.headers.get('if-match')) -header.has('en-US') // true -header.has('en-GB') // false +ifMatch.tags // ['"67ab43"', '"54ed21"'] +ifMatch.has('"67ab43"') // true +ifMatch.matches('"67ab43"') // true (checks precondition) +ifMatch.matches('"abc123"') // false -header.accepts('en-US') // true -header.accepts('en-GB') // true -header.accepts('en') // true -header.accepts('fr') // true +// Note: Uses strong comparison only (weak ETags never match) +let weak = parseIfMatch('W/"67ab43"') +weak.matches('W/"67ab43"') // false -header.getPreferred(['en-US', 'en-GB']) // 'en-US' -header.getPreferred(['en', 'fr']) // 'en' +// Modify and set header +ifMatch.add('"newetag"') +ifMatch.delete('"67ab43"') +headers.set('If-Match', ifMatch) -for (let [language, quality] of header) { - // ... -} - -// Alternative init styles -let header = new AcceptLanguage({ 'en-US': 1, en: 0.9 }) -let header = new AcceptLanguage(['en-US', ['en', 0.9]]) +// Construct directly +new IfMatch(['abc123', 'def456']) ``` -### Cache-Control +### If-None-Match -```ts -import { CacheControl } from '@remix-run/headers' - -let header = new CacheControl('public, max-age=3600, s-maxage=3600') -header.public // true -header.maxAge // 3600 -header.sMaxage // 3600 - -// Alternative init style -let header = new CacheControl({ public: true, maxAge: 3600 }) - -// Full set of supported properties -header.public // true/false -header.private // true/false -header.noCache // true/false -header.noStore // true/false -header.noTransform // true/false -header.mustRevalidate // true/false -header.proxyRevalidate // true/false -header.maxAge // number -header.sMaxage // number -header.minFresh // number -header.maxStale // number -header.onlyIfCached // true/false -header.immutable // true/false -header.staleWhileRevalidate // number -header.staleIfError // number -``` +Parse, manipulate and stringify [`If-None-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match). -### Content-Disposition +Implements `Set`. ```ts -import { ContentDisposition } from '@remix-run/headers' - -let header = new ContentDisposition('attachment; name=file1; filename=file1.txt') -header.type // "attachment" -header.name // "file1" -header.filename // "file1.txt" -header.preferredFilename // "file1.txt" - -// Alternative init style -let header = new ContentDisposition({ - type: 'attachment', - name: 'file1', - filename: 'file1.txt', -}) -``` +import { parseIfNoneMatch, IfNoneMatch } from '@remix-run/headers' -### Content-Type +// Parse from headers +let ifNoneMatch = parseIfNoneMatch(request.headers.get('if-none-match')) -```ts -import { ContentType } from '@remix-run/headers' - -let header = new ContentType('text/html; charset=utf-8') -header.mediaType // "text/html" -header.boundary // undefined -header.charset // "utf-8" - -// Alternative init style -let header = new ContentType({ - mediaType: 'multipart/form-data', - boundary: '------WebKitFormBoundary12345', - charset: 'utf-8', -}) -``` +ifNoneMatch.tags // ['"67ab43"', '"54ed21"'] +ifNoneMatch.has('"67ab43"') // true +ifNoneMatch.matches('"67ab43"') // true -### Content-Range +// Supports weak comparison (unlike If-Match) +let weak = parseIfNoneMatch('W/"67ab43"') +weak.matches('W/"67ab43"') // true -```ts -import { ContentRange } from '@remix-run/headers' - -// Satisfied range -let header = new ContentRange('bytes 200-1000/67589') -header.unit // "bytes" -header.start // 200 -header.end // 1000 -header.size // 67589 +// Modify and set header +ifNoneMatch.add('"newetag"') +ifNoneMatch.delete('"67ab43"') +headers.set('If-None-Match', ifNoneMatch) -// Unsatisfied range -let header = new ContentRange('bytes */67589') -header.unit // "bytes" -header.start // null -header.end // null -header.size // 67589 - -// Alternative init style -let header = new ContentRange({ - unit: 'bytes', - start: 200, - end: 1000, - size: 67589, -}) +// Construct directly +new IfNoneMatch(['abc123']) ``` -### Cookie - -```ts -import { Cookie } from '@remix-run/headers' - -let header = new Cookie('theme=dark; session_id=123') -header.get('theme') // "dark" -header.set('theme', 'light') -header.delete('theme') -header.has('session_id') // true - -// Iterate over cookie name/value pairs -for (let [name, value] of header) { - // ... -} - -// Alternative init styles -let header = new Cookie({ theme: 'dark', session_id: '123' }) -let header = new Cookie([ - ['theme', 'dark'], - ['session_id', '123'], -]) -``` +### If-Range -### If-Match +Parse, manipulate and stringify [`If-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range). ```ts -import { IfMatch } from '@remix-run/headers' +import { parseIfRange, IfRange } from '@remix-run/headers' -let header = new IfMatch('"67ab43", "54ed21"') +// Parse from headers +let ifRange = parseIfRange(request.headers.get('if-range')) -header.has('67ab43') // true -header.has('21ba69') // false +// With HTTP date +ifRange.matches({ lastModified: 1609459200000 }) // true +ifRange.matches({ lastModified: new Date('2021-01-01') }) // true -// Check if precondition passes -header.matches('"67ab43"') // true -header.matches('"abc123"') // false +// With ETag +let etagHeader = parseIfRange('"67ab43"') +etagHeader.matches({ etag: '"67ab43"' }) // true -// Note: Uses strong comparison only (weak ETags never match) -let weakHeader = new IfMatch('W/"67ab43"') -weakHeader.matches('W/"67ab43"') // false +// Empty/null returns empty instance (range proceeds unconditionally) +let empty = parseIfRange(null) +empty.matches({ etag: '"any"' }) // true -// Alternative init styles -let header = new IfMatch(['67ab43', '54ed21']) -let header = new IfMatch({ - tags: ['67ab43', '54ed21'], -}) +// Construct and set +let newIfRange = new IfRange('"abc123"') +headers.set('If-Range', newIfRange) ``` -### If-None-Match +### Range -```ts -import { IfNoneMatch } from '@remix-run/headers' +Parse, manipulate and stringify [`Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range). -let header = new IfNoneMatch('"67ab43", "54ed21"') +```ts +import { parseRange, Range } from '@remix-run/headers' -header.has('67ab43') // true -header.has('21ba69') // false +// Parse from headers +let range = parseRange(request.headers.get('range')) -header.matches('"67ab43"') // true +range.unit // "bytes" +range.ranges // [{ start: 200, end: 1000 }] +range.canSatisfy(2000) // true +range.canSatisfy(500) // false +range.normalize(2000) // [{ start: 200, end: 1000 }] -// Alternative init styles -let header = new IfNoneMatch(['67ab43', '54ed21']) -let header = new IfNoneMatch({ - tags: ['67ab43', '54ed21'], -}) -``` +// Multiple ranges +let multi = parseRange('bytes=0-499, 1000-1499') +multi.ranges.length // 2 -### If-Range +// Suffix range (last N bytes) +let suffix = parseRange('bytes=-500') +suffix.normalize(2000) // [{ start: 1500, end: 1999 }] -```ts -import { IfRange } from '@remix-run/headers' +// Construct and set +let newRange = new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }) +headers.set('Range', newRange) +``` -// Initialize with HTTP date -let header = new IfRange('Fri, 01 Jan 2021 00:00:00 GMT') -header.matches({ lastModified: 1609459200000 }) // true -header.matches({ lastModified: new Date('2021-01-01T00:00:00Z') }) // true (Date also supported) +### Set-Cookie -// Initialize with Date object -let header = new IfRange(new Date('2021-01-01T00:00:00Z')) -header.matches({ lastModified: 1609459200000 }) // true +Parse, manipulate and stringify [`Set-Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie). -// Initialize with strong ETag -let header = new IfRange('"67ab43"') -header.matches({ etag: '"67ab43"' }) // true +```ts +import { parseSetCookie, SetCookie } from '@remix-run/headers' + +// Parse from headers +let setCookie = parseSetCookie(response.headers.get('set-cookie')) + +setCookie.name // "session_id" +setCookie.value // "abc" +setCookie.path // "/" +setCookie.httpOnly // true +setCookie.secure // true +setCookie.domain // undefined +setCookie.maxAge // undefined +setCookie.expires // undefined +setCookie.sameSite // undefined + +// Modify and set header +setCookie.maxAge = 3600 +setCookie.sameSite = 'Strict' +headers.set('Set-Cookie', setCookie) + +// Construct directly +new SetCookie('session_id=abc; Path=/; HttpOnly; Secure') +new SetCookie({ + name: 'session_id', + value: 'abc', + path: '/', + httpOnly: true, + secure: true, +}) +``` -// Never matches weak ETags -let weakHeader = new IfRange('W/"67ab43"') -header.matches({ etag: 'W/"67ab43"' }) // false +### Vary -// Returns true if header is not present (range should proceed unconditionally) -let emptyHeader = new IfRange('') -emptyHeader.matches({ etag: '"67ab43"' }) // true -``` +Parse, manipulate and stringify [`Vary` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary). -### Range +Implements `Set`. ```ts -import { Range } from '@remix-run/headers' +import { parseVary, Vary } from '@remix-run/headers' -let header = new Range('bytes=200-1000') +// Parse from headers +let vary = parseVary(response.headers.get('vary')) -header.unit // "bytes" -header.ranges // [{ start: 200, end: 1000 }] +vary.headerNames // ['accept-encoding', 'accept-language'] +vary.has('Accept-Encoding') // true (case-insensitive) +vary.size // 2 -// Check if ranges can be satisfied for a given file size -header.canSatisfy(2000) // true -header.canSatisfy(500) // false (end is beyond file size) +// Modify and set header +vary.add('User-Agent') +vary.delete('Accept-Language') +headers.set('Vary', vary) -// Multiple ranges -let header = new Range('bytes=0-499, 1000-1499') -header.ranges.length // 2 - -// Normalize to concrete start/end values for a given file size -let header = new Range('bytes=1000-') -header.normalize(2000) -// [{ start: 1000, end: 1999 }] - -// Alternative init style -let header = new Range({ - unit: 'bytes', - ranges: [ - { start: 200, end: 1000 }, - { start: 2000, end: 2999 }, - ], -}) +// Construct directly +new Vary('Accept-Encoding, Accept-Language') +new Vary(['Accept-Encoding', 'Accept-Language']) +new Vary({ headerNames: ['Accept-Encoding', 'Accept-Language'] }) ``` -### Set-Cookie +## Raw Headers + +Parse and stringify raw HTTP header strings. ```ts -import { SetCookie } from '@remix-run/headers' - -let header = new SetCookie('session_id=abc; Domain=example.com; Path=/; Secure; HttpOnly') -header.name // "session_id" -header.value // "abc" -header.domain // "example.com" -header.path // "/" -header.secure // true -header.httpOnly // true -header.sameSite // undefined -header.maxAge // undefined -header.expires // undefined - -// Alternative init styles -let header = new SetCookie({ - name: 'session_id', - value: 'abc', - domain: 'example.com', - path: '/', - secure: true, - httpOnly: true, -}) +import { parseRawHeaders, stringifyRawHeaders } from '@remix-run/headers' + +// Parse raw header string into Headers object +let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') +headers.get('content-type') // 'text/html' +headers.get('cache-control') // 'no-cache' + +// Stringify Headers object back to raw format +let raw = stringifyRawHeaders(headers) +// 'content-type: text/html\r\ncache-control: no-cache' ``` ## Related Packages diff --git a/packages/headers/src/index.ts b/packages/headers/src/index.ts index 403f74503ce..0c8597d4985 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -1,20 +1,19 @@ -export { type AcceptInit, Accept } from './lib/accept.ts' -export { type AcceptEncodingInit, AcceptEncoding } from './lib/accept-encoding.ts' -export { type AcceptLanguageInit, AcceptLanguage } from './lib/accept-language.ts' -export { type CacheControlInit, CacheControl } from './lib/cache-control.ts' -export { type ContentDispositionInit, ContentDisposition } from './lib/content-disposition.ts' -export { type ContentRangeInit, ContentRange } from './lib/content-range.ts' -export { type ContentTypeInit, ContentType } from './lib/content-type.ts' -export { type CookieInit, Cookie } from './lib/cookie.ts' -export { type IfMatchInit, IfMatch } from './lib/if-match.ts' -export { type IfNoneMatchInit, IfNoneMatch } from './lib/if-none-match.ts' -export { IfRange } from './lib/if-range.ts' -export { type RangeInit, Range } from './lib/range.ts' -export { type CookieProperties, type SetCookieInit, SetCookie } from './lib/set-cookie.ts' -export { type VaryInit, Vary } from './lib/vary.ts' - +export { type AcceptInit, Accept, parseAccept } from './lib/accept.ts' +export { type AcceptEncodingInit, AcceptEncoding, parseAcceptEncoding } from './lib/accept-encoding.ts' +export { type AcceptLanguageInit, AcceptLanguage, parseAcceptLanguage } from './lib/accept-language.ts' +export { type CacheControlInit, CacheControl, parseCacheControl } from './lib/cache-control.ts' export { - type SuperHeadersInit, - SuperHeaders, - SuperHeaders as default, -} from './lib/super-headers.ts' + type ContentDispositionInit, + ContentDisposition, + parseContentDisposition, +} from './lib/content-disposition.ts' +export { type ContentRangeInit, ContentRange, parseContentRange } from './lib/content-range.ts' +export { type ContentTypeInit, ContentType, parseContentType } from './lib/content-type.ts' +export { type CookieInit, Cookie, parseCookie } from './lib/cookie.ts' +export { type IfMatchInit, IfMatch, parseIfMatch } from './lib/if-match.ts' +export { type IfNoneMatchInit, IfNoneMatch, parseIfNoneMatch } from './lib/if-none-match.ts' +export { IfRange, parseIfRange } from './lib/if-range.ts' +export { type RangeInit, Range, parseRange } from './lib/range.ts' +export { type CookieProperties, type SetCookieInit, SetCookie, parseSetCookie } from './lib/set-cookie.ts' +export { type VaryInit, Vary, parseVary } from './lib/vary.ts' +export { parseRawHeaders, stringifyRawHeaders } from './lib/raw-headers.ts' diff --git a/packages/headers/src/lib/accept-encoding.ts b/packages/headers/src/lib/accept-encoding.ts index 279a449f897..c88c93d5197 100644 --- a/packages/headers/src/lib/accept-encoding.ts +++ b/packages/headers/src/lib/accept-encoding.ts @@ -219,3 +219,13 @@ export class AcceptEncoding implements HeaderValue, Iterable<[string, number]> { return pairs.join(',') } } + +/** + * Parse an Accept-Encoding header value. + * + * @param value The header value (string, init object, or null) + * @returns An AcceptEncoding instance (empty if null) + */ +export function parseAcceptEncoding(value: string | AcceptEncodingInit | null): AcceptEncoding { + return new AcceptEncoding(value ?? undefined) +} diff --git a/packages/headers/src/lib/accept-language.ts b/packages/headers/src/lib/accept-language.ts index 9e1b138f9ee..d5e9949f557 100644 --- a/packages/headers/src/lib/accept-language.ts +++ b/packages/headers/src/lib/accept-language.ts @@ -225,3 +225,13 @@ export class AcceptLanguage implements HeaderValue, Iterable<[string, number]> { return pairs.join(',') } } + +/** + * Parse an Accept-Language header value. + * + * @param value The header value (string, init object, or null) + * @returns An AcceptLanguage instance (empty if null) + */ +export function parseAcceptLanguage(value: string | AcceptLanguageInit | null): AcceptLanguage { + return new AcceptLanguage(value ?? undefined) +} diff --git a/packages/headers/src/lib/accept.ts b/packages/headers/src/lib/accept.ts index 9a30722b67d..bcbbe7b31a8 100644 --- a/packages/headers/src/lib/accept.ts +++ b/packages/headers/src/lib/accept.ts @@ -223,3 +223,13 @@ export class Accept implements HeaderValue, Iterable<[string, number]> { return pairs.join(',') } } + +/** + * Parse an Accept header value. + * + * @param value The header value (string, init object, or null) + * @returns An Accept instance (empty if null) + */ +export function parseAccept(value: string | AcceptInit | null): Accept { + return new Accept(value ?? undefined) +} diff --git a/packages/headers/src/lib/cache-control.ts b/packages/headers/src/lib/cache-control.ts index 03fc120a654..b9f62ac7525 100644 --- a/packages/headers/src/lib/cache-control.ts +++ b/packages/headers/src/lib/cache-control.ts @@ -321,3 +321,13 @@ export class CacheControl implements HeaderValue, CacheControlInit { return parts.join(', ') } } + +/** + * Parse a Cache-Control header value. + * + * @param value The header value (string, init object, or null) + * @returns A CacheControl instance (empty if null) + */ +export function parseCacheControl(value: string | CacheControlInit | null): CacheControl { + return new CacheControl(value ?? undefined) +} diff --git a/packages/headers/src/lib/content-disposition.ts b/packages/headers/src/lib/content-disposition.ts index b9c02298d42..a08357500ed 100644 --- a/packages/headers/src/lib/content-disposition.ts +++ b/packages/headers/src/lib/content-disposition.ts @@ -134,3 +134,13 @@ function percentDecode(value: string): string { return String.fromCharCode(parseInt(hex, 16)) }) } + +/** + * Parse a Content-Disposition header value. + * + * @param value The header value (string, init object, or null) + * @returns A ContentDisposition instance (empty if null) + */ +export function parseContentDisposition(value: string | ContentDispositionInit | null): ContentDisposition { + return new ContentDisposition(value ?? undefined) +} diff --git a/packages/headers/src/lib/content-range.ts b/packages/headers/src/lib/content-range.ts index 5ee97ea09c4..3e8b28db61c 100644 --- a/packages/headers/src/lib/content-range.ts +++ b/packages/headers/src/lib/content-range.ts @@ -74,3 +74,13 @@ export class ContentRange implements HeaderValue, ContentRangeInit { return `${this.unit} ${range}/${this.size}` } } + +/** + * Parse a Content-Range header value. + * + * @param value The header value (string, init object, or null) + * @returns A ContentRange instance (empty if null) + */ +export function parseContentRange(value: string | ContentRangeInit | null): ContentRange { + return new ContentRange(value ?? undefined) +} diff --git a/packages/headers/src/lib/content-type.ts b/packages/headers/src/lib/content-type.ts index 4feb3ce8f07..aa020e2c134 100644 --- a/packages/headers/src/lib/content-type.ts +++ b/packages/headers/src/lib/content-type.ts @@ -84,3 +84,13 @@ export class ContentType implements HeaderValue, ContentTypeInit { return parts.join('; ') } } + +/** + * Parse a Content-Type header value. + * + * @param value The header value (string, init object, or null) + * @returns A ContentType instance (empty if null) + */ +export function parseContentType(value: string | ContentTypeInit | null): ContentType { + return new ContentType(value ?? undefined) +} diff --git a/packages/headers/src/lib/cookie.ts b/packages/headers/src/lib/cookie.ts index f7391716038..39141901b01 100644 --- a/packages/headers/src/lib/cookie.ts +++ b/packages/headers/src/lib/cookie.ts @@ -147,3 +147,13 @@ export class Cookie implements HeaderValue, Iterable<[string, string]> { return pairs.join('; ') } } + +/** + * Parse a Cookie header value. + * + * @param value The header value (string, init object, or null) + * @returns A Cookie instance (empty if null) + */ +export function parseCookie(value: string | CookieInit | null): Cookie { + return new Cookie(value ?? undefined) +} diff --git a/packages/headers/src/lib/if-match.ts b/packages/headers/src/lib/if-match.ts index 475dd7c7168..a2103bd7efe 100644 --- a/packages/headers/src/lib/if-match.ts +++ b/packages/headers/src/lib/if-match.ts @@ -96,3 +96,13 @@ export class IfMatch implements HeaderValue, IfMatchInit { return this.tags.join(', ') } } + +/** + * Parse an If-Match header value. + * + * @param value The header value (string, string[], init object, or null) + * @returns An IfMatch instance (empty if null) + */ +export function parseIfMatch(value: string | string[] | IfMatchInit | null): IfMatch { + return new IfMatch(value ?? undefined) +} diff --git a/packages/headers/src/lib/if-none-match.ts b/packages/headers/src/lib/if-none-match.ts index 04930515a6e..846fda68ccc 100644 --- a/packages/headers/src/lib/if-none-match.ts +++ b/packages/headers/src/lib/if-none-match.ts @@ -67,3 +67,13 @@ export class IfNoneMatch implements HeaderValue, IfNoneMatchInit { return this.tags.join(', ') } } + +/** + * Parse an If-None-Match header value. + * + * @param value The header value (string, string[], init object, or null) + * @returns An IfNoneMatch instance (empty if null) + */ +export function parseIfNoneMatch(value: string | string[] | IfNoneMatchInit | null): IfNoneMatch { + return new IfNoneMatch(value ?? undefined) +} diff --git a/packages/headers/src/lib/if-range.ts b/packages/headers/src/lib/if-range.ts index 4975eb4a4d0..701671f31a2 100644 --- a/packages/headers/src/lib/if-range.ts +++ b/packages/headers/src/lib/if-range.ts @@ -41,6 +41,8 @@ export class IfRange implements HeaderValue { * Weak entity tags (prefixed with `W/`) are never considered a match. * * @param resource The current resource state to compare against + * @param resource.etag The current ETag of the resource + * @param resource.lastModified The last modified time of the resource * @returns `true` if the condition is satisfied, `false` otherwise * * @example @@ -90,3 +92,13 @@ export class IfRange implements HeaderValue { return this.value } } + +/** + * Parse an If-Range header value. + * + * @param value The header value (string, Date, or null) + * @returns An IfRange instance (empty if null) + */ +export function parseIfRange(value: string | Date | null): IfRange { + return new IfRange(value ?? undefined) +} diff --git a/packages/headers/src/lib/parse.test.ts b/packages/headers/src/lib/parse.test.ts new file mode 100644 index 00000000000..4ecb24df658 --- /dev/null +++ b/packages/headers/src/lib/parse.test.ts @@ -0,0 +1,183 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { Accept, parseAccept } from './accept.ts' +import { AcceptEncoding, parseAcceptEncoding } from './accept-encoding.ts' +import { AcceptLanguage, parseAcceptLanguage } from './accept-language.ts' +import { CacheControl, parseCacheControl } from './cache-control.ts' +import { ContentDisposition, parseContentDisposition } from './content-disposition.ts' +import { ContentRange, parseContentRange } from './content-range.ts' +import { ContentType, parseContentType } from './content-type.ts' +import { Cookie, parseCookie } from './cookie.ts' +import { IfMatch, parseIfMatch } from './if-match.ts' +import { IfNoneMatch, parseIfNoneMatch } from './if-none-match.ts' +import { IfRange, parseIfRange } from './if-range.ts' +import { Range, parseRange } from './range.ts' +import { SetCookie, parseSetCookie } from './set-cookie.ts' +import { Vary, parseVary } from './vary.ts' + +describe('parseAccept', () => { + it('parses a string value', () => { + let result = parseAccept('text/html, application/json;q=0.9') + assert(result instanceof Accept) + assert.equal(result.size, 2) + assert.equal(result.getWeight('text/html'), 1) + assert.equal(result.getWeight('application/json'), 0.9) + }) + + it('returns empty instance for null', () => { + let result = parseAccept(null) + assert(result instanceof Accept) + assert.equal(result.size, 0) + }) + + it('accepts init object', () => { + let result = parseAccept({ 'text/html': 1 }) + assert(result instanceof Accept) + assert.equal(result.size, 1) + }) +}) + +describe('parseAcceptEncoding', () => { + it('parses a string value', () => { + let result = parseAcceptEncoding('gzip, deflate;q=0.5') + assert(result instanceof AcceptEncoding) + assert.equal(result.size, 2) + }) +}) + +describe('parseAcceptLanguage', () => { + it('parses a string value', () => { + let result = parseAcceptLanguage('en-US, en;q=0.9') + assert(result instanceof AcceptLanguage) + assert.equal(result.size, 2) + }) +}) + +describe('parseCacheControl', () => { + it('parses a string value', () => { + let result = parseCacheControl('max-age=3600, public') + assert(result instanceof CacheControl) + assert.equal(result.maxAge, 3600) + assert.equal(result.public, true) + }) + + it('accepts init object', () => { + let result = parseCacheControl({ maxAge: 3600, public: true }) + assert(result instanceof CacheControl) + assert.equal(result.maxAge, 3600) + assert.equal(result.public, true) + }) +}) + +describe('parseContentDisposition', () => { + it('parses a string value', () => { + let result = parseContentDisposition('attachment; filename="test.txt"') + assert(result instanceof ContentDisposition) + assert.equal(result.type, 'attachment') + assert.equal(result.filename, 'test.txt') + }) +}) + +describe('parseContentRange', () => { + it('parses a string value', () => { + let result = parseContentRange('bytes 0-499/1234') + assert(result instanceof ContentRange) + assert.equal(result.unit, 'bytes') + assert.equal(result.start, 0) + assert.equal(result.end, 499) + assert.equal(result.size, 1234) + }) +}) + +describe('parseContentType', () => { + it('parses a string value', () => { + let result = parseContentType('text/html; charset=utf-8') + assert(result instanceof ContentType) + assert.equal(result.mediaType, 'text/html') + assert.equal(result.charset, 'utf-8') + }) + + it('accepts init object', () => { + let result = parseContentType({ mediaType: 'text/html', charset: 'utf-8' }) + assert(result instanceof ContentType) + assert.equal(result.mediaType, 'text/html') + assert.equal(result.charset, 'utf-8') + }) +}) + +describe('parseCookie', () => { + it('parses a string value', () => { + let result = parseCookie('session=abc123; user=john') + assert(result instanceof Cookie) + assert.equal(result.get('session'), 'abc123') + assert.equal(result.get('user'), 'john') + }) +}) + +describe('parseIfMatch', () => { + it('parses a string value', () => { + let result = parseIfMatch('"abc", "def"') + assert(result instanceof IfMatch) + assert.equal(result.tags.length, 2) + }) +}) + +describe('parseIfNoneMatch', () => { + it('parses a string value', () => { + let result = parseIfNoneMatch('"abc", "def"') + assert(result instanceof IfNoneMatch) + assert.equal(result.tags.length, 2) + }) +}) + +describe('parseIfRange', () => { + it('parses a string value', () => { + let result = parseIfRange('"abc"') + assert(result instanceof IfRange) + assert.equal(result.value, '"abc"') + }) + + it('parses a Date value', () => { + let date = new Date('2024-01-01T00:00:00.000Z') + let result = parseIfRange(date) + assert(result instanceof IfRange) + assert.equal(result.value, date.toUTCString()) + }) +}) + +describe('parseRange', () => { + it('parses a string value', () => { + let result = parseRange('bytes=0-499') + assert(result instanceof Range) + assert.equal(result.unit, 'bytes') + assert.equal(result.ranges.length, 1) + }) +}) + +describe('parseSetCookie', () => { + it('parses a string value', () => { + let result = parseSetCookie('session=abc123; Path=/; HttpOnly') + assert(result instanceof SetCookie) + assert.equal(result.name, 'session') + assert.equal(result.value, 'abc123') + assert.equal(result.path, '/') + assert.equal(result.httpOnly, true) + }) +}) + +describe('parseVary', () => { + it('parses a string value', () => { + let result = parseVary('Accept-Encoding, Accept-Language') + assert(result instanceof Vary) + assert.equal(result.size, 2) + assert.equal(result.has('Accept-Encoding'), true) + assert.equal(result.has('Accept-Language'), true) + }) + + it('parses an array value', () => { + let result = parseVary(['Accept-Encoding', 'Accept-Language']) + assert(result instanceof Vary) + assert.equal(result.size, 2) + }) +}) diff --git a/packages/headers/src/lib/range.ts b/packages/headers/src/lib/range.ts index ed98f18dd7e..d3fe2f96a4a 100644 --- a/packages/headers/src/lib/range.ts +++ b/packages/headers/src/lib/range.ts @@ -178,3 +178,13 @@ export class Range implements HeaderValue, RangeInit { return `${this.unit}=${rangeParts.join(',')}` } } + +/** + * Parse a Range header value. + * + * @param value The header value (string, init object, or null) + * @returns A Range instance (empty if null) + */ +export function parseRange(value: string | RangeInit | null): Range { + return new Range(value ?? undefined) +} diff --git a/packages/headers/src/lib/raw-headers.test.ts b/packages/headers/src/lib/raw-headers.test.ts new file mode 100644 index 00000000000..7eaa9e41c3e --- /dev/null +++ b/packages/headers/src/lib/raw-headers.test.ts @@ -0,0 +1,84 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parseRawHeaders, stringifyRawHeaders } from './raw-headers.ts' + +describe('parseRawHeaders', () => { + it('parses a single header', () => { + let headers = parseRawHeaders('Content-Type: text/html') + assert.equal(headers.get('content-type'), 'text/html') + }) + + it('parses multiple headers', () => { + let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') + assert.equal(headers.get('content-type'), 'text/html') + assert.equal(headers.get('cache-control'), 'no-cache') + }) + + it('trims whitespace from header names and values', () => { + let headers = parseRawHeaders(' Content-Type : text/html ') + assert.equal(headers.get('content-type'), 'text/html') + }) + + it('handles multiple values for the same header', () => { + let headers = parseRawHeaders('Set-Cookie: a=1\r\nSet-Cookie: b=2') + assert.equal(headers.get('set-cookie'), 'a=1, b=2') + }) + + it('ignores malformed lines', () => { + let headers = parseRawHeaders( + 'Content-Type: text/html\r\nmalformed line\r\nCache-Control: no-cache', + ) + assert.equal(headers.get('content-type'), 'text/html') + assert.equal(headers.get('cache-control'), 'no-cache') + }) + + it('returns empty Headers for empty string', () => { + let headers = parseRawHeaders('') + assert.equal([...headers].length, 0) + }) + + it('handles headers with colons in values', () => { + let headers = parseRawHeaders('Location: https://example.com:8080/path') + assert.equal(headers.get('location'), 'https://example.com:8080/path') + }) +}) + +describe('stringifyRawHeaders', () => { + it('stringifies a single header', () => { + let headers = new Headers({ 'Content-Type': 'text/html' }) + assert.equal(stringifyRawHeaders(headers), 'content-type: text/html') + }) + + it('stringifies multiple headers', () => { + let headers = new Headers() + headers.set('Content-Type', 'text/html') + headers.set('Cache-Control', 'no-cache') + let result = stringifyRawHeaders(headers) + assert.ok(result.includes('content-type: text/html')) + assert.ok(result.includes('cache-control: no-cache')) + assert.ok(result.includes('\r\n')) + }) + + it('returns empty string for empty Headers', () => { + let headers = new Headers() + assert.equal(stringifyRawHeaders(headers), '') + }) + + it('handles headers with colons in values', () => { + let headers = new Headers({ Location: 'https://example.com:8080/path' }) + assert.equal(stringifyRawHeaders(headers), 'location: https://example.com:8080/path') + }) + + it('round-trips with parseRawHeaders', () => { + let original = new Headers() + original.set('Content-Type', 'text/html') + original.set('Cache-Control', 'no-cache') + + let stringified = stringifyRawHeaders(original) + let parsed = parseRawHeaders(stringified) + + assert.equal(parsed.get('content-type'), 'text/html') + assert.equal(parsed.get('cache-control'), 'no-cache') + }) +}) diff --git a/packages/headers/src/lib/raw-headers.ts b/packages/headers/src/lib/raw-headers.ts new file mode 100644 index 00000000000..7428e725933 --- /dev/null +++ b/packages/headers/src/lib/raw-headers.ts @@ -0,0 +1,46 @@ +const CRLF = '\r\n' + +/** + * Parses a raw HTTP header string into a `Headers` object. + * + * @param raw A raw HTTP header string with headers separated by CRLF (`\r\n`) + * @returns A `Headers` object containing the parsed headers + * + * @example + * let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') + * headers.get('content-type') // 'text/html' + * headers.get('cache-control') // 'no-cache' + */ +export function parseRawHeaders(raw: string): Headers { + let headers = new Headers() + + for (let line of raw.split(CRLF)) { + let match = line.match(/^([^:]+):(.*)/) + if (match) { + headers.append(match[1].trim(), match[2].trim()) + } + } + + return headers +} + +/** + * Converts a `Headers` object to a raw HTTP header string. + * + * @param headers A `Headers` object to stringify + * @returns A raw HTTP header string with headers separated by CRLF (`\r\n`) + * + * @example + * let headers = new Headers({ 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' }) + * stringifyRawHeaders(headers) // 'content-type: text/html\r\ncache-control: no-cache' + */ +export function stringifyRawHeaders(headers: Headers): string { + let result = '' + + for (let [name, value] of headers) { + if (result) result += CRLF + result += `${name}: ${value}` + } + + return result +} diff --git a/packages/headers/src/lib/set-cookie.ts b/packages/headers/src/lib/set-cookie.ts index 8a7cdbb0fe8..aea8e57ec22 100644 --- a/packages/headers/src/lib/set-cookie.ts +++ b/packages/headers/src/lib/set-cookie.ts @@ -204,3 +204,13 @@ export class SetCookie implements HeaderValue, SetCookieInit { return parts.join('; ') } } + +/** + * Parse a Set-Cookie header value. + * + * @param value The header value (string, init object, or null) + * @returns A SetCookie instance (empty if null) + */ +export function parseSetCookie(value: string | SetCookieInit | null): SetCookie { + return new SetCookie(value ?? undefined) +} diff --git a/packages/headers/src/lib/super-headers.test.ts b/packages/headers/src/lib/super-headers.test.ts deleted file mode 100644 index ee251ce2c7d..00000000000 --- a/packages/headers/src/lib/super-headers.test.ts +++ /dev/null @@ -1,861 +0,0 @@ -import * as assert from 'node:assert/strict' -import { describe, it } from 'node:test' - -import { Accept } from './accept.ts' -import { AcceptEncoding } from './accept-encoding.ts' -import { AcceptLanguage } from './accept-language.ts' -import { CacheControl } from './cache-control.ts' -import { ContentDisposition } from './content-disposition.ts' -import { ContentRange } from './content-range.ts' -import { ContentType } from './content-type.ts' -import { Cookie } from './cookie.ts' -import { IfMatch } from './if-match.ts' -import { IfNoneMatch } from './if-none-match.ts' -import { IfRange } from './if-range.ts' -import { Range } from './range.ts' -import { SuperHeaders } from './super-headers.ts' - -describe('SuperHeaders', () => { - it('is an instance of Headers', () => { - let headers = new SuperHeaders() - assert.ok(headers instanceof SuperHeaders) - assert.ok(headers instanceof Headers) - }) - - it('initializes with no arguments', () => { - let headers = new SuperHeaders() - assert.equal(headers.get('Content-Type'), null) - }) - - it('initializes from an object of header name/value pairs', () => { - let headers = new SuperHeaders({ 'Content-Type': 'text/plain' }) - assert.equal(headers.get('Content-Type'), 'text/plain') - }) - - it('initializes from an array of key-value pairs', () => { - let headers = new SuperHeaders([ - ['Content-Type', 'text/plain'], - ['X-Custom', 'value'], - ]) - assert.equal(headers.get('Content-Type'), 'text/plain') - assert.equal(headers.get('X-Custom'), 'value') - }) - - it('initializes from a Headers instance', () => { - let h1 = new Headers({ 'Content-Type': 'text/plain' }) - let h2 = new SuperHeaders(h1) - assert.equal(h2.get('Content-Type'), 'text/plain') - }) - - it('initializes from another SuperHeaders instance', () => { - let h1 = new SuperHeaders({ 'Content-Type': 'text/plain' }) - let h2 = new SuperHeaders(h1) - assert.equal(h2.get('Content-Type'), 'text/plain') - }) - - it('initializes from a string', () => { - let headers = new SuperHeaders('Content-Type: text/plain\r\nContent-Length: 42') - assert.equal(headers.get('Content-Type'), 'text/plain') - assert.equal(headers.get('Content-Length'), '42') - }) - - it('appends values', () => { - let headers = new SuperHeaders() - headers.append('X-Custom', 'value1') - headers.append('X-Custom', 'value2') - assert.equal(headers.get('X-Custom'), 'value1, value2') - }) - - it('sets values', () => { - let headers = new SuperHeaders() - headers.set('X-Custom', 'value1') - headers.set('X-Custom', 'value2') - assert.equal(headers.get('X-Custom'), 'value2') - }) - - it('deletes values', () => { - let headers = new SuperHeaders({ 'X-Custom': 'value' }) - headers.delete('X-Custom') - assert.equal(headers.has('X-Custom'), false) - }) - - it('checks if a header exists', () => { - let headers = new SuperHeaders({ 'X-Custom': 'value' }) - assert.equal(headers.has('X-Custom'), true) - assert.equal(headers.has('Content-Type'), false) - - // Accessing this property should not change the result of has() - let _ = headers.contentType - assert.equal(headers.has('Content-Type'), false) - }) - - it('iterates over entries', () => { - let headers = new SuperHeaders({ - 'Content-Type': 'text/plain', - 'Content-Length': '42', - }) - let entries = Array.from(headers.entries()) - assert.deepEqual(entries, [ - ['content-type', 'text/plain'], - ['content-length', '42'], - ]) - }) - - it('iterates over keys', () => { - let headers = new SuperHeaders({ - 'Content-Type': 'text/plain', - 'Content-Length': '42', - }) - let keys = Array.from(headers.keys()) - assert.deepEqual(keys, ['content-type', 'content-length']) - }) - - it('iterates over set-cookie keys correctly', () => { - let headers = new SuperHeaders() - headers.append('Set-Cookie', 'session=abc') - headers.append('Set-Cookie', 'theme=dark') - let keys = Array.from(headers.keys()) - assert.deepEqual(keys, ['set-cookie', 'set-cookie']) - }) - - it('iterates over values', () => { - let headers = new SuperHeaders({ - 'Content-Type': 'text/plain', - 'Content-Length': '42', - }) - let values = Array.from(headers.values()) - assert.deepEqual(values, ['text/plain', '42']) - }) - - it('uses forEach correctly', () => { - let headers = new SuperHeaders({ - 'Content-Type': 'text/plain', - 'Content-Length': '42', - }) - let result: [string, string][] = [] - headers.forEach((value, key) => { - result.push([key, value]) - }) - assert.deepEqual(result, [ - ['content-type', 'text/plain'], - ['content-length', '42'], - ]) - }) - - it('is directly iterable', () => { - let headers = new SuperHeaders({ - 'Content-Type': 'text/plain', - 'Content-Length': '42', - }) - let entries = Array.from(headers) - assert.deepEqual(entries, [ - ['content-type', 'text/plain'], - ['content-length', '42'], - ]) - }) - - it('omits empty values when stringified', () => { - let headers = new SuperHeaders() - - // This should appear in the string since it has a media type, it's complete - headers.contentType = 'text/plain' - - // This should not appear in the string since it's incomplete, missing the type - headers.contentDisposition.filename = 'example.txt' - - assert.equal(headers.toString(), 'Content-Type: text/plain') - }) - - describe('constructor property init', () => { - it('handles the accept property', () => { - let headers = new SuperHeaders({ accept: { 'text/html': 1, 'application/json': 0.9 } }) - assert.equal(headers.get('Accept'), 'text/html,application/json;q=0.9') - }) - - it('handles the acceptEncoding property', () => { - let headers = new SuperHeaders({ acceptEncoding: { gzip: 1, deflate: 0.8 } }) - assert.equal(headers.get('Accept-Encoding'), 'gzip,deflate;q=0.8') - }) - - it('handles the acceptLanguage property', () => { - let headers = new SuperHeaders({ acceptLanguage: { 'en-US': 1, en: 0.9 } }) - assert.equal(headers.get('Accept-Language'), 'en-us,en;q=0.9') - }) - - it('handles the acceptRanges property', () => { - let headers = new SuperHeaders({ acceptRanges: 'bytes' }) - assert.equal(headers.get('Accept-Ranges'), 'bytes') - }) - - it('handles the age property', () => { - let headers = new SuperHeaders({ age: 42 }) - assert.equal(headers.get('Age'), '42') - }) - - it('handles the cacheControl property', () => { - let headers = new SuperHeaders({ cacheControl: { public: true, maxAge: 3600 } }) - assert.equal(headers.get('Cache-Control'), 'public, max-age=3600') - }) - - it('handles the connection property', () => { - let headers = new SuperHeaders({ connection: 'close' }) - assert.equal(headers.get('Connection'), 'close') - }) - - it('handles the contentDisposition property', () => { - let headers = new SuperHeaders({ - contentDisposition: { type: 'attachment', filename: 'example.txt' }, - }) - assert.equal(headers.get('Content-Disposition'), 'attachment; filename=example.txt') - }) - - it('handles the contentEncoding property', () => { - let headers = new SuperHeaders({ contentEncoding: 'gzip' }) - assert.equal(headers.get('Content-Encoding'), 'gzip') - }) - - it('handles the contentLanguage property', () => { - let headers = new SuperHeaders({ contentLanguage: 'en-US' }) - assert.equal(headers.get('Content-Language'), 'en-US') - }) - - it('handles the contentLength property', () => { - let headers = new SuperHeaders({ contentLength: 42 }) - assert.equal(headers.get('Content-Length'), '42') - }) - - it('handles the contentRange property', () => { - let headers = new SuperHeaders({ - contentRange: { unit: 'bytes', start: 200, end: 1000, size: 67589 }, - }) - assert.equal(headers.get('Content-Range'), 'bytes 200-1000/67589') - }) - - it('handles the contentType property', () => { - let headers = new SuperHeaders({ - contentType: { mediaType: 'text/plain', charset: 'utf-8' }, - }) - assert.equal(headers.get('Content-Type'), 'text/plain; charset=utf-8') - }) - - it('handles the cookie property', () => { - let headers = new SuperHeaders({ cookie: [['name', 'value']] }) - assert.equal(headers.get('Cookie'), 'name=value') - }) - - it('handles the date property', () => { - let headers = new SuperHeaders({ date: new Date('2021-01-01T00:00:00Z') }) - assert.equal(headers.get('Date'), 'Fri, 01 Jan 2021 00:00:00 GMT') - }) - - it('handles the etag property', () => { - let headers = new SuperHeaders({ etag: '"67ab43"' }) - assert.equal(headers.get('ETag'), '"67ab43"') - - let headers2 = new SuperHeaders({ etag: '67ab43' }) - assert.equal(headers2.get('ETag'), '"67ab43"') - - let headers3 = new SuperHeaders({ etag: 'W/"67ab43"' }) - assert.equal(headers3.get('ETag'), 'W/"67ab43"') - }) - - it('handles the expires property', () => { - let headers = new SuperHeaders({ expires: new Date('2021-01-01T00:00:00Z') }) - assert.equal(headers.get('Expires'), 'Fri, 01 Jan 2021 00:00:00 GMT') - }) - - it('handles the host property', () => { - let headers = new SuperHeaders({ host: 'example.com' }) - assert.equal(headers.get('Host'), 'example.com') - }) - - it('handles the ifModifiedSince property', () => { - let headers = new SuperHeaders({ ifModifiedSince: new Date('2021-01-01T00:00:00Z') }) - assert.equal(headers.get('If-Modified-Since'), 'Fri, 01 Jan 2021 00:00:00 GMT') - }) - - it('handles the ifMatch property', () => { - let headers = new SuperHeaders({ ifMatch: ['67ab43', '54ed21'] }) - assert.equal(headers.get('If-Match'), '"67ab43", "54ed21"') - }) - - it('handles the ifNoneMatch property', () => { - let headers = new SuperHeaders({ ifNoneMatch: ['67ab43', '54ed21'] }) - assert.equal(headers.get('If-None-Match'), '"67ab43", "54ed21"') - }) - - it('handles the ifUnmodifiedSince property', () => { - let headers = new SuperHeaders({ ifUnmodifiedSince: new Date('2021-01-01T00:00:00Z') }) - assert.equal(headers.get('If-Unmodified-Since'), 'Fri, 01 Jan 2021 00:00:00 GMT') - }) - - it('handles the lastModified property', () => { - let headers = new SuperHeaders({ lastModified: new Date('2021-01-01T00:00:00Z') }) - assert.equal(headers.get('Last-Modified'), 'Fri, 01 Jan 2021 00:00:00 GMT') - }) - - it('handles the location property', () => { - let headers = new SuperHeaders({ location: 'https://example.com' }) - assert.equal(headers.get('Location'), 'https://example.com') - }) - - it('handles the referer property', () => { - let headers = new SuperHeaders({ referer: 'https://example.com' }) - assert.equal(headers.get('Referer'), 'https://example.com') - }) - - it('handles the setCookie property', () => { - let headers = new SuperHeaders({ - setCookie: [ - { name: 'session', value: 'abc', path: '/' }, - { name: 'theme', value: 'dark', expires: new Date('2021-12-31T23:59:59Z') }, - ], - }) - assert.deepEqual(headers.getSetCookie(), [ - 'session=abc; Path=/', - 'theme=dark; Expires=Fri, 31 Dec 2021 23:59:59 GMT', - ]) - }) - - it('handles the vary property', () => { - let headers = new SuperHeaders({ vary: ['Accept-Encoding', 'Accept-Language'] }) - assert.equal(headers.get('Vary'), 'accept-encoding, accept-language') - }) - - it('stringifies unknown properties with non-string values', () => { - let headers = new SuperHeaders({ unknown: 42 }) - assert.equal(headers.get('Unknown'), '42') - }) - }) - - describe('property getters and setters', () => { - it('supports the accept property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.accept instanceof Accept) - - headers.accept = 'text/html,application/json;q=0.9' - assert.deepEqual(headers.accept.size, 2) - assert.deepEqual(headers.accept.mediaTypes, ['text/html', 'application/json']) - assert.deepEqual(headers.accept.weights, [1, 0.9]) - - headers.accept = { 'application/json': 0.8, 'text/html': 1 } - assert.deepEqual(headers.accept.size, 2) - assert.deepEqual(headers.accept.mediaTypes, ['text/html', 'application/json']) - assert.deepEqual(headers.accept.weights, [1, 0.8]) - - headers.accept = null - assert.ok(headers.accept instanceof Accept) - assert.equal(headers.accept.toString(), '') - }) - - it('supports the acceptEncoding property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.acceptEncoding instanceof AcceptEncoding) - - headers.acceptEncoding = 'gzip, deflate' - assert.deepEqual(headers.acceptEncoding.size, 2) - assert.deepEqual(headers.acceptEncoding.encodings, ['gzip', 'deflate']) - assert.deepEqual(headers.acceptEncoding.weights, [1, 1]) - - headers.acceptEncoding = { gzip: 1, deflate: 0.8 } - assert.deepEqual(headers.acceptEncoding.size, 2) - assert.deepEqual(headers.acceptEncoding.encodings, ['gzip', 'deflate']) - assert.deepEqual(headers.acceptEncoding.weights, [1, 0.8]) - - headers.acceptEncoding = null - assert.ok(headers.acceptEncoding instanceof AcceptEncoding) - assert.equal(headers.acceptEncoding.toString(), '') - }) - - it('supports the acceptLanguage property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.acceptLanguage instanceof AcceptLanguage) - - headers.acceptLanguage = 'en-US,en;q=0.9' - assert.deepEqual(headers.acceptLanguage.size, 2) - assert.deepEqual(headers.acceptLanguage.languages, ['en-us', 'en']) - assert.deepEqual(headers.acceptLanguage.weights, [1, 0.9]) - - headers.acceptLanguage = { en: 1, 'en-US': 0.8 } - assert.deepEqual(headers.acceptLanguage.size, 2) - assert.deepEqual(headers.acceptLanguage.languages, ['en', 'en-us']) - assert.deepEqual(headers.acceptLanguage.weights, [1, 0.8]) - - headers.acceptLanguage = null - assert.ok(headers.acceptLanguage instanceof AcceptLanguage) - assert.equal(headers.acceptLanguage.toString(), '') - }) - - it('supports the acceptRanges property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.acceptRanges, null) - - headers.acceptRanges = 'bytes' - assert.equal(headers.acceptRanges, 'bytes') - - headers.acceptRanges = null - assert.equal(headers.acceptRanges, null) - }) - - it('supports the age property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.age, null) - - headers.age = '42' - assert.equal(headers.age, 42) - - headers.age = 42 - assert.equal(headers.age, 42) - - headers.age = null - assert.equal(headers.age, null) - }) - - it('supports the allow property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.allow, null) - - headers.allow = 'GET, HEAD' - assert.equal(headers.allow, 'GET, HEAD') - - headers.allow = ['GET', 'POST', 'PUT', 'DELETE'] - assert.equal(headers.allow, 'GET, POST, PUT, DELETE') - - headers.allow = null - assert.equal(headers.allow, null) - }) - - it('supports the cacheControl property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.cacheControl instanceof CacheControl) - - headers.cacheControl = 'public, max-age=3600' - assert.equal(headers.cacheControl.public, true) - assert.equal(headers.cacheControl.maxAge, 3600) - - headers.cacheControl.maxAge = 1800 - assert.equal(headers.cacheControl.maxAge, 1800) - - headers.cacheControl = { noCache: true, noStore: true } - assert.equal(headers.cacheControl.noCache, true) - assert.equal(headers.cacheControl.noStore, true) - - headers.cacheControl = null - assert.ok(headers.cacheControl instanceof CacheControl) - assert.equal(headers.cacheControl.toString(), '') - }) - - it('supports the connection property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.connection, null) - - headers.connection = 'close' - assert.equal(headers.connection, 'close') - - headers.connection = null - assert.equal(headers.connection, null) - }) - - it('supports the contentDisposition property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.contentDisposition instanceof ContentDisposition) - - headers.contentDisposition = 'attachment; filename="example.txt"' - assert.equal(headers.contentDisposition.type, 'attachment') - assert.equal(headers.contentDisposition.filename, 'example.txt') - - headers.contentDisposition.filename = 'new.txt' - assert.equal(headers.contentDisposition.filename, 'new.txt') - - headers.contentDisposition = { type: 'inline', filename: 'index.html' } - assert.equal(headers.contentDisposition.type, 'inline') - assert.equal(headers.contentDisposition.filename, 'index.html') - - headers.contentDisposition = null - assert.ok(headers.contentDisposition instanceof ContentDisposition) - assert.equal(headers.contentDisposition.toString(), '') - }) - - it('supports the contentEncoding property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.contentEncoding, null) - - headers.contentEncoding = 'gzip' - assert.equal(headers.contentEncoding, 'gzip') - - headers.contentEncoding = ['deflate', 'gzip'] - assert.equal(headers.contentEncoding, 'deflate, gzip') - - headers.contentEncoding = null - assert.equal(headers.contentEncoding, null) - }) - - it('supports the contentLanguage property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.contentLanguage, null) - - headers.contentLanguage = 'en-US' - assert.equal(headers.contentLanguage, 'en-US') - - headers.contentLanguage = ['en', 'fr'] - assert.equal(headers.contentLanguage, 'en, fr') - - headers.contentLanguage = null - assert.equal(headers.contentLanguage, null) - }) - - it('supports the contentLength property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.contentLength, null) - - headers.contentLength = '42' - assert.equal(headers.contentLength, 42) - - headers.contentLength = 42 - assert.equal(headers.contentLength, 42) - - headers.contentLength = null - assert.equal(headers.contentLength, null) - }) - - it('supports the contentRange property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.contentRange instanceof ContentRange) - - headers.contentRange = 'bytes 200-1000/67589' - assert.equal(headers.contentRange.unit, 'bytes') - assert.equal(headers.contentRange.start, 200) - assert.equal(headers.contentRange.end, 1000) - assert.equal(headers.contentRange.size, 67589) - - headers.contentRange = { unit: 'bytes', start: 0, end: 999, size: '*' } - assert.equal(headers.contentRange.unit, 'bytes') - assert.equal(headers.contentRange.start, 0) - assert.equal(headers.contentRange.end, 999) - assert.equal(headers.contentRange.size, '*') - - headers.contentRange = null - assert.ok(headers.contentRange instanceof ContentRange) - assert.equal(headers.contentRange.toString(), '') - }) - - it('supports the contentType property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.contentType instanceof ContentType) - - headers.contentType = 'text/plain; charset=utf-8' - assert.equal(headers.contentType.mediaType, 'text/plain') - assert.equal(headers.contentType.charset, 'utf-8') - - headers.contentType.charset = 'iso-8859-1' - assert.equal(headers.contentType.charset, 'iso-8859-1') - - headers.contentType = { mediaType: 'text/html' } - assert.equal(headers.contentType.mediaType, 'text/html') - - headers.contentType = null - assert.ok(headers.contentType instanceof ContentType) - assert.equal(headers.contentType.toString(), '') - }) - - it('supports the cookie property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.cookie instanceof Cookie) - - headers.cookie = 'name1=value1; name2=value2' - assert.equal(headers.cookie.get('name1'), 'value1') - assert.equal(headers.cookie.get('name2'), 'value2') - - headers.cookie.set('name3', 'value3') - assert.equal(headers.cookie.get('name3'), 'value3') - - headers.cookie = [['name4', 'value4']] - assert.equal(headers.cookie.get('name4'), 'value4') - - headers.cookie = null - assert.ok(headers.cookie instanceof Cookie) - assert.equal(headers.cookie.toString(), '') - }) - - it('supports the date property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.date, null) - - headers.date = new Date('2021-01-01T00:00:00Z') - assert.ok(headers.date instanceof Date) - assert.equal(headers.date.toUTCString(), 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.date = null - assert.equal(headers.date, null) - }) - - it('supports the etag property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.etag, null) - - headers.etag = '"67ab43"' - assert.equal(headers.etag, '"67ab43"') - - headers.etag = '67ab43' - assert.equal(headers.etag, '"67ab43"') - - headers.etag = 'W/"67ab43"' - assert.equal(headers.etag, 'W/"67ab43"') - - headers.etag = '' - assert.equal(headers.etag, '""') - - headers.etag = '""' - assert.equal(headers.etag, '""') - - headers.etag = null - assert.equal(headers.etag, null) - }) - - it('supports the expires property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.expires, null) - - headers.expires = new Date('2021-01-01T00:00:00Z') - assert.ok(headers.expires instanceof Date) - assert.equal(headers.expires.toUTCString(), 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.expires = null - assert.equal(headers.expires, null) - }) - - it('supports the host property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.host, null) - - headers.host = 'example.com' - assert.equal(headers.host, 'example.com') - - headers.host = null - assert.equal(headers.host, null) - }) - - it('supports the ifModifiedSince property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.ifModifiedSince, null) - - headers.ifModifiedSince = new Date('2021-01-01T00:00:00Z') - assert.ok(headers.ifModifiedSince instanceof Date) - assert.equal(headers.ifModifiedSince.toUTCString(), 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.ifModifiedSince = null - assert.equal(headers.ifModifiedSince, null) - }) - - it('supports the ifMatch property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.ifMatch instanceof IfMatch) - assert.equal(headers.ifMatch.tags.length, 0) - - headers.ifMatch = '67ab43' - assert.deepEqual(headers.ifMatch.tags, ['"67ab43"']) - - headers.ifMatch = ['67ab43', '54ed21'] - assert.deepEqual(headers.ifMatch.tags, ['"67ab43"', '"54ed21"']) - - headers.ifMatch = { tags: ['W/"67ab43"'] } - assert.deepEqual(headers.ifMatch.tags, ['W/"67ab43"']) - - headers.ifMatch = null - assert.ok(headers.ifMatch instanceof IfMatch) - assert.equal(headers.ifMatch.tags.length, 0) - }) - - it('supports the ifNoneMatch property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.ifNoneMatch instanceof IfNoneMatch) - - headers.ifNoneMatch = '"67ab43", "54ed21"' - assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) - - headers.ifNoneMatch = ['67ab43', '54ed21'] - assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) - - headers.ifNoneMatch = { tags: ['67ab43', '54ed21'] } - assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) - - assert.equal(headers.ifNoneMatch.toString(), '"67ab43", "54ed21"') - - headers.ifNoneMatch = null - assert.ok(headers.ifNoneMatch instanceof IfNoneMatch) - assert.equal(headers.ifNoneMatch.toString(), '') - }) - - it('supports the ifRange property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.ifRange instanceof IfRange) - assert.equal(headers.ifRange.value, '') - - headers.ifRange = 'Fri, 01 Jan 2021 00:00:00 GMT' - assert.equal(headers.ifRange.value, 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.ifRange = new Date('2021-01-01T00:00:00Z') - assert.equal(headers.ifRange.value, 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.ifRange = '"67ab43"' - assert.equal(headers.ifRange.value, '"67ab43"') - - assert.equal(headers.ifRange.toString(), '"67ab43"') - }) - - it('supports the ifUnmodifiedSince property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.ifUnmodifiedSince, null) - - headers.ifUnmodifiedSince = new Date('2021-01-01T00:00:00Z') - assert.ok(headers.ifUnmodifiedSince instanceof Date) - assert.equal(headers.ifUnmodifiedSince.toUTCString(), 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.ifUnmodifiedSince = null - assert.equal(headers.ifUnmodifiedSince, null) - }) - - it('supports the lastModified property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.lastModified, null) - - headers.lastModified = new Date('2021-01-01T00:00:00Z') - assert.ok(headers.lastModified instanceof Date) - assert.equal(headers.lastModified.toUTCString(), 'Fri, 01 Jan 2021 00:00:00 GMT') - - headers.lastModified = null - assert.equal(headers.lastModified, null) - }) - - it('supports the location property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.location, null) - - headers.location = 'https://example.com' - assert.equal(headers.location, 'https://example.com') - - headers.location = null - assert.equal(headers.location, null) - }) - - it('supports the range property', () => { - let headers = new SuperHeaders() - - assert.ok(headers.range instanceof Range) - assert.equal(headers.range.ranges.length, 0) - - headers.range = 'bytes=0-99' - assert.equal(headers.range.unit, 'bytes') - assert.equal(headers.range.ranges.length, 1) - assert.equal(headers.range.ranges[0].start, 0) - assert.equal(headers.range.ranges[0].end, 99) - - headers.range = { unit: 'bytes', ranges: [{ start: 100, end: 199 }] } - assert.equal(headers.range.unit, 'bytes') - assert.equal(headers.range.ranges.length, 1) - assert.equal(headers.range.ranges[0].start, 100) - assert.equal(headers.range.ranges[0].end, 199) - - headers.range = null - assert.ok(headers.range instanceof Range) - assert.equal(headers.range.ranges.length, 0) - }) - - it('supports the referer property', () => { - let headers = new SuperHeaders() - - assert.equal(headers.referer, null) - - headers.referer = 'https://example.com' - assert.equal(headers.referer, 'https://example.com') - - headers.referer = null - assert.equal(headers.referer, null) - }) - - it('supports the setCookie property', () => { - let headers = new SuperHeaders() - - assert.deepEqual(headers.setCookie, []) - - headers.setCookie = 'session=abc' - assert.equal(headers.setCookie.length, 1) - assert.equal(headers.setCookie[0].name, 'session') - assert.equal(headers.setCookie[0].value, 'abc') - - headers.setCookie = { name: 'session', value: 'def' } - assert.equal(headers.setCookie.length, 1) - assert.equal(headers.setCookie[0].name, 'session') - assert.equal(headers.setCookie[0].value, 'def') - - headers.setCookie = ['session=abc', 'theme=dark'] - assert.equal(headers.setCookie.length, 2) - assert.equal(headers.setCookie[0].name, 'session') - assert.equal(headers.setCookie[0].value, 'abc') - assert.equal(headers.setCookie[1].name, 'theme') - assert.equal(headers.setCookie[1].value, 'dark') - - // Can use ...spread to add new cookies - headers.setCookie = [...headers.setCookie, 'lang=en'] - assert.equal(headers.setCookie.length, 3) - assert.equal(headers.setCookie[2].name, 'lang') - assert.equal(headers.setCookie[2].value, 'en') - - headers.setCookie = [ - { name: 'session', value: 'def' }, - { name: 'theme', value: 'light' }, - ] - assert.equal(headers.setCookie.length, 2) - assert.equal(headers.setCookie[0].name, 'session') - assert.equal(headers.setCookie[0].value, 'def') - assert.equal(headers.setCookie[1].name, 'theme') - assert.equal(headers.setCookie[1].value, 'light') - - // Can use push() to add new cookies - headers.setCookie.push({ name: 'lang', value: 'fr' }) - assert.equal(headers.setCookie.length, 3) - assert.equal(headers.setCookie[2].name, 'lang') - assert.equal(headers.setCookie[2].value, 'fr') - - headers.setCookie = null - assert.deepEqual(headers.setCookie, []) - }) - - it('supports the vary property', () => { - let headers = new SuperHeaders() - - headers.vary = 'Accept-Encoding, Accept-Language' - assert.equal(headers.vary.has('accept-encoding'), true) - assert.equal(headers.vary.has('accept-language'), true) - - headers.vary = null - assert.equal(headers.vary.size, 0) - }) - }) -}) diff --git a/packages/headers/src/lib/super-headers.ts b/packages/headers/src/lib/super-headers.ts deleted file mode 100644 index bec67153f2a..00000000000 --- a/packages/headers/src/lib/super-headers.ts +++ /dev/null @@ -1,967 +0,0 @@ -import { type AcceptInit, Accept } from './accept.ts' -import { type AcceptEncodingInit, AcceptEncoding } from './accept-encoding.ts' -import { type AcceptLanguageInit, AcceptLanguage } from './accept-language.ts' -import { type CacheControlInit, CacheControl } from './cache-control.ts' -import { type ContentDispositionInit, ContentDisposition } from './content-disposition.ts' -import { type ContentRangeInit, ContentRange } from './content-range.ts' -import { type ContentTypeInit, ContentType } from './content-type.ts' -import { type CookieInit, Cookie } from './cookie.ts' -import { canonicalHeaderName } from './header-names.ts' -import { type HeaderValue } from './header-value.ts' -import { type IfMatchInit, IfMatch } from './if-match.ts' -import { type IfNoneMatchInit, IfNoneMatch } from './if-none-match.ts' -import { IfRange } from './if-range.ts' -import { type RangeInit, Range } from './range.ts' -import { type SetCookieInit, SetCookie } from './set-cookie.ts' -import { type VaryInit, Vary } from './vary.ts' -import { isIterable, quoteEtag } from './utils.ts' - -type DateInit = number | Date - -/** - * Property-based initializer for `SuperHeaders`. - */ -interface SuperHeadersPropertyInit { - /** - * The [`Accept`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) header value. - */ - accept?: string | AcceptInit - /** - * The [`Accept-Encoding`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding) header value. - */ - acceptEncoding?: string | AcceptEncodingInit - /** - * The [`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) header value. - */ - acceptLanguage?: string | AcceptLanguageInit - /** - * The [`Accept-Ranges`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges) header value. - */ - acceptRanges?: string - /** - * The [`Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Age) header value. - */ - age?: string | number - /** - * The [`Allow`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow) header value. - */ - allow?: string | string[] - /** - * The [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value. - */ - cacheControl?: string | CacheControlInit - /** - * The [`Connection`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection) header value. - */ - connection?: string - /** - * The [`Content-Disposition`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) header value. - */ - contentDisposition?: string | ContentDispositionInit - /** - * The [`Content-Encoding`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) header value. - */ - contentEncoding?: string | string[] - /** - * The [`Content-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language) header value. - */ - contentLanguage?: string | string[] - /** - * The [`Content-Length`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) header value. - */ - contentLength?: string | number - /** - * The [`Content-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) header value. - */ - contentRange?: string | ContentRangeInit - /** - * The [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header value. - */ - contentType?: string | ContentTypeInit - /** - * The [`Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) header value. - */ - cookie?: string | CookieInit - /** - * The [`Date`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date) header value. - */ - date?: string | DateInit - /** - * The [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) header value. - */ - etag?: string - /** - * The [`Expires`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires) header value. - */ - expires?: string | DateInit - /** - * The [`Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header value. - */ - host?: string - /** - * The [`If-Modified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) header value. - */ - ifModifiedSince?: string | DateInit - /** - * The [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) header value. - */ - ifMatch?: string | string[] | IfMatchInit - /** - * The [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) header value. - */ - ifNoneMatch?: string | string[] | IfNoneMatchInit - /** - * The [`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) header value. - */ - ifRange?: string | Date - /** - * The [`If-Unmodified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since) header value. - */ - ifUnmodifiedSince?: string | DateInit - /** - * The [`Last-Modified`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified) header value. - */ - lastModified?: string | DateInit - /** - * The [`Location`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) header value. - */ - location?: string - /** - * The [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) header value. - */ - range?: string | RangeInit - /** - * The [`Referer`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer) header value. - */ - referer?: string - /** - * The [`Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) header value(s). - */ - setCookie?: string | (string | SetCookieInit)[] - /** - * The [`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary) header value. - */ - vary?: string | string[] | VaryInit -} - -/** - * Initializer for `SuperHeaders`. - */ -export type SuperHeadersInit = - | Iterable<[string, string]> - | (SuperHeadersPropertyInit & Record) - -const CRLF = '\r\n' - -const AcceptKey = 'accept' -const AcceptEncodingKey = 'accept-encoding' -const AcceptLanguageKey = 'accept-language' -const AcceptRangesKey = 'accept-ranges' -const AgeKey = 'age' -const AllowKey = 'allow' -const CacheControlKey = 'cache-control' -const ConnectionKey = 'connection' -const ContentDispositionKey = 'content-disposition' -const ContentEncodingKey = 'content-encoding' -const ContentLanguageKey = 'content-language' -const ContentLengthKey = 'content-length' -const ContentRangeKey = 'content-range' -const ContentTypeKey = 'content-type' -const CookieKey = 'cookie' -const DateKey = 'date' -const ETagKey = 'etag' -const ExpiresKey = 'expires' -const HostKey = 'host' -const IfMatchKey = 'if-match' -const IfModifiedSinceKey = 'if-modified-since' -const IfNoneMatchKey = 'if-none-match' -const IfRangeKey = 'if-range' -const IfUnmodifiedSinceKey = 'if-unmodified-since' -const LastModifiedKey = 'last-modified' -const LocationKey = 'location' -const RangeKey = 'range' -const RefererKey = 'referer' -const SetCookieKey = 'set-cookie' -const VaryKey = 'vary' - -/** - * An enhanced JavaScript `Headers` interface with type-safe access. - * - * [API Reference](https://github.com/remix-run/remix/tree/main/packages/headers) - * - * [MDN `Headers` Base Class Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers) - */ -export class SuperHeaders extends Headers { - #map: Map - #setCookies: (string | SetCookie)[] = [] - - /** - * @param init A string, iterable, object, or `Headers` instance to initialize with - */ - constructor(init?: string | SuperHeadersInit | Headers) { - super() - - this.#map = new Map() - - if (init) { - if (typeof init === 'string') { - let lines = init.split(CRLF) - for (let line of lines) { - let match = line.match(/^([^:]+):(.*)/) - if (match) { - this.append(match[1].trim(), match[2].trim()) - } - } - } else if (isIterable(init)) { - for (let [name, value] of init) { - this.append(name, value) - } - } else if (typeof init === 'object') { - for (let name of Object.getOwnPropertyNames(init)) { - let value = init[name] - - let descriptor = Object.getOwnPropertyDescriptor(SuperHeaders.prototype, name) - if (descriptor?.set) { - descriptor.set.call(this, value) - } else { - this.set(name, value.toString()) - } - } - } - } - } - - /** - * Appends a new header value to the existing set of values for a header, - * or adds the header if it does not already exist. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/append) - * - * @param name The name of the header to append to - * @param value The value to append - */ - override append(name: string, value: string): void { - let key = name.toLowerCase() - if (key === SetCookieKey) { - this.#setCookies.push(value) - } else { - let existingValue = this.#map.get(key) - this.#map.set(key, existingValue ? `${existingValue}, ${value}` : value) - } - } - - /** - * Removes a header. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/delete) - * - * @param name The name of the header to delete - */ - override delete(name: string): void { - let key = name.toLowerCase() - if (key === SetCookieKey) { - this.#setCookies = [] - } else { - this.#map.delete(key) - } - } - - /** - * Returns a string of all the values for a header, or `null` if the header does not exist. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/get) - * - * @param name The name of the header to get - * @returns The header value, or `null` if not found - */ - override get(name: string): string | null { - let key = name.toLowerCase() - if (key === SetCookieKey) { - return this.getSetCookie().join(', ') - } else { - let value = this.#map.get(key) - if (typeof value === 'string') { - return value - } else if (value != null) { - let str = value.toString() - return str === '' ? null : str - } else { - return null - } - } - } - - /** - * Returns an array of all values associated with the `Set-Cookie` header. This is - * useful when building headers for a HTTP response since multiple `Set-Cookie` headers - * must be sent on separate lines. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/getSetCookie) - * - * @returns An array of `Set-Cookie` header values - */ - override getSetCookie(): string[] { - return this.#setCookies.map((v) => (typeof v === 'string' ? v : v.toString())) - } - - /** - * Returns `true` if the header is present in the list of headers. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/has) - * - * @param name The name of the header to check - * @returns `true` if the header is present, `false` otherwise - */ - override has(name: string): boolean { - let key = name.toLowerCase() - return key === SetCookieKey ? this.#setCookies.length > 0 : this.get(key) != null - } - - /** - * Sets a new value for the given header. If the header already exists, the new value - * will replace the existing value. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/set) - * - * @param name The name of the header to set - * @param value The value to set - */ - override set(name: string, value: string): void { - let key = name.toLowerCase() - if (key === SetCookieKey) { - this.#setCookies = [value] - } else { - this.#map.set(key, value) - } - } - - /** - * Returns an iterator of all header keys (lowercase). - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/keys) - * - * @returns An iterator of header keys - */ - override *keys(): HeadersIterator { - for (let [key] of this) yield key - } - - /** - * Returns an iterator of all header values. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/values) - * - * @returns An iterator of header values - */ - override *values(): HeadersIterator { - for (let [, value] of this) yield value - } - - /** - * Returns an iterator of all header key/value pairs. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/entries) - * - * @returns An iterator of `[key, value]` tuples - */ - override *entries(): HeadersIterator<[string, string]> { - for (let [key] of this.#map) { - let str = this.get(key) - if (str) yield [key, str] - } - - for (let value of this.getSetCookie()) { - yield [SetCookieKey, value] - } - } - - override [Symbol.iterator](): HeadersIterator<[string, string]> { - return this.entries() - } - - /** - * Invokes the `callback` for each header key/value pair. - * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/forEach) - * - * @param callback The function to call for each pair - * @param thisArg The value to use as `this` when calling the callback - */ - override forEach(callback: (value: string, key: string, parent: Headers) => void, thisArg?: any): void { - for (let [key, value] of this) { - callback.call(thisArg, value, key, this) - } - } - - /** - * Returns a string representation of the headers suitable for use in a HTTP message. - * - * @returns The headers formatted for HTTP - */ - override toString(): string { - let lines: string[] = [] - - for (let [key, value] of this) { - lines.push(`${canonicalHeaderName(key)}: ${value}`) - } - - return lines.join(CRLF) - } - - // Header-specific getters and setters - - /** - * The `Accept` header is used by clients to indicate the media types that are acceptable - * in the response. - * - * [MDN `Accept` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2) - */ - get accept(): Accept { - return this.#getHeaderValue(AcceptKey, Accept) - } - - set accept(value: string | AcceptInit | undefined | null) { - this.#setHeaderValue(AcceptKey, Accept, value) - } - - /** - * The `Accept-Encoding` header contains information about the content encodings that the client - * is willing to accept in the response. - * - * [MDN `Accept-Encoding` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.4) - */ - get acceptEncoding(): AcceptEncoding { - return this.#getHeaderValue(AcceptEncodingKey, AcceptEncoding) - } - - set acceptEncoding(value: string | AcceptEncodingInit | undefined | null) { - this.#setHeaderValue(AcceptEncodingKey, AcceptEncoding, value) - } - - /** - * The `Accept-Language` header contains information about preferred natural language for the - * response. - * - * [MDN `Accept-Language` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5) - */ - get acceptLanguage(): AcceptLanguage { - return this.#getHeaderValue(AcceptLanguageKey, AcceptLanguage) - } - - set acceptLanguage(value: string | AcceptLanguageInit | undefined | null) { - this.#setHeaderValue(AcceptLanguageKey, AcceptLanguage, value) - } - - /** - * The `Accept-Ranges` header indicates the server's acceptance of range requests. - * - * [MDN `Accept-Ranges` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7233#section-2.3) - */ - get acceptRanges(): string | null { - return this.#getStringValue(AcceptRangesKey) - } - - set acceptRanges(value: string | undefined | null) { - this.#setStringValue(AcceptRangesKey, value) - } - - /** - * The `Age` header contains the time in seconds an object was in a proxy cache. - * - * [MDN `Age` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Age) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7234#section-5.1) - */ - get age(): number | null { - return this.#getNumberValue(AgeKey) - } - - set age(value: string | number | undefined | null) { - this.#setNumberValue(AgeKey, value) - } - - /** - * The `Allow` header lists the HTTP methods that are supported by the resource. - * - * [MDN `Allow` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow) - * - * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.allow) - */ - get allow(): string | null { - return this.#getStringValue(AllowKey) - } - - set allow(value: string | string[] | undefined | null) { - this.#setStringValue(AllowKey, Array.isArray(value) ? value.join(', ') : value) - } - - /** - * The `Cache-Control` header contains directives for caching mechanisms in both requests and responses. - * - * [MDN `Cache-Control` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7234#section-5.2) - */ - get cacheControl(): CacheControl { - return this.#getHeaderValue(CacheControlKey, CacheControl) - } - - set cacheControl(value: string | CacheControlInit | undefined | null) { - this.#setHeaderValue(CacheControlKey, CacheControl, value) - } - - /** - * The `Connection` header controls whether the network connection stays open after the current - * transaction finishes. - * - * [MDN `Connection` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7230#section-6.1) - */ - get connection(): string | null { - return this.#getStringValue(ConnectionKey) - } - - set connection(value: string | undefined | null) { - this.#setStringValue(ConnectionKey, value) - } - - /** - * The `Content-Disposition` header is a response-type header that describes how the payload is displayed. - * - * [MDN `Content-Disposition` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) - * - * [RFC 6266](https://datatracker.ietf.org/doc/html/rfc6266) - */ - get contentDisposition(): ContentDisposition { - return this.#getHeaderValue(ContentDispositionKey, ContentDisposition) - } - - set contentDisposition(value: string | ContentDispositionInit | undefined | null) { - this.#setHeaderValue(ContentDispositionKey, ContentDisposition, value) - } - - /** - * The `Content-Encoding` header specifies the encoding of the resource. - * - * Note: If multiple encodings have been used, this value may be a comma-separated list. However, most often this - * header will only contain a single value. - * - * [MDN `Content-Encoding` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) - * - * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-encoding) - */ - get contentEncoding(): string | null { - return this.#getStringValue(ContentEncodingKey) - } - - set contentEncoding(value: string | string[] | undefined | null) { - this.#setStringValue(ContentEncodingKey, Array.isArray(value) ? value.join(', ') : value) - } - - /** - * The `Content-Language` header describes the natural language(s) of the intended audience for the response content. - * - * Note: If the response content is intended for multiple audiences, this value may be a comma-separated list. However, - * most often this header will only contain a single value. - * - * [MDN `Content-Language` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language) - * - * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-language) - */ - get contentLanguage(): string | null { - return this.#getStringValue(ContentLanguageKey) - } - - set contentLanguage(value: string | string[] | undefined | null) { - this.#setStringValue(ContentLanguageKey, Array.isArray(value) ? value.join(', ') : value) - } - - /** - * The `Content-Length` header indicates the size of the entity-body in bytes. - * - * [MDN `Content-Length` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2) - */ - get contentLength(): number | null { - return this.#getNumberValue(ContentLengthKey) - } - - set contentLength(value: string | number | undefined | null) { - this.#setNumberValue(ContentLengthKey, value) - } - - /** - * The `Content-Range` header indicates where the content of a response body - * belongs in relation to a complete resource. - * - * [MDN `Content-Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) - * - * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-range) - */ - get contentRange(): ContentRange { - return this.#getHeaderValue(ContentRangeKey, ContentRange) - } - - set contentRange(value: string | ContentRangeInit | undefined | null) { - this.#setHeaderValue(ContentRangeKey, ContentRange, value) - } - - /** - * The `Content-Type` header indicates the media type of the resource. - * - * [MDN `Content-Type` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.5) - */ - get contentType(): ContentType { - return this.#getHeaderValue(ContentTypeKey, ContentType) - } - - set contentType(value: string | ContentTypeInit | undefined | null) { - this.#setHeaderValue(ContentTypeKey, ContentType, value) - } - - /** - * The `Cookie` request header contains stored HTTP cookies previously sent by the server with - * the `Set-Cookie` header. - * - * [MDN `Cookie` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc6265#section-5.4) - */ - get cookie(): Cookie { - return this.#getHeaderValue(CookieKey, Cookie) - } - - set cookie(value: string | CookieInit | undefined | null) { - this.#setHeaderValue(CookieKey, Cookie, value) - } - - /** - * The `Date` header contains the date and time at which the message was sent. - * - * [MDN `Date` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.2) - */ - get date(): Date | null { - return this.#getDateValue(DateKey) - } - - set date(value: string | DateInit | undefined | null) { - this.#setDateValue(DateKey, value) - } - - /** - * The `ETag` header provides a unique identifier for the current version of the resource. - * - * [MDN `ETag` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-2.3) - */ - get etag(): string | null { - return this.#getStringValue(ETagKey) - } - - set etag(value: string | undefined | null) { - this.#setStringValue(ETagKey, typeof value === 'string' ? quoteEtag(value) : value) - } - - /** - * The `Expires` header contains the date/time after which the response is considered stale. - * - * [MDN `Expires` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7234#section-5.3) - */ - get expires(): Date | null { - return this.#getDateValue(ExpiresKey) - } - - set expires(value: string | DateInit | undefined | null) { - this.#setDateValue(ExpiresKey, value) - } - - /** - * The `Host` header specifies the domain name of the server and (optionally) the TCP port number. - * - * [MDN `Host` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7230#section-5.4) - */ - get host(): string | null { - return this.#getStringValue(HostKey) - } - - set host(value: string | undefined | null) { - this.#setStringValue(HostKey, value) - } - - /** - * The `If-Modified-Since` header makes a request conditional on the last modification date of the - * requested resource. - * - * [MDN `If-Modified-Since` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.3) - */ - get ifModifiedSince(): Date | null { - return this.#getDateValue(IfModifiedSinceKey) - } - - set ifModifiedSince(value: string | DateInit | undefined | null) { - this.#setDateValue(IfModifiedSinceKey, value) - } - - /** - * The `If-Match` header makes a request conditional on the presence of a matching ETag. - * - * [MDN `If-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.1) - */ - get ifMatch(): IfMatch { - return this.#getHeaderValue(IfMatchKey, IfMatch) - } - - set ifMatch(value: string | string[] | IfMatchInit | undefined | null) { - this.#setHeaderValue(IfMatchKey, IfMatch, value) - } - - /** - * The `If-None-Match` header makes a request conditional on the absence of a matching ETag. - * - * [MDN `If-None-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.2) - */ - get ifNoneMatch(): IfNoneMatch { - return this.#getHeaderValue(IfNoneMatchKey, IfNoneMatch) - } - - set ifNoneMatch(value: string | string[] | IfNoneMatchInit | undefined | null) { - this.#setHeaderValue(IfNoneMatchKey, IfNoneMatch, value) - } - - /** - * The `If-Range` header makes a range request conditional on the resource state. - * Can contain either an entity tag (ETag) or an HTTP date. - * - * [MDN `If-Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7233#section-3.2) - */ - get ifRange(): IfRange { - return this.#getHeaderValue(IfRangeKey, IfRange) - } - - set ifRange(value: string | Date | undefined | null) { - this.#setHeaderValue(IfRangeKey, IfRange, value) - } - - /** - * The `If-Unmodified-Since` header makes a request conditional on the last modification date of the - * requested resource. - * - * [MDN `If-Unmodified-Since` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.4) - */ - get ifUnmodifiedSince(): Date | null { - return this.#getDateValue(IfUnmodifiedSinceKey) - } - - set ifUnmodifiedSince(value: string | DateInit | undefined | null) { - this.#setDateValue(IfUnmodifiedSinceKey, value) - } - - /** - * The `Last-Modified` header contains the date and time at which the resource was last modified. - * - * [MDN `Last-Modified` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-2.2) - */ - get lastModified(): Date | null { - return this.#getDateValue(LastModifiedKey) - } - - set lastModified(value: string | DateInit | undefined | null) { - this.#setDateValue(LastModifiedKey, value) - } - - /** - * The `Location` header indicates the URL to redirect to. - * - * [MDN `Location` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2) - */ - get location(): string | null { - return this.#getStringValue(LocationKey) - } - - set location(value: string | undefined | null) { - this.#setStringValue(LocationKey, value) - } - - /** - * The `Range` header indicates the part of a resource that the client wants to receive. - * - * [MDN `Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - * - * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.range) - */ - get range(): Range { - return this.#getHeaderValue(RangeKey, Range) - } - - set range(value: string | RangeInit | undefined | null) { - this.#setHeaderValue(RangeKey, Range, value) - } - - /** - * The `Referer` header contains the address of the previous web page from which a link to the - * currently requested page was followed. - * - * [MDN `Referer` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.5.2) - */ - get referer(): string | null { - return this.#getStringValue(RefererKey) - } - - set referer(value: string | undefined | null) { - this.#setStringValue(RefererKey, value) - } - - /** - * The `Set-Cookie` header is used to send cookies from the server to the user agent. - * - * [MDN `Set-Cookie` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) - * - * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1) - */ - get setCookie(): SetCookie[] { - let setCookies = this.#setCookies - - for (let i = 0; i < setCookies.length; ++i) { - if (typeof setCookies[i] === 'string') { - setCookies[i] = new SetCookie(setCookies[i]) - } - } - - return setCookies as SetCookie[] - } - - set setCookie(value: (string | SetCookieInit)[] | string | SetCookieInit | undefined | null) { - if (value != null) { - this.#setCookies = (Array.isArray(value) ? value : [value]).map((v) => - typeof v === 'string' ? v : new SetCookie(v), - ) - } else { - this.#setCookies = [] - } - } - - /** - * The `Vary` header indicates the set of request headers that determine whether - * a cached response can be used rather than requesting a fresh response from the origin server. - * - * Common values include `Accept-Encoding`, `Accept-Language`, `Accept`, `User-Agent`, etc. - * - * [MDN `Vary` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary) - * - * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.vary) - */ - get vary(): Vary { - return this.#getHeaderValue(VaryKey, Vary) - } - - set vary(value: string | string[] | VaryInit | undefined | null) { - this.#setHeaderValue(VaryKey, Vary, value) - } - - // Helpers - - #getHeaderValue(key: string, ctor: new (init?: any) => T): T { - let value = this.#map.get(key) - - if (value !== undefined) { - if (typeof value === 'string') { - let obj = new ctor(value) - this.#map.set(key, obj) // cache the new object - return obj - } else { - return value as T - } - } - - let obj = new ctor() - this.#map.set(key, obj) // cache the new object - return obj - } - - #setHeaderValue(key: string, ctor: new (init?: string) => HeaderValue, value: any): void { - if (value != null) { - this.#map.set(key, typeof value === 'string' ? value : new ctor(value)) - } else { - this.#map.delete(key) - } - } - - #getDateValue(key: string): Date | null { - let value = this.#map.get(key) - return value === undefined ? null : new Date(value as string) - } - - #setDateValue(key: string, value: string | DateInit | undefined | null): void { - if (value != null) { - this.#map.set( - key, - typeof value === 'string' - ? value - : (typeof value === 'number' ? new Date(value) : value).toUTCString(), - ) - } else { - this.#map.delete(key) - } - } - - #getNumberValue(key: string): number | null { - let value = this.#map.get(key) - return value === undefined ? null : parseInt(value as string, 10) - } - - #setNumberValue(key: string, value: string | number | undefined | null): void { - if (value != null) { - this.#map.set(key, typeof value === 'string' ? value : value.toString()) - } else { - this.#map.delete(key) - } - } - - #getStringValue(key: string): string | null { - let value = this.#map.get(key) - return value === undefined ? null : (value as string) - } - - #setStringValue(key: string, value: string | undefined | null): void { - if (value != null) { - this.#map.set(key, value) - } else { - this.#map.delete(key) - } - } -} diff --git a/packages/headers/src/lib/vary.ts b/packages/headers/src/lib/vary.ts index ba0049f93fb..ecc3261fce0 100644 --- a/packages/headers/src/lib/vary.ts +++ b/packages/headers/src/lib/vary.ts @@ -119,3 +119,13 @@ export class Vary implements HeaderValue, VaryInit, Iterable { return Array.from(this.#set).join(', ') } } + +/** + * Parse a Vary header value. + * + * @param value The header value (string, string[], init object, or null) + * @returns A Vary instance (empty if null) + */ +export function parseVary(value: string | string[] | VaryInit | null): Vary { + return new Vary(value ?? undefined) +} diff --git a/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md b/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md new file mode 100644 index 00000000000..2cc383237c1 --- /dev/null +++ b/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md @@ -0,0 +1 @@ +Update `@remix-run/headers` peer dependency to use the new header parsing functions. diff --git a/packages/multipart-parser/src/lib/multipart.ts b/packages/multipart-parser/src/lib/multipart.ts index ff92e8703df..2cdb75a0d9b 100644 --- a/packages/multipart-parser/src/lib/multipart.ts +++ b/packages/multipart-parser/src/lib/multipart.ts @@ -1,4 +1,4 @@ -import Headers from '@remix-run/headers' +import { parseContentDisposition, parseContentType, parseRawHeaders } from '@remix-run/headers' import { createSearch, @@ -327,6 +327,7 @@ export class MultipartParser { if (this.#state !== MultipartParserStateDone) { throw new MultipartParseError('Multipart stream not finished') } + return undefined } } @@ -381,7 +382,7 @@ export class MultipartPart { */ get headers(): Headers { if (!this.#headers) { - this.#headers = new Headers(decoder.decode(this.#header)) + this.#headers = parseRawHeaders(decoder.decode(this.#header)) } return this.#headers @@ -405,21 +406,21 @@ export class MultipartPart { * The filename of the part, if it is a file upload. */ get filename(): string | undefined { - return this.headers.contentDisposition.preferredFilename + return parseContentDisposition(this.headers.get('content-disposition')).preferredFilename } /** * The media type of the part. */ get mediaType(): string | undefined { - return this.headers.contentType.mediaType + return parseContentType(this.headers.get('content-type')).mediaType } /** * The name of the part, usually the `name` of the field in the `
` that submitted the request. */ get name(): string | undefined { - return this.headers.contentDisposition.name + return parseContentDisposition(this.headers.get('content-disposition')).name } /** diff --git a/packages/multipart-parser/test/utils.ts b/packages/multipart-parser/test/utils.ts index b874cc4d14a..c7041878505 100644 --- a/packages/multipart-parser/test/utils.ts +++ b/packages/multipart-parser/test/utils.ts @@ -1,4 +1,4 @@ -import SuperHeaders from '@remix-run/headers' +import { ContentDisposition, ContentType } from '@remix-run/headers' export type PartValue = | string @@ -28,31 +28,30 @@ export function createMultipartMessage( pushLine(`--${boundary}`) if (typeof value === 'string') { - let headers = new SuperHeaders({ - contentDisposition: { - type: 'form-data', - name, - }, + let contentDisposition = new ContentDisposition({ + type: 'form-data', + name, }) - pushLine(`${headers}`) + pushLine(`Content-Disposition: ${contentDisposition}`) pushLine() pushLine(value) } else { - let headers = new SuperHeaders({ - contentDisposition: { - type: 'form-data', - name, - filename: value.filename, - filenameSplat: value.filenameSplat, - }, + let contentDisposition = new ContentDisposition({ + type: 'form-data', + name, + filename: value.filename, + filenameSplat: value.filenameSplat, }) + let headerLines = [`Content-Disposition: ${contentDisposition}`] + if (value.mediaType) { - headers.contentType = value.mediaType + let contentType = new ContentType({ mediaType: value.mediaType }) + headerLines.push(`Content-Type: ${contentType}`) } - pushLine(`${headers}`) + pushLine(headerLines.join('\r\n')) pushLine() if (typeof value.content === 'string') { pushLine(value.content) diff --git a/packages/response/.changes/patch.use-headers-parse-functions.md b/packages/response/.changes/patch.use-headers-parse-functions.md new file mode 100644 index 00000000000..2cc383237c1 --- /dev/null +++ b/packages/response/.changes/patch.use-headers-parse-functions.md @@ -0,0 +1 @@ +Update `@remix-run/headers` peer dependency to use the new header parsing functions. diff --git a/packages/response/src/lib/compress.test.ts b/packages/response/src/lib/compress.test.ts index 663cd7f8041..b97dcb21b58 100644 --- a/packages/response/src/lib/compress.test.ts +++ b/packages/response/src/lib/compress.test.ts @@ -13,7 +13,7 @@ import { Readable } from 'node:stream' import { EventEmitter } from 'node:events' import { describe, it } from 'node:test' -import { SuperHeaders } from '@remix-run/headers' +import { parseVary } from '@remix-run/headers' import { compressResponse, compressStream, type Encoding } from './compress.ts' const isWindows = process.platform === 'win32' @@ -50,8 +50,8 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'gzip') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') - let headers = new SuperHeaders(compressed.headers) - assert.ok(headers.vary.has('Accept-Encoding')) + let vary = parseVary(compressed.headers.get('vary')) + assert.ok(vary.has('Accept-Encoding')) let buffer = Buffer.from(await compressed.arrayBuffer()) let decompressed = await gunzipAsync(buffer) @@ -85,8 +85,8 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'deflate') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') - let headers = new SuperHeaders(compressed.headers) - assert.ok(headers.vary.has('Accept-Encoding')) + let vary = parseVary(compressed.headers.get('vary')) + assert.ok(vary.has('Accept-Encoding')) let buffer = Buffer.from(await compressed.arrayBuffer()) let decompressed = await inflateAsync(buffer) @@ -642,8 +642,8 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'gzip') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') assert.equal(compressed.headers.get('Content-Length'), null) - let headers = new SuperHeaders(compressed.headers) - assert.ok(headers.vary.has('Accept-Encoding')) + let vary = parseVary(compressed.headers.get('vary')) + assert.ok(vary.has('Accept-Encoding')) assert.equal(compressed.body, null) }) @@ -692,8 +692,8 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'gzip') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') assert.equal(compressed.headers.get('Content-Length'), null) - let headers = new SuperHeaders(compressed.headers) - assert.ok(headers.vary.has('Accept-Encoding')) + let vary = parseVary(compressed.headers.get('vary')) + assert.ok(vary.has('Accept-Encoding')) assert.equal(compressed.body, null) }) diff --git a/packages/response/src/lib/compress.ts b/packages/response/src/lib/compress.ts index 84fc54f10bf..c503eaa64f8 100644 --- a/packages/response/src/lib/compress.ts +++ b/packages/response/src/lib/compress.ts @@ -9,7 +9,8 @@ import { } from 'node:zlib' import type { BrotliOptions, ZlibOptions } from 'node:zlib' -import { AcceptEncoding, SuperHeaders } from '@remix-run/headers' +import type { AcceptEncoding } from '@remix-run/headers' +import { parseAcceptEncoding, parseCacheControl, parseVary } from '@remix-run/headers' export type Encoding = 'br' | 'gzip' | 'deflate' const defaultEncodings: Encoding[] = ['br', 'gzip', 'deflate'] @@ -84,7 +85,13 @@ export async function compressResponse( let supportedEncodings = compressOptions.encodings ?? defaultEncodings let threshold = compressOptions.threshold ?? 1024 let acceptEncodingHeader = request.headers.get('Accept-Encoding') - let responseHeaders = new SuperHeaders(response.headers) + let responseHeaders = new Headers(response.headers) + + let contentEncodingHeader = responseHeaders.get('content-encoding') + let contentLengthHeader = responseHeaders.get('content-length') + let contentLength = contentLengthHeader != null ? parseInt(contentLengthHeader, 10) : null + let acceptRangesHeader = responseHeaders.get('accept-ranges') + let cacheControl = parseCacheControl(responseHeaders.get('cache-control')) if ( !acceptEncodingHeader || @@ -92,20 +99,20 @@ export async function compressResponse( // Empty response (request.method !== 'HEAD' && !response.body) || // Already compressed - responseHeaders.contentEncoding != null || + contentEncodingHeader != null || // Content-Length below threshold - (responseHeaders.contentLength != null && responseHeaders.contentLength < threshold) || + (contentLength != null && contentLength < threshold) || // Cache-Control: no-transform - responseHeaders.cacheControl.noTransform || + cacheControl.noTransform || // Response advertising range support - responseHeaders.acceptRanges === 'bytes' || + acceptRangesHeader === 'bytes' || // Partial content responses response.status === 206 ) { return response } - let acceptEncoding = new AcceptEncoding(acceptEncodingHeader) + let acceptEncoding = parseAcceptEncoding(acceptEncodingHeader) let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings) if (selectedEncoding === null) { // Client has explicitly rejected all supported encodings, including 'identity' @@ -155,15 +162,20 @@ function negotiateEncoding( return preferred } -function setCompressionHeaders(headers: SuperHeaders, encoding: string): void { - headers.contentEncoding = encoding - headers.acceptRanges = 'none' - headers.contentLength = null - headers.vary.add('Accept-Encoding') +function setCompressionHeaders(headers: Headers, encoding: string): void { + headers.set('content-encoding', encoding) + headers.set('accept-ranges', 'none') + headers.delete('content-length') + + // Update Vary header to include Accept-Encoding + let vary = parseVary(headers.get('vary')) + vary.add('Accept-Encoding') + headers.set('vary', vary.toString()) // Convert strong ETags to weak since compressed representation is byte-different - if (headers.etag && !headers.etag.startsWith('W/')) { - headers.etag = `W/${headers.etag}` + let etagHeader = headers.get('etag') + if (etagHeader && !etagHeader.startsWith('W/')) { + headers.set('etag', `W/${etagHeader}`) } } @@ -177,7 +189,7 @@ const brotliFlushOptions = { function applyCompression( response: Response, - responseHeaders: SuperHeaders, + responseHeaders: Headers, encoding: Encoding, options: CompressResponseOptions, ): Response { @@ -186,8 +198,8 @@ function applyCompression( } // Detect SSE for automatic flush configuration - let contentType = response.headers.get('Content-Type') - let mediaType = contentType?.split(';')[0].trim() + let contentTypeHeader = response.headers.get('Content-Type') + let mediaType = contentTypeHeader?.split(';')[0].trim() let isSSE = mediaType === 'text/event-stream' let compressor = createCompressor(encoding, { @@ -216,6 +228,10 @@ function applyCompression( * Compresses a response stream that bridges node:zlib to Web Streams. * Reads from the input stream, compresses chunks through the compressor, * and returns a new ReadableStream with the compressed data. + * + * @param input The input stream to compress + * @param compressor The zlib compressor instance + * @returns A compressed ReadableStream */ export function compressStream( input: ReadableStream, diff --git a/packages/response/src/lib/file.ts b/packages/response/src/lib/file.ts index d52c26d1842..4e5e1689f7c 100644 --- a/packages/response/src/lib/file.ts +++ b/packages/response/src/lib/file.ts @@ -1,4 +1,11 @@ -import SuperHeaders from '@remix-run/headers' +import { + type ContentRangeInit, + ContentRange, + parseIfMatch, + parseIfNoneMatch, + parseIfRange, + parseRange, +} from '@remix-run/headers' import { isCompressibleMimeType } from '@remix-run/mime' /** @@ -103,7 +110,7 @@ export async function createFileResponse( acceptRanges: acceptRangesOption, } = options - let headers = new SuperHeaders(request.headers) + let headers = request.headers let contentType = file.type let contentLength = file.size @@ -134,33 +141,33 @@ export async function createFileResponse( let hasIfMatch = headers.has('If-Match') // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match - if (etag && hasIfMatch && !headers.ifMatch.matches(etag)) { - return new Response('Precondition Failed', { - status: 412, - headers: new SuperHeaders( - omitNullableValues({ + if (etag && hasIfMatch) { + let ifMatch = parseIfMatch(headers.get('if-match')) + if (!ifMatch.matches(etag)) { + return new Response('Precondition Failed', { + status: 412, + headers: buildResponseHeaders({ etag, lastModified, acceptRanges, }), - ), - }) + }) + } } // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since if (lastModified && !hasIfMatch) { - let ifUnmodifiedSince = headers.ifUnmodifiedSince - if (ifUnmodifiedSince != null) { + let ifUnmodifiedSinceHeader = headers.get('if-unmodified-since') + if (ifUnmodifiedSinceHeader != null) { + let ifUnmodifiedSince = new Date(ifUnmodifiedSinceHeader) if (removeMilliseconds(lastModified) > removeMilliseconds(ifUnmodifiedSince)) { return new Response('Precondition Failed', { status: 412, - headers: new SuperHeaders( - omitNullableValues({ - etag, - lastModified, - acceptRanges, - }), - ), + headers: buildResponseHeaders({ + etag, + lastModified, + acceptRanges, + }), }) } } @@ -170,12 +177,14 @@ export async function createFileResponse( // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since if (etag || lastModified) { let shouldReturnNotModified = false + let ifNoneMatch = parseIfNoneMatch(headers.get('if-none-match')) - if (etag && headers.ifNoneMatch.matches(etag)) { + if (etag && ifNoneMatch.matches(etag)) { shouldReturnNotModified = true - } else if (lastModified && headers.ifNoneMatch.tags.length === 0) { - let ifModifiedSince = headers.ifModifiedSince - if (ifModifiedSince != null) { + } else if (lastModified && ifNoneMatch.tags.length === 0) { + let ifModifiedSinceHeader = headers.get('if-modified-since') + if (ifModifiedSinceHeader != null) { + let ifModifiedSince = new Date(ifModifiedSinceHeader) if (removeMilliseconds(lastModified) <= removeMilliseconds(ifModifiedSince)) { shouldReturnNotModified = true } @@ -185,13 +194,11 @@ export async function createFileResponse( if (shouldReturnNotModified) { return new Response(null, { status: 304, - headers: new SuperHeaders( - omitNullableValues({ - etag, - lastModified, - acceptRanges, - }), - ), + headers: buildResponseHeaders({ + etag, + lastModified, + acceptRanges, + }), }) } } @@ -199,7 +206,7 @@ export async function createFileResponse( // Range support: https://httpwg.org/specs/rfc9110.html#field.range // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range if (acceptRanges && request.method === 'GET' && headers.has('Range')) { - let range = headers.range + let range = parseRange(headers.get('range')) // Check if the Range header was sent but parsing resulted in no valid ranges (malformed) if (range.ranges.length === 0) { @@ -209,8 +216,9 @@ export async function createFileResponse( } // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range + let ifRange = parseIfRange(headers.get('if-range')) if ( - headers.ifRange.matches({ + ifRange.matches({ etag, lastModified, }) @@ -218,8 +226,8 @@ export async function createFileResponse( if (!range.canSatisfy(file.size)) { return new Response('Range Not Satisfiable', { status: 416, - headers: new SuperHeaders({ - contentRange: { unit: 'bytes', size: file.size }, + headers: buildResponseHeaders({ + contentRange: new ContentRange({ unit: 'bytes', size: file.size }), }), }) } @@ -230,8 +238,8 @@ export async function createFileResponse( if (normalizedRanges.length > 1) { return new Response('Range Not Satisfiable', { status: 416, - headers: new SuperHeaders({ - contentRange: { unit: 'bytes', size: file.size }, + headers: buildResponseHeaders({ + contentRange: new ContentRange({ unit: 'bytes', size: file.size }), }), }) } @@ -241,33 +249,29 @@ export async function createFileResponse( return new Response(file.slice(start, end + 1), { status: 206, - headers: new SuperHeaders( - omitNullableValues({ - contentType, - contentLength: end - start + 1, - contentRange: { unit: 'bytes', start, end, size }, - etag, - lastModified, - cacheControl, - acceptRanges, - }), - ), + headers: buildResponseHeaders({ + contentType, + contentLength: end - start + 1, + contentRange: { unit: 'bytes', start, end, size }, + etag, + lastModified, + cacheControl, + acceptRanges, + }), }) } } return new Response(request.method === 'HEAD' ? null : file, { status: 200, - headers: new SuperHeaders( - omitNullableValues({ - contentType, - contentLength, - etag, - lastModified, - cacheControl, - acceptRanges, - }), - ), + headers: buildResponseHeaders({ + contentType, + contentLength, + etag, + lastModified, + cacheControl, + acceptRanges, + }), }) } @@ -275,18 +279,43 @@ function generateWeakETag(file: File): string { return `W/"${file.size}-${file.lastModified}"` } -type OmitNullableValues = { - [K in keyof T as T[K] extends null | undefined ? never : K]: NonNullable +interface ResponseHeaderValues { + contentType?: string + contentLength?: number + contentRange?: ContentRangeInit + etag?: string + lastModified?: number + cacheControl?: string + acceptRanges?: 'bytes' } -function omitNullableValues>(headers: T): OmitNullableValues { - let result: any = {} - for (let key in headers) { - if (headers[key] != null) { - result[key] = headers[key] - } +function buildResponseHeaders(values: ResponseHeaderValues): Headers { + let headers = new Headers() + + if (values.contentType) { + headers.set('Content-Type', values.contentType) } - return result + if (values.contentLength != null) { + headers.set('Content-Length', String(values.contentLength)) + } + if (values.contentRange) { + let str = new ContentRange(values.contentRange).toString() + if (str) headers.set('Content-Range', str) + } + if (values.etag) { + headers.set('ETag', values.etag) + } + if (values.lastModified != null) { + headers.set('Last-Modified', new Date(values.lastModified).toUTCString()) + } + if (values.cacheControl) { + headers.set('Cache-Control', values.cacheControl) + } + if (values.acceptRanges) { + headers.set('Accept-Ranges', values.acceptRanges) + } + + return headers } /** @@ -326,6 +355,9 @@ async function hashFile(file: File, algorithm: AlgorithmIdentifier = 'SHA-256'): /** * Removes milliseconds from a timestamp, returning seconds. * HTTP dates only have second precision, so this is useful for date comparisons. + * + * @param time The timestamp or Date to convert + * @returns The timestamp in seconds (without milliseconds) */ function removeMilliseconds(time: number | Date): number { let timestamp = time instanceof Date ? time.getTime() : time From 865df2cc49ebbf6c2516cdc8c327f0fe1c0bed0e Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:29:31 +1100 Subject: [PATCH 02/15] Update migration guide code formatting --- packages/fetch-router/.changes/minor.plain-headers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fetch-router/.changes/minor.plain-headers.md b/packages/fetch-router/.changes/minor.plain-headers.md index 92654af6e2d..b2f4f579701 100644 --- a/packages/fetch-router/.changes/minor.plain-headers.md +++ b/packages/fetch-router/.changes/minor.plain-headers.md @@ -3,8 +3,6 @@ BREAKING CHANGE: `RequestContext.headers` now returns a standard `Headers` insta If you were relying on the type-safe property accessors on `RequestContext.headers`, you should use the new parse functions from `@remix-run/headers` instead: ```ts -import { parseAccept } from '@remix-run/headers' - // Before: router.get('/api/users', (context) => { let acceptsJson = context.headers.accept.accepts('application/json') @@ -12,6 +10,8 @@ router.get('/api/users', (context) => { }) // After: +import { parseAccept } from '@remix-run/headers' + router.get('/api/users', (context) => { let accept = parseAccept(context.headers.get('accept')) let acceptsJson = accept.accepts('application/json') From b2f67194ccdce25123704e23f34d8d4005d7d687 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:32:15 +1100 Subject: [PATCH 03/15] Update stale `headers.accept` documentation --- packages/fetch-router/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 97d1c3dbfd2..c840b91483f 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -592,7 +592,7 @@ router.get('/posts/:id', ({ request, url, params, storage }) => { #### Content Negotiation -- use `headers.accept.accepts()` to serve different responses based on the client's `Accept` header +- use `parseAccept` from `@remix-run/headers` to serve different responses based on the client's `Accept` header - maybe put this on `context.accepts()` for convenience? #### Sessions From 261b101346fe0cd8223cf38dff8670ce2ac9f0df Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:35:46 +1100 Subject: [PATCH 04/15] Co-locate header parsing tests --- .../headers/src/lib/accept-encoding.test.ts | 10 +- .../headers/src/lib/accept-language.test.ts | 10 +- packages/headers/src/lib/accept.test.ts | 24 ++- .../headers/src/lib/cache-control.test.ts | 23 ++- .../src/lib/content-disposition.test.ts | 11 +- .../headers/src/lib/content-range.test.ts | 13 +- packages/headers/src/lib/content-type.test.ts | 18 +- packages/headers/src/lib/cookie.test.ts | 11 +- packages/headers/src/lib/if-match.test.ts | 10 +- .../headers/src/lib/if-none-match.test.ts | 10 +- packages/headers/src/lib/if-range.test.ts | 17 +- packages/headers/src/lib/parse.test.ts | 183 ------------------ packages/headers/src/lib/range.test.ts | 11 +- packages/headers/src/lib/set-cookie.test.ts | 13 +- packages/headers/src/lib/vary.test.ts | 18 +- 15 files changed, 182 insertions(+), 200 deletions(-) delete mode 100644 packages/headers/src/lib/parse.test.ts diff --git a/packages/headers/src/lib/accept-encoding.test.ts b/packages/headers/src/lib/accept-encoding.test.ts index b869aabfe67..17b80f10240 100644 --- a/packages/headers/src/lib/accept-encoding.test.ts +++ b/packages/headers/src/lib/accept-encoding.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { AcceptEncoding } from './accept-encoding.ts' +import { AcceptEncoding, parseAcceptEncoding } from './accept-encoding.ts' describe('Accept-Encoding', () => { it('initializes with an empty string', () => { @@ -149,3 +149,11 @@ describe('Accept-Encoding', () => { assert.equal(header.toString(), 'br,deflate;q=0.9,gzip;q=0.8') }) }) + +describe('parseAcceptEncoding', () => { + it('parses a string value', () => { + let result = parseAcceptEncoding('gzip, deflate;q=0.5') + assert.ok(result instanceof AcceptEncoding) + assert.equal(result.size, 2) + }) +}) diff --git a/packages/headers/src/lib/accept-language.test.ts b/packages/headers/src/lib/accept-language.test.ts index 075113b1888..1b824b61fed 100644 --- a/packages/headers/src/lib/accept-language.test.ts +++ b/packages/headers/src/lib/accept-language.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { AcceptLanguage } from './accept-language.ts' +import { AcceptLanguage, parseAcceptLanguage } from './accept-language.ts' describe('Accept-Language', () => { it('initializes with an empty string', () => { @@ -162,3 +162,11 @@ describe('Accept-Language', () => { assert.equal(header.toString(), 'fi,en;q=0.9,en-us;q=0.8') }) }) + +describe('parseAcceptLanguage', () => { + it('parses a string value', () => { + let result = parseAcceptLanguage('en-US, en;q=0.9') + assert.ok(result instanceof AcceptLanguage) + assert.equal(result.size, 2) + }) +}) diff --git a/packages/headers/src/lib/accept.test.ts b/packages/headers/src/lib/accept.test.ts index d1aa7e16681..96388aba795 100644 --- a/packages/headers/src/lib/accept.test.ts +++ b/packages/headers/src/lib/accept.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Accept } from './accept.ts' +import { Accept, parseAccept } from './accept.ts' describe('Accept', () => { it('initializes with an empty string', () => { @@ -160,3 +160,25 @@ describe('Accept', () => { assert.deepEqual(header.mediaTypes, ['text/html', 'application/json']) }) }) + +describe('parseAccept', () => { + it('parses a string value', () => { + let result = parseAccept('text/html, application/json;q=0.9') + assert.ok(result instanceof Accept) + assert.equal(result.size, 2) + assert.equal(result.getWeight('text/html'), 1) + assert.equal(result.getWeight('application/json'), 0.9) + }) + + it('returns empty instance for null', () => { + let result = parseAccept(null) + assert.ok(result instanceof Accept) + assert.equal(result.size, 0) + }) + + it('accepts init object', () => { + let result = parseAccept({ 'text/html': 1 }) + assert.ok(result instanceof Accept) + assert.equal(result.size, 1) + }) +}) diff --git a/packages/headers/src/lib/cache-control.test.ts b/packages/headers/src/lib/cache-control.test.ts index 113fb3df017..849ee61faac 100644 --- a/packages/headers/src/lib/cache-control.test.ts +++ b/packages/headers/src/lib/cache-control.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { CacheControl } from './cache-control.ts' +import { CacheControl, parseCacheControl } from './cache-control.ts' const paramTestCases: Array<[string, keyof CacheControl, string, unknown]> = [ ['max-age', 'maxAge', '3600', 3600], @@ -19,9 +19,8 @@ const paramTestCases: Array<[string, keyof CacheControl, string, unknown]> = [ ['public', 'public', '', true], ['immutable', 'immutable', '', true], ['stale-while-revalidate', 'staleWhileRevalidate', '60', 60], - ['stale-if-error', 'staleIfError', '120', 120] -]; - + ['stale-if-error', 'staleIfError', '120', 120], +] describe('CacheControl', () => { it('initializes with an empty string', () => { @@ -94,3 +93,19 @@ describe('CacheControl', () => { assert.equal(header.toString(), 'max-age=0') }) }) + +describe('parseCacheControl', () => { + it('parses a string value', () => { + let result = parseCacheControl('max-age=3600, public') + assert.ok(result instanceof CacheControl) + assert.equal(result.maxAge, 3600) + assert.equal(result.public, true) + }) + + it('accepts init object', () => { + let result = parseCacheControl({ maxAge: 3600, public: true }) + assert.ok(result instanceof CacheControl) + assert.equal(result.maxAge, 3600) + assert.equal(result.public, true) + }) +}) diff --git a/packages/headers/src/lib/content-disposition.test.ts b/packages/headers/src/lib/content-disposition.test.ts index 71ab2854c6e..9b61a47b397 100644 --- a/packages/headers/src/lib/content-disposition.test.ts +++ b/packages/headers/src/lib/content-disposition.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ContentDisposition } from './content-disposition.ts' +import { ContentDisposition, parseContentDisposition } from './content-disposition.ts' describe('ContentDisposition', () => { it('initializes with an empty string', () => { @@ -217,3 +217,12 @@ describe('ContentDisposition', () => { }) }) }) + +describe('parseContentDisposition', () => { + it('parses a string value', () => { + let result = parseContentDisposition('attachment; filename="test.txt"') + assert.ok(result instanceof ContentDisposition) + assert.equal(result.type, 'attachment') + assert.equal(result.filename, 'test.txt') + }) +}) diff --git a/packages/headers/src/lib/content-range.test.ts b/packages/headers/src/lib/content-range.test.ts index f7ec1463e85..176f4f8e402 100644 --- a/packages/headers/src/lib/content-range.test.ts +++ b/packages/headers/src/lib/content-range.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ContentRange } from './content-range.ts' +import { ContentRange, parseContentRange } from './content-range.ts' describe('ContentRange', () => { it('initializes with an empty string', () => { @@ -159,3 +159,14 @@ describe('ContentRange', () => { assert.equal(contentRange.toString(), 'bytes 0-0/1') }) }) + +describe('parseContentRange', () => { + it('parses a string value', () => { + let result = parseContentRange('bytes 0-499/1234') + assert.ok(result instanceof ContentRange) + assert.equal(result.unit, 'bytes') + assert.equal(result.start, 0) + assert.equal(result.end, 499) + assert.equal(result.size, 1234) + }) +}) diff --git a/packages/headers/src/lib/content-type.test.ts b/packages/headers/src/lib/content-type.test.ts index ec6165332fd..276c66bb09c 100644 --- a/packages/headers/src/lib/content-type.test.ts +++ b/packages/headers/src/lib/content-type.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ContentType } from './content-type.ts' +import { ContentType, parseContentType } from './content-type.ts' describe('ContentType', () => { it('initializes with an empty string', () => { @@ -106,3 +106,19 @@ describe('ContentType', () => { assert.equal(header.toString(), 'multipart/form-data; charset=utf-8; boundary=abc123') }) }) + +describe('parseContentType', () => { + it('parses a string value', () => { + let result = parseContentType('text/html; charset=utf-8') + assert.ok(result instanceof ContentType) + assert.equal(result.mediaType, 'text/html') + assert.equal(result.charset, 'utf-8') + }) + + it('accepts init object', () => { + let result = parseContentType({ mediaType: 'text/html', charset: 'utf-8' }) + assert.ok(result instanceof ContentType) + assert.equal(result.mediaType, 'text/html') + assert.equal(result.charset, 'utf-8') + }) +}) diff --git a/packages/headers/src/lib/cookie.test.ts b/packages/headers/src/lib/cookie.test.ts index f3a6207c4eb..2b87443805e 100644 --- a/packages/headers/src/lib/cookie.test.ts +++ b/packages/headers/src/lib/cookie.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Cookie } from './cookie.ts' +import { Cookie, parseCookie } from './cookie.ts' describe('Cookie', () => { it('initializes with an empty string', () => { @@ -142,3 +142,12 @@ describe('Cookie', () => { assert.equal(header.get('name'), 'value2') }) }) + +describe('parseCookie', () => { + it('parses a string value', () => { + let result = parseCookie('session=abc123; user=john') + assert.ok(result instanceof Cookie) + assert.equal(result.get('session'), 'abc123') + assert.equal(result.get('user'), 'john') + }) +}) diff --git a/packages/headers/src/lib/if-match.test.ts b/packages/headers/src/lib/if-match.test.ts index d50538bc3b8..54e54628bb7 100644 --- a/packages/headers/src/lib/if-match.test.ts +++ b/packages/headers/src/lib/if-match.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { IfMatch } from './if-match.ts' +import { IfMatch, parseIfMatch } from './if-match.ts' describe('IfMatch', () => { it('initializes with an empty string', () => { @@ -134,3 +134,11 @@ describe('IfMatch', () => { }) }) }) + +describe('parseIfMatch', () => { + it('parses a string value', () => { + let result = parseIfMatch('"abc", "def"') + assert.ok(result instanceof IfMatch) + assert.equal(result.tags.length, 2) + }) +}) diff --git a/packages/headers/src/lib/if-none-match.test.ts b/packages/headers/src/lib/if-none-match.test.ts index 58d997827c5..62ef55a045a 100644 --- a/packages/headers/src/lib/if-none-match.test.ts +++ b/packages/headers/src/lib/if-none-match.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { IfNoneMatch } from './if-none-match.ts' +import { IfNoneMatch, parseIfNoneMatch } from './if-none-match.ts' describe('IfNoneMatch', () => { it('initializes with an empty string', () => { @@ -92,3 +92,11 @@ describe('IfNoneMatch', () => { assert.equal(header.toString(), 'W/"67ab43", "54ed21"') }) }) + +describe('parseIfNoneMatch', () => { + it('parses a string value', () => { + let result = parseIfNoneMatch('"abc", "def"') + assert.ok(result instanceof IfNoneMatch) + assert.equal(result.tags.length, 2) + }) +}) diff --git a/packages/headers/src/lib/if-range.test.ts b/packages/headers/src/lib/if-range.test.ts index c7c0d8ff74c..b11ee72242e 100644 --- a/packages/headers/src/lib/if-range.test.ts +++ b/packages/headers/src/lib/if-range.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { IfRange } from './if-range.ts' +import { IfRange, parseIfRange } from './if-range.ts' describe('IfRange', () => { let testDate = new Date('2021-01-01T00:00:00Z') @@ -156,3 +156,18 @@ describe('IfRange', () => { }) }) }) + +describe('parseIfRange', () => { + it('parses a string value', () => { + let result = parseIfRange('"abc"') + assert.ok(result instanceof IfRange) + assert.equal(result.value, '"abc"') + }) + + it('parses a Date value', () => { + let date = new Date('2024-01-01T00:00:00.000Z') + let result = parseIfRange(date) + assert.ok(result instanceof IfRange) + assert.equal(result.value, date.toUTCString()) + }) +}) diff --git a/packages/headers/src/lib/parse.test.ts b/packages/headers/src/lib/parse.test.ts deleted file mode 100644 index 4ecb24df658..00000000000 --- a/packages/headers/src/lib/parse.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -import { Accept, parseAccept } from './accept.ts' -import { AcceptEncoding, parseAcceptEncoding } from './accept-encoding.ts' -import { AcceptLanguage, parseAcceptLanguage } from './accept-language.ts' -import { CacheControl, parseCacheControl } from './cache-control.ts' -import { ContentDisposition, parseContentDisposition } from './content-disposition.ts' -import { ContentRange, parseContentRange } from './content-range.ts' -import { ContentType, parseContentType } from './content-type.ts' -import { Cookie, parseCookie } from './cookie.ts' -import { IfMatch, parseIfMatch } from './if-match.ts' -import { IfNoneMatch, parseIfNoneMatch } from './if-none-match.ts' -import { IfRange, parseIfRange } from './if-range.ts' -import { Range, parseRange } from './range.ts' -import { SetCookie, parseSetCookie } from './set-cookie.ts' -import { Vary, parseVary } from './vary.ts' - -describe('parseAccept', () => { - it('parses a string value', () => { - let result = parseAccept('text/html, application/json;q=0.9') - assert(result instanceof Accept) - assert.equal(result.size, 2) - assert.equal(result.getWeight('text/html'), 1) - assert.equal(result.getWeight('application/json'), 0.9) - }) - - it('returns empty instance for null', () => { - let result = parseAccept(null) - assert(result instanceof Accept) - assert.equal(result.size, 0) - }) - - it('accepts init object', () => { - let result = parseAccept({ 'text/html': 1 }) - assert(result instanceof Accept) - assert.equal(result.size, 1) - }) -}) - -describe('parseAcceptEncoding', () => { - it('parses a string value', () => { - let result = parseAcceptEncoding('gzip, deflate;q=0.5') - assert(result instanceof AcceptEncoding) - assert.equal(result.size, 2) - }) -}) - -describe('parseAcceptLanguage', () => { - it('parses a string value', () => { - let result = parseAcceptLanguage('en-US, en;q=0.9') - assert(result instanceof AcceptLanguage) - assert.equal(result.size, 2) - }) -}) - -describe('parseCacheControl', () => { - it('parses a string value', () => { - let result = parseCacheControl('max-age=3600, public') - assert(result instanceof CacheControl) - assert.equal(result.maxAge, 3600) - assert.equal(result.public, true) - }) - - it('accepts init object', () => { - let result = parseCacheControl({ maxAge: 3600, public: true }) - assert(result instanceof CacheControl) - assert.equal(result.maxAge, 3600) - assert.equal(result.public, true) - }) -}) - -describe('parseContentDisposition', () => { - it('parses a string value', () => { - let result = parseContentDisposition('attachment; filename="test.txt"') - assert(result instanceof ContentDisposition) - assert.equal(result.type, 'attachment') - assert.equal(result.filename, 'test.txt') - }) -}) - -describe('parseContentRange', () => { - it('parses a string value', () => { - let result = parseContentRange('bytes 0-499/1234') - assert(result instanceof ContentRange) - assert.equal(result.unit, 'bytes') - assert.equal(result.start, 0) - assert.equal(result.end, 499) - assert.equal(result.size, 1234) - }) -}) - -describe('parseContentType', () => { - it('parses a string value', () => { - let result = parseContentType('text/html; charset=utf-8') - assert(result instanceof ContentType) - assert.equal(result.mediaType, 'text/html') - assert.equal(result.charset, 'utf-8') - }) - - it('accepts init object', () => { - let result = parseContentType({ mediaType: 'text/html', charset: 'utf-8' }) - assert(result instanceof ContentType) - assert.equal(result.mediaType, 'text/html') - assert.equal(result.charset, 'utf-8') - }) -}) - -describe('parseCookie', () => { - it('parses a string value', () => { - let result = parseCookie('session=abc123; user=john') - assert(result instanceof Cookie) - assert.equal(result.get('session'), 'abc123') - assert.equal(result.get('user'), 'john') - }) -}) - -describe('parseIfMatch', () => { - it('parses a string value', () => { - let result = parseIfMatch('"abc", "def"') - assert(result instanceof IfMatch) - assert.equal(result.tags.length, 2) - }) -}) - -describe('parseIfNoneMatch', () => { - it('parses a string value', () => { - let result = parseIfNoneMatch('"abc", "def"') - assert(result instanceof IfNoneMatch) - assert.equal(result.tags.length, 2) - }) -}) - -describe('parseIfRange', () => { - it('parses a string value', () => { - let result = parseIfRange('"abc"') - assert(result instanceof IfRange) - assert.equal(result.value, '"abc"') - }) - - it('parses a Date value', () => { - let date = new Date('2024-01-01T00:00:00.000Z') - let result = parseIfRange(date) - assert(result instanceof IfRange) - assert.equal(result.value, date.toUTCString()) - }) -}) - -describe('parseRange', () => { - it('parses a string value', () => { - let result = parseRange('bytes=0-499') - assert(result instanceof Range) - assert.equal(result.unit, 'bytes') - assert.equal(result.ranges.length, 1) - }) -}) - -describe('parseSetCookie', () => { - it('parses a string value', () => { - let result = parseSetCookie('session=abc123; Path=/; HttpOnly') - assert(result instanceof SetCookie) - assert.equal(result.name, 'session') - assert.equal(result.value, 'abc123') - assert.equal(result.path, '/') - assert.equal(result.httpOnly, true) - }) -}) - -describe('parseVary', () => { - it('parses a string value', () => { - let result = parseVary('Accept-Encoding, Accept-Language') - assert(result instanceof Vary) - assert.equal(result.size, 2) - assert.equal(result.has('Accept-Encoding'), true) - assert.equal(result.has('Accept-Language'), true) - }) - - it('parses an array value', () => { - let result = parseVary(['Accept-Encoding', 'Accept-Language']) - assert(result instanceof Vary) - assert.equal(result.size, 2) - }) -}) diff --git a/packages/headers/src/lib/range.test.ts b/packages/headers/src/lib/range.test.ts index 1865bfc73b5..eb18ce67a03 100644 --- a/packages/headers/src/lib/range.test.ts +++ b/packages/headers/src/lib/range.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Range } from './range.ts' +import { Range, parseRange } from './range.ts' describe('Range', () => { it('initializes with an empty string', () => { @@ -248,3 +248,12 @@ describe('Range', () => { }) }) }) + +describe('parseRange', () => { + it('parses a string value', () => { + let result = parseRange('bytes=0-499') + assert.ok(result instanceof Range) + assert.equal(result.unit, 'bytes') + assert.equal(result.ranges.length, 1) + }) +}) diff --git a/packages/headers/src/lib/set-cookie.test.ts b/packages/headers/src/lib/set-cookie.test.ts index f27f1352bad..650f9e10efd 100644 --- a/packages/headers/src/lib/set-cookie.test.ts +++ b/packages/headers/src/lib/set-cookie.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { SetCookie } from './set-cookie.ts' +import { SetCookie, parseSetCookie } from './set-cookie.ts' describe('SetCookie', () => { it('initializes with an empty string', () => { @@ -218,3 +218,14 @@ describe('SetCookie', () => { assert.equal(header.toString(), 'test="need; quotes"') }) }) + +describe('parseSetCookie', () => { + it('parses a string value', () => { + let result = parseSetCookie('session=abc123; Path=/; HttpOnly') + assert.ok(result instanceof SetCookie) + assert.equal(result.name, 'session') + assert.equal(result.value, 'abc123') + assert.equal(result.path, '/') + assert.equal(result.httpOnly, true) + }) +}) diff --git a/packages/headers/src/lib/vary.test.ts b/packages/headers/src/lib/vary.test.ts index bbd707c6e47..d79339a6438 100644 --- a/packages/headers/src/lib/vary.test.ts +++ b/packages/headers/src/lib/vary.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Vary } from './vary.ts' +import { Vary, parseVary } from './vary.ts' describe('Vary', () => { it('initializes with an empty string', () => { @@ -144,3 +144,19 @@ describe('Vary', () => { assert.deepEqual(names, ['accept-encoding', 'accept-language']) }) }) + +describe('parseVary', () => { + it('parses a string value', () => { + let result = parseVary('Accept-Encoding, Accept-Language') + assert.ok(result instanceof Vary) + assert.equal(result.size, 2) + assert.equal(result.has('Accept-Encoding'), true) + assert.equal(result.has('Accept-Language'), true) + }) + + it('parses an array value', () => { + let result = parseVary(['Accept-Encoding', 'Accept-Language']) + assert.ok(result instanceof Vary) + assert.equal(result.size, 2) + }) +}) From 779f29df72a02e661c19d0a98475d6fd9665ad8d Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:37:55 +1100 Subject: [PATCH 05/15] Use `stringifyRawHeaders` in multipart-parser test --- packages/multipart-parser/test/utils.ts | 46 ++++++++++--------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/multipart-parser/test/utils.ts b/packages/multipart-parser/test/utils.ts index c7041878505..1e552813289 100644 --- a/packages/multipart-parser/test/utils.ts +++ b/packages/multipart-parser/test/utils.ts @@ -1,4 +1,4 @@ -import { ContentDisposition, ContentType } from '@remix-run/headers' +import { ContentDisposition, ContentType, stringifyRawHeaders } from '@remix-run/headers' export type PartValue = | string @@ -27,38 +27,30 @@ export function createMultipartMessage( for (let [name, value] of Object.entries(parts)) { pushLine(`--${boundary}`) - if (typeof value === 'string') { - let contentDisposition = new ContentDisposition({ + let headers = new Headers({ + 'Content-Disposition': new ContentDisposition({ type: 'form-data', name, - }) + filename: typeof value === 'object' ? value.filename : undefined, + filenameSplat: typeof value === 'object' ? value.filenameSplat : undefined, + }).toString(), + }) + + if (typeof value === 'object' && value.mediaType) { + let contentType = new ContentType({ mediaType: value.mediaType }) + headers.set('Content-Type', contentType.toString()) + } - pushLine(`Content-Disposition: ${contentDisposition}`) - pushLine() + pushLine(stringifyRawHeaders(headers)) + pushLine() + + if (typeof value === 'string') { pushLine(value) + } else if (typeof value.content === 'string') { + pushLine(value.content) } else { - let contentDisposition = new ContentDisposition({ - type: 'form-data', - name, - filename: value.filename, - filenameSplat: value.filenameSplat, - }) - - let headerLines = [`Content-Disposition: ${contentDisposition}`] - - if (value.mediaType) { - let contentType = new ContentType({ mediaType: value.mediaType }) - headerLines.push(`Content-Type: ${contentType}`) - } - - pushLine(headerLines.join('\r\n')) + chunks.push(value.content as Uint8Array) pushLine() - if (typeof value.content === 'string') { - pushLine(value.content) - } else { - chunks.push(value.content as Uint8Array) - pushLine() - } } } } From a0f3a66ebdfa0e4d75ad308e14cb02cc72081c52 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:51:12 +1100 Subject: [PATCH 06/15] Reduce test diff --- packages/multipart-parser/test/utils.ts | 49 +++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/multipart-parser/test/utils.ts b/packages/multipart-parser/test/utils.ts index 1e552813289..082aed32a94 100644 --- a/packages/multipart-parser/test/utils.ts +++ b/packages/multipart-parser/test/utils.ts @@ -27,30 +27,39 @@ export function createMultipartMessage( for (let [name, value] of Object.entries(parts)) { pushLine(`--${boundary}`) - let headers = new Headers({ - 'Content-Disposition': new ContentDisposition({ - type: 'form-data', - name, - filename: typeof value === 'object' ? value.filename : undefined, - filenameSplat: typeof value === 'object' ? value.filenameSplat : undefined, - }).toString(), - }) - - if (typeof value === 'object' && value.mediaType) { - let contentType = new ContentType({ mediaType: value.mediaType }) - headers.set('Content-Type', contentType.toString()) - } - - pushLine(stringifyRawHeaders(headers)) - pushLine() - if (typeof value === 'string') { + let headers = new Headers({ + 'Content-Disposition': new ContentDisposition({ + type: 'form-data', + name, + }), + }) + + pushLine(stringifyRawHeaders(headers)) + pushLine() pushLine(value) - } else if (typeof value.content === 'string') { - pushLine(value.content) } else { - chunks.push(value.content as Uint8Array) + let headers = new Headers({ + 'Content-Disposition': new ContentDisposition({ + type: 'form-data', + name, + filename: value.filename, + filenameSplat: value.filenameSplat, + }), + }) + + if (value.mediaType) { + headers.set('Content-Type', new ContentType({ mediaType: value.mediaType })) + } + + pushLine(stringifyRawHeaders(headers)) pushLine() + if (typeof value.content === 'string') { + pushLine(value.content) + } else { + chunks.push(value.content as Uint8Array) + pushLine() + } } } } From 0f6a54d362ce41b2dcfc59050a07e37a39aad45c Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 10:56:33 +1100 Subject: [PATCH 07/15] Use canonical names when stringifying raw headers --- packages/headers/README.md | 2 +- packages/headers/src/lib/raw-headers.test.ts | 19 +++++++++++++++---- packages/headers/src/lib/raw-headers.ts | 6 ++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/headers/README.md b/packages/headers/README.md index 6e7fedc1c0f..8af33b8b6cf 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -455,7 +455,7 @@ headers.get('cache-control') // 'no-cache' // Stringify Headers object back to raw format let raw = stringifyRawHeaders(headers) -// 'content-type: text/html\r\ncache-control: no-cache' +// 'Content-Type: text/html\r\nCache-Control: no-cache' ``` ## Related Packages diff --git a/packages/headers/src/lib/raw-headers.test.ts b/packages/headers/src/lib/raw-headers.test.ts index 7eaa9e41c3e..5b096a7c529 100644 --- a/packages/headers/src/lib/raw-headers.test.ts +++ b/packages/headers/src/lib/raw-headers.test.ts @@ -47,7 +47,7 @@ describe('parseRawHeaders', () => { describe('stringifyRawHeaders', () => { it('stringifies a single header', () => { let headers = new Headers({ 'Content-Type': 'text/html' }) - assert.equal(stringifyRawHeaders(headers), 'content-type: text/html') + assert.equal(stringifyRawHeaders(headers), 'Content-Type: text/html') }) it('stringifies multiple headers', () => { @@ -55,8 +55,8 @@ describe('stringifyRawHeaders', () => { headers.set('Content-Type', 'text/html') headers.set('Cache-Control', 'no-cache') let result = stringifyRawHeaders(headers) - assert.ok(result.includes('content-type: text/html')) - assert.ok(result.includes('cache-control: no-cache')) + assert.ok(result.includes('Content-Type: text/html')) + assert.ok(result.includes('Cache-Control: no-cache')) assert.ok(result.includes('\r\n')) }) @@ -67,7 +67,18 @@ describe('stringifyRawHeaders', () => { it('handles headers with colons in values', () => { let headers = new Headers({ Location: 'https://example.com:8080/path' }) - assert.equal(stringifyRawHeaders(headers), 'location: https://example.com:8080/path') + assert.equal(stringifyRawHeaders(headers), 'Location: https://example.com:8080/path') + }) + + it('uses canonical header name casing', () => { + let headers = new Headers() + headers.set('etag', '"abc"') + headers.set('www-authenticate', 'Basic') + headers.set('x-custom-header', 'value') + let result = stringifyRawHeaders(headers) + assert.ok(result.includes('ETag: "abc"')) + assert.ok(result.includes('WWW-Authenticate: Basic')) + assert.ok(result.includes('X-Custom-Header: value')) }) it('round-trips with parseRawHeaders', () => { diff --git a/packages/headers/src/lib/raw-headers.ts b/packages/headers/src/lib/raw-headers.ts index 7428e725933..b16c6167d55 100644 --- a/packages/headers/src/lib/raw-headers.ts +++ b/packages/headers/src/lib/raw-headers.ts @@ -1,3 +1,5 @@ +import { canonicalHeaderName } from './header-names.ts' + const CRLF = '\r\n' /** @@ -32,14 +34,14 @@ export function parseRawHeaders(raw: string): Headers { * * @example * let headers = new Headers({ 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' }) - * stringifyRawHeaders(headers) // 'content-type: text/html\r\ncache-control: no-cache' + * stringifyRawHeaders(headers) // 'Content-Type: text/html\r\nCache-Control: no-cache' */ export function stringifyRawHeaders(headers: Headers): string { let result = '' for (let [name, value] of headers) { if (result) result += CRLF - result += `${name}: ${value}` + result += `${canonicalHeaderName(name)}: ${value}` } return result From 646aced064a951fe8e8757cf716ad25468a8602f Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 19 Dec 2025 11:07:38 +1100 Subject: [PATCH 08/15] Update readme --- packages/headers/README.md | 116 ++++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/packages/headers/README.md b/packages/headers/README.md index 8af33b8b6cf..b37b46aa8b1 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -65,6 +65,13 @@ headers.set('Accept', accept) new Accept('text/html, text/*;q=0.9') new Accept({ 'text/html': 1, 'text/*': 0.9 }) new Accept(['text/html', ['text/*', 0.9]]) + +// Use class for type safety when setting Headers values +// via Accept's `.toString()` method +let headers = new Headers({ + Accept: new Accept({ 'text/html': 1, 'application/json': 0.8 }), +}) +headers.set('Accept', new Accept({ 'text/html': 1, 'application/json': 0.8 })) ``` ### Accept-Encoding @@ -94,6 +101,13 @@ headers.set('Accept-Encoding', acceptEncoding) // Construct directly new AcceptEncoding('gzip, deflate;q=0.8') new AcceptEncoding({ gzip: 1, deflate: 0.8 }) + +// Use class for type safety when setting Headers values +// via AcceptEncoding's `.toString()` method +let headers = new Headers({ + 'Accept-Encoding': new AcceptEncoding({ gzip: 1, br: 0.9 }), +}) +headers.set('Accept-Encoding', new AcceptEncoding({ gzip: 1, br: 0.9 })) ``` ### Accept-Language @@ -123,6 +137,13 @@ headers.set('Accept-Language', acceptLanguage) // Construct directly new AcceptLanguage('en-US, en;q=0.9') new AcceptLanguage({ 'en-US': 1, en: 0.9 }) + +// Use class for type safety when setting Headers values +// via AcceptLanguage's `.toString()` method +let headers = new Headers({ + 'Accept-Language': new AcceptLanguage({ 'en-US': 1, fr: 0.5 }), +}) +headers.set('Accept-Language', new AcceptLanguage({ 'en-US': 1, fr: 0.5 })) ``` ### Cache-Control @@ -152,6 +173,13 @@ headers.set('Cache-Control', cacheControl) // Construct directly new CacheControl('public, max-age=3600') new CacheControl({ public: true, maxAge: 3600 }) + +// Use class for type safety when setting Headers values +// via CacheControl's `.toString()` method +let headers = new Headers({ + 'Cache-Control': new CacheControl({ public: true, maxAge: 3600 }), +}) +headers.set('Cache-Control', new CacheControl({ public: true, maxAge: 3600 })) ``` ### Content-Disposition @@ -176,6 +204,16 @@ headers.set('Content-Disposition', contentDisposition) // Construct directly new ContentDisposition('attachment; filename="example.pdf"') new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }) + +// Use class for type safety when setting Headers values +// via ContentDisposition's `.toString()` method +let headers = new Headers({ + 'Content-Disposition': new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }), +}) +headers.set( + 'Content-Disposition', + new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }), +) ``` ### Content-Range @@ -199,9 +237,15 @@ unsatisfied.start // null unsatisfied.end // null unsatisfied.size // 67589 -// Construct and set -let newRange = new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }) -headers.set('Content-Range', newRange) +// Construct directly +new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }) + +// Use class for type safety when setting Headers values +// via ContentRange's `.toString()` method +let headers = new Headers({ + 'Content-Range': new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }), +}) +headers.set('Content-Range', new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 })) ``` ### Content-Type @@ -225,6 +269,13 @@ headers.set('Content-Type', contentType) // Construct directly new ContentType('text/html; charset=utf-8') new ContentType({ mediaType: 'text/html', charset: 'utf-8' }) + +// Use class for type safety when setting Headers values +// via ContentType's `.toString()` method +let headers = new Headers({ + 'Content-Type': new ContentType({ mediaType: 'text/html', charset: 'utf-8' }), +}) +headers.set('Content-Type', new ContentType({ mediaType: 'text/html', charset: 'utf-8' })) ``` ### Cookie @@ -261,6 +312,13 @@ new Cookie([ ['session_id', 'abc123'], ['theme', 'dark'], ]) + +// Use class for type safety when setting Headers values +// via Cookie's `.toString()` method +let headers = new Headers({ + Cookie: new Cookie({ session_id: 'abc123', theme: 'dark' }), +}) +headers.set('Cookie', new Cookie({ session_id: 'abc123', theme: 'dark' })) ``` ### If-Match @@ -291,6 +349,13 @@ headers.set('If-Match', ifMatch) // Construct directly new IfMatch(['abc123', 'def456']) + +// Use class for type safety when setting Headers values +// via IfMatch's `.toString()` method +let headers = new Headers({ + 'If-Match': new IfMatch(['"abc123"', '"def456"']), +}) +headers.set('If-Match', new IfMatch(['"abc123"', '"def456"'])) ``` ### If-None-Match @@ -320,6 +385,13 @@ headers.set('If-None-Match', ifNoneMatch) // Construct directly new IfNoneMatch(['abc123']) + +// Use class for type safety when setting Headers values +// via IfNoneMatch's `.toString()` method +let headers = new Headers({ + 'If-None-Match': new IfNoneMatch(['"abc123"']), +}) +headers.set('If-None-Match', new IfNoneMatch(['"abc123"'])) ``` ### If-Range @@ -344,9 +416,15 @@ etagHeader.matches({ etag: '"67ab43"' }) // true let empty = parseIfRange(null) empty.matches({ etag: '"any"' }) // true -// Construct and set -let newIfRange = new IfRange('"abc123"') -headers.set('If-Range', newIfRange) +// Construct directly +new IfRange('"abc123"') + +// Use class for type safety when setting Headers values +// via IfRange's `.toString()` method +let headers = new Headers({ + 'If-Range': new IfRange('"abc123"'), +}) +headers.set('If-Range', new IfRange('"abc123"')) ``` ### Range @@ -373,9 +451,15 @@ multi.ranges.length // 2 let suffix = parseRange('bytes=-500') suffix.normalize(2000) // [{ start: 1500, end: 1999 }] -// Construct and set -let newRange = new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }) -headers.set('Range', newRange) +// Construct directly +new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }) + +// Use class for type safety when setting Headers values +// via Range's `.toString()` method +let headers = new Headers({ + Range: new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }), +}) +headers.set('Range', new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] })) ``` ### Set-Cookie @@ -412,6 +496,13 @@ new SetCookie({ httpOnly: true, secure: true, }) + +// Use class for type safety when setting Headers values +// via SetCookie's `.toString()` method +let headers = new Headers({ + 'Set-Cookie': new SetCookie({ name: 'session_id', value: 'abc', httpOnly: true }), +}) +headers.set('Set-Cookie', new SetCookie({ name: 'session_id', value: 'abc', httpOnly: true })) ``` ### Vary @@ -439,6 +530,13 @@ headers.set('Vary', vary) new Vary('Accept-Encoding, Accept-Language') new Vary(['Accept-Encoding', 'Accept-Language']) new Vary({ headerNames: ['Accept-Encoding', 'Accept-Language'] }) + +// Use class for type safety when setting Headers values +// via Vary's `.toString()` method +let headers = new Headers({ + Vary: new Vary(['Accept-Encoding', 'Accept-Language']), +}) +headers.set('Vary', new Vary(['Accept-Encoding', 'Accept-Language'])) ``` ## Raw Headers From ad7d0e71c4357ba822cb5d87dfd2aca478ee24d8 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 6 Jan 2026 16:08:22 +1100 Subject: [PATCH 09/15] Convert header parse functions to static `from` method --- .../.changes/minor.remove-super-headers.md | 36 ++-- packages/headers/README.md | 72 +++---- packages/headers/src/index.ts | 32 ++-- .../headers/src/lib/accept-encoding.test.ts | 6 +- packages/headers/src/lib/accept-encoding.ts | 98 +++++----- .../headers/src/lib/accept-language.test.ts | 6 +- packages/headers/src/lib/accept-language.ts | 98 +++++----- packages/headers/src/lib/accept.test.ts | 10 +- packages/headers/src/lib/accept.ts | 98 +++++----- .../headers/src/lib/cache-control.test.ts | 8 +- packages/headers/src/lib/cache-control.ts | 177 +++++++++--------- .../src/lib/content-disposition.test.ts | 6 +- .../headers/src/lib/content-disposition.ts | 71 +++---- .../headers/src/lib/content-range.test.ts | 6 +- packages/headers/src/lib/content-range.ts | 59 +++--- packages/headers/src/lib/content-type.test.ts | 8 +- packages/headers/src/lib/content-type.ts | 63 ++++--- packages/headers/src/lib/cookie.test.ts | 6 +- packages/headers/src/lib/cookie.ts | 59 +++--- packages/headers/src/lib/if-match.test.ts | 6 +- packages/headers/src/lib/if-match.ts | 43 ++--- .../headers/src/lib/if-none-match.test.ts | 6 +- packages/headers/src/lib/if-none-match.ts | 43 ++--- packages/headers/src/lib/if-range.test.ts | 8 +- packages/headers/src/lib/if-range.ts | 39 ++-- packages/headers/src/lib/range.test.ts | 6 +- packages/headers/src/lib/range.ts | 121 ++++++------ packages/headers/src/lib/set-cookie.test.ts | 6 +- packages/headers/src/lib/set-cookie.ts | 147 +++++++-------- packages/headers/src/lib/vary.test.ts | 8 +- packages/headers/src/lib/vary.ts | 72 +++---- 31 files changed, 717 insertions(+), 707 deletions(-) diff --git a/packages/headers/.changes/minor.remove-super-headers.md b/packages/headers/.changes/minor.remove-super-headers.md index cf5eb15c53f..8b27216320e 100644 --- a/packages/headers/.changes/minor.remove-super-headers.md +++ b/packages/headers/.changes/minor.remove-super-headers.md @@ -1,21 +1,21 @@ -BREAKING CHANGE: Removed `Headers`/`SuperHeaders` class and default export. Use the native `Headers` class with the parse functions instead. +BREAKING CHANGE: Removed `Headers`/`SuperHeaders` class and default export. Use the native `Headers` class with the static `from()` method on each header class instead. -New individual header parsing utilities added: +New individual header `.from()` methods: -- `parseAccept()` -- `parseAcceptEncoding()` -- `parseAcceptLanguage()` -- `parseCacheControl()` -- `parseContentDisposition()` -- `parseContentRange()` -- `parseContentType()` -- `parseCookie()` -- `parseIfMatch()` -- `parseIfNoneMatch()` -- `parseIfRange()` -- `parseRange()` -- `parseSetCookie()` -- `parseVary()` +- `Accept.from()` +- `AcceptEncoding.from()` +- `AcceptLanguage.from()` +- `CacheControl.from()` +- `ContentDisposition.from()` +- `ContentRange.from()` +- `ContentType.from()` +- `Cookie.from()` +- `IfMatch.from()` +- `IfNoneMatch.from()` +- `IfRange.from()` +- `Range.from()` +- `SetCookie.from()` +- `Vary.from()` New raw header utilities added: @@ -31,8 +31,8 @@ let headers = new SuperHeaders(request.headers) let mediaType = headers.contentType.mediaType // After: -import { parseContentType } from '@remix-run/headers' -let contentType = parseContentType(request.headers.get('content-type')) +import { ContentType } from '@remix-run/headers' +let contentType = ContentType.from(request.headers.get('content-type')) let mediaType = contentType.mediaType ``` diff --git a/packages/headers/README.md b/packages/headers/README.md index b37b46aa8b1..0f920b80b7c 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -12,7 +12,7 @@ npm install @remix-run/headers ## Individual Header Utilities -Each supported header has a parse function and a class that represents the header value. Each class has a `toString()` method that returns the header value as a string, which you can either call manually, or will be called automatically when the header class is used in a context that expects a string. +Each supported header has a class that represents the header value. Use the static `from()` method to parse header values. Each class has a `toString()` method that returns the header value as a string, which you can either call manually, or will be called automatically when the header class is used in a context that expects a string. The following headers are currently supported: @@ -38,10 +38,10 @@ Parse, manipulate and stringify [`Accept` headers](https://developer.mozilla.org Implements `Map`. ```ts -import { parseAccept, Accept } from '@remix-run/headers' +import { Accept } from '@remix-run/headers' // Parse from headers -let accept = parseAccept(request.headers.get('accept')) +let accept = Accept.from(request.headers.get('accept')) accept.mediaTypes // ['text/html', 'text/*'] accept.weights // [1, 0.9] @@ -81,10 +81,10 @@ Parse, manipulate and stringify [`Accept-Encoding` headers](https://developer.mo Implements `Map`. ```ts -import { parseAcceptEncoding, AcceptEncoding } from '@remix-run/headers' +import { AcceptEncoding } from '@remix-run/headers' // Parse from headers -let acceptEncoding = parseAcceptEncoding(request.headers.get('accept-encoding')) +let acceptEncoding = AcceptEncoding.from(request.headers.get('accept-encoding')) acceptEncoding.encodings // ['gzip', 'deflate'] acceptEncoding.weights // [1, 0.8] @@ -117,10 +117,10 @@ Parse, manipulate and stringify [`Accept-Language` headers](https://developer.mo Implements `Map`. ```ts -import { parseAcceptLanguage, AcceptLanguage } from '@remix-run/headers' +import { AcceptLanguage } from '@remix-run/headers' // Parse from headers -let acceptLanguage = parseAcceptLanguage(request.headers.get('accept-language')) +let acceptLanguage = AcceptLanguage.from(request.headers.get('accept-language')) acceptLanguage.languages // ['en-us', 'en'] acceptLanguage.weights // [1, 0.9] @@ -151,10 +151,10 @@ headers.set('Accept-Language', new AcceptLanguage({ 'en-US': 1, fr: 0.5 })) Parse, manipulate and stringify [`Cache-Control` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). ```ts -import { parseCacheControl, CacheControl } from '@remix-run/headers' +import { CacheControl } from '@remix-run/headers' // Parse from headers -let cacheControl = parseCacheControl(response.headers.get('cache-control')) +let cacheControl = CacheControl.from(response.headers.get('cache-control')) cacheControl.public // true cacheControl.maxAge // 3600 @@ -187,10 +187,10 @@ headers.set('Cache-Control', new CacheControl({ public: true, maxAge: 3600 })) Parse, manipulate and stringify [`Content-Disposition` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition). ```ts -import { parseContentDisposition, ContentDisposition } from '@remix-run/headers' +import { ContentDisposition } from '@remix-run/headers' // Parse from headers -let contentDisposition = parseContentDisposition(response.headers.get('content-disposition')) +let contentDisposition = ContentDisposition.from(response.headers.get('content-disposition')) contentDisposition.type // 'attachment' contentDisposition.filename // 'example.pdf' @@ -221,10 +221,10 @@ headers.set( Parse, manipulate and stringify [`Content-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range). ```ts -import { parseContentRange, ContentRange } from '@remix-run/headers' +import { ContentRange } from '@remix-run/headers' // Parse from headers -let contentRange = parseContentRange(response.headers.get('content-range')) +let contentRange = ContentRange.from(response.headers.get('content-range')) contentRange.unit // "bytes" contentRange.start // 200 @@ -232,7 +232,7 @@ contentRange.end // 1000 contentRange.size // 67589 // Unsatisfied range -let unsatisfied = parseContentRange('bytes */67589') +let unsatisfied = ContentRange.from('bytes */67589') unsatisfied.start // null unsatisfied.end // null unsatisfied.size // 67589 @@ -253,10 +253,10 @@ headers.set('Content-Range', new ContentRange({ unit: 'bytes', start: 0, end: 49 Parse, manipulate and stringify [`Content-Type` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type). ```ts -import { parseContentType, ContentType } from '@remix-run/headers' +import { ContentType } from '@remix-run/headers' // Parse from headers -let contentType = parseContentType(request.headers.get('content-type')) +let contentType = ContentType.from(request.headers.get('content-type')) contentType.mediaType // "text/html" contentType.charset // "utf-8" @@ -285,10 +285,10 @@ Parse, manipulate and stringify [`Cookie` headers](https://developer.mozilla.org Implements `Map`. ```ts -import { parseCookie, Cookie } from '@remix-run/headers' +import { Cookie } from '@remix-run/headers' // Parse from headers -let cookie = parseCookie(request.headers.get('cookie')) +let cookie = Cookie.from(request.headers.get('cookie')) cookie.get('session_id') // 'abc123' cookie.get('theme') // 'dark' @@ -328,10 +328,10 @@ Parse, manipulate and stringify [`If-Match` headers](https://developer.mozilla.o Implements `Set`. ```ts -import { parseIfMatch, IfMatch } from '@remix-run/headers' +import { IfMatch } from '@remix-run/headers' // Parse from headers -let ifMatch = parseIfMatch(request.headers.get('if-match')) +let ifMatch = IfMatch.from(request.headers.get('if-match')) ifMatch.tags // ['"67ab43"', '"54ed21"'] ifMatch.has('"67ab43"') // true @@ -339,7 +339,7 @@ ifMatch.matches('"67ab43"') // true (checks precondition) ifMatch.matches('"abc123"') // false // Note: Uses strong comparison only (weak ETags never match) -let weak = parseIfMatch('W/"67ab43"') +let weak = IfMatch.from('W/"67ab43"') weak.matches('W/"67ab43"') // false // Modify and set header @@ -365,17 +365,17 @@ Parse, manipulate and stringify [`If-None-Match` headers](https://developer.mozi Implements `Set`. ```ts -import { parseIfNoneMatch, IfNoneMatch } from '@remix-run/headers' +import { IfNoneMatch } from '@remix-run/headers' // Parse from headers -let ifNoneMatch = parseIfNoneMatch(request.headers.get('if-none-match')) +let ifNoneMatch = IfNoneMatch.from(request.headers.get('if-none-match')) ifNoneMatch.tags // ['"67ab43"', '"54ed21"'] ifNoneMatch.has('"67ab43"') // true ifNoneMatch.matches('"67ab43"') // true // Supports weak comparison (unlike If-Match) -let weak = parseIfNoneMatch('W/"67ab43"') +let weak = IfNoneMatch.from('W/"67ab43"') weak.matches('W/"67ab43"') // true // Modify and set header @@ -399,21 +399,21 @@ headers.set('If-None-Match', new IfNoneMatch(['"abc123"'])) Parse, manipulate and stringify [`If-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range). ```ts -import { parseIfRange, IfRange } from '@remix-run/headers' +import { IfRange } from '@remix-run/headers' // Parse from headers -let ifRange = parseIfRange(request.headers.get('if-range')) +let ifRange = IfRange.from(request.headers.get('if-range')) // With HTTP date ifRange.matches({ lastModified: 1609459200000 }) // true ifRange.matches({ lastModified: new Date('2021-01-01') }) // true // With ETag -let etagHeader = parseIfRange('"67ab43"') +let etagHeader = IfRange.from('"67ab43"') etagHeader.matches({ etag: '"67ab43"' }) // true // Empty/null returns empty instance (range proceeds unconditionally) -let empty = parseIfRange(null) +let empty = IfRange.from(null) empty.matches({ etag: '"any"' }) // true // Construct directly @@ -432,10 +432,10 @@ headers.set('If-Range', new IfRange('"abc123"')) Parse, manipulate and stringify [`Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range). ```ts -import { parseRange, Range } from '@remix-run/headers' +import { Range } from '@remix-run/headers' // Parse from headers -let range = parseRange(request.headers.get('range')) +let range = Range.from(request.headers.get('range')) range.unit // "bytes" range.ranges // [{ start: 200, end: 1000 }] @@ -444,11 +444,11 @@ range.canSatisfy(500) // false range.normalize(2000) // [{ start: 200, end: 1000 }] // Multiple ranges -let multi = parseRange('bytes=0-499, 1000-1499') +let multi = Range.from('bytes=0-499, 1000-1499') multi.ranges.length // 2 // Suffix range (last N bytes) -let suffix = parseRange('bytes=-500') +let suffix = Range.from('bytes=-500') suffix.normalize(2000) // [{ start: 1500, end: 1999 }] // Construct directly @@ -467,10 +467,10 @@ headers.set('Range', new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] Parse, manipulate and stringify [`Set-Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie). ```ts -import { parseSetCookie, SetCookie } from '@remix-run/headers' +import { SetCookie } from '@remix-run/headers' // Parse from headers -let setCookie = parseSetCookie(response.headers.get('set-cookie')) +let setCookie = SetCookie.from(response.headers.get('set-cookie')) setCookie.name // "session_id" setCookie.value // "abc" @@ -512,10 +512,10 @@ Parse, manipulate and stringify [`Vary` headers](https://developer.mozilla.org/e Implements `Set`. ```ts -import { parseVary, Vary } from '@remix-run/headers' +import { Vary } from '@remix-run/headers' // Parse from headers -let vary = parseVary(response.headers.get('vary')) +let vary = Vary.from(response.headers.get('vary')) vary.headerNames // ['accept-encoding', 'accept-language'] vary.has('Accept-Encoding') // true (case-insensitive) diff --git a/packages/headers/src/index.ts b/packages/headers/src/index.ts index 0c8597d4985..4998247be59 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -1,19 +1,15 @@ -export { type AcceptInit, Accept, parseAccept } from './lib/accept.ts' -export { type AcceptEncodingInit, AcceptEncoding, parseAcceptEncoding } from './lib/accept-encoding.ts' -export { type AcceptLanguageInit, AcceptLanguage, parseAcceptLanguage } from './lib/accept-language.ts' -export { type CacheControlInit, CacheControl, parseCacheControl } from './lib/cache-control.ts' -export { - type ContentDispositionInit, - ContentDisposition, - parseContentDisposition, -} from './lib/content-disposition.ts' -export { type ContentRangeInit, ContentRange, parseContentRange } from './lib/content-range.ts' -export { type ContentTypeInit, ContentType, parseContentType } from './lib/content-type.ts' -export { type CookieInit, Cookie, parseCookie } from './lib/cookie.ts' -export { type IfMatchInit, IfMatch, parseIfMatch } from './lib/if-match.ts' -export { type IfNoneMatchInit, IfNoneMatch, parseIfNoneMatch } from './lib/if-none-match.ts' -export { IfRange, parseIfRange } from './lib/if-range.ts' -export { type RangeInit, Range, parseRange } from './lib/range.ts' -export { type CookieProperties, type SetCookieInit, SetCookie, parseSetCookie } from './lib/set-cookie.ts' -export { type VaryInit, Vary, parseVary } from './lib/vary.ts' +export { type AcceptInit, Accept } from './lib/accept.ts' +export { type AcceptEncodingInit, AcceptEncoding } from './lib/accept-encoding.ts' +export { type AcceptLanguageInit, AcceptLanguage } from './lib/accept-language.ts' +export { type CacheControlInit, CacheControl } from './lib/cache-control.ts' +export { type ContentDispositionInit, ContentDisposition } from './lib/content-disposition.ts' +export { type ContentRangeInit, ContentRange } from './lib/content-range.ts' +export { type ContentTypeInit, ContentType } from './lib/content-type.ts' +export { type CookieInit, Cookie } from './lib/cookie.ts' +export { type IfMatchInit, IfMatch } from './lib/if-match.ts' +export { type IfNoneMatchInit, IfNoneMatch } from './lib/if-none-match.ts' +export { IfRange } from './lib/if-range.ts' +export { type RangeInit, Range } from './lib/range.ts' +export { type CookieProperties, type SetCookieInit, SetCookie } from './lib/set-cookie.ts' +export { type VaryInit, Vary } from './lib/vary.ts' export { parseRawHeaders, stringifyRawHeaders } from './lib/raw-headers.ts' diff --git a/packages/headers/src/lib/accept-encoding.test.ts b/packages/headers/src/lib/accept-encoding.test.ts index 17b80f10240..29865e119ba 100644 --- a/packages/headers/src/lib/accept-encoding.test.ts +++ b/packages/headers/src/lib/accept-encoding.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { AcceptEncoding, parseAcceptEncoding } from './accept-encoding.ts' +import { AcceptEncoding } from './accept-encoding.ts' describe('Accept-Encoding', () => { it('initializes with an empty string', () => { @@ -150,9 +150,9 @@ describe('Accept-Encoding', () => { }) }) -describe('parseAcceptEncoding', () => { +describe('AcceptEncoding.from', () => { it('parses a string value', () => { - let result = parseAcceptEncoding('gzip, deflate;q=0.5') + let result = AcceptEncoding.from('gzip, deflate;q=0.5') assert.ok(result instanceof AcceptEncoding) assert.equal(result.size, 2) }) diff --git a/packages/headers/src/lib/accept-encoding.ts b/packages/headers/src/lib/accept-encoding.ts index c88c93d5197..8ce55be896b 100644 --- a/packages/headers/src/lib/accept-encoding.ts +++ b/packages/headers/src/lib/accept-encoding.ts @@ -15,49 +15,11 @@ export type AcceptEncodingInit = Iterable | Record { - #map: Map + #map!: Map - /** - * @param init A string, iterable, or record to initialize the header - */ constructor(init?: string | AcceptEncodingInit) { + if (init) return AcceptEncoding.from(init) this.#map = new Map() - - if (init) { - if (typeof init === 'string') { - for (let piece of init.split(/\s*,\s*/)) { - let params = parseParams(piece) - if (params.length < 1) continue - - let encoding = params[0][0] - let weight = 1 - - for (let i = 1; i < params.length; i++) { - let [key, value] = params[i] - if (key === 'q') { - weight = Number(value) - break - } - } - - this.#map.set(encoding.toLowerCase(), weight) - } - } else if (isIterable(init)) { - for (let value of init) { - if (Array.isArray(value)) { - this.#map.set(value[0].toLowerCase(), value[1]) - } else { - this.#map.set(value.toLowerCase(), 1) - } - } - } else { - for (let encoding of Object.getOwnPropertyNames(init)) { - this.#map.set(encoding.toLowerCase(), init[encoding]) - } - } - - this.#sort() - } } #sort() { @@ -218,14 +180,52 @@ export class AcceptEncoding implements HeaderValue, Iterable<[string, number]> { return pairs.join(',') } -} -/** - * Parse an Accept-Encoding header value. - * - * @param value The header value (string, init object, or null) - * @returns An AcceptEncoding instance (empty if null) - */ -export function parseAcceptEncoding(value: string | AcceptEncodingInit | null): AcceptEncoding { - return new AcceptEncoding(value ?? undefined) + /** + * Parse an Accept-Encoding header value. + * + * @param value The header value (string, init object, or null) + * @returns An AcceptEncoding instance (empty if null) + */ + static from(value: string | AcceptEncodingInit | null): AcceptEncoding { + let header = new AcceptEncoding() + + if (value !== null) { + if (typeof value === 'string') { + for (let piece of value.split(/\s*,\s*/)) { + let params = parseParams(piece) + if (params.length < 1) continue + + let encoding = params[0][0] + let weight = 1 + + for (let i = 1; i < params.length; i++) { + let [key, val] = params[i] + if (key === 'q') { + weight = Number(val) + break + } + } + + header.#map.set(encoding.toLowerCase(), weight) + } + } else if (isIterable(value)) { + for (let item of value) { + if (Array.isArray(item)) { + header.#map.set(item[0].toLowerCase(), item[1]) + } else { + header.#map.set(item.toLowerCase(), 1) + } + } + } else { + for (let encoding of Object.getOwnPropertyNames(value)) { + header.#map.set(encoding.toLowerCase(), value[encoding]) + } + } + + header.#sort() + } + + return header + } } diff --git a/packages/headers/src/lib/accept-language.test.ts b/packages/headers/src/lib/accept-language.test.ts index 1b824b61fed..090204fddac 100644 --- a/packages/headers/src/lib/accept-language.test.ts +++ b/packages/headers/src/lib/accept-language.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { AcceptLanguage, parseAcceptLanguage } from './accept-language.ts' +import { AcceptLanguage } from './accept-language.ts' describe('Accept-Language', () => { it('initializes with an empty string', () => { @@ -163,9 +163,9 @@ describe('Accept-Language', () => { }) }) -describe('parseAcceptLanguage', () => { +describe('AcceptLanguage.from', () => { it('parses a string value', () => { - let result = parseAcceptLanguage('en-US, en;q=0.9') + let result = AcceptLanguage.from('en-US, en;q=0.9') assert.ok(result instanceof AcceptLanguage) assert.equal(result.size, 2) }) diff --git a/packages/headers/src/lib/accept-language.ts b/packages/headers/src/lib/accept-language.ts index d5e9949f557..5259857e5a0 100644 --- a/packages/headers/src/lib/accept-language.ts +++ b/packages/headers/src/lib/accept-language.ts @@ -15,49 +15,11 @@ export type AcceptLanguageInit = Iterable | Record { - #map: Map + #map!: Map - /** - * @param init A string, iterable, or record to initialize the header - */ constructor(init?: string | AcceptLanguageInit) { + if (init) return AcceptLanguage.from(init) this.#map = new Map() - - if (init) { - if (typeof init === 'string') { - for (let piece of init.split(/\s*,\s*/)) { - let params = parseParams(piece) - if (params.length < 1) continue - - let language = params[0][0] - let weight = 1 - - for (let i = 1; i < params.length; i++) { - let [key, value] = params[i] - if (key === 'q') { - weight = Number(value) - break - } - } - - this.#map.set(language.toLowerCase(), weight) - } - } else if (isIterable(init)) { - for (let value of init) { - if (Array.isArray(value)) { - this.#map.set(value[0].toLowerCase(), value[1]) - } else { - this.#map.set(value.toLowerCase(), 1) - } - } - } else { - for (let language of Object.getOwnPropertyNames(init)) { - this.#map.set(language.toLowerCase(), init[language]) - } - } - - this.#sort() - } } #sort() { @@ -224,14 +186,52 @@ export class AcceptLanguage implements HeaderValue, Iterable<[string, number]> { return pairs.join(',') } -} -/** - * Parse an Accept-Language header value. - * - * @param value The header value (string, init object, or null) - * @returns An AcceptLanguage instance (empty if null) - */ -export function parseAcceptLanguage(value: string | AcceptLanguageInit | null): AcceptLanguage { - return new AcceptLanguage(value ?? undefined) + /** + * Parse an Accept-Language header value. + * + * @param value The header value (string, init object, or null) + * @returns An AcceptLanguage instance (empty if null) + */ + static from(value: string | AcceptLanguageInit | null): AcceptLanguage { + let header = new AcceptLanguage() + + if (value !== null) { + if (typeof value === 'string') { + for (let piece of value.split(/\s*,\s*/)) { + let params = parseParams(piece) + if (params.length < 1) continue + + let language = params[0][0] + let weight = 1 + + for (let i = 1; i < params.length; i++) { + let [key, val] = params[i] + if (key === 'q') { + weight = Number(val) + break + } + } + + header.#map.set(language.toLowerCase(), weight) + } + } else if (isIterable(value)) { + for (let item of value) { + if (Array.isArray(item)) { + header.#map.set(item[0].toLowerCase(), item[1]) + } else { + header.#map.set(item.toLowerCase(), 1) + } + } + } else { + for (let language of Object.getOwnPropertyNames(value)) { + header.#map.set(language.toLowerCase(), value[language]) + } + } + + header.#sort() + } + + return header + } } diff --git a/packages/headers/src/lib/accept.test.ts b/packages/headers/src/lib/accept.test.ts index 96388aba795..d6bbb513e35 100644 --- a/packages/headers/src/lib/accept.test.ts +++ b/packages/headers/src/lib/accept.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Accept, parseAccept } from './accept.ts' +import { Accept } from './accept.ts' describe('Accept', () => { it('initializes with an empty string', () => { @@ -161,9 +161,9 @@ describe('Accept', () => { }) }) -describe('parseAccept', () => { +describe('Accept.from', () => { it('parses a string value', () => { - let result = parseAccept('text/html, application/json;q=0.9') + let result = Accept.from('text/html, application/json;q=0.9') assert.ok(result instanceof Accept) assert.equal(result.size, 2) assert.equal(result.getWeight('text/html'), 1) @@ -171,13 +171,13 @@ describe('parseAccept', () => { }) it('returns empty instance for null', () => { - let result = parseAccept(null) + let result = Accept.from(null) assert.ok(result instanceof Accept) assert.equal(result.size, 0) }) it('accepts init object', () => { - let result = parseAccept({ 'text/html': 1 }) + let result = Accept.from({ 'text/html': 1 }) assert.ok(result instanceof Accept) assert.equal(result.size, 1) }) diff --git a/packages/headers/src/lib/accept.ts b/packages/headers/src/lib/accept.ts index bcbbe7b31a8..09b3690961c 100644 --- a/packages/headers/src/lib/accept.ts +++ b/packages/headers/src/lib/accept.ts @@ -15,49 +15,11 @@ export type AcceptInit = Iterable | Record { - #map: Map + #map!: Map - /** - * @param init A string, iterable, or record to initialize the header - */ constructor(init?: string | AcceptInit) { + if (init) return Accept.from(init) this.#map = new Map() - - if (init) { - if (typeof init === 'string') { - for (let piece of init.split(/\s*,\s*/)) { - let params = parseParams(piece) - if (params.length < 1) continue - - let mediaType = params[0][0] - let weight = 1 - - for (let i = 1; i < params.length; i++) { - let [key, value] = params[i] - if (key === 'q') { - weight = Number(value) - break - } - } - - this.#map.set(mediaType.toLowerCase(), weight) - } - } else if (isIterable(init)) { - for (let mediaType of init) { - if (Array.isArray(mediaType)) { - this.#map.set(mediaType[0].toLowerCase(), mediaType[1]) - } else { - this.#map.set(mediaType.toLowerCase(), 1) - } - } - } else { - for (let mediaType of Object.getOwnPropertyNames(init)) { - this.#map.set(mediaType.toLowerCase(), init[mediaType]) - } - } - - this.#sort() - } } #sort() { @@ -222,14 +184,52 @@ export class Accept implements HeaderValue, Iterable<[string, number]> { return pairs.join(',') } -} -/** - * Parse an Accept header value. - * - * @param value The header value (string, init object, or null) - * @returns An Accept instance (empty if null) - */ -export function parseAccept(value: string | AcceptInit | null): Accept { - return new Accept(value ?? undefined) + /** + * Parse an Accept header value. + * + * @param value The header value (string, init object, or null) + * @returns An Accept instance (empty if null) + */ + static from(value: string | AcceptInit | null): Accept { + let header = new Accept() + + if (value !== null) { + if (typeof value === 'string') { + for (let piece of value.split(/\s*,\s*/)) { + let params = parseParams(piece) + if (params.length < 1) continue + + let mediaType = params[0][0] + let weight = 1 + + for (let i = 1; i < params.length; i++) { + let [key, val] = params[i] + if (key === 'q') { + weight = Number(val) + break + } + } + + header.#map.set(mediaType.toLowerCase(), weight) + } + } else if (isIterable(value)) { + for (let mediaType of value) { + if (Array.isArray(mediaType)) { + header.#map.set(mediaType[0].toLowerCase(), mediaType[1]) + } else { + header.#map.set(mediaType.toLowerCase(), 1) + } + } + } else { + for (let mediaType of Object.getOwnPropertyNames(value)) { + header.#map.set(mediaType.toLowerCase(), value[mediaType]) + } + } + + header.#sort() + } + + return header + } } diff --git a/packages/headers/src/lib/cache-control.test.ts b/packages/headers/src/lib/cache-control.test.ts index 849ee61faac..43761e60a47 100644 --- a/packages/headers/src/lib/cache-control.test.ts +++ b/packages/headers/src/lib/cache-control.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { CacheControl, parseCacheControl } from './cache-control.ts' +import { CacheControl } from './cache-control.ts' const paramTestCases: Array<[string, keyof CacheControl, string, unknown]> = [ ['max-age', 'maxAge', '3600', 3600], @@ -94,16 +94,16 @@ describe('CacheControl', () => { }) }) -describe('parseCacheControl', () => { +describe('CacheControl.from', () => { it('parses a string value', () => { - let result = parseCacheControl('max-age=3600, public') + let result = CacheControl.from('max-age=3600, public') assert.ok(result instanceof CacheControl) assert.equal(result.maxAge, 3600) assert.equal(result.public, true) }) it('accepts init object', () => { - let result = parseCacheControl({ maxAge: 3600, public: true }) + let result = CacheControl.from({ maxAge: 3600, public: true }) assert.ok(result instanceof CacheControl) assert.equal(result.maxAge, 3600) assert.equal(result.public, true) diff --git a/packages/headers/src/lib/cache-control.ts b/packages/headers/src/lib/cache-control.ts index b9f62ac7525..d5736293514 100644 --- a/packages/headers/src/lib/cache-control.ts +++ b/packages/headers/src/lib/cache-control.ts @@ -179,86 +179,8 @@ export class CacheControl implements HeaderValue, CacheControlInit { staleWhileRevalidate?: number staleIfError?: number - /** - * @param init A string or object to initialize the header - */ constructor(init?: string | CacheControlInit) { - if (init) { - if (typeof init === 'string') { - let params = parseParams(init, ',') - if (params.length > 0) { - for (let [name, value] of params) { - switch (name) { - case 'max-age': - this.maxAge = Number(value) - break - case 'max-stale': - this.maxStale = Number(value) - break - case 'min-fresh': - this.minFresh = Number(value) - break - case 's-maxage': - this.sMaxage = Number(value) - break - case 'no-cache': - this.noCache = true - break - case 'no-store': - this.noStore = true - break - case 'no-transform': - this.noTransform = true - break - case 'only-if-cached': - this.onlyIfCached = true - break - case 'must-revalidate': - this.mustRevalidate = true - break - case 'proxy-revalidate': - this.proxyRevalidate = true - break - case 'must-understand': - this.mustUnderstand = true - break - case 'private': - this.private = true - break - case 'public': - this.public = true - break - case 'immutable': - this.immutable = true - break - case 'stale-while-revalidate': - this.staleWhileRevalidate = Number(value) - break - case 'stale-if-error': - this.staleIfError = Number(value) - break - } - } - } - } else { - this.maxAge = init.maxAge - this.maxStale = init.maxStale - this.minFresh = init.minFresh - this.sMaxage = init.sMaxage - this.noCache = init.noCache - this.noStore = init.noStore - this.noTransform = init.noTransform - this.onlyIfCached = init.onlyIfCached - this.mustRevalidate = init.mustRevalidate - this.proxyRevalidate = init.proxyRevalidate - this.mustUnderstand = init.mustUnderstand - this.private = init.private - this.public = init.public - this.immutable = init.immutable - this.staleWhileRevalidate = init.staleWhileRevalidate - this.staleIfError = init.staleIfError - } - } + if (init) return CacheControl.from(init) } /** @@ -320,14 +242,93 @@ export class CacheControl implements HeaderValue, CacheControlInit { return parts.join(', ') } -} -/** - * Parse a Cache-Control header value. - * - * @param value The header value (string, init object, or null) - * @returns A CacheControl instance (empty if null) - */ -export function parseCacheControl(value: string | CacheControlInit | null): CacheControl { - return new CacheControl(value ?? undefined) + /** + * Parse a Cache-Control header value. + * + * @param value The header value (string, init object, or null) + * @returns A CacheControl instance (empty if null) + */ + static from(value: string | CacheControlInit | null): CacheControl { + let header = new CacheControl() + + if (value !== null) { + if (typeof value === 'string') { + let params = parseParams(value, ',') + if (params.length > 0) { + for (let [name, val] of params) { + switch (name) { + case 'max-age': + header.maxAge = Number(val) + break + case 'max-stale': + header.maxStale = Number(val) + break + case 'min-fresh': + header.minFresh = Number(val) + break + case 's-maxage': + header.sMaxage = Number(val) + break + case 'no-cache': + header.noCache = true + break + case 'no-store': + header.noStore = true + break + case 'no-transform': + header.noTransform = true + break + case 'only-if-cached': + header.onlyIfCached = true + break + case 'must-revalidate': + header.mustRevalidate = true + break + case 'proxy-revalidate': + header.proxyRevalidate = true + break + case 'must-understand': + header.mustUnderstand = true + break + case 'private': + header.private = true + break + case 'public': + header.public = true + break + case 'immutable': + header.immutable = true + break + case 'stale-while-revalidate': + header.staleWhileRevalidate = Number(val) + break + case 'stale-if-error': + header.staleIfError = Number(val) + break + } + } + } + } else { + header.maxAge = value.maxAge + header.maxStale = value.maxStale + header.minFresh = value.minFresh + header.sMaxage = value.sMaxage + header.noCache = value.noCache + header.noStore = value.noStore + header.noTransform = value.noTransform + header.onlyIfCached = value.onlyIfCached + header.mustRevalidate = value.mustRevalidate + header.proxyRevalidate = value.proxyRevalidate + header.mustUnderstand = value.mustUnderstand + header.private = value.private + header.public = value.public + header.immutable = value.immutable + header.staleWhileRevalidate = value.staleWhileRevalidate + header.staleIfError = value.staleIfError + } + } + + return header + } } diff --git a/packages/headers/src/lib/content-disposition.test.ts b/packages/headers/src/lib/content-disposition.test.ts index 9b61a47b397..d7f40296192 100644 --- a/packages/headers/src/lib/content-disposition.test.ts +++ b/packages/headers/src/lib/content-disposition.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ContentDisposition, parseContentDisposition } from './content-disposition.ts' +import { ContentDisposition } from './content-disposition.ts' describe('ContentDisposition', () => { it('initializes with an empty string', () => { @@ -218,9 +218,9 @@ describe('ContentDisposition', () => { }) }) -describe('parseContentDisposition', () => { +describe('ContentDisposition.from', () => { it('parses a string value', () => { - let result = parseContentDisposition('attachment; filename="test.txt"') + let result = ContentDisposition.from('attachment; filename="test.txt"') assert.ok(result instanceof ContentDisposition) assert.equal(result.type, 'attachment') assert.equal(result.filename, 'test.txt') diff --git a/packages/headers/src/lib/content-disposition.ts b/packages/headers/src/lib/content-disposition.ts index a08357500ed..2d2551c9980 100644 --- a/packages/headers/src/lib/content-disposition.ts +++ b/packages/headers/src/lib/content-disposition.ts @@ -37,32 +37,8 @@ export class ContentDisposition implements HeaderValue, ContentDispositionInit { name?: string type?: string - /** - * @param init A string or object to initialize the header - */ constructor(init?: string | ContentDispositionInit) { - if (init) { - if (typeof init === 'string') { - let params = parseParams(init) - if (params.length > 0) { - this.type = params[0][0] - for (let [name, value] of params.slice(1)) { - if (name === 'filename') { - this.filename = value - } else if (name === 'filename*') { - this.filenameSplat = value - } else if (name === 'name') { - this.name = value - } - } - } - } else { - this.filename = init.filename - this.filenameSplat = init.filenameSplat - this.name = init.name - this.type = init.type - } - } + if (init) return ContentDisposition.from(init) } /** @@ -109,6 +85,41 @@ export class ContentDisposition implements HeaderValue, ContentDispositionInit { return parts.join('; ') } + + /** + * Parse a Content-Disposition header value. + * + * @param value The header value (string, init object, or null) + * @returns A ContentDisposition instance (empty if null) + */ + static from(value: string | ContentDispositionInit | null): ContentDisposition { + let header = new ContentDisposition() + + if (value !== null) { + if (typeof value === 'string') { + let params = parseParams(value) + if (params.length > 0) { + header.type = params[0][0] + for (let [name, val] of params.slice(1)) { + if (name === 'filename') { + header.filename = val + } else if (name === 'filename*') { + header.filenameSplat = val + } else if (name === 'name') { + header.name = val + } + } + } + } else { + header.filename = value.filename + header.filenameSplat = value.filenameSplat + header.name = value.name + header.type = value.type + } + } + + return header + } } function decodeFilenameSplat(value: string): string | null { @@ -134,13 +145,3 @@ function percentDecode(value: string): string { return String.fromCharCode(parseInt(hex, 16)) }) } - -/** - * Parse a Content-Disposition header value. - * - * @param value The header value (string, init object, or null) - * @returns A ContentDisposition instance (empty if null) - */ -export function parseContentDisposition(value: string | ContentDispositionInit | null): ContentDisposition { - return new ContentDisposition(value ?? undefined) -} diff --git a/packages/headers/src/lib/content-range.test.ts b/packages/headers/src/lib/content-range.test.ts index 176f4f8e402..d2e812da2dd 100644 --- a/packages/headers/src/lib/content-range.test.ts +++ b/packages/headers/src/lib/content-range.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ContentRange, parseContentRange } from './content-range.ts' +import { ContentRange } from './content-range.ts' describe('ContentRange', () => { it('initializes with an empty string', () => { @@ -160,9 +160,9 @@ describe('ContentRange', () => { }) }) -describe('parseContentRange', () => { +describe('ContentRange.from', () => { it('parses a string value', () => { - let result = parseContentRange('bytes 0-499/1234') + let result = ContentRange.from('bytes 0-499/1234') assert.ok(result instanceof ContentRange) assert.equal(result.unit, 'bytes') assert.equal(result.start, 0) diff --git a/packages/headers/src/lib/content-range.ts b/packages/headers/src/lib/content-range.ts index 3e8b28db61c..5817c519cd5 100644 --- a/packages/headers/src/lib/content-range.ts +++ b/packages/headers/src/lib/content-range.ts @@ -38,27 +38,8 @@ export class ContentRange implements HeaderValue, ContentRangeInit { end: number | null = null size?: number | '*' - /** - * @param init A string or object to initialize the header - */ constructor(init?: string | ContentRangeInit) { - if (init) { - if (typeof init === 'string') { - // Parse: "bytes 200-1000/67589" or "bytes */67589" or "bytes 200-1000/*" - let match = init.match(/^(\w+)\s+(?:(\d+)-(\d+)|\*)\/((?:\d+|\*))$/) - if (match) { - this.unit = match[1] - this.start = match[2] ? parseInt(match[2], 10) : null - this.end = match[3] ? parseInt(match[3], 10) : null - this.size = match[4] === '*' ? '*' : parseInt(match[4], 10) - } - } else { - if (init.unit !== undefined) this.unit = init.unit - if (init.start !== undefined) this.start = init.start - if (init.end !== undefined) this.end = init.end - if (init.size !== undefined) this.size = init.size - } - } + if (init) return ContentRange.from(init) } /** @@ -73,14 +54,34 @@ export class ContentRange implements HeaderValue, ContentRangeInit { return `${this.unit} ${range}/${this.size}` } -} -/** - * Parse a Content-Range header value. - * - * @param value The header value (string, init object, or null) - * @returns A ContentRange instance (empty if null) - */ -export function parseContentRange(value: string | ContentRangeInit | null): ContentRange { - return new ContentRange(value ?? undefined) + /** + * Parse a Content-Range header value. + * + * @param value The header value (string, init object, or null) + * @returns A ContentRange instance (empty if null) + */ + static from(value: string | ContentRangeInit | null): ContentRange { + let header = new ContentRange() + + if (value !== null) { + if (typeof value === 'string') { + // Parse: "bytes 200-1000/67589" or "bytes */67589" or "bytes 200-1000/*" + let match = value.match(/^(\w+)\s+(?:(\d+)-(\d+)|\*)\/((?:\d+|\*))$/) + if (match) { + header.unit = match[1] + header.start = match[2] ? parseInt(match[2], 10) : null + header.end = match[3] ? parseInt(match[3], 10) : null + header.size = match[4] === '*' ? '*' : parseInt(match[4], 10) + } + } else { + if (value.unit !== undefined) header.unit = value.unit + if (value.start !== undefined) header.start = value.start + if (value.end !== undefined) header.end = value.end + if (value.size !== undefined) header.size = value.size + } + } + + return header + } } diff --git a/packages/headers/src/lib/content-type.test.ts b/packages/headers/src/lib/content-type.test.ts index 276c66bb09c..92e25494880 100644 --- a/packages/headers/src/lib/content-type.test.ts +++ b/packages/headers/src/lib/content-type.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ContentType, parseContentType } from './content-type.ts' +import { ContentType } from './content-type.ts' describe('ContentType', () => { it('initializes with an empty string', () => { @@ -107,16 +107,16 @@ describe('ContentType', () => { }) }) -describe('parseContentType', () => { +describe('ContentType.from', () => { it('parses a string value', () => { - let result = parseContentType('text/html; charset=utf-8') + let result = ContentType.from('text/html; charset=utf-8') assert.ok(result instanceof ContentType) assert.equal(result.mediaType, 'text/html') assert.equal(result.charset, 'utf-8') }) it('accepts init object', () => { - let result = parseContentType({ mediaType: 'text/html', charset: 'utf-8' }) + let result = ContentType.from({ mediaType: 'text/html', charset: 'utf-8' }) assert.ok(result instanceof ContentType) assert.equal(result.mediaType, 'text/html') assert.equal(result.charset, 'utf-8') diff --git a/packages/headers/src/lib/content-type.ts b/packages/headers/src/lib/content-type.ts index aa020e2c134..fa739c2ceb9 100644 --- a/packages/headers/src/lib/content-type.ts +++ b/packages/headers/src/lib/content-type.ts @@ -37,29 +37,8 @@ export class ContentType implements HeaderValue, ContentTypeInit { charset?: string mediaType?: string - /** - * @param init A string or object to initialize the header - */ constructor(init?: string | ContentTypeInit) { - if (init) { - if (typeof init === 'string') { - let params = parseParams(init) - if (params.length > 0) { - this.mediaType = params[0][0] - for (let [name, value] of params.slice(1)) { - if (name === 'boundary') { - this.boundary = value - } else if (name === 'charset') { - this.charset = value - } - } - } - } else { - this.boundary = init.boundary - this.charset = init.charset - this.mediaType = init.mediaType - } - } + if (init) return ContentType.from(init) } /** @@ -83,14 +62,36 @@ export class ContentType implements HeaderValue, ContentTypeInit { return parts.join('; ') } -} -/** - * Parse a Content-Type header value. - * - * @param value The header value (string, init object, or null) - * @returns A ContentType instance (empty if null) - */ -export function parseContentType(value: string | ContentTypeInit | null): ContentType { - return new ContentType(value ?? undefined) + /** + * Parse a Content-Type header value. + * + * @param value The header value (string, init object, or null) + * @returns A ContentType instance (empty if null) + */ + static from(value: string | ContentTypeInit | null): ContentType { + let header = new ContentType() + + if (value !== null) { + if (typeof value === 'string') { + let params = parseParams(value) + if (params.length > 0) { + header.mediaType = params[0][0] + for (let [name, val] of params.slice(1)) { + if (name === 'boundary') { + header.boundary = val + } else if (name === 'charset') { + header.charset = val + } + } + } + } else { + header.boundary = value.boundary + header.charset = value.charset + header.mediaType = value.mediaType + } + } + + return header + } } diff --git a/packages/headers/src/lib/cookie.test.ts b/packages/headers/src/lib/cookie.test.ts index 2b87443805e..d1dca9b611f 100644 --- a/packages/headers/src/lib/cookie.test.ts +++ b/packages/headers/src/lib/cookie.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Cookie, parseCookie } from './cookie.ts' +import { Cookie } from './cookie.ts' describe('Cookie', () => { it('initializes with an empty string', () => { @@ -143,9 +143,9 @@ describe('Cookie', () => { }) }) -describe('parseCookie', () => { +describe('Cookie.from', () => { it('parses a string value', () => { - let result = parseCookie('session=abc123; user=john') + let result = Cookie.from('session=abc123; user=john') assert.ok(result instanceof Cookie) assert.equal(result.get('session'), 'abc123') assert.equal(result.get('user'), 'john') diff --git a/packages/headers/src/lib/cookie.ts b/packages/headers/src/lib/cookie.ts index 39141901b01..e115ba24332 100644 --- a/packages/headers/src/lib/cookie.ts +++ b/packages/headers/src/lib/cookie.ts @@ -15,29 +15,11 @@ export type CookieInit = Iterable<[string, string]> | Record * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc6265#section-4.2) */ export class Cookie implements HeaderValue, Iterable<[string, string]> { - #map: Map + #map!: Map - /** - * @param init A string, iterable, or record to initialize the header - */ constructor(init?: string | CookieInit) { + if (init) return Cookie.from(init) this.#map = new Map() - if (init) { - if (typeof init === 'string') { - let params = parseParams(init) - for (let [name, value] of params) { - this.#map.set(name, value ?? '') - } - } else if (isIterable(init)) { - for (let [name, value] of init) { - this.#map.set(name, value) - } - } else { - for (let name of Object.getOwnPropertyNames(init)) { - this.#map.set(name, init[name]) - } - } - } } /** @@ -146,14 +128,33 @@ export class Cookie implements HeaderValue, Iterable<[string, string]> { return pairs.join('; ') } -} -/** - * Parse a Cookie header value. - * - * @param value The header value (string, init object, or null) - * @returns A Cookie instance (empty if null) - */ -export function parseCookie(value: string | CookieInit | null): Cookie { - return new Cookie(value ?? undefined) + /** + * Parse a Cookie header value. + * + * @param value The header value (string, init object, or null) + * @returns A Cookie instance (empty if null) + */ + static from(value: string | CookieInit | null): Cookie { + let header = new Cookie() + + if (value !== null) { + if (typeof value === 'string') { + let params = parseParams(value) + for (let [name, val] of params) { + header.#map.set(name, val ?? '') + } + } else if (isIterable(value)) { + for (let [name, val] of value) { + header.#map.set(name, val) + } + } else { + for (let name of Object.getOwnPropertyNames(value)) { + header.#map.set(name, value[name]) + } + } + } + + return header + } } diff --git a/packages/headers/src/lib/if-match.test.ts b/packages/headers/src/lib/if-match.test.ts index 54e54628bb7..dcb664b0580 100644 --- a/packages/headers/src/lib/if-match.test.ts +++ b/packages/headers/src/lib/if-match.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { IfMatch, parseIfMatch } from './if-match.ts' +import { IfMatch } from './if-match.ts' describe('IfMatch', () => { it('initializes with an empty string', () => { @@ -135,9 +135,9 @@ describe('IfMatch', () => { }) }) -describe('parseIfMatch', () => { +describe('IfMatch.from', () => { it('parses a string value', () => { - let result = parseIfMatch('"abc", "def"') + let result = IfMatch.from('"abc", "def"') assert.ok(result instanceof IfMatch) assert.equal(result.tags.length, 2) }) diff --git a/packages/headers/src/lib/if-match.ts b/packages/headers/src/lib/if-match.ts index a2103bd7efe..ea609d2f784 100644 --- a/packages/headers/src/lib/if-match.ts +++ b/packages/headers/src/lib/if-match.ts @@ -21,19 +21,8 @@ export interface IfMatchInit { export class IfMatch implements HeaderValue, IfMatchInit { tags: string[] = [] - /** - * @param init A string, array of strings, or object to initialize the header - */ constructor(init?: string | string[] | IfMatchInit) { - if (init) { - if (typeof init === 'string') { - this.tags.push(...init.split(/\s*,\s*/).map(quoteEtag)) - } else if (Array.isArray(init)) { - this.tags.push(...init.map(quoteEtag)) - } else { - this.tags.push(...init.tags.map(quoteEtag)) - } - } + if (init) return IfMatch.from(init) } /** @@ -95,14 +84,26 @@ export class IfMatch implements HeaderValue, IfMatchInit { toString() { return this.tags.join(', ') } -} -/** - * Parse an If-Match header value. - * - * @param value The header value (string, string[], init object, or null) - * @returns An IfMatch instance (empty if null) - */ -export function parseIfMatch(value: string | string[] | IfMatchInit | null): IfMatch { - return new IfMatch(value ?? undefined) + /** + * Parse an If-Match header value. + * + * @param value The header value (string, string[], init object, or null) + * @returns An IfMatch instance (empty if null) + */ + static from(value: string | string[] | IfMatchInit | null): IfMatch { + let header = new IfMatch() + + if (value !== null) { + if (typeof value === 'string') { + header.tags.push(...value.split(/\s*,\s*/).map(quoteEtag)) + } else if (Array.isArray(value)) { + header.tags.push(...value.map(quoteEtag)) + } else { + header.tags.push(...value.tags.map(quoteEtag)) + } + } + + return header + } } diff --git a/packages/headers/src/lib/if-none-match.test.ts b/packages/headers/src/lib/if-none-match.test.ts index 62ef55a045a..784129d142a 100644 --- a/packages/headers/src/lib/if-none-match.test.ts +++ b/packages/headers/src/lib/if-none-match.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { IfNoneMatch, parseIfNoneMatch } from './if-none-match.ts' +import { IfNoneMatch } from './if-none-match.ts' describe('IfNoneMatch', () => { it('initializes with an empty string', () => { @@ -93,9 +93,9 @@ describe('IfNoneMatch', () => { }) }) -describe('parseIfNoneMatch', () => { +describe('IfNoneMatch.from', () => { it('parses a string value', () => { - let result = parseIfNoneMatch('"abc", "def"') + let result = IfNoneMatch.from('"abc", "def"') assert.ok(result instanceof IfNoneMatch) assert.equal(result.tags.length, 2) }) diff --git a/packages/headers/src/lib/if-none-match.ts b/packages/headers/src/lib/if-none-match.ts index 846fda68ccc..c829d4e915c 100644 --- a/packages/headers/src/lib/if-none-match.ts +++ b/packages/headers/src/lib/if-none-match.ts @@ -21,19 +21,8 @@ export interface IfNoneMatchInit { export class IfNoneMatch implements HeaderValue, IfNoneMatchInit { tags: string[] = [] - /** - * @param init A string, array of strings, or object to initialize the header - */ constructor(init?: string | string[] | IfNoneMatchInit) { - if (init) { - if (typeof init === 'string') { - this.tags.push(...init.split(/\s*,\s*/).map(quoteEtag)) - } else if (Array.isArray(init)) { - this.tags.push(...init.map(quoteEtag)) - } else { - this.tags.push(...init.tags.map(quoteEtag)) - } - } + if (init) return IfNoneMatch.from(init) } /** @@ -66,14 +55,26 @@ export class IfNoneMatch implements HeaderValue, IfNoneMatchInit { toString() { return this.tags.join(', ') } -} -/** - * Parse an If-None-Match header value. - * - * @param value The header value (string, string[], init object, or null) - * @returns An IfNoneMatch instance (empty if null) - */ -export function parseIfNoneMatch(value: string | string[] | IfNoneMatchInit | null): IfNoneMatch { - return new IfNoneMatch(value ?? undefined) + /** + * Parse an If-None-Match header value. + * + * @param value The header value (string, string[], init object, or null) + * @returns An IfNoneMatch instance (empty if null) + */ + static from(value: string | string[] | IfNoneMatchInit | null): IfNoneMatch { + let header = new IfNoneMatch() + + if (value !== null) { + if (typeof value === 'string') { + header.tags.push(...value.split(/\s*,\s*/).map(quoteEtag)) + } else if (Array.isArray(value)) { + header.tags.push(...value.map(quoteEtag)) + } else { + header.tags.push(...value.tags.map(quoteEtag)) + } + } + + return header + } } diff --git a/packages/headers/src/lib/if-range.test.ts b/packages/headers/src/lib/if-range.test.ts index b11ee72242e..b70fbac039e 100644 --- a/packages/headers/src/lib/if-range.test.ts +++ b/packages/headers/src/lib/if-range.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { IfRange, parseIfRange } from './if-range.ts' +import { IfRange } from './if-range.ts' describe('IfRange', () => { let testDate = new Date('2021-01-01T00:00:00Z') @@ -157,16 +157,16 @@ describe('IfRange', () => { }) }) -describe('parseIfRange', () => { +describe('IfRange.from', () => { it('parses a string value', () => { - let result = parseIfRange('"abc"') + let result = IfRange.from('"abc"') assert.ok(result instanceof IfRange) assert.equal(result.value, '"abc"') }) it('parses a Date value', () => { let date = new Date('2024-01-01T00:00:00.000Z') - let result = parseIfRange(date) + let result = IfRange.from(date) assert.ok(result instanceof IfRange) assert.equal(result.value, date.toUTCString()) }) diff --git a/packages/headers/src/lib/if-range.ts b/packages/headers/src/lib/if-range.ts index 701671f31a2..a02882d3907 100644 --- a/packages/headers/src/lib/if-range.ts +++ b/packages/headers/src/lib/if-range.ts @@ -14,17 +14,8 @@ import { quoteEtag } from './utils.ts' export class IfRange implements HeaderValue { value: string = '' - /** - * @param init A string or Date to initialize the header - */ constructor(init?: string | Date) { - if (init) { - if (typeof init === 'string') { - this.value = init - } else { - this.value = init.toUTCString() - } - } + if (init) return IfRange.from(init) } /** @@ -91,14 +82,24 @@ export class IfRange implements HeaderValue { toString() { return this.value } -} -/** - * Parse an If-Range header value. - * - * @param value The header value (string, Date, or null) - * @returns An IfRange instance (empty if null) - */ -export function parseIfRange(value: string | Date | null): IfRange { - return new IfRange(value ?? undefined) + /** + * Parse an If-Range header value. + * + * @param value The header value (string, Date, or null) + * @returns An IfRange instance (empty if null) + */ + static from(value: string | Date | null): IfRange { + let header = new IfRange() + + if (value !== null) { + if (typeof value === 'string') { + header.value = value + } else { + header.value = value.toUTCString() + } + } + + return header + } } diff --git a/packages/headers/src/lib/range.test.ts b/packages/headers/src/lib/range.test.ts index eb18ce67a03..85c869516e0 100644 --- a/packages/headers/src/lib/range.test.ts +++ b/packages/headers/src/lib/range.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Range, parseRange } from './range.ts' +import { Range } from './range.ts' describe('Range', () => { it('initializes with an empty string', () => { @@ -249,9 +249,9 @@ describe('Range', () => { }) }) -describe('parseRange', () => { +describe('Range.from', () => { it('parses a string value', () => { - let result = parseRange('bytes=0-499') + let result = Range.from('bytes=0-499') assert.ok(result instanceof Range) assert.equal(result.unit, 'bytes') assert.equal(result.ranges.length, 1) diff --git a/packages/headers/src/lib/range.ts b/packages/headers/src/lib/range.ts index d3fe2f96a4a..72e7f0d5cb2 100644 --- a/packages/headers/src/lib/range.ts +++ b/packages/headers/src/lib/range.ts @@ -28,58 +28,8 @@ export class Range implements HeaderValue, RangeInit { unit: string = '' ranges: Array<{ start?: number; end?: number }> = [] - /** - * @param init A string or object to initialize the header - */ constructor(init?: string | RangeInit) { - if (init) { - if (typeof init === 'string') { - // Parse: "bytes=200-1000" or "bytes=200-" or "bytes=-500" or "bytes=0-99,200-299" - let match = init.match(/^(\w+)=(.+)$/) - if (match) { - this.unit = match[1] - let rangeParts = match[2].split(',') - - // Track if any range part is invalid to mark the entire header as malformed - let hasInvalidPart = false - - for (let part of rangeParts) { - let rangeMatch = part.trim().match(/^(\d*)-(\d*)$/) - if (!rangeMatch) { - // Invalid syntax for this range part - hasInvalidPart = true - continue - } - - let [, startStr, endStr] = rangeMatch - // At least one bound must be specified - if (!startStr && !endStr) { - hasInvalidPart = true - continue - } - - let start = startStr ? parseInt(startStr, 10) : undefined - let end = endStr ? parseInt(endStr, 10) : undefined - - // If both bounds are specified, start must be <= end - if (start !== undefined && end !== undefined && start > end) { - hasInvalidPart = true - continue - } - - this.ranges.push({ start, end }) - } - - // If any part was invalid, mark as malformed by clearing ranges - if (hasInvalidPart) { - this.ranges = [] - } - } - } else { - if (init.unit !== undefined) this.unit = init.unit - if (init.ranges !== undefined) this.ranges = init.ranges - } - } + if (init) return Range.from(init) } /** @@ -177,14 +127,65 @@ export class Range implements HeaderValue, RangeInit { return `${this.unit}=${rangeParts.join(',')}` } -} -/** - * Parse a Range header value. - * - * @param value The header value (string, init object, or null) - * @returns A Range instance (empty if null) - */ -export function parseRange(value: string | RangeInit | null): Range { - return new Range(value ?? undefined) + /** + * Parse a Range header value. + * + * @param value The header value (string, init object, or null) + * @returns A Range instance (empty if null) + */ + static from(value: string | RangeInit | null): Range { + let header = new Range() + + if (value !== null) { + if (typeof value === 'string') { + // Parse: "bytes=200-1000" or "bytes=200-" or "bytes=-500" or "bytes=0-99,200-299" + let match = value.match(/^(\w+)=(.+)$/) + if (match) { + header.unit = match[1] + let rangeParts = match[2].split(',') + + // Track if any range part is invalid to mark the entire header as malformed + let hasInvalidPart = false + + for (let part of rangeParts) { + let rangeMatch = part.trim().match(/^(\d*)-(\d*)$/) + if (!rangeMatch) { + // Invalid syntax for this range part + hasInvalidPart = true + continue + } + + let [, startStr, endStr] = rangeMatch + // At least one bound must be specified + if (!startStr && !endStr) { + hasInvalidPart = true + continue + } + + let start = startStr ? parseInt(startStr, 10) : undefined + let end = endStr ? parseInt(endStr, 10) : undefined + + // If both bounds are specified, start must be <= end + if (start !== undefined && end !== undefined && start > end) { + hasInvalidPart = true + continue + } + + header.ranges.push({ start, end }) + } + + // If any part was invalid, mark as malformed by clearing ranges + if (hasInvalidPart) { + header.ranges = [] + } + } + } else { + if (value.unit !== undefined) header.unit = value.unit + if (value.ranges !== undefined) header.ranges = value.ranges + } + } + + return header + } } diff --git a/packages/headers/src/lib/set-cookie.test.ts b/packages/headers/src/lib/set-cookie.test.ts index 650f9e10efd..b738c031d07 100644 --- a/packages/headers/src/lib/set-cookie.test.ts +++ b/packages/headers/src/lib/set-cookie.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { SetCookie, parseSetCookie } from './set-cookie.ts' +import { SetCookie } from './set-cookie.ts' describe('SetCookie', () => { it('initializes with an empty string', () => { @@ -219,9 +219,9 @@ describe('SetCookie', () => { }) }) -describe('parseSetCookie', () => { +describe('SetCookie.from', () => { it('parses a string value', () => { - let result = parseSetCookie('session=abc123; Path=/; HttpOnly') + let result = SetCookie.from('session=abc123; Path=/; HttpOnly') assert.ok(result instanceof SetCookie) assert.equal(result.name, 'session') assert.equal(result.value, 'abc123') diff --git a/packages/headers/src/lib/set-cookie.ts b/packages/headers/src/lib/set-cookie.ts index aea8e57ec22..516679d959d 100644 --- a/packages/headers/src/lib/set-cookie.ts +++ b/packages/headers/src/lib/set-cookie.ts @@ -97,71 +97,8 @@ export class SetCookie implements HeaderValue, SetCookieInit { secure?: boolean value?: string - /** - * @param init A string or object to initialize the header - */ constructor(init?: string | SetCookieInit) { - if (init) { - if (typeof init === 'string') { - let params = parseParams(init) - if (params.length > 0) { - this.name = params[0][0] - this.value = params[0][1] - - for (let [key, value] of params.slice(1)) { - switch (key.toLowerCase()) { - case 'domain': - this.domain = value - break - case 'expires': { - if (typeof value === 'string') { - let date = new Date(value) - if (isValidDate(date)) { - this.expires = date - } - } - break - } - case 'httponly': - this.httpOnly = true - break - case 'max-age': { - if (typeof value === 'string') { - let v = parseInt(value, 10) - if (!isNaN(v)) this.maxAge = v - } - break - } - case 'partitioned': - this.partitioned = true - break - case 'path': - this.path = value - break - case 'samesite': - if (typeof value === 'string' && /strict|lax|none/i.test(value)) { - this.sameSite = capitalize(value) as SameSiteValue - } - break - case 'secure': - this.secure = true - break - } - } - } - } else { - this.domain = init.domain - this.expires = init.expires - this.httpOnly = init.httpOnly - this.maxAge = init.maxAge - this.name = init.name - this.partitioned = init.partitioned - this.path = init.path - this.sameSite = init.sameSite - this.secure = init.secure - this.value = init.value - } - } + if (init) return SetCookie.from(init) } /** @@ -203,14 +140,78 @@ export class SetCookie implements HeaderValue, SetCookieInit { return parts.join('; ') } -} -/** - * Parse a Set-Cookie header value. - * - * @param value The header value (string, init object, or null) - * @returns A SetCookie instance (empty if null) - */ -export function parseSetCookie(value: string | SetCookieInit | null): SetCookie { - return new SetCookie(value ?? undefined) + /** + * Parse a Set-Cookie header value. + * + * @param value The header value (string, init object, or null) + * @returns A SetCookie instance (empty if null) + */ + static from(value: string | SetCookieInit | null): SetCookie { + let header = new SetCookie() + + if (value !== null) { + if (typeof value === 'string') { + let params = parseParams(value) + if (params.length > 0) { + header.name = params[0][0] + header.value = params[0][1] + + for (let [key, val] of params.slice(1)) { + switch (key.toLowerCase()) { + case 'domain': + header.domain = val + break + case 'expires': { + if (typeof val === 'string') { + let date = new Date(val) + if (isValidDate(date)) { + header.expires = date + } + } + break + } + case 'httponly': + header.httpOnly = true + break + case 'max-age': { + if (typeof val === 'string') { + let v = parseInt(val, 10) + if (!isNaN(v)) header.maxAge = v + } + break + } + case 'partitioned': + header.partitioned = true + break + case 'path': + header.path = val + break + case 'samesite': + if (typeof val === 'string' && /strict|lax|none/i.test(val)) { + header.sameSite = capitalize(val) as SameSiteValue + } + break + case 'secure': + header.secure = true + break + } + } + } + } else { + header.domain = value.domain + header.expires = value.expires + header.httpOnly = value.httpOnly + header.maxAge = value.maxAge + header.name = value.name + header.partitioned = value.partitioned + header.path = value.path + header.sameSite = value.sameSite + header.secure = value.secure + header.value = value.value + } + } + + return header + } } diff --git a/packages/headers/src/lib/vary.test.ts b/packages/headers/src/lib/vary.test.ts index d79339a6438..3029eb4e9a3 100644 --- a/packages/headers/src/lib/vary.test.ts +++ b/packages/headers/src/lib/vary.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { Vary, parseVary } from './vary.ts' +import { Vary } from './vary.ts' describe('Vary', () => { it('initializes with an empty string', () => { @@ -145,9 +145,9 @@ describe('Vary', () => { }) }) -describe('parseVary', () => { +describe('Vary.from', () => { it('parses a string value', () => { - let result = parseVary('Accept-Encoding, Accept-Language') + let result = Vary.from('Accept-Encoding, Accept-Language') assert.ok(result instanceof Vary) assert.equal(result.size, 2) assert.equal(result.has('Accept-Encoding'), true) @@ -155,7 +155,7 @@ describe('parseVary', () => { }) it('parses an array value', () => { - let result = parseVary(['Accept-Encoding', 'Accept-Language']) + let result = Vary.from(['Accept-Encoding', 'Accept-Language']) assert.ok(result instanceof Vary) assert.equal(result.size, 2) }) diff --git a/packages/headers/src/lib/vary.ts b/packages/headers/src/lib/vary.ts index ecc3261fce0..a9c5e324728 100644 --- a/packages/headers/src/lib/vary.ts +++ b/packages/headers/src/lib/vary.ts @@ -20,34 +20,11 @@ export interface VaryInit { * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.vary) */ export class Vary implements HeaderValue, VaryInit, Iterable { - #set: Set + #set!: Set constructor(init?: string | string[] | VaryInit) { + if (init) return Vary.from(init) this.#set = new Set() - if (init) { - if (typeof init === 'string') { - for (let headerName of init.split(',')) { - let trimmed = headerName.trim() - if (trimmed) { - this.#set.add(trimmed.toLowerCase()) - } - } - } else if (Array.isArray(init)) { - for (let headerName of init) { - let trimmed = headerName.trim() - if (trimmed) { - this.#set.add(trimmed.toLowerCase()) - } - } - } else { - for (let headerName of init.headerNames) { - let trimmed = headerName.trim() - if (trimmed) { - this.#set.add(trimmed.toLowerCase()) - } - } - } - } } /** @@ -118,14 +95,41 @@ export class Vary implements HeaderValue, VaryInit, Iterable { toString() { return Array.from(this.#set).join(', ') } -} -/** - * Parse a Vary header value. - * - * @param value The header value (string, string[], init object, or null) - * @returns A Vary instance (empty if null) - */ -export function parseVary(value: string | string[] | VaryInit | null): Vary { - return new Vary(value ?? undefined) + /** + * Parse a Vary header value. + * + * @param value The header value (string, string[], init object, or null) + * @returns A Vary instance (empty if null) + */ + static from(value: string | string[] | VaryInit | null): Vary { + let header = new Vary() + + if (value !== null) { + if (typeof value === 'string') { + for (let headerName of value.split(',')) { + let trimmed = headerName.trim() + if (trimmed) { + header.#set.add(trimmed.toLowerCase()) + } + } + } else if (Array.isArray(value)) { + for (let headerName of value) { + let trimmed = headerName.trim() + if (trimmed) { + header.#set.add(trimmed.toLowerCase()) + } + } + } else { + for (let headerName of value.headerNames) { + let trimmed = headerName.trim() + if (trimmed) { + header.#set.add(trimmed.toLowerCase()) + } + } + } + } + + return header + } } From 751c3ade8bc92addaa6123019e0427ce5687ebab Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 6 Jan 2026 16:20:27 +1100 Subject: [PATCH 10/15] Update header parser consumers --- packages/fetch-router/README.md | 2 +- .../patch.use-headers-from-methods.md | 1 + .../patch.use-headers-parse-functions.md | 2 +- .../multipart-parser/src/lib/multipart.ts | 8 +++---- packages/multipart-parser/test/utils.ts | 10 ++++----- .../patch.use-headers-from-methods.md | 1 + .../patch.use-headers-parse-functions.md | 2 +- packages/response/src/lib/compress.test.ts | 10 ++++----- packages/response/src/lib/compress.ts | 9 ++++---- packages/response/src/lib/file.ts | 22 +++++++++---------- 10 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 packages/multipart-parser/.changes/patch.use-headers-from-methods.md create mode 100644 packages/response/.changes/patch.use-headers-from-methods.md diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index c840b91483f..f76e0ec27df 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -592,7 +592,7 @@ router.get('/posts/:id', ({ request, url, params, storage }) => { #### Content Negotiation -- use `parseAccept` from `@remix-run/headers` to serve different responses based on the client's `Accept` header +- use `Accept.from()` from `@remix-run/headers` to serve different responses based on the client's `Accept` header - maybe put this on `context.accepts()` for convenience? #### Sessions diff --git a/packages/multipart-parser/.changes/patch.use-headers-from-methods.md b/packages/multipart-parser/.changes/patch.use-headers-from-methods.md new file mode 100644 index 00000000000..a8f68378fed --- /dev/null +++ b/packages/multipart-parser/.changes/patch.use-headers-from-methods.md @@ -0,0 +1 @@ +Update `@remix-run/headers` peer dependency to use the new header parsing methods. diff --git a/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md b/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md index 2cc383237c1..a8f68378fed 100644 --- a/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md +++ b/packages/multipart-parser/.changes/patch.use-headers-parse-functions.md @@ -1 +1 @@ -Update `@remix-run/headers` peer dependency to use the new header parsing functions. +Update `@remix-run/headers` peer dependency to use the new header parsing methods. diff --git a/packages/multipart-parser/src/lib/multipart.ts b/packages/multipart-parser/src/lib/multipart.ts index 2cdb75a0d9b..30977a05348 100644 --- a/packages/multipart-parser/src/lib/multipart.ts +++ b/packages/multipart-parser/src/lib/multipart.ts @@ -1,4 +1,4 @@ -import { parseContentDisposition, parseContentType, parseRawHeaders } from '@remix-run/headers' +import { ContentDisposition, ContentType, parseRawHeaders } from '@remix-run/headers' import { createSearch, @@ -406,21 +406,21 @@ export class MultipartPart { * The filename of the part, if it is a file upload. */ get filename(): string | undefined { - return parseContentDisposition(this.headers.get('content-disposition')).preferredFilename + return ContentDisposition.from(this.headers.get('content-disposition')).preferredFilename } /** * The media type of the part. */ get mediaType(): string | undefined { - return parseContentType(this.headers.get('content-type')).mediaType + return ContentType.from(this.headers.get('content-type')).mediaType } /** * The name of the part, usually the `name` of the field in the `` that submitted the request. */ get name(): string | undefined { - return parseContentDisposition(this.headers.get('content-disposition')).name + return ContentDisposition.from(this.headers.get('content-disposition')).name } /** diff --git a/packages/multipart-parser/test/utils.ts b/packages/multipart-parser/test/utils.ts index 082aed32a94..1ac26fb48e4 100644 --- a/packages/multipart-parser/test/utils.ts +++ b/packages/multipart-parser/test/utils.ts @@ -29,10 +29,10 @@ export function createMultipartMessage( if (typeof value === 'string') { let headers = new Headers({ - 'Content-Disposition': new ContentDisposition({ + 'Content-Disposition': ContentDisposition.from({ type: 'form-data', name, - }), + }).toString(), }) pushLine(stringifyRawHeaders(headers)) @@ -40,16 +40,16 @@ export function createMultipartMessage( pushLine(value) } else { let headers = new Headers({ - 'Content-Disposition': new ContentDisposition({ + 'Content-Disposition': ContentDisposition.from({ type: 'form-data', name, filename: value.filename, filenameSplat: value.filenameSplat, - }), + }).toString(), }) if (value.mediaType) { - headers.set('Content-Type', new ContentType({ mediaType: value.mediaType })) + headers.set('Content-Type', ContentType.from({ mediaType: value.mediaType }).toString()) } pushLine(stringifyRawHeaders(headers)) diff --git a/packages/response/.changes/patch.use-headers-from-methods.md b/packages/response/.changes/patch.use-headers-from-methods.md new file mode 100644 index 00000000000..a8f68378fed --- /dev/null +++ b/packages/response/.changes/patch.use-headers-from-methods.md @@ -0,0 +1 @@ +Update `@remix-run/headers` peer dependency to use the new header parsing methods. diff --git a/packages/response/.changes/patch.use-headers-parse-functions.md b/packages/response/.changes/patch.use-headers-parse-functions.md index 2cc383237c1..a8f68378fed 100644 --- a/packages/response/.changes/patch.use-headers-parse-functions.md +++ b/packages/response/.changes/patch.use-headers-parse-functions.md @@ -1 +1 @@ -Update `@remix-run/headers` peer dependency to use the new header parsing functions. +Update `@remix-run/headers` peer dependency to use the new header parsing methods. diff --git a/packages/response/src/lib/compress.test.ts b/packages/response/src/lib/compress.test.ts index b97dcb21b58..747ae5d75ac 100644 --- a/packages/response/src/lib/compress.test.ts +++ b/packages/response/src/lib/compress.test.ts @@ -13,7 +13,7 @@ import { Readable } from 'node:stream' import { EventEmitter } from 'node:events' import { describe, it } from 'node:test' -import { parseVary } from '@remix-run/headers' +import { Vary } from '@remix-run/headers' import { compressResponse, compressStream, type Encoding } from './compress.ts' const isWindows = process.platform === 'win32' @@ -50,7 +50,7 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'gzip') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') - let vary = parseVary(compressed.headers.get('vary')) + let vary = Vary.from(compressed.headers.get('vary')) assert.ok(vary.has('Accept-Encoding')) let buffer = Buffer.from(await compressed.arrayBuffer()) @@ -85,7 +85,7 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'deflate') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') - let vary = parseVary(compressed.headers.get('vary')) + let vary = Vary.from(compressed.headers.get('vary')) assert.ok(vary.has('Accept-Encoding')) let buffer = Buffer.from(await compressed.arrayBuffer()) @@ -642,7 +642,7 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'gzip') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') assert.equal(compressed.headers.get('Content-Length'), null) - let vary = parseVary(compressed.headers.get('vary')) + let vary = Vary.from(compressed.headers.get('vary')) assert.ok(vary.has('Accept-Encoding')) assert.equal(compressed.body, null) }) @@ -692,7 +692,7 @@ describe('compressResponse()', () => { assert.equal(compressed.headers.get('Content-Encoding'), 'gzip') assert.equal(compressed.headers.get('Accept-Ranges'), 'none') assert.equal(compressed.headers.get('Content-Length'), null) - let vary = parseVary(compressed.headers.get('vary')) + let vary = Vary.from(compressed.headers.get('vary')) assert.ok(vary.has('Accept-Encoding')) assert.equal(compressed.body, null) }) diff --git a/packages/response/src/lib/compress.ts b/packages/response/src/lib/compress.ts index c503eaa64f8..23d5cf1e488 100644 --- a/packages/response/src/lib/compress.ts +++ b/packages/response/src/lib/compress.ts @@ -9,8 +9,7 @@ import { } from 'node:zlib' import type { BrotliOptions, ZlibOptions } from 'node:zlib' -import type { AcceptEncoding } from '@remix-run/headers' -import { parseAcceptEncoding, parseCacheControl, parseVary } from '@remix-run/headers' +import { AcceptEncoding, CacheControl, Vary } from '@remix-run/headers' export type Encoding = 'br' | 'gzip' | 'deflate' const defaultEncodings: Encoding[] = ['br', 'gzip', 'deflate'] @@ -91,7 +90,7 @@ export async function compressResponse( let contentLengthHeader = responseHeaders.get('content-length') let contentLength = contentLengthHeader != null ? parseInt(contentLengthHeader, 10) : null let acceptRangesHeader = responseHeaders.get('accept-ranges') - let cacheControl = parseCacheControl(responseHeaders.get('cache-control')) + let cacheControl = CacheControl.from(responseHeaders.get('cache-control')) if ( !acceptEncodingHeader || @@ -112,7 +111,7 @@ export async function compressResponse( return response } - let acceptEncoding = parseAcceptEncoding(acceptEncodingHeader) + let acceptEncoding = AcceptEncoding.from(acceptEncodingHeader) let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings) if (selectedEncoding === null) { // Client has explicitly rejected all supported encodings, including 'identity' @@ -168,7 +167,7 @@ function setCompressionHeaders(headers: Headers, encoding: string): void { headers.delete('content-length') // Update Vary header to include Accept-Encoding - let vary = parseVary(headers.get('vary')) + let vary = Vary.from(headers.get('vary')) vary.add('Accept-Encoding') headers.set('vary', vary.toString()) diff --git a/packages/response/src/lib/file.ts b/packages/response/src/lib/file.ts index a75a23ec828..9f51643fd69 100644 --- a/packages/response/src/lib/file.ts +++ b/packages/response/src/lib/file.ts @@ -1,10 +1,10 @@ import { type ContentRangeInit, ContentRange, - parseIfMatch, - parseIfNoneMatch, - parseIfRange, - parseRange, + IfMatch, + IfNoneMatch, + IfRange, + Range, } from '@remix-run/headers' import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime' @@ -142,7 +142,7 @@ export async function createFileResponse( // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match if (etag && hasIfMatch) { - let ifMatch = parseIfMatch(headers.get('if-match')) + let ifMatch = IfMatch.from(headers.get('if-match')) if (!ifMatch.matches(etag)) { return new Response('Precondition Failed', { status: 412, @@ -177,7 +177,7 @@ export async function createFileResponse( // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since if (etag || lastModified) { let shouldReturnNotModified = false - let ifNoneMatch = parseIfNoneMatch(headers.get('if-none-match')) + let ifNoneMatch = IfNoneMatch.from(headers.get('if-none-match')) if (etag && ifNoneMatch.matches(etag)) { shouldReturnNotModified = true @@ -206,7 +206,7 @@ export async function createFileResponse( // Range support: https://httpwg.org/specs/rfc9110.html#field.range // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range if (acceptRanges && request.method === 'GET' && headers.has('Range')) { - let range = parseRange(headers.get('range')) + let range = Range.from(headers.get('range')) // Check if the Range header was sent but parsing resulted in no valid ranges (malformed) if (range.ranges.length === 0) { @@ -216,7 +216,7 @@ export async function createFileResponse( } // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range - let ifRange = parseIfRange(headers.get('if-range')) + let ifRange = IfRange.from(headers.get('if-range')) if ( ifRange.matches({ etag, @@ -227,7 +227,7 @@ export async function createFileResponse( return new Response('Range Not Satisfiable', { status: 416, headers: buildResponseHeaders({ - contentRange: new ContentRange({ unit: 'bytes', size: file.size }), + contentRange: ContentRange.from({ unit: 'bytes', size: file.size }), }), }) } @@ -239,7 +239,7 @@ export async function createFileResponse( return new Response('Range Not Satisfiable', { status: 416, headers: buildResponseHeaders({ - contentRange: new ContentRange({ unit: 'bytes', size: file.size }), + contentRange: ContentRange.from({ unit: 'bytes', size: file.size }), }), }) } @@ -299,7 +299,7 @@ function buildResponseHeaders(values: ResponseHeaderValues): Headers { headers.set('Content-Length', String(values.contentLength)) } if (values.contentRange) { - let str = new ContentRange(values.contentRange).toString() + let str = ContentRange.from(values.contentRange).toString() if (str) headers.set('Content-Range', str) } if (values.etag) { From 8d353567c089c84056068d5d420829e246be335f Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 7 Jan 2026 09:06:59 +1100 Subject: [PATCH 11/15] Update packages/fetch-router/.changes/minor.plain-headers.md Co-authored-by: Michael Jackson --- packages/fetch-router/.changes/minor.plain-headers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fetch-router/.changes/minor.plain-headers.md b/packages/fetch-router/.changes/minor.plain-headers.md index b2f4f579701..9a0a4f53c90 100644 --- a/packages/fetch-router/.changes/minor.plain-headers.md +++ b/packages/fetch-router/.changes/minor.plain-headers.md @@ -10,10 +10,10 @@ router.get('/api/users', (context) => { }) // After: -import { parseAccept } from '@remix-run/headers' +import { Accept } from '@remix-run/headers' router.get('/api/users', (context) => { - let accept = parseAccept(context.headers.get('accept')) + let accept = Accept.from(context.headers.get('accept')) let acceptsJson = accept.accepts('application/json') // ... }) From 65435b1824a5414d192614565aa1b9c53a25c697 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 7 Jan 2026 09:29:08 +1100 Subject: [PATCH 12/15] Rename raw header utils to parse and stringify --- .../.changes/minor.remove-super-headers.md | 18 +++++++++--------- packages/headers/README.md | 8 +++----- packages/headers/src/index.ts | 2 +- packages/headers/src/lib/raw-headers.test.ts | 2 +- packages/headers/src/lib/raw-headers.ts | 8 ++++---- packages/multipart-parser/src/lib/multipart.ts | 2 +- packages/multipart-parser/test/utils.ts | 6 +++++- 7 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/headers/.changes/minor.remove-super-headers.md b/packages/headers/.changes/minor.remove-super-headers.md index 8b27216320e..7c915dbb5af 100644 --- a/packages/headers/.changes/minor.remove-super-headers.md +++ b/packages/headers/.changes/minor.remove-super-headers.md @@ -19,8 +19,8 @@ New individual header `.from()` methods: New raw header utilities added: -- `parseRawHeaders()` -- `stringifyRawHeaders()` +- `parse()` +- `stringify()` Migration example: @@ -36,7 +36,7 @@ let contentType = ContentType.from(request.headers.get('content-type')) let mediaType = contentType.mediaType ``` -If you were using the `Headers` constructor to parse raw HTTP header strings, use `parseRawHeaders()` instead: +If you were using the `Headers` constructor to parse raw HTTP header strings, use `parse()` instead: ```ts // Before: @@ -44,22 +44,22 @@ import SuperHeaders from '@remix-run/headers' let headers = new SuperHeaders('Content-Type: text/html\r\nCache-Control: no-cache') // After: -import { parseRawHeaders } from '@remix-run/headers' -let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') +import { parse } from '@remix-run/headers' +let headers = parse('Content-Type: text/html\r\nCache-Control: no-cache') ``` -If you were using `headers.toString()` to convert headers to raw format, use `stringifyRawHeaders()` instead: +If you were using `headers.toString()` to convert headers to raw format, use `stringify()` instead: ```ts // Before: import SuperHeaders from '@remix-run/headers' let headers = new SuperHeaders() headers.set('Content-Type', 'text/html') -let raw = headers.toString() +let rawHeaders = headers.toString() // After: -import { stringifyRawHeaders } from '@remix-run/headers' +import { stringify } from '@remix-run/headers' let headers = new Headers() headers.set('Content-Type', 'text/html') -let raw = stringifyRawHeaders(headers) +let rawHeaders = stringify(headers) ``` diff --git a/packages/headers/README.md b/packages/headers/README.md index 0f920b80b7c..1bea3436594 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -544,15 +544,13 @@ headers.set('Vary', new Vary(['Accept-Encoding', 'Accept-Language'])) Parse and stringify raw HTTP header strings. ```ts -import { parseRawHeaders, stringifyRawHeaders } from '@remix-run/headers' +import { parse, stringify } from '@remix-run/headers' -// Parse raw header string into Headers object -let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') +let headers = parse('Content-Type: text/html\r\nCache-Control: no-cache') headers.get('content-type') // 'text/html' headers.get('cache-control') // 'no-cache' -// Stringify Headers object back to raw format -let raw = stringifyRawHeaders(headers) +stringify(headers) // 'Content-Type: text/html\r\nCache-Control: no-cache' ``` diff --git a/packages/headers/src/index.ts b/packages/headers/src/index.ts index 4998247be59..6742c449300 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -12,4 +12,4 @@ export { IfRange } from './lib/if-range.ts' export { type RangeInit, Range } from './lib/range.ts' export { type CookieProperties, type SetCookieInit, SetCookie } from './lib/set-cookie.ts' export { type VaryInit, Vary } from './lib/vary.ts' -export { parseRawHeaders, stringifyRawHeaders } from './lib/raw-headers.ts' +export { parse, stringify } from './lib/raw-headers.ts' diff --git a/packages/headers/src/lib/raw-headers.test.ts b/packages/headers/src/lib/raw-headers.test.ts index 5b096a7c529..396eaf03018 100644 --- a/packages/headers/src/lib/raw-headers.test.ts +++ b/packages/headers/src/lib/raw-headers.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { parseRawHeaders, stringifyRawHeaders } from './raw-headers.ts' +import { parse as parseRawHeaders, stringify as stringifyRawHeaders } from './raw-headers.ts' describe('parseRawHeaders', () => { it('parses a single header', () => { diff --git a/packages/headers/src/lib/raw-headers.ts b/packages/headers/src/lib/raw-headers.ts index b16c6167d55..0eed1c96892 100644 --- a/packages/headers/src/lib/raw-headers.ts +++ b/packages/headers/src/lib/raw-headers.ts @@ -9,11 +9,11 @@ const CRLF = '\r\n' * @returns A `Headers` object containing the parsed headers * * @example - * let headers = parseRawHeaders('Content-Type: text/html\r\nCache-Control: no-cache') + * let headers = parse('Content-Type: text/html\r\nCache-Control: no-cache') * headers.get('content-type') // 'text/html' * headers.get('cache-control') // 'no-cache' */ -export function parseRawHeaders(raw: string): Headers { +export function parse(raw: string): Headers { let headers = new Headers() for (let line of raw.split(CRLF)) { @@ -34,9 +34,9 @@ export function parseRawHeaders(raw: string): Headers { * * @example * let headers = new Headers({ 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' }) - * stringifyRawHeaders(headers) // 'Content-Type: text/html\r\nCache-Control: no-cache' + * stringify(headers) // 'Content-Type: text/html\r\nCache-Control: no-cache' */ -export function stringifyRawHeaders(headers: Headers): string { +export function stringify(headers: Headers): string { let result = '' for (let [name, value] of headers) { diff --git a/packages/multipart-parser/src/lib/multipart.ts b/packages/multipart-parser/src/lib/multipart.ts index 30977a05348..82c3227df9a 100644 --- a/packages/multipart-parser/src/lib/multipart.ts +++ b/packages/multipart-parser/src/lib/multipart.ts @@ -1,4 +1,4 @@ -import { ContentDisposition, ContentType, parseRawHeaders } from '@remix-run/headers' +import { ContentDisposition, ContentType, parse as parseRawHeaders } from '@remix-run/headers' import { createSearch, diff --git a/packages/multipart-parser/test/utils.ts b/packages/multipart-parser/test/utils.ts index 1ac26fb48e4..94c85fd78b8 100644 --- a/packages/multipart-parser/test/utils.ts +++ b/packages/multipart-parser/test/utils.ts @@ -1,4 +1,8 @@ -import { ContentDisposition, ContentType, stringifyRawHeaders } from '@remix-run/headers' +import { + ContentDisposition, + ContentType, + stringify as stringifyRawHeaders, +} from '@remix-run/headers' export type PartValue = | string From 4b09a913106082c6220c78db5dc6f24b876daabb Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 7 Jan 2026 09:41:09 +1100 Subject: [PATCH 13/15] Copy request headers into fetch router request context --- .../src/lib/request-context.test.ts | 19 ++++++++++++++++++- .../fetch-router/src/lib/request-context.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/fetch-router/src/lib/request-context.test.ts b/packages/fetch-router/src/lib/request-context.test.ts index 0dda4cb6ba2..da19599c2f4 100644 --- a/packages/fetch-router/src/lib/request-context.test.ts +++ b/packages/fetch-router/src/lib/request-context.test.ts @@ -12,7 +12,24 @@ describe('new RequestContext()', () => { let context = new RequestContext(req) assert.equal(context.headers.get('content-type'), 'application/json') - assert.equal(context.headers, context.request.headers) + }) + + it('provides a copy of request headers that can be mutated independently', () => { + let req = new Request('https://remix.run/test', { + headers: { 'X-Original': 'value' }, + }) + let context = new RequestContext(req) + + context.headers.set('X-New', 'new-value') + context.headers.delete('X-Original') + + // context.headers was mutated + assert.equal(context.headers.get('X-New'), 'new-value') + assert.equal(context.headers.get('X-Original'), null) + + // original request.headers unchanged + assert.equal(req.headers.get('X-Original'), 'value') + assert.equal(req.headers.get('X-New'), null) }) it('does not provide formData on GET requests', () => { diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index 8b84f41638e..6111f5a501f 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -19,7 +19,7 @@ export class RequestContext< * @param request The incoming request */ constructor(request: Request) { - this.headers = request.headers + this.headers = new Headers(request.headers) this.method = request.method.toUpperCase() as RequestMethod this.params = {} as params this.request = request From 49dde6bd859344f9317e25775d008693a6e4f1c4 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 9 Jan 2026 15:19:16 +1100 Subject: [PATCH 14/15] Fix lint error --- packages/multipart-parser/src/lib/multipart.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/multipart-parser/src/lib/multipart.ts b/packages/multipart-parser/src/lib/multipart.ts index 6726ccec9d7..82c3227df9a 100644 --- a/packages/multipart-parser/src/lib/multipart.ts +++ b/packages/multipart-parser/src/lib/multipart.ts @@ -320,6 +320,8 @@ export class MultipartParser { * * Note: This will throw if the multipart message is incomplete or * wasn't properly terminated. + * + * @returns void */ finish(): void { if (this.#state !== MultipartParserStateDone) { From b514da9030fed3f7ef5820eab033c08a223b09ef Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 9 Jan 2026 15:21:05 +1100 Subject: [PATCH 15/15] Remove redundant return statement --- packages/multipart-parser/src/lib/multipart.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/multipart-parser/src/lib/multipart.ts b/packages/multipart-parser/src/lib/multipart.ts index 82c3227df9a..c78c0eee1f2 100644 --- a/packages/multipart-parser/src/lib/multipart.ts +++ b/packages/multipart-parser/src/lib/multipart.ts @@ -320,14 +320,11 @@ export class MultipartParser { * * Note: This will throw if the multipart message is incomplete or * wasn't properly terminated. - * - * @returns void */ finish(): void { if (this.#state !== MultipartParserStateDone) { throw new MultipartParseError('Multipart stream not finished') } - return undefined } }