1/**
2 * Models HTTP cookies and cookie collections for requests and responses.
3 *
4 * A `Cookie` stores a name, value, encoded value, and standard cookie
5 * attributes. A `Cookies` value is an immutable collection keyed by cookie
6 * name. This module parses request `Cookie` headers, builds response
7 * `Set-Cookie` headers, and provides helpers for adding, removing, merging, and
8 * expiring cookies.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as Data from "../Data.ts"
14import * as Duration from "../Duration.ts"
15import { dual } from "../Function.ts"
16import * as Inspectable from "../Inspectable.ts"
17import * as InternalRecord from "../internal/record.ts"
18import * as Option from "../Option.ts"
19import { type Pipeable, pipeArguments } from "../Pipeable.ts"
20import * as Predicate from "../Predicate.ts"
21import * as Record from "../Record.ts"
22import * as Result from "../Result.ts"
23import type * as Types from "../Types.ts"
24
25const TypeId = "~effect/http/Cookies"
26
27/**
28 * Returns `true` when a value is a `Cookies` collection.
29 *
30 * @stability unstable
31 * @category guards
32 * @since 4.0.0
33 */
34export const isCookies = (u: unknown): u is Cookies => Predicate.hasProperty(u, TypeId)
35
36/**
37 * Immutable collection of HTTP cookies keyed by cookie name.
38 *
39 * @stability unstable
40 * @category models
41 * @since 4.0.0
42 */
43export interface Cookies extends Pipeable, Inspectable.Inspectable {
44 readonly [TypeId]: typeof TypeId
45 readonly cookies: Record.ReadonlyRecord<string, Cookie>
46}
47
48const CookieTypeId = "~effect/http/Cookies/Cookie"
49
50/**
51 * HTTP cookie value with its decoded value, encoded value, and optional cookie
52 * attributes such as domain, path, expiration, security, and same-site settings.
53 *
54 * @stability unstable
55 * @category cookies
56 * @since 4.0.0
57 */
58export interface Cookie extends Inspectable.Inspectable {
59 readonly [CookieTypeId]: typeof CookieTypeId
60 readonly name: string
61 readonly value: string
62 readonly valueEncoded: string
63 readonly options?: {
64 readonly domain?: string | undefined
65 readonly expires?: Date | undefined
66 readonly maxAge?: Duration.Input | undefined
67 readonly path?: string | undefined
68 readonly priority?: "low" | "medium" | "high" | undefined
69 readonly httpOnly?: boolean | undefined
70 readonly secure?: boolean | undefined
71 readonly partitioned?: boolean | undefined
72 readonly sameSite?: "lax" | "strict" | "none" | undefined
73 } | undefined
74}
75
76/**
77 * Returns `true` when a value is a `Cookie`.
78 *
79 * @stability unstable
80 * @category guards
81 * @since 4.0.0
82 */
83export const isCookie = (u: unknown): u is Cookie => Predicate.hasProperty(u, CookieTypeId)
84
85const CookieErrorTypeId = "~effect/http/Cookies/CookieError"
86
87/**
88 * Error reason describing why cookie construction failed, such as invalid name,
89 * value, domain, path, or infinite max-age.
90 *
91 * @stability unstable
92 * @category errors
93 * @since 4.0.0
94 */
95export class CookiesErrorReason extends Data.Error<{
96 readonly _tag:
97 | "InvalidCookieName"
98 | "InvalidCookieValue"
99 | "InvalidCookieDomain"
100 | "InvalidCookiePath"
101 | "CookieInfinityMaxAge"
102 readonly cause?: unknown
103}> {}
104
105/**
106 * Error returned when a cookie name, value, domain, path, or max-age option is invalid.
107 *
108 * **Details**
109 *
110 * Inspect `reason` to determine the specific validation failure.
111 *
112 * @stability unstable
113 * @category errors
114 * @since 4.0.0
115 */
116export class CookiesError extends Data.TaggedError("CookiesError")<{
117 readonly reason: CookiesErrorReason
118}> {
119 /**
120 * Creates a cookie error from a reason tag and optional cause.
121 *
122 * @stability unstable
123 * @since 4.0.0
124 */
125 static fromReason(reason: CookiesError["reason"]["_tag"], cause?: unknown): CookiesError {
126 return new CookiesError({ reason: new CookiesErrorReason({ _tag: reason, cause }) })
127 }
128
129 /**
130 * Marks this value as a cookie validation error for runtime guards.
131 *
132 * @stability unstable
133 * @since 4.0.0
134 */
135 readonly [CookieErrorTypeId] = CookieErrorTypeId
136
137 /**
138 * Uses the concrete cookie error reason as the public message.
139 *
140 * @stability unstable
141 * @since 4.0.0
142 */
143 override get message() {
144 return this.reason._tag
145 }
146}
147
148/**
149 * Checks whether a value is a `CookiesError`.
150 *
151 * @stability unstable
152 * @category guards
153 * @since 4.0.0
154 */
155export const isCookiesError = (u: unknown): u is CookiesError => Predicate.hasProperty(u, CookieErrorTypeId)
156
157const Proto: Omit<Cookies, "cookies"> = {
158 [TypeId]: TypeId,
159 ...Inspectable.BaseProto,
160 toJSON(this: Cookies) {
161 return {
162 _id: "effect/Cookies",
163 cookies: Record.map(this.cookies, (cookie) => cookie.toJSON())
164 }
165 },
166 pipe() {
167 return pipeArguments(this, arguments)
168 }
169}
170
171/**
172 * Creates a `Cookies` collection from an existing readonly record of cookies keyed by cookie name.
173 *
174 * @stability unstable
175 * @category constructors
176 * @since 4.0.0
177 */
178export const fromReadonlyRecord = (cookies: Record.ReadonlyRecord<string, Cookie>): Cookies => {
179 const self = Object.create(Proto)
180 self.cookies = cookies
181 return self
182}
183
184/**
185 * Create a Cookies object from an Iterable
186 *
187 * @stability unstable
188 * @category constructors
189 * @since 4.0.0
190 */
191export const fromIterable = (cookies: Iterable<Cookie>): Cookies => {
192 const record: Record<string, Cookie> = {}
193 for (const cookie of cookies) {
194 InternalRecord.assignProperty(record, cookie.name, cookie)
195 }
196 return fromReadonlyRecord(record)
197}
198
199/**
200 * Create a Cookies object from a set of Set-Cookie headers
201 *
202 * @stability unstable
203 * @category constructors
204 * @since 4.0.0
205 */
206export const fromSetCookie = (headers: Iterable<string> | string): Cookies => {
207 const arrayHeaders = typeof headers === "string" ? [headers] : headers
208 const cookies: Array<Cookie> = []
209 for (const header of arrayHeaders) {
210 const cookie = parseSetCookie(header.trim())
211 if (cookie) {
212 cookies.push(cookie)
213 }
214 }
215
216 return fromIterable(cookies)
217}
218
219function parseSetCookie(header: string): Cookie | undefined {
220 const parts = header.split(";").map((_) => _.trim()).filter((_) => _ !== "")
221 if (parts.length === 0) {
222 return undefined
223 }
224
225 const firstEqual = parts[0].indexOf("=")
226 if (firstEqual === -1) {
227 return undefined
228 }
229 const name = parts[0].slice(0, firstEqual)
230 if (!cookieNameRegExp.test(name)) {
231 return undefined
232 }
233
234 const valueEncoded = parts[0].slice(firstEqual + 1)
235 const value = tryDecodeURIComponent(valueEncoded)
236
237 if (parts.length === 1) {
238 return Object.assign(Object.create(CookieProto), {
239 name,
240 value,
241 valueEncoded
242 })
243 }
244
245 const options: Types.Mutable<Cookie["options"]> = {}
246
247 for (let i = 1; i < parts.length; i++) {
248 const part = parts[i]
249 const equalIndex = part.indexOf("=")
250 const key = equalIndex === -1 ? part : part.slice(0, equalIndex).trim()
251 const value = equalIndex === -1 ? undefined : part.slice(equalIndex + 1).trim()
252
253 switch (key.toLowerCase()) {
254 case "domain": {
255 if (value === undefined) {
256 break
257 }
258 const domain = value.trim().replace(/^\./, "")
259 if (domain) {
260 options.domain = domain
261 }
262 break
263 }
264 case "expires": {
265 if (value === undefined) {
266 break
267 }
268 const date = new Date(value)
269 if (!isNaN(date.getTime())) {
270 options.expires = date
271 }
272 break
273 }
274 case "max-age": {
275 if (value === undefined) {
276 break
277 }
278 const maxAge = parseInt(value, 10)
279 if (!isNaN(maxAge)) {
280 options.maxAge = Duration.seconds(maxAge)
281 }
282 break
283 }
284 case "path": {
285 if (value === undefined) {
286 break
287 }
288 if (value[0] === "/") {
289 options.path = value
290 }
291 break
292 }
293 case "priority": {
294 if (value === undefined) {
295 break
296 }
297 switch (value.toLowerCase()) {
298 case "low":
299 options.priority = "low"
300 break
301 case "medium":
302 options.priority = "medium"
303 break
304 case "high":
305 options.priority = "high"
306 break
307 }
308 break
309 }
310 case "httponly": {
311 options.httpOnly = true
312 break
313 }
314 case "secure": {
315 options.secure = true
316 break
317 }
318 case "partitioned": {
319 options.partitioned = true
320 break
321 }
322 case "samesite": {
323 if (value === undefined) {
324 break
325 }
326 switch (value.toLowerCase()) {
327 case "lax":
328 options.sameSite = "lax"
329 break
330 case "strict":
331 options.sameSite = "strict"
332 break
333 case "none":
334 options.sameSite = "none"
335 break
336 }
337 break
338 }
339 }
340 }
341
342 return Object.assign(Object.create(CookieProto), {
343 name,
344 value,
345 valueEncoded,
346 options: Object.keys(options).length > 0 ? options : undefined
347 })
348}
349
350/**
351 * An empty Cookies object
352 *
353 * @stability unstable
354 * @category constructors
355 * @since 4.0.0
356 */
357export const empty: Cookies = fromIterable([])
358
359/**
360 * Returns `true` when the `Cookies` collection contains no cookies.
361 *
362 * @stability unstable
363 * @category predicates
364 * @since 4.0.0
365 */
366export const isEmpty = (self: Cookies): boolean => Record.isEmptyRecord(self.cookies)
367
368// oxlint-disable-next-line no-control-regex
369const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/
370const cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
371// oxlint-disable-next-line no-control-regex
372const cookieDomainRegExp = /^[\u0009\u0020-\u003a\u003c-\u007e\u0080-\u00ff]+$/
373const cookiePathRegExp = /^[\u0020-\u003a\u003c-\u007e]+$/
374
375const CookieProto = {
376 [CookieTypeId]: CookieTypeId,
377 ...Inspectable.BaseProto,
378 toJSON(this: Cookie) {
379 return {
380 _id: "effect/Cookies/Cookie",
381 name: this.name,
382 value: this.value,
383 options: this.options
384 }
385 }
386}
387
388/**
389 * Creates a cookie, validating the name, encoded value, domain, path, and finite `maxAge`.
390 *
391 * **Details**
392 *
393 * Returns a `CookiesError` in the `Result` failure channel when validation fails.
394 *
395 * @stability unstable
396 * @category constructors
397 * @since 4.0.0
398 */
399export function makeCookie(
400 name: string,
401 value: string,
402 options?: Cookie["options"] | undefined
403): Result.Result<Cookie, CookiesError> {
404 const encodedValue = encodeURIComponent(value)
405 const error = validateCookie(name, encodedValue, options)
406 if (error !== undefined) {
407 return Result.fail(error)
408 }
409
410 return Result.succeed(Object.assign(Object.create(CookieProto), {
411 name,
412 value,
413 valueEncoded: encodedValue,
414 options
415 }))
416}
417
418function validateCookie(
419 name: string,
420 encodedValue: string,
421 options: Cookie["options"] | undefined
422): CookiesError | undefined {
423 if (!cookieNameRegExp.test(name)) {
424 return CookiesError.fromReason("InvalidCookieName")
425 }
426 if (encodedValue && !fieldContentRegExp.test(encodedValue)) {
427 return CookiesError.fromReason("InvalidCookieValue")
428 }
429 if (options?.domain !== undefined && !cookieDomainRegExp.test(options.domain)) {
430 return CookiesError.fromReason("InvalidCookieDomain")
431 }
432 if (options?.path !== undefined && !cookiePathRegExp.test(options.path)) {
433 return CookiesError.fromReason("InvalidCookiePath")
434 }
435 if (options?.maxAge !== undefined && !Duration.isFinite(Duration.fromInputUnsafe(options.maxAge))) {
436 return CookiesError.fromReason("CookieInfinityMaxAge")
437 }
438}
439
440/**
441 * Create a new cookie, throwing an error if invalid
442 *
443 * @stability unstable
444 * @category constructors
445 * @since 4.0.0
446 */
447export const makeCookieUnsafe = (
448 name: string,
449 value: string,
450 options?: Cookie["options"] | undefined
451): Cookie => Result.getOrThrow(makeCookie(name, value, options))
452
453/**
454 * Adds a cookie to a Cookies object
455 *
456 * @stability unstable
457 * @category combinators
458 * @since 4.0.0
459 */
460export const setCookie: {
461 (cookie: Cookie): (self: Cookies) => Cookies
462 (self: Cookies, cookie: Cookie): Cookies
463} = dual(
464 2,
465 (self: Cookies, cookie: Cookie) =>
466 fromReadonlyRecord(Record.set(
467 self.cookies,
468 cookie.name,
469 cookie
470 ))
471)
472
473/**
474 * Adds multiple cookies to a Cookies object
475 *
476 * @stability unstable
477 * @category combinators
478 * @since 4.0.0
479 */
480export const setAllCookie: {
481 (cookies: Iterable<Cookie>): (self: Cookies) => Cookies
482 (self: Cookies, cookies: Iterable<Cookie>): Cookies
483} = dual(2, (self: Cookies, cookies: Iterable<Cookie>) => {
484 const record = { ...self.cookies }
485 for (const cookie of cookies) {
486 InternalRecord.assignProperty(record, cookie.name, cookie)
487 }
488 return fromReadonlyRecord(record)
489})
490
491/**
492 * Combines two Cookies objects, removing duplicates from the first
493 *
494 * @stability unstable
495 * @category combinators
496 * @since 4.0.0
497 */
498export const merge: {
499 (that: Cookies): (self: Cookies) => Cookies
500 (self: Cookies, that: Cookies): Cookies
501} = dual(2, (self: Cookies, that: Cookies) =>
502 fromReadonlyRecord({
503 ...self.cookies,
504 ...that.cookies
505 }))
506
507/**
508 * Removes a cookie by name
509 *
510 * @stability unstable
511 * @category combinators
512 * @since 4.0.0
513 */
514export const remove: {
515 (name: string): (self: Cookies) => Cookies
516 (self: Cookies, name: string): Cookies
517} = dual(2, (self: Cookies, name: string) => fromReadonlyRecord(Record.remove(self.cookies, name)))
518
519/**
520 * Gets a cookie from a Cookies object safely.
521 *
522 * @stability unstable
523 * @category combinators
524 * @since 4.0.0
525 */
526export const get: {
527 (name: string): (self: Cookies) => Option.Option<Cookie>
528 (self: Cookies, name: string): Option.Option<Cookie>
529} = dual(
530 (args) => isCookies(args[0]),
531 (self: Cookies, name: string): Option.Option<Cookie> =>
532 Option.fromUndefinedOr(Object.hasOwn(self.cookies, name) ? self.cookies[name] : undefined)
533)
534
535/**
536 * Gets the decoded value of a cookie by name safely.
537 *
538 * **Details**
539 *
540 * Returns `Option.none()` when the cookie is not present.
541 *
542 * @stability unstable
543 * @category combinators
544 * @since 4.0.0
545 */
546export const getValue: {
547 (name: string): (self: Cookies) => Option.Option<string>
548 (self: Cookies, name: string): Option.Option<string>
549} = dual(
550 (args) => isCookies(args[0]),
551 (self: Cookies, name: string): Option.Option<string> => Option.map(get(self, name), (cookie) => cookie.value)
552)
553
554/**
555 * Creates and adds a cookie safely by name and value.
556 *
557 * **Details**
558 *
559 * The cookie fields are validated first; invalid input returns a `CookiesError` in the `Result` failure channel.
560 *
561 * @stability unstable
562 * @category combinators
563 * @since 4.0.0
564 */
565export const set: {
566 (
567 name: string,
568 value: string,
569 options?: Cookie["options"]
570 ): (self: Cookies) => Result.Result<Cookies, CookiesError>
571 (
572 self: Cookies,
573 name: string,
574 value: string,
575 options?: Cookie["options"]
576 ): Result.Result<Cookies, CookiesError>
577} = dual(
578 (args) => isCookies(args[0]),
579 (self: Cookies, name: string, value: string, options?: Cookie["options"]) =>
580 Result.map(
581 makeCookie(name, value, options),
582 (cookie) => fromReadonlyRecord(Record.set(self.cookies, name, cookie))
583 )
584)
585
586/**
587 * Creates and adds a cookie by name and value, throwing if the cookie fields are invalid.
588 *
589 * @stability unstable
590 * @category combinators
591 * @since 4.0.0
592 */
593export const setUnsafe: {
594 (
595 name: string,
596 value: string,
597 options?: Cookie["options"]
598 ): (self: Cookies) => Cookies
599 (
600 self: Cookies,
601 name: string,
602 value: string,
603 options?: Cookie["options"]
604 ): Cookies
605} = dual(
606 (args) => isCookies(args[0]),
607 (self: Cookies, name: string, value: string, options?: Cookie["options"]) =>
608 fromReadonlyRecord(Record.set(
609 self.cookies,
610 name,
611 makeCookieUnsafe(name, value, options)
612 ))
613)
614
615/**
616 * Adds an expired cookie safely with an empty value, `Max-Age=0`, and an epoch `Expires` value.
617 *
618 * **Details**
619 *
620 * Returns a `CookiesError` in the `Result` failure channel when the name or options are invalid.
621 *
622 * @stability unstable
623 * @category combinators
624 * @since 4.0.0
625 */
626export const expireCookie: {
627 (
628 name: string,
629 options?: Omit<NonNullable<Cookie["options"]>, "expires" | "maxAge">
630 ): (self: Cookies) => Result.Result<Cookies, CookiesError>
631 (
632 self: Cookies,
633 name: string,
634 options?: Omit<NonNullable<Cookie["options"]>, "expires" | "maxAge">
635 ): Result.Result<Cookies, CookiesError>
636} = dual(
637 (args) => isCookies(args[0]),
638 (
639 self: Cookies,
640 name: string,
641 options?: Omit<NonNullable<Cookie["options"]>, "expires" | "maxAge">
642 ) =>
643 set(self, name, "", {
644 ...options,
645 maxAge: 0,
646 expires: new Date(0)
647 })
648)
649
650/**
651 * Adds an expired cookie to a Cookies object, throwing an error if invalid
652 *
653 * @stability unstable
654 * @category combinators
655 * @since 4.0.0
656 */
657export const expireCookieUnsafe: {
658 (
659 name: string,
660 options?: Omit<NonNullable<Cookie["options"]>, "expires" | "maxAge">
661 ): (self: Cookies) => Cookies
662 (
663 self: Cookies,
664 name: string,
665 options?: Omit<NonNullable<Cookie["options"]>, "expires" | "maxAge">
666 ): Cookies
667} = dual(
668 (args) => isCookies(args[0]),
669 (
670 self: Cookies,
671 name: string,
672 options?: Omit<NonNullable<Cookie["options"]>, "expires" | "maxAge">
673 ): Cookies =>
674 setUnsafe(self, name, "", {
675 ...options,
676 maxAge: 0,
677 expires: new Date(0)
678 })
679)
680
681/**
682 * Creates and adds multiple cookies safely from name/value/options tuples.
683 *
684 * **Details**
685 *
686 * If any tuple is invalid, returns the first `CookiesError` and leaves the original collection unchanged.
687 *
688 * @stability unstable
689 * @category combinators
690 * @since 4.0.0
691 */
692export const setAll: {
693 (
694 cookies: Iterable<readonly [name: string, value: string, options?: Cookie["options"]]>
695 ): (self: Cookies) => Result.Result<Cookies, CookiesError>
696 (
697 self: Cookies,
698 cookies: Iterable<readonly [name: string, value: string, options?: Cookie["options"]]>
699 ): Result.Result<Cookies, CookiesError>
700} = dual(
701 2,
702 (
703 self: Cookies,
704 cookies: Iterable<readonly [name: string, value: string, options?: Cookie["options"]]>
705 ): Result.Result<Cookies, CookiesError> => {
706 const record: Record<string, Cookie> = { ...self.cookies }
707 for (const [name, value, options] of cookies) {
708 const result = makeCookie(name, value, options)
709 if (Result.isFailure(result)) {
710 return result as Result.Failure<never, CookiesError>
711 }
712 InternalRecord.assignProperty(record, name, result.success)
713 }
714 return Result.succeed(fromReadonlyRecord(record))
715 }
716)
717
718/**
719 * Adds multiple cookies to a Cookies object, throwing an error if invalid
720 *
721 * @stability unstable
722 * @category combinators
723 * @since 4.0.0
724 */
725export const setAllUnsafe: {
726 (cookies: Iterable<readonly [name: string, value: string, options?: Cookie["options"]]>): (self: Cookies) => Cookies
727 (self: Cookies, cookies: Iterable<readonly [name: string, value: string, options?: Cookie["options"]]>): Cookies
728} = dual(
729 2,
730 (
731 self: Cookies,
732 cookies: Iterable<readonly [name: string, value: string, options?: Cookie["options"]]>
733 ): Cookies => Result.getOrThrow(setAll(self, cookies))
734)
735
736/**
737 * Serializes a cookie into a string.
738 *
739 * **Details**
740 *
741 * Adapted from https://github.com/fastify/fastify-cookie under MIT License
742 *
743 * @stability unstable
744 * @category encoding
745 * @since 4.0.0
746 */
747export function serializeCookie(self: Cookie): string {
748 const error = validateCookie(self.name, self.valueEncoded, self.options)
749 if (error !== undefined) {
750 throw error
751 }
752 let str = self.name + "=" + self.valueEncoded
753
754 if (self.options === undefined) {
755 return str
756 }
757 const options = self.options
758
759 if (options.maxAge !== undefined) {
760 const maxAge = Duration.toSeconds(Duration.fromInputUnsafe(options.maxAge))
761 str += "; Max-Age=" + Math.trunc(maxAge)
762 }
763
764 if (options.domain !== undefined) {
765 str += "; Domain=" + options.domain
766 }
767
768 if (options.path !== undefined) {
769 str += "; Path=" + options.path
770 }
771
772 if (options.priority !== undefined) {
773 switch (options.priority) {
774 case "low":
775 str += "; Priority=Low"
776 break
777 case "medium":
778 str += "; Priority=Medium"
779 break
780 case "high":
781 str += "; Priority=High"
782 break
783 }
784 }
785
786 if (options.expires !== undefined) {
787 str += "; Expires=" + options.expires.toUTCString()
788 }
789
790 if (options.httpOnly) {
791 str += "; HttpOnly"
792 }
793
794 if (options.secure) {
795 str += "; Secure"
796 }
797
798 // Draft implementation to support Chrome from 2024-Q1 forward.
799 // See https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1
800 if (options.partitioned) {
801 str += "; Partitioned"
802 }
803
804 if (options.sameSite !== undefined) {
805 switch (options.sameSite) {
806 case "lax":
807 str += "; SameSite=Lax"
808 break
809 case "strict":
810 str += "; SameSite=Strict"
811 break
812 case "none":
813 str += "; SameSite=None"
814 break
815 }
816 }
817
818 return str
819}
820
821/**
822 * Serializes a `Cookies` object into a Cookie header.
823 *
824 * @stability unstable
825 * @category encoding
826 * @since 4.0.0
827 */
828export const toCookieHeader = (self: Cookies): string =>
829 Object.values(self.cookies).map((cookie) => `${cookie.name}=${cookie.valueEncoded}`).join("; ")
830
831/**
832 * Converts a `Cookies` collection to a record of decoded cookie values keyed by cookie name.
833 *
834 * @stability unstable
835 * @category encoding
836 * @since 4.0.0
837 */
838export const toRecord = (self: Cookies): Record<string, string> => {
839 const record: Record<string, string> = {}
840 const cookies = Object.values(self.cookies)
841 for (let index = 0; index < cookies.length; index++) {
842 const cookie = cookies[index]
843 InternalRecord.assignProperty(record, cookie.name, cookie.value)
844 }
845 return record
846}
847
848/**
849 * Serializes a `Cookies` collection into an array of `Set-Cookie` header values.
850 *
851 * @stability unstable
852 * @category encoding
853 * @since 4.0.0
854 */
855export const toSetCookieHeaders = (self: Cookies): Array<string> => Object.values(self.cookies).map(serializeCookie)
856
857/**
858 * Parses a cookie header into a record of key-value pairs
859 *
860 * **Details**
861 *
862 * Adapted from https://github.com/fastify/fastify-cookie under MIT License
863 *
864 * @stability unstable
865 * @category decoding
866 * @since 4.0.0
867 */
868export function parseHeader(header: string): Record<string, string> {
869 const result: Record<string, string> = {}
870
871 const strLen = header.length
872 let pos = 0
873 let terminatorPos = 0
874
875 while (true) {
876 if (terminatorPos === strLen) break
877 terminatorPos = header.indexOf(";", pos)
878 if (terminatorPos === -1) terminatorPos = strLen // This is the last pair
879
880 let eqIdx = header.indexOf("=", pos)
881 if (eqIdx === -1) break // No key-value pairs left
882 if (eqIdx > terminatorPos) {
883 // Malformed key-value pair
884 pos = terminatorPos + 1
885 continue
886 }
887
888 const key = header.substring(pos, eqIdx++).trim()
889 if (!Object.hasOwn(result, key)) {
890 const val = header.charCodeAt(eqIdx) === 0x22
891 ? header.substring(eqIdx + 1, terminatorPos - 1).trim()
892 : header.substring(eqIdx, terminatorPos).trim()
893
894 InternalRecord.assignProperty(
895 result,
896 key,
897 !(val.indexOf("%") === -1) ? tryDecodeURIComponent(val) : val
898 )
899 }
900
901 pos = terminatorPos + 1
902 }
903
904 return result
905}
906
907const tryDecodeURIComponent = (str: string): string => {
908 try {
909 return decodeURIComponent(str)
910 // oxlint-disable-next-line @typescript-eslint/no-unused-vars
911 } catch (_) {
912 return str
913 }
914}