Skip to content

Commit

Permalink
chore(cookies): exposes .splitCookiesString (#473)
Browse files Browse the repository at this point in the history
* chore(cookies): expose splitCookiesString

* chore: unify splitCookiesString implementation

closes #257

* build: use latest

* fix: linter

* docs: prefer null over empty string

* Create calm-paws-sip.md
  • Loading branch information
Kikobeats committed Jul 17, 2023
1 parent a9b2394 commit 8d21ddc
Show file tree
Hide file tree
Showing 14 changed files with 155 additions and 197 deletions.
10 changes: 10 additions & 0 deletions .changeset/calm-paws-sip.md
@@ -0,0 +1,10 @@
---
"@edge-runtime/cookies": patch
"@edge-runtime/node-utils": patch
"@edge-runtime/primitives": patch
"@edge-runtime/types": patch
"@edge-runtime/user-agent": patch
"@edge-runtime/docs": patch
---

chore(cookies): expose `.splitCookiesString`
17 changes: 9 additions & 8 deletions docs/pages/packages/cookies.mdx
Expand Up @@ -72,19 +72,20 @@ use the exported `ResponseCookies` constructor:
import { ResponseCookies } from '@edge-runtime/cookies'

function handleRequest(req: Request) {
// do something
const cookies = new ResponseCookies(new Headers())
const headers = new Headers()

const cookies = new ResponseCookies()
cookies.set('cookie-name', 'cookie-value', { maxAge: 1000 }) // make cookie persistent for 1000 seconds
cookies.delete('old-cookie')
return response

return new Response(null, {
headers: {
'set-cookie': headers.getAll?.('set-cookie'),
},
})
}
```

Notes:

- All mutations are performed in-place and will update the `Set-Cookie` headers
in the provided `Response` object.

#### Available methods

- `get` - A method that takes a cookie `name` and returns an object with `name` and `value`. If a cookie with `name` isn't found, it returns `undefined`. If multiple cookies match, it will only return the first match.
Expand Down
1 change: 1 addition & 0 deletions packages/cookies/src/index.ts
@@ -1,3 +1,4 @@
export type { CookieListItem, RequestCookie, ResponseCookie } from './types'
export { RequestCookies } from './request-cookies'
export { ResponseCookies } from './response-cookies'
export { splitCookiesString } from './serialize'
14 changes: 7 additions & 7 deletions packages/cookies/src/request-cookies.ts
@@ -1,5 +1,5 @@
import type { RequestCookie } from './types'
import { parseCookieString, serialize } from './serialize'
import { parseCookie, stringifyCookie } from './serialize'

/**
* A class for manipulating {@link Request} cookies (`Cookie` header).
Expand All @@ -14,7 +14,7 @@ export class RequestCookies {
this._headers = requestHeaders
const header = requestHeaders.get('cookie')
if (header) {
const parsed = parseCookieString(header)
const parsed = parseCookie(header)
for (const [name, value] of parsed) {
this._parsed.set(name, { name, value })
}
Expand Down Expand Up @@ -61,8 +61,8 @@ export class RequestCookies {
this._headers.set(
'cookie',
Array.from(map)
.map(([_, value]) => serialize(value))
.join('; ')
.map(([_, value]) => stringifyCookie(value))
.join('; '),
)
return this
}
Expand All @@ -72,7 +72,7 @@ export class RequestCookies {
*/
delete(
/** Name or names of the cookies to be deleted */
names: string | string[]
names: string | string[],
): boolean | boolean[] {
const map = this._parsed
const result = !Array.isArray(names)
Expand All @@ -81,8 +81,8 @@ export class RequestCookies {
this._headers.set(
'cookie',
Array.from(map)
.map(([_, value]) => serialize(value))
.join('; ')
.map(([_, value]) => stringifyCookie(value))
.join('; '),
)
return result
}
Expand Down
91 changes: 8 additions & 83 deletions packages/cookies/src/response-cookies.ts
@@ -1,5 +1,9 @@
import type { ResponseCookie } from './types'
import { parseSetCookieString, serialize } from './serialize'
import {
splitCookiesString,
parseSetCookie,
stringifyCookie,
} from './serialize'

/**
* A class for manipulating {@link Response} cookies (`Set-Cookie` header).
Expand All @@ -26,7 +30,7 @@ export class ResponseCookies {
: splitCookiesString(setCookie)

for (const cookieString of cookieStrings) {
const parsed = parseSetCookieString(cookieString)
const parsed = parseSetCookie(cookieString)
if (parsed) this._parsed.set(parsed.name, parsed)
}
}
Expand Down Expand Up @@ -85,14 +89,14 @@ export class ResponseCookies {
}

toString() {
return [...this._parsed.values()].map(serialize).join('; ')
return [...this._parsed.values()].map(stringifyCookie).join('; ')
}
}

function replace(bag: Map<string, ResponseCookie>, headers: Headers) {
headers.delete('set-cookie')
for (const [, value] of bag) {
const serialized = serialize(value)
const serialized = stringifyCookie(value)
headers.append('set-cookie', serialized)
}
}
Expand All @@ -112,82 +116,3 @@ function normalizeCookie(cookie: ResponseCookie = { name: '', value: '' }) {

return cookie
}

/**
* @source https://github.com/nfriedly/set-cookie-parser/blob/master/lib/set-cookie.js
*
* Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas
* that are within a single set-cookie field-value, such as in the Expires portion.
* This is uncommon, but explicitly allowed - see https://tools.ietf.org/html/rfc2616#section-4.2
* Node.js does this for every header *except* set-cookie - see https://github.com/nodejs/node/blob/d5e363b77ebaf1caf67cd7528224b651c86815c1/lib/_http_incoming.js#L128
* React Native's fetch does this for *every* header, including set-cookie.
*
* Based on: https://github.com/google/j2objc/commit/16820fdbc8f76ca0c33472810ce0cb03d20efe25
* Credits to: https://github.com/tomball for original and https://github.com/chrusart for JavaScript implementation
*/
function splitCookiesString(cookiesString: string) {
if (!cookiesString) return []
var cookiesStrings = []
var pos = 0
var start
var ch
var lastComma
var nextStart
var cookiesSeparatorFound

function skipWhitespace() {
while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) {
pos += 1
}
return pos < cookiesString.length
}

function notSpecialChar() {
ch = cookiesString.charAt(pos)

return ch !== '=' && ch !== ';' && ch !== ','
}

while (pos < cookiesString.length) {
start = pos
cookiesSeparatorFound = false

while (skipWhitespace()) {
ch = cookiesString.charAt(pos)
if (ch === ',') {
// ',' is a cookie separator if we have later first '=', not ';' or ','
lastComma = pos
pos += 1

skipWhitespace()
nextStart = pos

while (pos < cookiesString.length && notSpecialChar()) {
pos += 1
}

// currently special character
if (pos < cookiesString.length && cookiesString.charAt(pos) === '=') {
// we found cookies separator
cookiesSeparatorFound = true
// pos is inside the next cookie, so back up and return it.
pos = nextStart
cookiesStrings.push(cookiesString.substring(start, lastComma))
start = pos
} else {
// in param ',' or param separator ';',
// we continue from that comma
pos = lastComma + 1
}
} else {
pos += 1
}
}

if (!cookiesSeparatorFound || pos >= cookiesString.length) {
cookiesStrings.push(cookiesString.substring(start, cookiesString.length))
}
}

return cookiesStrings
}
92 changes: 85 additions & 7 deletions packages/cookies/src/serialize.ts
@@ -1,6 +1,6 @@
import type { RequestCookie, ResponseCookie } from './types'

export function serialize(c: ResponseCookie | RequestCookie): string {
export function stringifyCookie(c: ResponseCookie | RequestCookie): string {
const attrs = [
'path' in c && c.path && `Path=${c.path}`,
'expires' in c &&
Expand All @@ -20,7 +20,7 @@ export function serialize(c: ResponseCookie | RequestCookie): string {
}

/** Parse a `Cookie` header value */
export function parseCookieString(cookie: string) {
export function parseCookie(cookie: string) {
const map = new Map<string, string>()

for (const pair of cookie.split(/; */)) {
Expand Down Expand Up @@ -48,17 +48,15 @@ export function parseCookieString(cookie: string) {
}

/** Parse a `Set-Cookie` header value */
export function parseSetCookieString(
setCookie: string
): undefined | ResponseCookie {
export function parseSetCookie(setCookie: string): undefined | ResponseCookie {
if (!setCookie) {
return undefined
}

const [[name, value], ...attributes] = parseCookieString(setCookie)
const [[name, value], ...attributes] = parseCookie(setCookie)
const { domain, expires, httponly, maxage, path, samesite, secure } =
Object.fromEntries(
attributes.map(([key, value]) => [key.toLowerCase(), value])
attributes.map(([key, value]) => [key.toLowerCase(), value]),
)
const cookie: ResponseCookie = {
name,
Expand Down Expand Up @@ -86,9 +84,89 @@ function compact<T>(t: T): T {
}

const SAME_SITE: ResponseCookie['sameSite'][] = ['strict', 'lax', 'none']

function parseSameSite(string: string): ResponseCookie['sameSite'] {
string = string.toLowerCase()
return SAME_SITE.includes(string as any)
? (string as ResponseCookie['sameSite'])
: undefined
}

/**
* @source https://github.com/nfriedly/set-cookie-parser/blob/master/lib/set-cookie.js
*
* Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas
* that are within a single set-cookie field-value, such as in the Expires portion.
* This is uncommon, but explicitly allowed - see https://tools.ietf.org/html/rfc2616#section-4.2
* Node.js does this for every header *except* set-cookie - see https://github.com/nodejs/node/blob/d5e363b77ebaf1caf67cd7528224b651c86815c1/lib/_http_incoming.js#L128
* React Native's fetch does this for *every* header, including set-cookie.
*
* Based on: https://github.com/google/j2objc/commit/16820fdbc8f76ca0c33472810ce0cb03d20efe25
* Credits to: https://github.com/tomball for original and https://github.com/chrusart for JavaScript implementation
*/
export function splitCookiesString(cookiesString: string) {
if (!cookiesString) return []
var cookiesStrings = []
var pos = 0
var start
var ch
var lastComma
var nextStart
var cookiesSeparatorFound

function skipWhitespace() {
while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) {
pos += 1
}
return pos < cookiesString.length
}

function notSpecialChar() {
ch = cookiesString.charAt(pos)

return ch !== '=' && ch !== ';' && ch !== ','
}

while (pos < cookiesString.length) {
start = pos
cookiesSeparatorFound = false

while (skipWhitespace()) {
ch = cookiesString.charAt(pos)
if (ch === ',') {
// ',' is a cookie separator if we have later first '=', not ';' or ','
lastComma = pos
pos += 1

skipWhitespace()
nextStart = pos

while (pos < cookiesString.length && notSpecialChar()) {
pos += 1
}

// currently special character
if (pos < cookiesString.length && cookiesString.charAt(pos) === '=') {
// we found cookies separator
cookiesSeparatorFound = true
// pos is inside the next cookie, so back up and return it.
pos = nextStart
cookiesStrings.push(cookiesString.substring(start, lastComma))
start = pos
} else {
// in param ',' or param separator ';',
// we continue from that comma
pos = lastComma + 1
}
} else {
pos += 1
}
}

if (!cookiesSeparatorFound || pos >= cookiesString.length) {
cookiesStrings.push(cookiesString.substring(start, cookiesString.length))
}
}

return cookiesStrings
}
12 changes: 6 additions & 6 deletions packages/cookies/test/request-cookies.test.ts
@@ -1,6 +1,6 @@
import { RequestCookies } from '../src/request-cookies'
import { createFormat } from '@edge-runtime/format'
import { parseCookieString } from '../src/serialize'
import { parseCookie } from '../src/serialize'

describe('input parsing', () => {
test('empty cookie header element', () => {
Expand Down Expand Up @@ -83,7 +83,7 @@ test('formatting with @edge-runtime/format', () => {
const format = createFormat()
const result = format(cookies)
expect(result).toMatchInlineSnapshot(
`"RequestCookies {"a":{"name":"a","value":"1"},"b":{"name":"b","value":"2"}}"`
`"RequestCookies {"a":{"name":"a","value":"1"},"b":{"name":"b","value":"2"}}"`,
)
})

Expand All @@ -102,28 +102,28 @@ function mapToCookieString(map: Map<string, string>) {
describe('parse cookie string', () => {
it('with a plain value', async () => {
const input = new Map([['foo', 'bar']])
const result = parseCookieString(mapToCookieString(input))
const result = parseCookie(mapToCookieString(input))
expect(result).toEqual(input)
})
it('with multiple `=`', async () => {
const input = new Map([['foo', 'bar=']])
const result = parseCookieString(mapToCookieString(input))
const result = parseCookie(mapToCookieString(input))
expect(result).toEqual(input)
})
it('with multiple plain values', async () => {
const input = new Map([
['foo', 'bar'],
['baz', 'qux'],
])
const result = parseCookieString(mapToCookieString(input))
const result = parseCookie(mapToCookieString(input))
expect(result).toEqual(input)
})
it('with multiple values with `=`', async () => {
const input = new Map([
['foo', 'bar=='],
['baz', '=qux'],
])
const result = parseCookieString(mapToCookieString(input))
const result = parseCookie(mapToCookieString(input))
expect(result).toEqual(input)
})
})

1 comment on commit 8d21ddc

@vercel
Copy link

@vercel vercel bot commented on 8d21ddc Jul 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

edge-runtime – ./

edge-runtime-git-main.vercel.sh
edge-runtime.vercel.sh
edge-runtime.vercel.app

Please sign in to comment.