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..9a0a4f53c90 --- /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 +// Before: +router.get('/api/users', (context) => { + let acceptsJson = context.headers.accept.accepts('application/json') + // ... +}) + +// After: +import { Accept } from '@remix-run/headers' + +router.get('/api/users', (context) => { + let accept = Accept.from(context.headers.get('accept')) + let acceptsJson = accept.accepts('application/json') + // ... +}) +``` diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 0a82b338fa2..bf4b62d674a 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 `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/fetch-router/package.json b/packages/fetch-router/package.json index a3de151e510..5886b63cb83 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..da19599c2f4 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,25 @@ 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') + }) + + 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 7f24d581ebc..6111f5a501f 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 = new 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..7c915dbb5af --- /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 static `from()` method on each header class instead. + +New individual header `.from()` methods: + +- `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: + +- `parse()` +- `stringify()` + +Migration example: + +```ts +// Before: +import SuperHeaders from '@remix-run/headers' +let headers = new SuperHeaders(request.headers) +let mediaType = headers.contentType.mediaType + +// After: +import { ContentType } from '@remix-run/headers' +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 `parse()` instead: + +```ts +// Before: +import SuperHeaders from '@remix-run/headers' +let headers = new SuperHeaders('Content-Type: text/html\r\nCache-Control: no-cache') + +// After: +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 `stringify()` instead: + +```ts +// Before: +import SuperHeaders from '@remix-run/headers' +let headers = new SuperHeaders() +headers.set('Content-Type', 'text/html') +let rawHeaders = headers.toString() + +// After: +import { stringify } from '@remix-run/headers' +let headers = new Headers() +headers.set('Content-Type', 'text/html') +let rawHeaders = stringify(headers) +``` diff --git a/packages/headers/README.md b/packages/headers/README.md index 934dbb7c1a8..1bea3436594 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,548 @@ 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' - -headers.acceptLanguage.languages // [ 'en-us', 'en' ] -Object.fromEntries(headers.acceptLanguage.entries()) // { 'en-us': 1, en: 0.9 } - -headers.acceptLanguage.accepts('en') // true -headers.acceptLanguage.accepts('ja') // false - -headers.acceptLanguage.getPreferred(['en-US', 'en-GB']) // 'en-US' -headers.acceptLanguage.getPreferred(['en', 'fr']) // 'en' - -// Accept-Ranges -headers.acceptRanges = 'bytes' +## Individual Header Utilities -// Allow -headers.allow = ['GET', 'POST', 'PUT'] -headers.get('Allow') // 'GET, POST, PUT' +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. -// Connection -headers.connection = 'close' +The following headers are currently supported: -// Content-Type -headers.contentType = 'application/json; charset=utf-8' +- [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) -headers.contentType.mediaType // "application/json" -headers.contentType.charset // "utf-8" - -headers.contentType.charset = 'iso-8859-1' - -headers.get('Content-Type') // "application/json; charset=iso-8859-1" - -// Content-Disposition -headers.contentDisposition = - 'attachment; filename="example.pdf"; filename*=UTF-8\'\'%E4%BE%8B%E5%AD%90.pdf' - -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' - -// Cookie -headers.cookie = 'session_id=abc123; user_id=12345' - -headers.cookie.get('session_id') // 'abc123' -headers.cookie.get('user_id') // '12345' - -headers.cookie.set('theme', 'dark') -headers.get('Cookie') // 'session_id=abc123; user_id=12345; theme=dark' - -// Host -headers.host = 'example.com' - -// If-Match -headers.ifMatch = ['67ab43', '54ed21'] -headers.get('If-Match') // '"67ab43", "54ed21"' +### Accept -headers.ifMatch.matches('67ab43') // true -headers.ifMatch.matches('abc123') // false +Parse, manipulate and stringify [`Accept` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept). -// If-None-Match -headers.ifNoneMatch = ['67ab43', '54ed21'] -headers.get('If-None-Match') // '"67ab43", "54ed21"' +Implements `Map`. -headers.ifNoneMatch.matches('67ab43') // true -headers.ifNoneMatch.matches('abc123') // false +```ts +import { Accept } from '@remix-run/headers' -// If-Range -headers.ifRange = new Date('2021-01-01T00:00:00Z') -headers.get('If-Range') // 'Fri, 01 Jan 2021 00:00:00 GMT' +// Parse from headers +let accept = Accept.from(request.headers.get('accept')) -headers.ifRange.matches({ lastModified: 1609459200000 }) // true (timestamp) -headers.ifRange.matches({ lastModified: new Date('2021-01-01T00:00:00Z') }) // true (Date) +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' -// 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' +// Iterate +for (let [mediaType, quality] of accept) { + // ... +} -// Location -headers.location = 'https://example.com' +// Modify and set header +accept.set('application/json', 0.8) +accept.delete('text/*') +headers.set('Accept', accept) -// Range -headers.range = 'bytes=200-1000' +// 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]]) -headers.range.unit // "bytes" -headers.range.ranges // [{ start: 200, end: 1000 }] -headers.range.canSatisfy(2000) // true +// 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 })) +``` -// Referer -headers.referer = 'https://example.com/' +### Accept-Encoding -// Set-Cookie -headers.setCookie = ['session_id=abc123; Path=/; HttpOnly'] +Parse, manipulate and stringify [`Accept-Encoding` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding). -headers.setCookie[0].name // 'session_id' -headers.setCookie[0].value // 'abc123' -headers.setCookie[0].path // '/' -headers.setCookie[0].httpOnly // true +Implements `Map`. -// Modifying Set-Cookie attributes -headers.setCookie[0].maxAge = 3600 -headers.setCookie[0].secure = true +```ts +import { AcceptEncoding } from '@remix-run/headers' -headers.get('Set-Cookie') // 'session_id=abc123; Path=/; HttpOnly; Max-Age=3600; Secure' +// Parse from headers +let acceptEncoding = AcceptEncoding.from(request.headers.get('accept-encoding')) -// Setting multiple cookies is easy, it's just an array -headers.setCookie.push('user_id=12345; Path=/api; Secure') -// or headers.setCookie = [...headers.setCookie, '...'] +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' -// 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 -``` +// Modify and set header +acceptEncoding.set('br', 1) +acceptEncoding.delete('deflate') +headers.set('Accept-Encoding', acceptEncoding) -`Headers` can be initialized with an object config: +// Construct directly +new AcceptEncoding('gzip, deflate;q=0.8') +new AcceptEncoding({ gzip: 1, deflate: 0.8 }) -```ts +// Use class for type safety when setting Headers values +// via AcceptEncoding's `.toString()` method 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') }, - ], + 'Accept-Encoding': new AcceptEncoding({ gzip: 1, br: 0.9 }), }) - -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 +headers.set('Accept-Encoding', new AcceptEncoding({ gzip: 1, br: 0.9 })) ``` -`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`. +### Accept-Language -```ts -import Headers from '@remix-run/headers' +Parse, manipulate and stringify [`Accept-Language` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). -// Use in a fetch() -let response = await fetch('https://example.com', { - headers: new Headers(), -}) +Implements `Map`. -// Convert from DOM Headers -let headers = new Headers(response.headers) +```ts +import { AcceptLanguage } from '@remix-run/headers' -headers.set('Content-Type', 'text/html') -headers.get('Content-Type') // "text/html" -``` +// Parse from headers +let acceptLanguage = AcceptLanguage.from(request.headers.get('accept-language')) -If you're familiar with using DOM `Headers`, everything works as you'd expect. +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' -`Headers` are iterable: +// Modify and set header +acceptLanguage.set('fr', 0.5) +acceptLanguage.delete('en') +headers.set('Accept-Language', acceptLanguage) -```ts +// 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({ - 'Content-Type': 'application/json', - 'X-API-Key': 'secret-key', - 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Language': new AcceptLanguage({ 'en-US': 1, fr: 0.5 }), }) - -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 +headers.set('Accept-Language', new AcceptLanguage({ 'en-US': 1, fr: 0.5 })) ``` -If you're assembling HTTP messages, you can easily convert to a multiline string suitable for using as a Request/Response header block: +### Cache-Control + +Parse, manipulate and stringify [`Cache-Control` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). ```ts +import { CacheControl } from '@remix-run/headers' + +// Parse from headers +let cacheControl = CacheControl.from(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 }) + +// Use class for type safety when setting Headers values +// via CacheControl's `.toString()` method let headers = new Headers({ - 'Content-Type': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': new CacheControl({ public: true, maxAge: 3600 }), }) - -console.log(`${headers}`) -// Content-Type: application/json -// Accept-Language: en-US,en;q=0.9 +headers.set('Cache-Control', new CacheControl({ public: true, maxAge: 3600 })) ``` -## Individual Header Utility Classes - -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. - -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. +### Content-Disposition -### Accept +Parse, manipulate and stringify [`Content-Disposition` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition). ```ts -import { Accept } from '@remix-run/headers' - -let header = new Accept('text/html;text/*;q=0.9') +import { ContentDisposition } from '@remix-run/headers' -header.has('text/html') // true -header.has('text/plain') // false +// Parse from headers +let contentDisposition = ContentDisposition.from(response.headers.get('content-disposition')) -header.accepts('text/html') // true -header.accepts('text/plain') // true -header.accepts('text/*') // true -header.accepts('image/jpeg') // false +contentDisposition.type // 'attachment' +contentDisposition.filename // 'example.pdf' +contentDisposition.filenameSplat // "UTF-8''%E4%BE%8B%E5%AD%90.pdf" +contentDisposition.preferredFilename // '例子.pdf' (decoded from filename*) -header.getPreferred(['text/html', 'text/plain']) // 'text/html' +// Modify and set header +contentDisposition.filename = 'download.pdf' +headers.set('Content-Disposition', contentDisposition) -for (let [mediaType, quality] of header) { - // ... -} +// Construct directly +new ContentDisposition('attachment; filename="example.pdf"') +new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }) -// Alternative init styles -let header = new Accept({ 'text/html': 1, 'text/*': 0.9 }) -let header = new Accept(['text/html', ['text/*', 0.9]]) +// 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' }), +) ``` -### Accept-Encoding - -```ts -import { AcceptEncoding } from '@remix-run/headers' - -let header = new AcceptEncoding('gzip,deflate;q=0.9') - -header.has('gzip') // true -header.has('br') // false - -header.accepts('gzip') // true -header.accepts('deflate') // true -header.accepts('identity') // true -header.accepts('br') // true - -header.getPreferred(['gzip', 'deflate']) // 'gzip' - -for (let [encoding, weight] of header) { - // ... -} +### Content-Range -// Alternative init styles -let header = new AcceptEncoding({ gzip: 1, deflate: 0.9 }) -let header = new AcceptEncoding(['gzip', ['deflate', 0.9]]) -``` - -### Accept-Language +Parse, manipulate and stringify [`Content-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range). ```ts -import { AcceptLanguage } from '@remix-run/headers' - -let header = new AcceptLanguage('en-US,en;q=0.9') - -header.has('en-US') // true -header.has('en-GB') // false - -header.accepts('en-US') // true -header.accepts('en-GB') // true -header.accepts('en') // true -header.accepts('fr') // true - -header.getPreferred(['en-US', 'en-GB']) // 'en-US' -header.getPreferred(['en', 'fr']) // 'en' - -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]]) -``` - -### Cache-Control +import { ContentRange } from '@remix-run/headers' -```ts -import { CacheControl } from '@remix-run/headers' +// Parse from headers +let contentRange = ContentRange.from(response.headers.get('content-range')) -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 -``` +contentRange.unit // "bytes" +contentRange.start // 200 +contentRange.end // 1000 +contentRange.size // 67589 -### Content-Disposition +// Unsatisfied range +let unsatisfied = ContentRange.from('bytes */67589') +unsatisfied.start // null +unsatisfied.end // null +unsatisfied.size // 67589 -```ts -import { ContentDisposition } from '@remix-run/headers' +// Construct directly +new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }) -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', +// 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 +Parse, manipulate and stringify [`Content-Type` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type). + ```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" +// Parse from headers +let contentType = ContentType.from(request.headers.get('content-type')) -// Alternative init style -let header = new ContentType({ - mediaType: 'multipart/form-data', - boundary: '------WebKitFormBoundary12345', - charset: 'utf-8', -}) -``` +contentType.mediaType // "text/html" +contentType.charset // "utf-8" +contentType.boundary // undefined (or boundary string for multipart) -### Content-Range +// Modify and set header +contentType.charset = 'iso-8859-1' +headers.set('Content-Type', contentType) -```ts -import { ContentRange } from '@remix-run/headers' +// Construct directly +new ContentType('text/html; charset=utf-8') +new ContentType({ mediaType: 'text/html', charset: 'utf-8' }) -// Satisfied range -let header = new ContentRange('bytes 200-1000/67589') -header.unit // "bytes" -header.start // 200 -header.end // 1000 -header.size // 67589 - -// 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, +// 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 +Parse, manipulate and stringify [`Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie). + +Implements `Map`. + ```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 +// Parse from headers +let cookie = Cookie.from(request.headers.get('cookie')) -// Iterate over cookie name/value pairs -for (let [name, value] of header) { +cookie.get('session_id') // 'abc123' +cookie.get('theme') // 'dark' +cookie.has('session_id') // true +cookie.size // 2 + +// Iterate +for (let [name, value] of cookie) { // ... } -// Alternative init styles -let header = new Cookie({ theme: 'dark', session_id: '123' }) -let header = new Cookie([ +// Modify and set header +cookie.set('theme', 'light') +cookie.delete('session_id') +headers.set('Cookie', cookie) + +// Construct directly +new Cookie('session_id=abc123; theme=dark') +new Cookie({ session_id: 'abc123', theme: 'dark' }) +new Cookie([ + ['session_id', 'abc123'], ['theme', 'dark'], - ['session_id', '123'], ]) + +// 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 +Parse, manipulate and stringify [`If-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match). + +Implements `Set`. + ```ts import { IfMatch } from '@remix-run/headers' -let header = new IfMatch('"67ab43", "54ed21"') - -header.has('67ab43') // true -header.has('21ba69') // false +// Parse from headers +let ifMatch = IfMatch.from(request.headers.get('if-match')) -// Check if precondition passes -header.matches('"67ab43"') // true -header.matches('"abc123"') // false +ifMatch.tags // ['"67ab43"', '"54ed21"'] +ifMatch.has('"67ab43"') // true +ifMatch.matches('"67ab43"') // true (checks precondition) +ifMatch.matches('"abc123"') // false // Note: Uses strong comparison only (weak ETags never match) -let weakHeader = new IfMatch('W/"67ab43"') -weakHeader.matches('W/"67ab43"') // false +let weak = IfMatch.from('W/"67ab43"') +weak.matches('W/"67ab43"') // false + +// Modify and set header +ifMatch.add('"newetag"') +ifMatch.delete('"67ab43"') +headers.set('If-Match', ifMatch) + +// Construct directly +new IfMatch(['abc123', 'def456']) -// Alternative init styles -let header = new IfMatch(['67ab43', '54ed21']) -let header = new IfMatch({ - tags: ['67ab43', '54ed21'], +// 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 +Parse, manipulate and stringify [`If-None-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match). + +Implements `Set`. + ```ts import { IfNoneMatch } from '@remix-run/headers' -let header = new IfNoneMatch('"67ab43", "54ed21"') +// Parse from headers +let ifNoneMatch = IfNoneMatch.from(request.headers.get('if-none-match')) -header.has('67ab43') // true -header.has('21ba69') // false +ifNoneMatch.tags // ['"67ab43"', '"54ed21"'] +ifNoneMatch.has('"67ab43"') // true +ifNoneMatch.matches('"67ab43"') // true -header.matches('"67ab43"') // true +// Supports weak comparison (unlike If-Match) +let weak = IfNoneMatch.from('W/"67ab43"') +weak.matches('W/"67ab43"') // true -// Alternative init styles -let header = new IfNoneMatch(['67ab43', '54ed21']) -let header = new IfNoneMatch({ - tags: ['67ab43', '54ed21'], +// Modify and set header +ifNoneMatch.add('"newetag"') +ifNoneMatch.delete('"67ab43"') +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 +Parse, manipulate and stringify [`If-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range). + ```ts import { IfRange } from '@remix-run/headers' -// 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) +// Parse from headers +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 -// Initialize with Date object -let header = new IfRange(new Date('2021-01-01T00:00:00Z')) -header.matches({ lastModified: 1609459200000 }) // true +// With ETag +let etagHeader = IfRange.from('"67ab43"') +etagHeader.matches({ etag: '"67ab43"' }) // true -// Initialize with strong ETag -let header = new IfRange('"67ab43"') -header.matches({ etag: '"67ab43"' }) // true +// Empty/null returns empty instance (range proceeds unconditionally) +let empty = IfRange.from(null) +empty.matches({ etag: '"any"' }) // true -// Never matches weak ETags -let weakHeader = new IfRange('W/"67ab43"') -header.matches({ etag: 'W/"67ab43"' }) // false +// Construct directly +new IfRange('"abc123"') -// Returns true if header is not present (range should proceed unconditionally) -let emptyHeader = new IfRange('') -emptyHeader.matches({ etag: '"67ab43"' }) // true +// 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 +Parse, manipulate and stringify [`Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range). + ```ts import { Range } from '@remix-run/headers' -let header = new Range('bytes=200-1000') +// Parse from headers +let range = Range.from(request.headers.get('range')) -header.unit // "bytes" -header.ranges // [{ start: 200, end: 1000 }] - -// Check if ranges can be satisfied for a given file size -header.canSatisfy(2000) // true -header.canSatisfy(500) // false (end is beyond file size) +range.unit // "bytes" +range.ranges // [{ start: 200, end: 1000 }] +range.canSatisfy(2000) // true +range.canSatisfy(500) // false +range.normalize(2000) // [{ start: 200, end: 1000 }] // 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 }, - ], +let multi = Range.from('bytes=0-499, 1000-1499') +multi.ranges.length // 2 + +// Suffix range (last N bytes) +let suffix = Range.from('bytes=-500') +suffix.normalize(2000) // [{ start: 1500, end: 1999 }] + +// 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 +Parse, manipulate and stringify [`Set-Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie). + ```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({ +// Parse from headers +let setCookie = SetCookie.from(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', - domain: 'example.com', path: '/', - secure: true, 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 + +Parse, manipulate and stringify [`Vary` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary). + +Implements `Set`. + +```ts +import { Vary } from '@remix-run/headers' + +// Parse from headers +let vary = Vary.from(response.headers.get('vary')) + +vary.headerNames // ['accept-encoding', 'accept-language'] +vary.has('Accept-Encoding') // true (case-insensitive) +vary.size // 2 + +// Modify and set header +vary.add('User-Agent') +vary.delete('Accept-Language') +headers.set('Vary', vary) + +// Construct directly +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 + +Parse and stringify raw HTTP header strings. + +```ts +import { parse, stringify } from '@remix-run/headers' + +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) +// '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..6742c449300 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -12,9 +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 { - type SuperHeadersInit, - SuperHeaders, - SuperHeaders as default, -} from './lib/super-headers.ts' +export { parse, stringify } 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 b869aabfe67..29865e119ba 100644 --- a/packages/headers/src/lib/accept-encoding.test.ts +++ b/packages/headers/src/lib/accept-encoding.test.ts @@ -149,3 +149,11 @@ describe('Accept-Encoding', () => { assert.equal(header.toString(), 'br,deflate;q=0.9,gzip;q=0.8') }) }) + +describe('AcceptEncoding.from', () => { + it('parses a string value', () => { + 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 279a449f897..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,4 +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) + */ + 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 075113b1888..090204fddac 100644 --- a/packages/headers/src/lib/accept-language.test.ts +++ b/packages/headers/src/lib/accept-language.test.ts @@ -162,3 +162,11 @@ describe('Accept-Language', () => { assert.equal(header.toString(), 'fi,en;q=0.9,en-us;q=0.8') }) }) + +describe('AcceptLanguage.from', () => { + it('parses a string value', () => { + 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 9e1b138f9ee..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,4 +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) + */ + 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 d1aa7e16681..d6bbb513e35 100644 --- a/packages/headers/src/lib/accept.test.ts +++ b/packages/headers/src/lib/accept.test.ts @@ -160,3 +160,25 @@ describe('Accept', () => { assert.deepEqual(header.mediaTypes, ['text/html', 'application/json']) }) }) + +describe('Accept.from', () => { + it('parses a string value', () => { + 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) + assert.equal(result.getWeight('application/json'), 0.9) + }) + + it('returns empty instance for null', () => { + let result = Accept.from(null) + assert.ok(result instanceof Accept) + assert.equal(result.size, 0) + }) + + it('accepts init object', () => { + 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 9a30722b67d..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,4 +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) + */ + 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 68f4954bd2f..43761e60a47 100644 --- a/packages/headers/src/lib/cache-control.test.ts +++ b/packages/headers/src/lib/cache-control.test.ts @@ -93,3 +93,19 @@ describe('CacheControl', () => { assert.equal(header.toString(), 'max-age=0') }) }) + +describe('CacheControl.from', () => { + it('parses a string value', () => { + 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 = 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 03fc120a654..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,4 +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) + */ + 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 71ab2854c6e..d7f40296192 100644 --- a/packages/headers/src/lib/content-disposition.test.ts +++ b/packages/headers/src/lib/content-disposition.test.ts @@ -217,3 +217,12 @@ describe('ContentDisposition', () => { }) }) }) + +describe('ContentDisposition.from', () => { + it('parses a string value', () => { + 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 b9c02298d42..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 { diff --git a/packages/headers/src/lib/content-range.test.ts b/packages/headers/src/lib/content-range.test.ts index f7ec1463e85..d2e812da2dd 100644 --- a/packages/headers/src/lib/content-range.test.ts +++ b/packages/headers/src/lib/content-range.test.ts @@ -159,3 +159,14 @@ describe('ContentRange', () => { assert.equal(contentRange.toString(), 'bytes 0-0/1') }) }) + +describe('ContentRange.from', () => { + it('parses a string value', () => { + let result = ContentRange.from('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-range.ts b/packages/headers/src/lib/content-range.ts index 5ee97ea09c4..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,4 +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) + */ + 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 ec6165332fd..92e25494880 100644 --- a/packages/headers/src/lib/content-type.test.ts +++ b/packages/headers/src/lib/content-type.test.ts @@ -106,3 +106,19 @@ describe('ContentType', () => { assert.equal(header.toString(), 'multipart/form-data; charset=utf-8; boundary=abc123') }) }) + +describe('ContentType.from', () => { + it('parses a string value', () => { + 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 = 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 4feb3ce8f07..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,4 +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) + */ + 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 f3a6207c4eb..d1dca9b611f 100644 --- a/packages/headers/src/lib/cookie.test.ts +++ b/packages/headers/src/lib/cookie.test.ts @@ -142,3 +142,12 @@ describe('Cookie', () => { assert.equal(header.get('name'), 'value2') }) }) + +describe('Cookie.from', () => { + it('parses a string value', () => { + 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 f7391716038..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,4 +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) + */ + 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 d50538bc3b8..dcb664b0580 100644 --- a/packages/headers/src/lib/if-match.test.ts +++ b/packages/headers/src/lib/if-match.test.ts @@ -134,3 +134,11 @@ describe('IfMatch', () => { }) }) }) + +describe('IfMatch.from', () => { + it('parses a string value', () => { + 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 475dd7c7168..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,4 +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) + */ + 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 58d997827c5..784129d142a 100644 --- a/packages/headers/src/lib/if-none-match.test.ts +++ b/packages/headers/src/lib/if-none-match.test.ts @@ -92,3 +92,11 @@ describe('IfNoneMatch', () => { assert.equal(header.toString(), 'W/"67ab43", "54ed21"') }) }) + +describe('IfNoneMatch.from', () => { + it('parses a string value', () => { + 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 04930515a6e..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,4 +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) + */ + 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 c7c0d8ff74c..b70fbac039e 100644 --- a/packages/headers/src/lib/if-range.test.ts +++ b/packages/headers/src/lib/if-range.test.ts @@ -156,3 +156,18 @@ describe('IfRange', () => { }) }) }) + +describe('IfRange.from', () => { + it('parses a string value', () => { + 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 = 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 0a822f3e034..370cae8a958 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,4 +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) + */ + 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 1865bfc73b5..85c869516e0 100644 --- a/packages/headers/src/lib/range.test.ts +++ b/packages/headers/src/lib/range.test.ts @@ -248,3 +248,12 @@ describe('Range', () => { }) }) }) + +describe('Range.from', () => { + it('parses a string value', () => { + 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 ed98f18dd7e..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,4 +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) + */ + 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/raw-headers.test.ts b/packages/headers/src/lib/raw-headers.test.ts new file mode 100644 index 00000000000..396eaf03018 --- /dev/null +++ b/packages/headers/src/lib/raw-headers.test.ts @@ -0,0 +1,95 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parse as parseRawHeaders, stringify as 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('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', () => { + 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..0eed1c96892 --- /dev/null +++ b/packages/headers/src/lib/raw-headers.ts @@ -0,0 +1,48 @@ +import { canonicalHeaderName } from './header-names.ts' + +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 = parse('Content-Type: text/html\r\nCache-Control: no-cache') + * headers.get('content-type') // 'text/html' + * headers.get('cache-control') // 'no-cache' + */ +export function parse(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' }) + * stringify(headers) // 'Content-Type: text/html\r\nCache-Control: no-cache' + */ +export function stringify(headers: Headers): string { + let result = '' + + for (let [name, value] of headers) { + if (result) result += CRLF + result += `${canonicalHeaderName(name)}: ${value}` + } + + return result +} diff --git a/packages/headers/src/lib/set-cookie.test.ts b/packages/headers/src/lib/set-cookie.test.ts index f27f1352bad..b738c031d07 100644 --- a/packages/headers/src/lib/set-cookie.test.ts +++ b/packages/headers/src/lib/set-cookie.test.ts @@ -218,3 +218,14 @@ describe('SetCookie', () => { assert.equal(header.toString(), 'test="need; quotes"') }) }) + +describe('SetCookie.from', () => { + it('parses a string value', () => { + let result = SetCookie.from('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/set-cookie.ts b/packages/headers/src/lib/set-cookie.ts index 8a7cdbb0fe8..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,4 +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) + */ + 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/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 550d89d6078..00000000000 --- a/packages/headers/src/lib/super-headers.ts +++ /dev/null @@ -1,970 +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.test.ts b/packages/headers/src/lib/vary.test.ts index bbd707c6e47..3029eb4e9a3 100644 --- a/packages/headers/src/lib/vary.test.ts +++ b/packages/headers/src/lib/vary.test.ts @@ -144,3 +144,19 @@ describe('Vary', () => { assert.deepEqual(names, ['accept-encoding', 'accept-language']) }) }) + +describe('Vary.from', () => { + it('parses a string value', () => { + 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) + assert.equal(result.has('Accept-Language'), true) + }) + + it('parses an array value', () => { + 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 ba0049f93fb..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,4 +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) + */ + 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 + } } 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 new file mode 100644 index 00000000000..a8f68378fed --- /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 methods. diff --git a/packages/multipart-parser/src/lib/multipart.ts b/packages/multipart-parser/src/lib/multipart.ts index 3d277040ca3..c78c0eee1f2 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 { ContentDisposition, ContentType, parse as parseRawHeaders } from '@remix-run/headers' import { createSearch, @@ -379,7 +379,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 @@ -403,21 +403,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 ContentDisposition.from(this.headers.get('content-disposition')).preferredFilename } /** * The media type of the part. */ get mediaType(): string | undefined { - return this.headers.contentType.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 this.headers.contentDisposition.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 b874cc4d14a..94c85fd78b8 100644 --- a/packages/multipart-parser/test/utils.ts +++ b/packages/multipart-parser/test/utils.ts @@ -1,4 +1,8 @@ -import SuperHeaders from '@remix-run/headers' +import { + ContentDisposition, + ContentType, + stringify as stringifyRawHeaders, +} from '@remix-run/headers' export type PartValue = | string @@ -28,31 +32,31 @@ export function createMultipartMessage( pushLine(`--${boundary}`) if (typeof value === 'string') { - let headers = new SuperHeaders({ - contentDisposition: { + let headers = new Headers({ + 'Content-Disposition': ContentDisposition.from({ type: 'form-data', name, - }, + }).toString(), }) - pushLine(`${headers}`) + pushLine(stringifyRawHeaders(headers)) pushLine() pushLine(value) } else { - let headers = new SuperHeaders({ - contentDisposition: { + let headers = new Headers({ + 'Content-Disposition': ContentDisposition.from({ type: 'form-data', name, filename: value.filename, filenameSplat: value.filenameSplat, - }, + }).toString(), }) if (value.mediaType) { - headers.contentType = value.mediaType + headers.set('Content-Type', ContentType.from({ mediaType: value.mediaType }).toString()) } - pushLine(`${headers}`) + pushLine(stringifyRawHeaders(headers)) pushLine() if (typeof value.content === 'string') { pushLine(value.content) 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 new file mode 100644 index 00000000000..a8f68378fed --- /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 methods. diff --git a/packages/response/src/lib/compress.test.ts b/packages/response/src/lib/compress.test.ts index 663cd7f8041..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 { SuperHeaders } from '@remix-run/headers' +import { Vary } 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 = Vary.from(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 = Vary.from(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 = Vary.from(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 = 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 bea5ecd2b03..ab326aa56f1 100644 --- a/packages/response/src/lib/compress.ts +++ b/packages/response/src/lib/compress.ts @@ -9,7 +9,7 @@ import { } from 'node:zlib' import type { BrotliOptions, ZlibOptions } from 'node:zlib' -import { AcceptEncoding, SuperHeaders } from '@remix-run/headers' +import { AcceptEncoding, CacheControl, Vary } from '@remix-run/headers' export type Encoding = 'br' | 'gzip' | 'deflate' const defaultEncodings: Encoding[] = ['br', 'gzip', 'deflate'] @@ -84,7 +84,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 = CacheControl.from(responseHeaders.get('cache-control')) if ( !acceptEncodingHeader || @@ -92,20 +98,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 = AcceptEncoding.from(acceptEncodingHeader) let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings) if (selectedEncoding === null) { // Client has explicitly rejected all supported encodings, including 'identity' @@ -155,15 +161,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 = Vary.from(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 +188,7 @@ const brotliFlushOptions = { function applyCompression( response: Response, - responseHeaders: SuperHeaders, + responseHeaders: Headers, encoding: Encoding, options: CompressResponseOptions, ): Response { @@ -186,8 +197,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, { diff --git a/packages/response/src/lib/file.ts b/packages/response/src/lib/file.ts index 1bd7f99a806..5053f76c0dd 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, + IfMatch, + IfNoneMatch, + IfRange, + Range, +} from '@remix-run/headers' import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime' /** @@ -131,7 +138,7 @@ export async function createFileResponse( acceptRanges: acceptRangesOption, } = options - let headers = new SuperHeaders(request.headers) + let headers = request.headers let contentType = mimeTypeToContentType(file.type) let contentLength = file.size @@ -162,33 +169,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 = IfMatch.from(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, + }), }) } } @@ -198,12 +205,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 = IfNoneMatch.from(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 } @@ -213,13 +222,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, + }), }) } } @@ -227,7 +234,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 = 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) { @@ -237,8 +244,9 @@ export async function createFileResponse( } // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range + let ifRange = IfRange.from(headers.get('if-range')) if ( - headers.ifRange.matches({ + ifRange.matches({ etag, lastModified, }) @@ -246,8 +254,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: ContentRange.from({ unit: 'bytes', size: file.size }), }), }) } @@ -258,8 +266,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: ContentRange.from({ unit: 'bytes', size: file.size }), }), }) } @@ -269,33 +277,29 @@ export async function createFileResponse( return new Response(file.slice(start, end + 1).stream(), { 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.stream(), { status: 200, - headers: new SuperHeaders( - omitNullableValues({ - contentType, - contentLength, - etag, - lastModified, - cacheControl, - acceptRanges, - }), - ), + headers: buildResponseHeaders({ + contentType, + contentLength, + etag, + lastModified, + cacheControl, + acceptRanges, + }), }) } @@ -303,18 +307,43 @@ function generateWeakETag(file: FileLike): 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) + } + if (values.contentLength != null) { + headers.set('Content-Length', String(values.contentLength)) + } + if (values.contentRange) { + let str = ContentRange.from(values.contentRange).toString() + if (str) headers.set('Content-Range', str) + } + if (values.etag) { + headers.set('ETag', values.etag) } - return result + 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 } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d50d484647a..3ceae766b7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,9 +355,6 @@ importers: packages/fetch-router: dependencies: - '@remix-run/headers': - specifier: workspace:^ - version: link:../headers '@remix-run/route-pattern': specifier: workspace:^ version: link:../route-pattern