Skip to content

Commit

Permalink
feat!: port upstream useCookie (#988)
Browse files Browse the repository at this point in the history
  • Loading branch information
wattanx authored Nov 30, 2023
1 parent 3c40b3f commit 532e9d7
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 23 deletions.
1 change: 1 addition & 0 deletions packages/bridge/src/runtime/app.plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default async (ctx, inject) => {

if (process.server) {
nuxtApp.ssrContext = ctx.ssrContext
nuxtApp.ssrContext.nuxtApp = nuxtApp
}

ctx.app.created.push(function () {
Expand Down
151 changes: 134 additions & 17 deletions packages/bridge/src/runtime/composables/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Ref, ref, watch } from 'vue'
import { parse, serialize, CookieParseOptions, CookieSerializeOptions } from 'cookie-es'
import { appendHeader } from 'h3'
import type { Ref } from 'vue'
import { ref, watch, getCurrentScope, onScopeDispose, customRef, nextTick } from 'vue'
import { parse, serialize } from 'cookie-es'
import type { CookieParseOptions, CookieSerializeOptions } from 'cookie-es'
import { deleteCookie, getCookie, getRequestHeader, setCookie } from 'h3'
import type { H3Event } from 'h3'
import destr from 'destr'
import { isEqual } from 'ohash'
import { useNuxtApp } from '../nuxt'
import { useRequestEvent } from './ssr'

Expand All @@ -12,31 +15,92 @@ export interface CookieOptions<T = any> extends _CookieOptions {
decode?(value: string): T
encode?(value: T): string
default?: () => T
watch?: boolean | 'shallow'
readonly?: boolean
}

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

const CookieDefaults: CookieOptions<any> = {
const CookieDefaults = {
path: '/',
watch: true,
decode: val => destr(decodeURIComponent(val)),
encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val))
}
} satisfies CookieOptions<any>

export function useCookie<T = string> (name: string, _opts?: CookieOptions<T>): CookieRef<T> {
export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T> & { readonly?: false }): CookieRef<T>
export function useCookie<T = string | null | undefined> (name: string, _opts: CookieOptions<T> & { readonly: true }): Readonly<CookieRef<T>>
export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T>): CookieRef<T> {
const opts = { ...CookieDefaults, ..._opts }
const cookies = readRawCookies(opts)
const cookies = readRawCookies(opts) || {}

let delay: number | undefined

if (opts.maxAge !== undefined) {
delay = opts.maxAge * 1000 // convert to ms for setTimeout
} else if (opts.expires) {
// getTime() already returns time in ms
delay = opts.expires.getTime() - Date.now()
}

const cookie = ref(cookies[name] ?? opts.default?.())
const hasExpired = delay !== undefined && delay <= 0
const cookieValue = hasExpired ? undefined : (cookies[name] as any) ?? opts.default?.()

// use a custom ref to expire the cookie on client side otherwise use basic ref
const cookie = process.client && delay && !hasExpired
? cookieRef<T | undefined>(cookieValue, delay)
: ref<T | undefined>(cookieValue)

if (process.dev && hasExpired) {
console.warn(`[nuxt] not setting cookie \`${name}\` as it has already expired.`)
}

if (process.client) {
watch(cookie, () => { writeClientCookie(name, cookie.value, opts as CookieSerializeOptions) })
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`)
const callback = () => {
if (opts.readonly || isEqual(cookie.value, cookies[name])) { return }
writeClientCookie(name, cookie.value, opts as CookieSerializeOptions)
channel?.postMessage(opts.encode(cookie.value as T))
}

let watchPaused = false

if (getCurrentScope()) {
onScopeDispose(() => {
watchPaused = true
callback()
channel?.close()
})
}

if (channel) {
channel.onmessage = (event) => {
watchPaused = true
cookies[name] = cookie.value = opts.decode(event.data)
nextTick(() => { watchPaused = false })
}
}

if (opts.watch) {
watch(cookie, () => {
if (watchPaused) { return }
callback()
},
{ deep: opts.watch !== 'shallow' })
} else {
callback()
}
} else if (process.server) {
const initialValue = cookie.value
const nuxtApp = useNuxtApp()
nuxtApp.hooks.hookOnce('app:rendered', () => {
if (cookie.value !== initialValue) {
writeServerCookie(useRequestEvent(nuxtApp), name, cookie.value, opts)
}
const writeFinalCookieValue = () => {
if (opts.readonly || isEqual(cookie.value, cookies[name])) { return }
writeServerCookie(useRequestEvent(nuxtApp), name, cookie.value, opts as CookieOptions<any>)
}

const unhook = nuxtApp.hooks.hookOnce('app:rendered', writeFinalCookieValue)
nuxtApp.hooks.hookOnce('app:error', () => {
unhook() // don't write cookie subsequently when app:rendered is called
return writeFinalCookieValue()
})
}

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

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

function writeServerCookie (event: H3Event, name: string, value: any, opts: CookieSerializeOptions = {}) {
if (event) {
// TODO: Try to smart join with existing Set-Cookie headers
appendHeader(event, 'Set-Cookie', serializeCookie(name, value, opts))
// update if value is set
if (value !== null && value !== undefined) {
return setCookie(event, name, value, opts)
}

// delete if cookie exists in browser and value is null/undefined
if (getCookie(event, name) !== undefined) {
return deleteCookie(event, name, opts)
}

// else ignore if cookie doesn't exist in browser and value is null/undefined
}
}

/**
* The maximum value allowed on a timeout delay.
*
* Reference: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value
*/
const MAX_TIMEOUT_DELAY = 2147483647

// custom ref that will update the value to undefined if the cookie expires
function cookieRef<T> (value: T | undefined, delay: number) {
let timeout: NodeJS.Timeout
let elapsed = 0
if (getCurrentScope()) {
onScopeDispose(() => { clearTimeout(timeout) })
}

return customRef((track, trigger) => {
function createExpirationTimeout () {
clearTimeout(timeout)
const timeRemaining = delay - elapsed
const timeoutLength = timeRemaining < MAX_TIMEOUT_DELAY ? timeRemaining : MAX_TIMEOUT_DELAY
timeout = setTimeout(() => {
elapsed += timeoutLength
if (elapsed < delay) { return createExpirationTimeout() }

value = undefined
trigger()
}, timeoutLength)
}

return {
get () {
track()
return value
},
set (newValue) {
createExpirationTimeout()

value = newValue
trigger()
}
}
})
}
10 changes: 6 additions & 4 deletions packages/bridge/src/runtime/composables/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { H3Event } from 'h3'
import type { NuxtAppCompat } from '@nuxt/bridge-schema'
import { getRequestHeaders } from 'h3'
import { useNuxtApp } from '../nuxt'

export function useRequestHeaders<K extends string = string> (include: K[]): Record<K, string>
export function useRequestHeaders<K extends string = string> (include: K[]): { [key in Lowercase<K>]?: string }
export function useRequestHeaders (): Readonly<Record<string, string>>
export function useRequestHeaders (include?) {
export function useRequestHeaders (include?: any[]) {
if (process.client) { return {} }
const headers: Record<string, string> = useNuxtApp().ssrContext?.event.node.req.headers ?? {}
const event = useRequestEvent()
const headers = event ? getRequestHeaders(event) : {}
if (!include) { return headers }
return Object.fromEntries(include.filter(key => headers[key]).map(key => [key, headers[key]]))
return Object.fromEntries(include.map(key => key.toLowerCase()).filter(key => headers[key]).map(key => [key, headers[key]]))
}

export function useRequestEvent (nuxtApp: NuxtAppCompat = useNuxtApp()): H3Event {
Expand Down
7 changes: 6 additions & 1 deletion packages/bridge/src/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SSRContext } from 'vue-bundle-renderer/runtime'
import { H3Event, getQuery } from 'h3'
import devalue from '@nuxt/devalue'
import type { RuntimeConfig } from '@nuxt/schema'
import type { NuxtAppCompat } from '@nuxt/bridge-schema'
import type { RenderResponse } from 'nitropack'
// @ts-ignore
import { useRuntimeConfig, defineRenderHandler, getRouteRules } from '#internal/nitro'
Expand Down Expand Up @@ -49,6 +50,7 @@ interface NuxtSSRContext extends SSRContext {
nuxt?: any
payload?: any
renderMeta?: () => Promise<any>
nuxtApp?: NuxtAppCompat
}

interface RenderResult {
Expand Down Expand Up @@ -148,7 +150,8 @@ export default defineRenderHandler(async (event) => {
error: ssrError,
redirected: undefined,
nuxt: undefined as undefined | Record<string, any>, /* Nuxt 2 payload */
payload: undefined
payload: undefined,
nuxtApp: undefined
}

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

ssrContext.nuxtApp?.hooks.callHook('app:rendered', { ssrContext, renderResult: _rendered })

ssrContext.nuxt = ssrContext.nuxt || {}

if (process.env.NUXT_FULL_STATIC) {
Expand Down
2 changes: 1 addition & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default defineNuxtConfig({
})
}
],
plugins: ['~/plugins/setup.js', '~/plugins/store.js'],
plugins: ['~/plugins/setup.js', '~/plugins/store.js', '~/plugins/cookie'],
nitro: {
routeRules: {
'/route-rules/spa': { ssr: false }
Expand Down
19 changes: 19 additions & 0 deletions playground/pages/cookies.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup>
useCookie('accessed-but-not-used')
useCookie('accessed-with-default-value', () => 'default')
useCookie('set').value = 'set'
useCookie('set-to-null').value = null
useCookie('set-to-null-with-default', () => 'default').value = null
// the next set are all sent by browser
useCookie('browser-accessed-but-not-used')
useCookie('browser-accessed-with-default-value', () => 'default')
useCookie('browser-set').value = 'set'
useCookie('browser-set-to-null').value = null
useCookie('browser-set-to-null-with-default', () => 'default').value = null
</script>

<template>
<div>
<div>cookies testing page</div>
</div>
</template>
3 changes: 3 additions & 0 deletions playground/plugins/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default defineNuxtPlugin(() => {
useCookie('set-in-plugin').value = 'true'
})
18 changes: 18 additions & 0 deletions test/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ await setup({
}
})

describe('nuxt composables', () => {
it('sets cookies correctly', async () => {
const res = await fetch('/cookies', {
headers: {
cookie: Object.entries({
'browser-accessed-but-not-used': 'provided-by-browser',
'browser-accessed-with-default-value': 'provided-by-browser',
'browser-set': 'provided-by-browser',
'browser-set-to-null': 'provided-by-browser',
'browser-set-to-null-with-default': 'provided-by-browser'
}).map(([key, value]) => `${key}=${value}`).join('; ')
}
})
const cookies = res.headers.get('set-cookie')
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=/"')
})
})

describe('head tags', () => {
it('SSR should render tags', async () => {
const headHtml = await $fetch('/head')
Expand Down

0 comments on commit 532e9d7

Please sign in to comment.