Skip to content

Commit 679a3a4

Browse files
committed
refactor: migrate cookies to built-in implementation, close #129
1 parent 5bb488b commit 679a3a4

File tree

15 files changed

+677
-337
lines changed

15 files changed

+677
-337
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"cSpell.words": [
33
"esbuild",
44
"importee",
5+
"keygrip",
56
"metafile",
67
"middlewares",
78
"mockjs",

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"devDependencies": {
2323
"@pengzhanbo/eslint-config": "catalog:dev",
2424
"@types/co-body": "catalog:dev",
25-
"@types/cookies": "catalog:dev",
2625
"@types/cors": "catalog:dev",
2726
"@types/debug": "catalog:dev",
2827
"@types/formidable": "catalog:dev",

pnpm-lock.yaml

Lines changed: 219 additions & 319 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ catalogs:
66
dev:
77
'@pengzhanbo/eslint-config': ^1.39.0
88
'@types/co-body': ^6.1.3
9-
'@types/cookies': ^0.9.2
109
'@types/cors': ^2.8.19
1110
'@types/debug': ^4.1.12
1211
'@types/formidable': ^3.4.6
@@ -19,22 +18,21 @@ catalogs:
1918
bumpp: ^10.3.1
2019
conventional-changelog-cli: ^5.0.0
2120
esbuild: ^0.25.11
22-
eslint: ^9.38.0
21+
eslint: ^9.39.0
2322
mockjs: ^1.1.0
2423
rolldown: ^1.0.0-beta.45
25-
tsdown: ^0.15.11
24+
tsdown: ^0.15.12
2625
typescript: ^5.9.3
2726
vite: 'npm:rolldown-vite@latest'
2827
vitepress: ^2.0.0-alpha.12
2928
vitepress-plugin-group-icons: ^1.6.5
3029
vitepress-plugin-llms: ^1.8.1
31-
vitest: ^4.0.4
30+
vitest: ^4.0.6
3231
prod:
3332
'@pengzhanbo/utils': ^2.1.0
3433
ansis: ^4.2.0
3534
chokidar: ^4.0.3
3635
co-body: ^6.2.0
37-
cookies: ^0.9.1
3836
cors: ^2.8.5
3937
debug: ^4.4.3
4038
formidable: 3.5.4

vite-plugin-mock-dev-server/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@
6767
"ansis": "catalog:prod",
6868
"chokidar": "catalog:prod",
6969
"co-body": "catalog:prod",
70-
"cookies": "catalog:prod",
7170
"cors": "catalog:prod",
7271
"debug": "catalog:prod",
7372
"formidable": "catalog:prod",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { SetCookieOption } from './types'
2+
import {
3+
fieldContentRegExp,
4+
PRIORITY_REGEXP,
5+
RESTRICTED_NAME_CHARS_REGEXP,
6+
RESTRICTED_VALUE_CHARS_REGEXP,
7+
SAME_SITE_REGEXP,
8+
} from './constants'
9+
10+
export class Cookie {
11+
name!: string
12+
value: string | undefined | null
13+
14+
maxAge: number | undefined
15+
expires: Date | undefined
16+
path = '/'
17+
domain: string | undefined
18+
secure = false
19+
httpOnly = true
20+
sameSite: SetCookieOption['sameSite'] = false
21+
overwrite = false
22+
priority: SetCookieOption['priority'] | undefined
23+
partitioned: boolean | undefined
24+
25+
constructor(name: string, value?: string | null, options: SetCookieOption = {}) {
26+
if (!fieldContentRegExp.test(name) || RESTRICTED_NAME_CHARS_REGEXP.test(name)) {
27+
throw new TypeError('argument name is invalid')
28+
}
29+
30+
if (value && (!fieldContentRegExp.test(value) || RESTRICTED_VALUE_CHARS_REGEXP.test(value)))
31+
throw new TypeError('argument value is invalid')
32+
33+
this.name = name
34+
this.value = value
35+
36+
Object.assign(this, options)
37+
38+
if (!this.value) {
39+
this.expires = new Date(0)
40+
this.maxAge = undefined
41+
}
42+
43+
if (this.path && !fieldContentRegExp.test(this.path)) {
44+
throw new TypeError('[Cookie] option path is invalid')
45+
}
46+
47+
if (this.domain && !fieldContentRegExp.test(this.domain)) {
48+
throw new TypeError('[Cookie] option domain is invalid')
49+
}
50+
51+
if (typeof this.maxAge === 'number' ? (Number.isNaN(this.maxAge) || !Number.isFinite(this.maxAge)) : this.maxAge) {
52+
throw new TypeError('[Cookie] option maxAge is invalid')
53+
}
54+
55+
if (this.priority && !PRIORITY_REGEXP.test(this.priority)) {
56+
throw new TypeError('[Cookie] option priority is invalid')
57+
}
58+
59+
if (this.sameSite && this.sameSite !== true && !SAME_SITE_REGEXP.test(this.sameSite)) {
60+
throw new TypeError('[Cookie] option sameSite is invalid')
61+
}
62+
}
63+
64+
toString(): string {
65+
return `${this.name}=${this.value}`
66+
}
67+
68+
toHeader(): string {
69+
let header = this.toString()
70+
71+
if (this.maxAge)
72+
this.expires = new Date(Date.now() + this.maxAge)
73+
74+
if (this.path)
75+
header += `; path=${this.path}`
76+
if (this.expires)
77+
header += `; expires=${this.expires.toUTCString()}`
78+
if (this.domain)
79+
header += `; domain=${this.domain}`
80+
if (this.priority)
81+
header += `; priority=${this.priority.toLowerCase()}`
82+
if (this.sameSite)
83+
header += `; samesite=${this.sameSite === true ? 'strict' : this.sameSite.toLowerCase()}`
84+
if (this.secure)
85+
header += '; secure'
86+
if (this.httpOnly)
87+
header += '; httponly'
88+
if (this.partitioned)
89+
header += '; partitioned'
90+
91+
return header
92+
}
93+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { IncomingMessage, ServerResponse } from 'node:http'
2+
import type { CookiesOption, GetCookieOption, SetCookieOption } from './types'
3+
import http from 'node:http'
4+
import { isArray, toArray } from '@pengzhanbo/utils'
5+
import { REGEXP_CACHE, REGEXP_ESCAPE_CHARS_REGEXP } from './constants'
6+
import { Cookie } from './Cookie'
7+
import { Keygrip } from './Keygrip'
8+
9+
export class Cookies {
10+
request: IncomingMessage
11+
response: ServerResponse<IncomingMessage>
12+
secure: boolean | undefined
13+
keys: Keygrip | undefined
14+
15+
constructor(
16+
req: IncomingMessage,
17+
res: ServerResponse<IncomingMessage>,
18+
options: CookiesOption = {},
19+
) {
20+
this.request = req
21+
this.response = res
22+
this.secure = options.secure
23+
24+
if (options.keys instanceof Keygrip) {
25+
this.keys = options.keys
26+
}
27+
else if (isArray(options.keys)) {
28+
this.keys = new Keygrip(options.keys)
29+
}
30+
}
31+
32+
set(name: string, value?: string | null, options?: SetCookieOption): this {
33+
const req = this.request
34+
const res = this.response
35+
const headers = toArray(res.getHeader('Set-Cookie')) as string[]
36+
const cookie = new Cookie(name, value, options)
37+
const signed = options?.signed ?? !!this.keys
38+
const secure = this.secure === undefined
39+
? (req as IncomingMessage & { protocol: string }).protocol === 'https' || isRequestEncrypted(req)
40+
: Boolean(this.secure)
41+
42+
if (!secure && options?.secure) {
43+
throw new Error('Cannot send secure cookie over unencrypted connection')
44+
}
45+
46+
cookie.secure = options?.secure ?? secure
47+
48+
pushCookie(headers, cookie)
49+
50+
if (signed && options) {
51+
if (!this.keys)
52+
throw new Error('.keys required for signed cookies')
53+
cookie.value = this.keys.sign(cookie.toString())
54+
cookie.name += '.sig'
55+
pushCookie(headers, cookie)
56+
}
57+
58+
const setHeader = (res as any).set ? http.OutgoingMessage.prototype.setHeader : res.setHeader
59+
setHeader.call(res, 'Set-Cookie', headers)
60+
61+
return this
62+
}
63+
64+
get(name: string, options?: GetCookieOption): string | void {
65+
const signName = `${name}.sig`
66+
const signed = options?.signed ?? !!this.keys
67+
68+
const header = this.request.headers.cookie
69+
70+
if (!header)
71+
return
72+
73+
const match = header.match(getPattern(name))
74+
75+
if (!match)
76+
return
77+
78+
let value = match[1]
79+
80+
if (value[0] === '"')
81+
value = value.slice(1, -1)
82+
83+
if (!options || !signed)
84+
return value
85+
86+
const remote = this.get(signName)
87+
if (!remote)
88+
return
89+
90+
const data = `${name}=${value}`
91+
92+
if (!this.keys)
93+
throw new Error('.keys required for signed cookies')
94+
95+
const index = this.keys.index(data, remote)
96+
if (index < 0) {
97+
this.set(signName, null, { path: '/', signed: false })
98+
}
99+
else {
100+
index && this.set(signName, this.keys.sign(data), { signed: false })
101+
return value
102+
}
103+
}
104+
}
105+
106+
/**
107+
* Get the pattern to search for a cookie in a string.
108+
*/
109+
function getPattern(name: string): RegExp {
110+
if (!REGEXP_CACHE[name]) {
111+
REGEXP_CACHE[name] = new RegExp(
112+
`(?:^|;) *${
113+
name.replace(REGEXP_ESCAPE_CHARS_REGEXP, '\\$&')
114+
}=([^;]*)`,
115+
)
116+
}
117+
118+
return REGEXP_CACHE[name]
119+
}
120+
121+
/**
122+
* Get the encrypted status for a request.
123+
*/
124+
function isRequestEncrypted(req: IncomingMessage): boolean {
125+
return Boolean(req.socket
126+
? (req.socket as any).encrypted
127+
: (req.connection as any).encrypted)
128+
}
129+
130+
function pushCookie(headers: string[], cookie: Cookie): void {
131+
if (cookie.overwrite) {
132+
for (let i = headers.length - 1; i >= 0; i--) {
133+
if (headers[i].indexOf(`${cookie.name}=`) === 0) {
134+
headers.splice(i, 1)
135+
}
136+
}
137+
}
138+
139+
headers.push(cookie.toHeader())
140+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import crypto from 'node:crypto'
2+
import { timeSafeCompare } from './timeSafeCompare'
3+
4+
const SLASH_PATTERN = /[/+=]/g
5+
const REPLACE_MAP: Record<string, string> = { '/': '_', '+': '-', '=': '' }
6+
7+
export class Keygrip {
8+
private algorithm!: string
9+
private encoding!: crypto.BinaryToTextEncoding
10+
private keys: string[] = []
11+
12+
constructor(keys: string[], algorithm?: string, encoding?: crypto.BinaryToTextEncoding) {
13+
this.keys = keys
14+
this.algorithm = algorithm || 'sha256'
15+
this.encoding = encoding || 'base64'
16+
}
17+
18+
sign(data: string, key: string = this.keys[0]): string {
19+
return crypto.createHmac(this.algorithm, key).update(data).digest(this.encoding).replace(SLASH_PATTERN, m => REPLACE_MAP[m])
20+
}
21+
22+
index(data: string, digest: string): number {
23+
for (let i = 0, l = this.keys.length; i < l; i++) {
24+
if (timeSafeCompare(digest, this.sign(data, this.keys[i]))) {
25+
return i
26+
}
27+
}
28+
29+
return -1
30+
}
31+
32+
verify(data: string, digest: string): boolean {
33+
return this.index(data, digest) > -1
34+
}
35+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* RegExp to match field-content in RFC 7230 sec 3.2
3+
*
4+
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
5+
* field-vchar = VCHAR / obs-text
6+
* obs-text = %x80-FF
7+
*/
8+
9+
export const fieldContentRegExp: RegExp = /^[\t\u0020-\u007E\u0080-\u00FF]+$/
10+
11+
/**
12+
* RegExp to match Priority cookie attribute value.
13+
*/
14+
15+
export const PRIORITY_REGEXP: RegExp = /^(?:low|medium|high)$/i
16+
17+
/**
18+
* Cache for generated name regular expressions.
19+
*/
20+
21+
export const REGEXP_CACHE: Record<string, RegExp> = Object.create(null)
22+
23+
/**
24+
* RegExp to match all characters to escape in a RegExp.
25+
*/
26+
27+
export const REGEXP_ESCAPE_CHARS_REGEXP: RegExp = /[\^$\\.*+?()[\]{}|]/g
28+
29+
/**
30+
* RegExp to match basic restricted name characters for loose validation.
31+
*/
32+
33+
export const RESTRICTED_NAME_CHARS_REGEXP: RegExp = /[;=]/
34+
35+
/**
36+
* RegExp to match basic restricted value characters for loose validation.
37+
*/
38+
39+
export const RESTRICTED_VALUE_CHARS_REGEXP: RegExp = /;/
40+
41+
/**
42+
* RegExp to match Same-Site cookie attribute value.
43+
*/
44+
45+
export const SAME_SITE_REGEXP: RegExp = /^(?:lax|none|strict)$/i
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './Cookie'
2+
export * from './Cookies'
3+
export * from './Keygrip'
4+
export type * from './types'

0 commit comments

Comments
 (0)