Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new cookie store #34

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/CookieStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Cookie, parse as parseCookie } from 'set-cookie-parser'

type Store = Set<Cookie>

const kIssuerOrigin = Symbol('kIssuerOrigin')

/**
* A virtual cookie store.
* Associates response cookies with requests
* and returns the appropriate cookies for a given request.
* This store is primarily designed to provide a
* browser-like cookie forwarding for Node.js environments.
*/
export class CookieStore {
protected store: Store = new Set()

/**
* Parse the given cookie string.
*/
static parse(cookieString: string): Array<Cookie> {
return parseCookie(cookieString)
}

/**
* Return the list of cookies appropriate for this request.
* Takes the request `credentials` and the given `documentOrigin`
* into account.
*
* This is a static method so you can call it in the browser on
* `document.cookie` and get the list of cookies appropriate
* for any given request.
*/
static getCookiesForRequest(
cookies: Array<Cookie> | Store,
request: Request,
documentOrigin: string,
): Array<Cookie> {
const store = cookies instanceof Set ? cookies : new Set(cookies)
deleteExpiredCookies(store)

// Return no cookies if the request omits credentials.
/**
* @todo Use `isPropertyAccessible` to support React Native.
*/
if (request.credentials === 'omit') {
return []
}

const requestUrl = new URL(request.url)

// Return no cookies if the request is cross-origin.
if (
request.credentials === 'same-origin' &&
requestUrl.origin !== documentOrigin
) {
return []
}

const result: Array<Cookie> = []

for (const cookie of store) {
const responseOrigin = Reflect.get(cookie, kIssuerOrigin) as string

if (cookie.domain) {
// If the cookie domain is specified, the cookie
// is available on the server that sends it and its subdomains.
if (!requestUrl.hostname.endsWith(cookie.domain)) {
continue
}
} else if (responseOrigin !== documentOrigin) {
// If the cookie domain is not specified,
// the cookie is only availble on the server that sends it.
continue
}

// Skip the cookie if the request's path is not
// under the cookie's path.
if (!requestUrl.pathname.startsWith(cookie.path || '/')) {
continue
}

result.push(cookie)
}

return result
}

/**
* Add the given response cookies for the request.
*/
public add(request: Request, response: Response): void {
const requestPath = new URL(request.url).pathname

/**
* @todo Use `.getSetCookie()` once Node.js v20 is the
* minimal supported version.
*/
const responseCookies = response.headers.get('set-cookie')

if (responseCookies == null) {
return
}

const responseOrigin = new URL(response.url).origin
const parsedCookies = CookieStore.parse(responseCookies).map((cookie) => {
return this.normalizeParsedCookie(cookie, requestPath)
})

for (const cookie of parsedCookies) {
Object.defineProperty(cookie, kIssuerOrigin, {
value: responseOrigin,
enumerable: false,
writable: false,
})

this.store.add(cookie)
}
}

/**
* Return the list of cookies appropriate for this request.
* Takes the request `credentials` and the given `documentOrigin`
* into account.
*/
public get(request: Request, documentOrigin: string): Array<Cookie> {
return CookieStore.getCookiesForRequest(this.store, request, documentOrigin)
}

/**
* Return all cookies present in the store.
* This does not perform any cookie matching.
*/
public getAll(): Array<Cookie> {
deleteExpiredCookies(this.store)
return Array.from(this.store)
}

/**
* Clear all cookies from the store.
*/
public clear(): void {
this.store.clear()
}

private normalizeParsedCookie(
cookie: Cookie,
requestPathname: string,
): Cookie {
cookie.expires =
cookie.maxAge === undefined
? cookie.expires
: new Date(Date.now() + cookie.maxAge * 1000)

cookie.path = cookie.path ?? requestPathname

return cookie
}
}

/**
* Delete all expired cookies from the store.
*/
function deleteExpiredCookies(store: Store): void {
if (store.size === 0) {
return
}

const now = Date.now()

for (const cookie of store) {
if (cookie.expires !== undefined && cookie.expires.getTime() <= now) {
store.delete(cookie)
}
}
}

// Export the store as a singleton
// to act as the persistence layer in Node.js.
export const store = new CookieStore()
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './store'
export { store, CookieStore } from './CookieStore'
Loading