diff --git a/packages/bridge/src/runtime/app.plugin.mjs b/packages/bridge/src/runtime/app.plugin.mjs index 7f9fbfbb..4a2828ac 100644 --- a/packages/bridge/src/runtime/app.plugin.mjs +++ b/packages/bridge/src/runtime/app.plugin.mjs @@ -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 () { diff --git a/packages/bridge/src/runtime/composables/cookie.ts b/packages/bridge/src/runtime/composables/cookie.ts index 7a6eee29..240912c8 100644 --- a/packages/bridge/src/runtime/composables/cookie.ts +++ b/packages/bridge/src/runtime/composables/cookie.ts @@ -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' @@ -12,31 +15,92 @@ export interface CookieOptions extends _CookieOptions { decode?(value: string): T encode?(value: T): string default?: () => T + watch?: boolean | 'shallow' + readonly?: boolean } export interface CookieRef extends Ref {} -const CookieDefaults: CookieOptions = { +const CookieDefaults = { path: '/', + watch: true, decode: val => destr(decodeURIComponent(val)), encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)) -} +} satisfies CookieOptions -export function useCookie (name: string, _opts?: CookieOptions): CookieRef { +export function useCookie (name: string, _opts?: CookieOptions & { readonly?: false }): CookieRef +export function useCookie (name: string, _opts: CookieOptions & { readonly: true }): Readonly> +export function useCookie (name: string, _opts?: CookieOptions): CookieRef { 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(cookieValue, delay) + : ref(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) + } + + 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() }) } @@ -45,7 +109,7 @@ export function useCookie (name: string, _opts?: CookieOptions): function readRawCookies (opts: CookieOptions = {}): Record { 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) } @@ -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 (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() + } + } + }) } diff --git a/packages/bridge/src/runtime/composables/ssr.ts b/packages/bridge/src/runtime/composables/ssr.ts index ec34cd42..03c5ea4c 100644 --- a/packages/bridge/src/runtime/composables/ssr.ts +++ b/packages/bridge/src/runtime/composables/ssr.ts @@ -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 (include: K[]): Record +export function useRequestHeaders (include: K[]): { [key in Lowercase]?: string } export function useRequestHeaders (): Readonly> -export function useRequestHeaders (include?) { +export function useRequestHeaders (include?: any[]) { if (process.client) { return {} } - const headers: Record = 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 { diff --git a/packages/bridge/src/runtime/nitro/renderer.ts b/packages/bridge/src/runtime/nitro/renderer.ts index 71cea2dd..bf51a400 100644 --- a/packages/bridge/src/runtime/nitro/renderer.ts +++ b/packages/bridge/src/runtime/nitro/renderer.ts @@ -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' @@ -49,6 +50,7 @@ interface NuxtSSRContext extends SSRContext { nuxt?: any payload?: any renderMeta?: () => Promise + nuxtApp?: NuxtAppCompat } interface RenderResult { @@ -148,7 +150,8 @@ export default defineRenderHandler(async (event) => { error: ssrError, redirected: undefined, nuxt: undefined as undefined | Record, /* Nuxt 2 payload */ - payload: undefined + payload: undefined, + nuxtApp: undefined } // Render app @@ -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) { diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index dfbf7a58..9067f6db 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -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 } diff --git a/playground/pages/cookies.vue b/playground/pages/cookies.vue new file mode 100644 index 00000000..45c67460 --- /dev/null +++ b/playground/pages/cookies.vue @@ -0,0 +1,19 @@ + + + diff --git a/playground/plugins/cookie.ts b/playground/plugins/cookie.ts new file mode 100644 index 00000000..8e969548 --- /dev/null +++ b/playground/plugins/cookie.ts @@ -0,0 +1,3 @@ +export default defineNuxtPlugin(() => { + useCookie('set-in-plugin').value = 'true' +}) diff --git a/test/bridge.test.ts b/test/bridge.test.ts index a7798d65..c74b6be3 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -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')