Skip to content

Commit 532e9d7

Browse files
authored
feat!: port upstream useCookie (#988)
1 parent 3c40b3f commit 532e9d7

File tree

8 files changed

+188
-23
lines changed

8 files changed

+188
-23
lines changed

Diff for: packages/bridge/src/runtime/app.plugin.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export default async (ctx, inject) => {
9494

9595
if (process.server) {
9696
nuxtApp.ssrContext = ctx.ssrContext
97+
nuxtApp.ssrContext.nuxtApp = nuxtApp
9798
}
9899

99100
ctx.app.created.push(function () {

Diff for: packages/bridge/src/runtime/composables/cookie.ts

+134-17
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { Ref, ref, watch } from 'vue'
2-
import { parse, serialize, CookieParseOptions, CookieSerializeOptions } from 'cookie-es'
3-
import { appendHeader } from 'h3'
1+
import type { Ref } from 'vue'
2+
import { ref, watch, getCurrentScope, onScopeDispose, customRef, nextTick } from 'vue'
3+
import { parse, serialize } from 'cookie-es'
4+
import type { CookieParseOptions, CookieSerializeOptions } from 'cookie-es'
5+
import { deleteCookie, getCookie, getRequestHeader, setCookie } from 'h3'
46
import type { H3Event } from 'h3'
57
import destr from 'destr'
8+
import { isEqual } from 'ohash'
69
import { useNuxtApp } from '../nuxt'
710
import { useRequestEvent } from './ssr'
811

@@ -12,31 +15,92 @@ export interface CookieOptions<T = any> extends _CookieOptions {
1215
decode?(value: string): T
1316
encode?(value: T): string
1417
default?: () => T
18+
watch?: boolean | 'shallow'
19+
readonly?: boolean
1520
}
1621

1722
export interface CookieRef<T> extends Ref<T> {}
1823

19-
const CookieDefaults: CookieOptions<any> = {
24+
const CookieDefaults = {
2025
path: '/',
26+
watch: true,
2127
decode: val => destr(decodeURIComponent(val)),
2228
encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val))
23-
}
29+
} satisfies CookieOptions<any>
2430

25-
export function useCookie<T = string> (name: string, _opts?: CookieOptions<T>): CookieRef<T> {
31+
export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T> & { readonly?: false }): CookieRef<T>
32+
export function useCookie<T = string | null | undefined> (name: string, _opts: CookieOptions<T> & { readonly: true }): Readonly<CookieRef<T>>
33+
export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T>): CookieRef<T> {
2634
const opts = { ...CookieDefaults, ..._opts }
27-
const cookies = readRawCookies(opts)
35+
const cookies = readRawCookies(opts) || {}
36+
37+
let delay: number | undefined
38+
39+
if (opts.maxAge !== undefined) {
40+
delay = opts.maxAge * 1000 // convert to ms for setTimeout
41+
} else if (opts.expires) {
42+
// getTime() already returns time in ms
43+
delay = opts.expires.getTime() - Date.now()
44+
}
2845

29-
const cookie = ref(cookies[name] ?? opts.default?.())
46+
const hasExpired = delay !== undefined && delay <= 0
47+
const cookieValue = hasExpired ? undefined : (cookies[name] as any) ?? opts.default?.()
48+
49+
// use a custom ref to expire the cookie on client side otherwise use basic ref
50+
const cookie = process.client && delay && !hasExpired
51+
? cookieRef<T | undefined>(cookieValue, delay)
52+
: ref<T | undefined>(cookieValue)
53+
54+
if (process.dev && hasExpired) {
55+
console.warn(`[nuxt] not setting cookie \`${name}\` as it has already expired.`)
56+
}
3057

3158
if (process.client) {
32-
watch(cookie, () => { writeClientCookie(name, cookie.value, opts as CookieSerializeOptions) })
59+
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`)
60+
const callback = () => {
61+
if (opts.readonly || isEqual(cookie.value, cookies[name])) { return }
62+
writeClientCookie(name, cookie.value, opts as CookieSerializeOptions)
63+
channel?.postMessage(opts.encode(cookie.value as T))
64+
}
65+
66+
let watchPaused = false
67+
68+
if (getCurrentScope()) {
69+
onScopeDispose(() => {
70+
watchPaused = true
71+
callback()
72+
channel?.close()
73+
})
74+
}
75+
76+
if (channel) {
77+
channel.onmessage = (event) => {
78+
watchPaused = true
79+
cookies[name] = cookie.value = opts.decode(event.data)
80+
nextTick(() => { watchPaused = false })
81+
}
82+
}
83+
84+
if (opts.watch) {
85+
watch(cookie, () => {
86+
if (watchPaused) { return }
87+
callback()
88+
},
89+
{ deep: opts.watch !== 'shallow' })
90+
} else {
91+
callback()
92+
}
3393
} else if (process.server) {
34-
const initialValue = cookie.value
3594
const nuxtApp = useNuxtApp()
36-
nuxtApp.hooks.hookOnce('app:rendered', () => {
37-
if (cookie.value !== initialValue) {
38-
writeServerCookie(useRequestEvent(nuxtApp), name, cookie.value, opts)
39-
}
95+
const writeFinalCookieValue = () => {
96+
if (opts.readonly || isEqual(cookie.value, cookies[name])) { return }
97+
writeServerCookie(useRequestEvent(nuxtApp), name, cookie.value, opts as CookieOptions<any>)
98+
}
99+
100+
const unhook = nuxtApp.hooks.hookOnce('app:rendered', writeFinalCookieValue)
101+
nuxtApp.hooks.hookOnce('app:error', () => {
102+
unhook() // don't write cookie subsequently when app:rendered is called
103+
return writeFinalCookieValue()
40104
})
41105
}
42106

@@ -45,7 +109,7 @@ export function useCookie<T = string> (name: string, _opts?: CookieOptions<T>):
45109

46110
function readRawCookies (opts: CookieOptions = {}): Record<string, string> {
47111
if (process.server) {
48-
return parse(useRequestEvent()?.req.headers.cookie || '', opts)
112+
return parse(getRequestHeader(useRequestEvent(), 'cookie') || '', opts)
49113
} else if (process.client) {
50114
return parse(document.cookie, opts)
51115
}
@@ -66,7 +130,60 @@ function writeClientCookie (name: string, value: any, opts: CookieSerializeOptio
66130

67131
function writeServerCookie (event: H3Event, name: string, value: any, opts: CookieSerializeOptions = {}) {
68132
if (event) {
69-
// TODO: Try to smart join with existing Set-Cookie headers
70-
appendHeader(event, 'Set-Cookie', serializeCookie(name, value, opts))
133+
// update if value is set
134+
if (value !== null && value !== undefined) {
135+
return setCookie(event, name, value, opts)
136+
}
137+
138+
// delete if cookie exists in browser and value is null/undefined
139+
if (getCookie(event, name) !== undefined) {
140+
return deleteCookie(event, name, opts)
141+
}
142+
143+
// else ignore if cookie doesn't exist in browser and value is null/undefined
144+
}
145+
}
146+
147+
/**
148+
* The maximum value allowed on a timeout delay.
149+
*
150+
* Reference: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value
151+
*/
152+
const MAX_TIMEOUT_DELAY = 2147483647
153+
154+
// custom ref that will update the value to undefined if the cookie expires
155+
function cookieRef<T> (value: T | undefined, delay: number) {
156+
let timeout: NodeJS.Timeout
157+
let elapsed = 0
158+
if (getCurrentScope()) {
159+
onScopeDispose(() => { clearTimeout(timeout) })
71160
}
161+
162+
return customRef((track, trigger) => {
163+
function createExpirationTimeout () {
164+
clearTimeout(timeout)
165+
const timeRemaining = delay - elapsed
166+
const timeoutLength = timeRemaining < MAX_TIMEOUT_DELAY ? timeRemaining : MAX_TIMEOUT_DELAY
167+
timeout = setTimeout(() => {
168+
elapsed += timeoutLength
169+
if (elapsed < delay) { return createExpirationTimeout() }
170+
171+
value = undefined
172+
trigger()
173+
}, timeoutLength)
174+
}
175+
176+
return {
177+
get () {
178+
track()
179+
return value
180+
},
181+
set (newValue) {
182+
createExpirationTimeout()
183+
184+
value = newValue
185+
trigger()
186+
}
187+
}
188+
})
72189
}

Diff for: packages/bridge/src/runtime/composables/ssr.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { H3Event } from 'h3'
22
import type { NuxtAppCompat } from '@nuxt/bridge-schema'
3+
import { getRequestHeaders } from 'h3'
34
import { useNuxtApp } from '../nuxt'
45

5-
export function useRequestHeaders<K extends string = string> (include: K[]): Record<K, string>
6+
export function useRequestHeaders<K extends string = string> (include: K[]): { [key in Lowercase<K>]?: string }
67
export function useRequestHeaders (): Readonly<Record<string, string>>
7-
export function useRequestHeaders (include?) {
8+
export function useRequestHeaders (include?: any[]) {
89
if (process.client) { return {} }
9-
const headers: Record<string, string> = useNuxtApp().ssrContext?.event.node.req.headers ?? {}
10+
const event = useRequestEvent()
11+
const headers = event ? getRequestHeaders(event) : {}
1012
if (!include) { return headers }
11-
return Object.fromEntries(include.filter(key => headers[key]).map(key => [key, headers[key]]))
13+
return Object.fromEntries(include.map(key => key.toLowerCase()).filter(key => headers[key]).map(key => [key, headers[key]]))
1214
}
1315

1416
export function useRequestEvent (nuxtApp: NuxtAppCompat = useNuxtApp()): H3Event {

Diff for: packages/bridge/src/runtime/nitro/renderer.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SSRContext } from 'vue-bundle-renderer/runtime'
33
import { H3Event, getQuery } from 'h3'
44
import devalue from '@nuxt/devalue'
55
import type { RuntimeConfig } from '@nuxt/schema'
6+
import type { NuxtAppCompat } from '@nuxt/bridge-schema'
67
import type { RenderResponse } from 'nitropack'
78
// @ts-ignore
89
import { useRuntimeConfig, defineRenderHandler, getRouteRules } from '#internal/nitro'
@@ -49,6 +50,7 @@ interface NuxtSSRContext extends SSRContext {
4950
nuxt?: any
5051
payload?: any
5152
renderMeta?: () => Promise<any>
53+
nuxtApp?: NuxtAppCompat
5254
}
5355

5456
interface RenderResult {
@@ -148,7 +150,8 @@ export default defineRenderHandler(async (event) => {
148150
error: ssrError,
149151
redirected: undefined,
150152
nuxt: undefined as undefined | Record<string, any>, /* Nuxt 2 payload */
151-
payload: undefined
153+
payload: undefined,
154+
nuxtApp: undefined
152155
}
153156

154157
// Render app
@@ -172,6 +175,8 @@ export default defineRenderHandler(async (event) => {
172175
throw ssrContext.nuxt.error
173176
}
174177

178+
ssrContext.nuxtApp?.hooks.callHook('app:rendered', { ssrContext, renderResult: _rendered })
179+
175180
ssrContext.nuxt = ssrContext.nuxt || {}
176181

177182
if (process.env.NUXT_FULL_STATIC) {

Diff for: playground/nuxt.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default defineNuxtConfig({
2929
})
3030
}
3131
],
32-
plugins: ['~/plugins/setup.js', '~/plugins/store.js'],
32+
plugins: ['~/plugins/setup.js', '~/plugins/store.js', '~/plugins/cookie'],
3333
nitro: {
3434
routeRules: {
3535
'/route-rules/spa': { ssr: false }

Diff for: playground/pages/cookies.vue

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup>
2+
useCookie('accessed-but-not-used')
3+
useCookie('accessed-with-default-value', () => 'default')
4+
useCookie('set').value = 'set'
5+
useCookie('set-to-null').value = null
6+
useCookie('set-to-null-with-default', () => 'default').value = null
7+
// the next set are all sent by browser
8+
useCookie('browser-accessed-but-not-used')
9+
useCookie('browser-accessed-with-default-value', () => 'default')
10+
useCookie('browser-set').value = 'set'
11+
useCookie('browser-set-to-null').value = null
12+
useCookie('browser-set-to-null-with-default', () => 'default').value = null
13+
</script>
14+
15+
<template>
16+
<div>
17+
<div>cookies testing page</div>
18+
</div>
19+
</template>

Diff for: playground/plugins/cookie.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default defineNuxtPlugin(() => {
2+
useCookie('set-in-plugin').value = 'true'
3+
})

Diff for: test/bridge.test.ts

+18
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ await setup({
1717
}
1818
})
1919

20+
describe('nuxt composables', () => {
21+
it('sets cookies correctly', async () => {
22+
const res = await fetch('/cookies', {
23+
headers: {
24+
cookie: Object.entries({
25+
'browser-accessed-but-not-used': 'provided-by-browser',
26+
'browser-accessed-with-default-value': 'provided-by-browser',
27+
'browser-set': 'provided-by-browser',
28+
'browser-set-to-null': 'provided-by-browser',
29+
'browser-set-to-null-with-default': 'provided-by-browser'
30+
}).map(([key, value]) => `${key}=${value}`).join('; ')
31+
}
32+
})
33+
const cookies = res.headers.get('set-cookie')
34+
expect(cookies).toMatchInlineSnapshot('"set-in-plugin=true; Path=/, set=set; Path=/, browser-set=set; Path=/, browser-set-to-null=; Max-Age=0; Path=/, browser-set-to-null-with-default=; Max-Age=0; Path=/"')
35+
})
36+
})
37+
2038
describe('head tags', () => {
2139
it('SSR should render tags', async () => {
2240
const headHtml = await $fetch('/head')

0 commit comments

Comments
 (0)