From 9920181df3798cb6a54e0a353b8676c48d5fc1de Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Mon, 22 Nov 2021 21:43:00 +0100 Subject: [PATCH] feat(nuxt3, bridge): `useCookie` universal composable (#2085) --- docs/content/3.docs/1.usage/6-cookies.md | 156 +++++++++++++++++++ examples/use-cookie/app.vue | 23 +++ examples/use-cookie/nuxt.config.ts | 4 + examples/use-cookie/package.json | 12 ++ examples/use-cookie/tsconfig.json | 3 + packages/bridge/package.json | 4 + packages/bridge/src/runtime/composables.ts | 1 + packages/bridge/src/runtime/cookie.ts | 1 + packages/nuxt3/package.json | 4 + packages/nuxt3/src/app/composables/cookie.ts | 77 +++++++++ packages/nuxt3/src/app/composables/index.ts | 1 + packages/nuxt3/src/auto-imports/imports.ts | 3 +- yarn.lock | 23 +++ 13 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 docs/content/3.docs/1.usage/6-cookies.md create mode 100644 examples/use-cookie/app.vue create mode 100644 examples/use-cookie/nuxt.config.ts create mode 100644 examples/use-cookie/package.json create mode 100644 examples/use-cookie/tsconfig.json create mode 120000 packages/bridge/src/runtime/cookie.ts create mode 100644 packages/nuxt3/src/app/composables/cookie.ts diff --git a/docs/content/3.docs/1.usage/6-cookies.md b/docs/content/3.docs/1.usage/6-cookies.md new file mode 100644 index 0000000000..b5ae56bf89 --- /dev/null +++ b/docs/content/3.docs/1.usage/6-cookies.md @@ -0,0 +1,156 @@ +# Cookies + +> Nuxt provides SSR-friendly composable to read and write cookies. + +## Usage + +Within your pages, components, and plugins you can use `useCookie` to create a reactive reference bound to a specific cookie. + +```js +const cookie = useCookie(name, options) +``` + +::alert{icon=👉} +**`useCookie` only works during `setup` or `Lifecycle Hooks`** +:: + +::alert{icon=😌} +`useCookie` ref will be automatically serialize and deserialized cookie value to JSON. +:: + +## Example + +The example below creates a cookie called counter and if it doesn't exist set a random value. Whenever we update `counter`, the cookie will be updated. + +```vue + + + +``` + +:button-link[Open on StackBlitz]{href="https://stackblitz.com/github/nuxt/framework/tree/main/examples/use-cookie?terminal=dev" blank} + +## Options + +Cookie composable accepts these properties in the options. Use them to modify the behavior of cookies. + +Most of the options will be directly passed to [cookie](https://github.com/jshttp/cookie) package. + +### `maxAge` / `expires` + +**`maxAge`** Specifies the `number` (in seconds) to be the value for the [`Max-Age` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.2). +The given number will be converted to an integer by rounding down. By default, no maximum age is set. + +**`expires`**: Specifies the `Date` object to be the value for the [`Expires` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.1). +By default, no expiration is set, and most clients will consider this a "non-persistent cookie" and +will delete it on a condition like exiting a web browser application. + +::alert{icon=💡} +**Note:** The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states that if both `expires` and +`maxAge` are set, then `maxAge` takes precedence, but it is possible not all clients obey this, +so if both are set, they should point to the same date and time.eaks! +:: + +::alert +If neither of `expires` and `maxAge` are set, cookie will be session-only and removed if the user closes their browser. +:: + +#### `httpOnly` + +Specifies the `boolean` value for the [`HttpOnly` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.6). When truthy, +the `HttpOnly` attribute is set, otherwise, it is not. By default, the `HttpOnly` attribute is not set. + +::alert{icon=💡} +**Note** be careful when setting this to `true`, as compliant clients will not allow client-side +JavaScript to see the cookie in `document.cookie`. +:: + +#### `secure` + +Specifies the `boolean` value for the [`Secure` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.5). When truthy, +the `Secure` attribute is set, otherwise,it is not. By default, the `Secure` attribute is not set. + +::alert{icon=💡} +**Note:** be careful when setting this to `true`, as compliant clients will not send the cookie back to +the server in the future if the browser does not have an HTTPS connection. This can lead to hydration errors. +:: + +#### `domain` + +Specifies the value for the [`Domain` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.3). By default, no +domain is set, and most clients will consider the cookie to apply to only the current domain. + +#### `path` + +Specifies the value for the [`Path` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.4). By default, the path +is considered the ["default path"](https://tools.ietf.org/html/rfc6265#section-5.1.4). + +#### `sameSite` + +Specifies the `boolean` or `string` to be the value for the [`SameSite` `Set-Cookie` attribute](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7). + +- `true` will set the `SameSite` attribute to `Strict` for strict same site enforcement. +- `false` will not set the `SameSite` attribute. +- `'lax'` will set the `SameSite` attribute to `Lax` for lax same site enforcement. +- `'none'` will set the `SameSite` attribute to `None` for an explicit cross-site cookie. +- `'strict'` will set the `SameSite` attribute to `Strict` for strict same site enforcement. + +More information about the different enforcement levels can be found in [the specification](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7). + +#### `encode` + +Specifies a function that will be used to encode a cookie's value. Since value of a cookie +has a limited character set (and must be a simple string), this function can be used to encode +a value into a string suited for a cookie's value. + +The default encoder is the `JSON.stringify` + `encodeURIComponent`. + +#### `decode` + +Specifies a function that will be used to decode a cookie's value. Since the value of a cookie +has a limited character set (and must be a simple string), this function can be used to decode +a previously-encoded cookie value into a JavaScript string or other object. + +The default decoder is `decodeURIComponent` + [destr](https://github.com/unjs/destr). + +::alert{icon=💡} +**Note** if an error is thrown from this function, the original, non-decoded cookie value will +be returned as the cookie's value. +:: + +## Handling cookies in API routes + +You can use `useCookie` and `setCookie` from [`h3`](https://github.com/unjs/h3) package to set cookies in server API routes. + +**Example:** + +```js +import { useCookie, setCookie } from 'h3' + +export default (req, res) => { + // Reat counter cookie + let counter = useCookie(req, 'counter') || 0 + + // Increase counter cookie by 1 + setCookie(res, 'counter', ++counter) + + // Send JSON response + return { counter } +} +``` diff --git a/examples/use-cookie/app.vue b/examples/use-cookie/app.vue new file mode 100644 index 0000000000..7cbe0e6f98 --- /dev/null +++ b/examples/use-cookie/app.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/examples/use-cookie/nuxt.config.ts b/examples/use-cookie/nuxt.config.ts new file mode 100644 index 0000000000..a3e4d68096 --- /dev/null +++ b/examples/use-cookie/nuxt.config.ts @@ -0,0 +1,4 @@ +import { defineNuxtConfig } from 'nuxt3' + +export default defineNuxtConfig({ +}) diff --git a/examples/use-cookie/package.json b/examples/use-cookie/package.json new file mode 100644 index 0000000000..95fa988e2a --- /dev/null +++ b/examples/use-cookie/package.json @@ -0,0 +1,12 @@ +{ + "name": "example-use-cookie", + "private": true, + "devDependencies": { + "nuxt3": "latest" + }, + "scripts": { + "dev": "nuxi dev", + "build": "nuxi build", + "start": "node .output/server/index.mjs" + } +} diff --git a/examples/use-cookie/tsconfig.json b/examples/use-cookie/tsconfig.json new file mode 100644 index 0000000000..4b34df1571 --- /dev/null +++ b/examples/use-cookie/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/bridge/package.json b/packages/bridge/package.json index f96fa1f836..c5ec6f0c96 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -23,17 +23,21 @@ "@nuxt/nitro": "3.0.0", "@nuxt/postcss8": "^1.1.3", "@nuxt/schema": "3.0.0", + "@types/cookie": "^0.4.1", "@vitejs/plugin-legacy": "^1.6.3", "@vue/composition-api": "^1.4.0", "@vueuse/head": "^0.7.2", "acorn": "^8.6.0", "consola": "^2.15.3", + "cookie": "^0.4.1", "defu": "^5.0.0", + "destr": "^1.1.0", "enhanced-resolve": "^5.8.3", "estree-walker": "^2.0.2", "externality": "^0.1.5", "fs-extra": "^10.0.0", "globby": "^11.0.4", + "h3": "^0.3.3", "hash-sum": "^2.0.0", "magic-string": "^0.25.7", "mlly": "^0.3.13", diff --git a/packages/bridge/src/runtime/composables.ts b/packages/bridge/src/runtime/composables.ts index 97a427baaf..3673b7eabb 100644 --- a/packages/bridge/src/runtime/composables.ts +++ b/packages/bridge/src/runtime/composables.ts @@ -8,6 +8,7 @@ import { useNuxtApp } from './app' export { useLazyAsyncData } from './asyncData' export { useLazyFetch } from './fetch' +export { useCookie } from './cookie' export * from '@vue/composition-api' diff --git a/packages/bridge/src/runtime/cookie.ts b/packages/bridge/src/runtime/cookie.ts new file mode 120000 index 0000000000..34643ff8ad --- /dev/null +++ b/packages/bridge/src/runtime/cookie.ts @@ -0,0 +1 @@ +../../../nuxt3/src/app/composables/cookie.ts \ No newline at end of file diff --git a/packages/nuxt3/package.json b/packages/nuxt3/package.json index c5e28b0528..a581e235c9 100644 --- a/packages/nuxt3/package.json +++ b/packages/nuxt3/package.json @@ -25,13 +25,17 @@ "@nuxt/schema": "3.0.0", "@nuxt/vite-builder": "3.0.0", "@nuxt/webpack-builder": "3.0.0", + "@types/cookie": "^0.4.1", "@vue/reactivity": "^3.2.22", "@vue/shared": "^3.2.22", "@vueuse/head": "^0.7.2", "chokidar": "^3.5.2", "consola": "^2.15.3", + "cookie": "^0.4.1", "defu": "^5.0.0", + "destr": "^1.1.0", "globby": "^11.0.4", + "h3": "^0.3.3", "hash-sum": "^2.0.0", "hookable": "^5.0.0", "ignore": "^5.1.9", diff --git a/packages/nuxt3/src/app/composables/cookie.ts b/packages/nuxt3/src/app/composables/cookie.ts new file mode 100644 index 0000000000..d50af4008d --- /dev/null +++ b/packages/nuxt3/src/app/composables/cookie.ts @@ -0,0 +1,77 @@ +import type { ServerResponse } from 'http' +import { Ref, ref, watch } from 'vue' +import type { CookieParseOptions, CookieSerializeOptions } from 'cookie' +import { parse, serialize } from 'cookie' +import { appendHeader } from 'h3' +import type { NuxtApp } from '@nuxt/schema' +import destr from 'destr' +import { useNuxtApp } from '#app' + +type _CookieOptions = Omit +export interface CookieOptions extends _CookieOptions { + decode?(value: string): T + encode?(value: T): string; +} + +export interface CookieRef extends Ref {} + +const CookieDefaults: CookieOptions = { + decode: val => destr(decodeURIComponent(val)), + encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)) +} + +export function useCookie (name: string, _opts: CookieOptions): CookieRef { + const opts = { ...CookieDefaults, ..._opts } + const cookies = readRawCookies(opts) + + const cookie = ref(opts.decode(cookies[name])) + + if (process.client) { + watch(cookie, () => { writeClientCookie(name, cookie.value, opts) }) + } else if (process.server) { + const initialValue = cookie.value + const nuxtApp = useNuxtApp() + nuxtApp.hooks.hookOnce('app:rendered', () => { + if (cookie.value !== initialValue) { + // @ts-ignore + writeServerCookie(useSSRRes(nuxtApp), name, cookie.value, opts) + } + }) + } + + return cookie +} + +// @ts-ignore +function useSSRReq (nuxtApp?: NuxtApp = useNuxtApp()) { return nuxtApp.ssrContext?.req } + +// @ts-ignore +function useSSRRes (nuxtApp?: NuxtApp = useNuxtApp()) { return nuxtApp.ssrContext?.res } + +function readRawCookies (opts: CookieOptions = {}): Record { + if (process.server) { + return parse(useSSRReq().headers.cookie || '', opts) + } else if (process.client) { + return parse(document.cookie, opts) + } +} + +function serializeCookie (name: string, value: any, opts: CookieSerializeOptions = {}) { + if (value === null || value === undefined) { + opts.maxAge = -1 + } + return serialize(name, value, opts) +} + +function writeClientCookie (name: string, value: any, opts: CookieSerializeOptions = {}) { + if (process.client) { + document.cookie = serializeCookie(name, value, opts) + } +} + +function writeServerCookie (res: ServerResponse, name: string, value: any, opts: CookieSerializeOptions = {}) { + if (res) { + // TODO: Try to smart join with exisiting Set-Cookie headers + appendHeader(res, 'Set-Cookie', serializeCookie(name, value, opts)) + } +} diff --git a/packages/nuxt3/src/app/composables/index.ts b/packages/nuxt3/src/app/composables/index.ts index 263fa7def8..0344c0ef24 100644 --- a/packages/nuxt3/src/app/composables/index.ts +++ b/packages/nuxt3/src/app/composables/index.ts @@ -3,3 +3,4 @@ export { useAsyncData, useLazyAsyncData } from './asyncData' export { useHydration } from './hydrate' export { useState } from './state' export { useFetch, useLazyFetch } from './fetch' +export { useCookie } from './cookie' diff --git a/packages/nuxt3/src/auto-imports/imports.ts b/packages/nuxt3/src/auto-imports/imports.ts index 6c0504a7fc..1978c22dee 100644 --- a/packages/nuxt3/src/auto-imports/imports.ts +++ b/packages/nuxt3/src/auto-imports/imports.ts @@ -13,7 +13,8 @@ export const Nuxt3AutoImports: AutoImportSource[] = [ 'useRuntimeConfig', 'useState', 'useFetch', - 'useLazyFetch' + 'useLazyFetch', + 'useCookie' ] }, // #meta diff --git a/yarn.lock b/yarn.lock index 82251676f5..e6035b0eb1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2427,6 +2427,7 @@ __metadata: "@nuxt/postcss8": ^1.1.3 "@nuxt/schema": 3.0.0 "@nuxt/types": ^2.15.8 + "@types/cookie": ^0.4.1 "@types/fs-extra": ^9.0.13 "@types/hash-sum": ^1.0.0 "@types/node-fetch": ^3.0.2 @@ -2435,12 +2436,15 @@ __metadata: "@vueuse/head": ^0.7.2 acorn: ^8.6.0 consola: ^2.15.3 + cookie: ^0.4.1 defu: ^5.0.0 + destr: ^1.1.0 enhanced-resolve: ^5.8.3 estree-walker: ^2.0.2 externality: ^0.1.5 fs-extra: ^10.0.0 globby: ^11.0.4 + h3: ^0.3.3 hash-sum: ^2.0.0 magic-string: ^0.25.7 mlly: ^0.3.13 @@ -3800,6 +3804,13 @@ __metadata: languageName: node linkType: hard +"@types/cookie@npm:^0.4.1": + version: 0.4.1 + resolution: "@types/cookie@npm:0.4.1" + checksum: 3275534ed69a76c68eb1a77d547d75f99fedc80befb75a3d1d03662fb08d697e6f8b1274e12af1a74c6896071b11510631ba891f64d30c78528d0ec45a9c1a18 + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.0": version: 3.7.1 resolution: "@types/eslint-scope@npm:3.7.1" @@ -10491,6 +10502,14 @@ __metadata: languageName: unknown linkType: soft +"example-use-cookie@workspace:examples/use-cookie": + version: 0.0.0-use.local + resolution: "example-use-cookie@workspace:examples/use-cookie" + dependencies: + nuxt3: latest + languageName: unknown + linkType: soft + "example-use-fetch@workspace:examples/use-fetch": version: 0.0.0-use.local resolution: "example-use-fetch@workspace:examples/use-fetch" @@ -15269,14 +15288,18 @@ __metadata: "@nuxt/schema": 3.0.0 "@nuxt/vite-builder": 3.0.0 "@nuxt/webpack-builder": 3.0.0 + "@types/cookie": ^0.4.1 "@types/hash-sum": ^1.0.0 "@vue/reactivity": ^3.2.22 "@vue/shared": ^3.2.22 "@vueuse/head": ^0.7.2 chokidar: ^3.5.2 consola: ^2.15.3 + cookie: ^0.4.1 defu: ^5.0.0 + destr: ^1.1.0 globby: ^11.0.4 + h3: ^0.3.3 hash-sum: ^2.0.0 hookable: ^5.0.0 ignore: ^5.1.9