diff --git a/packages/react-network/package.json b/packages/react-network/package.json index cce43a83be..4c449fcd9c 100644 --- a/packages/react-network/package.json +++ b/packages/react-network/package.json @@ -27,8 +27,10 @@ "@shopify/network": "^1.4.2", "@shopify/react-effect": "^3.2.3", "@types/accept-language-parser": "1.5.0", - "@types/koa": "^2.0.46", "accept-language-parser": "1.5.0", + "@types/cookie": "^0.3.3", + "@types/koa": "^2.0.46", + "cookie": "^0.4.0", "tslib": "^1.9.3" }, "files": [ diff --git a/packages/react-network/src/hooks.ts b/packages/react-network/src/hooks.ts index 0ba9687ce8..713e87d0a1 100644 --- a/packages/react-network/src/hooks.ts +++ b/packages/react-network/src/hooks.ts @@ -1,10 +1,11 @@ -import {useContext} from 'react'; import {parse, Language} from 'accept-language-parser'; +import {useContext, useState, useEffect} from 'react'; +import {CookieSerializeOptions} from 'cookie'; import {CspDirective, StatusCode, Header} from '@shopify/network'; import {useServerEffect} from '@shopify/react-effect'; import {NetworkContext} from './context'; -import {NetworkManager} from './manager'; +import {NetworkManager, setBrowserCookie} from './manager'; export function useNetworkEffect(perform: (network: NetworkManager) => void) { const network = useContext(NetworkContext); @@ -48,3 +49,46 @@ export function useAcceptLanguage( return locales; } + +export function useServerCookie(key: string) { + console.log('using server cookie', key); + useNetworkEffect(network => { + console.log(network); + const cookie = network.getCookie(key); + console.log(cookie, key); + return cookie; + }); +} + +export function useCookie( + key: string, +): [ + string | undefined, + (value: string, options?: CookieSerializeOptions) => void +] { + const network = useContext(NetworkContext); + const [cookie, setCookieValue] = useState(() => { + console.log('the network is', network); + return network ? network.getCookie(key) : undefined; + }); + + useEffect( + () => { + console.log('the network is', network); + }, + [network], + ); + + const setCookie = (value: string, options?: CookieSerializeOptions) => { + setCookieValue(value); + + if (!network) { + setBrowserCookie(key, value, options); + return; + } + + network.setCookie(key, value, options); + }; + + return [cookie, setCookie]; +} diff --git a/packages/react-network/src/index.ts b/packages/react-network/src/index.ts index 40ca31a5e6..2a93c67ee2 100644 --- a/packages/react-network/src/index.ts +++ b/packages/react-network/src/index.ts @@ -11,4 +11,6 @@ export { useRequestHeader, useRedirect, useAcceptLanguage, + useCookie, + useServerCookie, } from './hooks'; diff --git a/packages/react-network/src/manager.ts b/packages/react-network/src/manager.ts index ff2a106054..e32906e78f 100644 --- a/packages/react-network/src/manager.ts +++ b/packages/react-network/src/manager.ts @@ -1,3 +1,5 @@ +import {Context} from 'koa'; +import cookie, {CookieSerializeOptions} from 'cookie'; import {StatusCode, CspDirective, Header} from '@shopify/network'; import {EffectKind} from '@shopify/react-effect'; @@ -5,10 +7,6 @@ export {NetworkContext} from './context'; export const EFFECT_ID = Symbol('network'); -interface Options { - headers?: Record; -} - export class NetworkManager { readonly effect: EffectKind = { id: EFFECT_ID, @@ -25,9 +23,24 @@ export class NetworkManager { private readonly csp = new Map(); private readonly headers = new Map(); private readonly requestHeaders: Record; - - constructor({headers}: Options = {}) { - this.requestHeaders = normalizeHeaders(headers); + private readonly cookies = new Map< + string, + { + value: string; + } & CookieSerializeOptions + >(); + + constructor(ctx: Context) { + this.requestHeaders = lowercaseEntries(ctx.headers); + const cookies = ctx.request.headers.cookie || ''; + + const parsedCookies: object = + typeof cookies === 'string' ? cookie.parse(cookies) : cookies; + + Object.entries(parsedCookies).forEach(([key, value]) => { + console.log(key, value); + this.cookies.set(key, {value}); + }); } reset() { @@ -45,6 +58,26 @@ export class NetworkManager { this.headers.set(header, value); } + getCookie(cookie: string) { + const value = this.cookies.get(cookie.toLowerCase()); + return value && value.value; + } + + setCookie( + cookie: string, + value: string, + options: CookieSerializeOptions = {}, + ) { + this.cookies.set(cookie, {value, ...options}); + // sync server cookie + setBrowserCookie(cookie, value, options); + } + + removeCookie(cookie: string) { + this.cookies.delete(cookie); + setBrowserCookie(cookie, ''); + } + redirectTo(url: string, status = StatusCode.Found) { this.addStatusCode(status); this.redirectUrl = url; @@ -89,6 +122,7 @@ export class NetworkManager { .join('; '); const headers = new Map(this.headers); + const cookies = new Map(this.cookies); if (csp) { headers.set(Header.ContentSecurityPolicy, csp); @@ -100,18 +134,37 @@ export class NetworkManager { ? this.statusCodes.reduce((large, code) => Math.max(large, code), 0) : undefined, headers, + cookies, redirectUrl: this.redirectUrl, }; } } -function normalizeHeaders(headers: undefined | Record) { - if (!headers) { +function lowercaseEntries(entries: undefined | Record) { + if (!entries) { return {}; } - return Object.entries(headers).reduce((accumulator, [key, value]) => { + return Object.entries(entries).reduce((accumulator, [key, value]) => { accumulator[key.toLowerCase()] = value; return accumulator; }, {}); } + +function isBrowser() { + return Boolean( + typeof document === 'object' && typeof document.cookie === 'string', + ); +} + +export function setBrowserCookie( + name: string, + value: string, + options?: CookieSerializeOptions, +) { + if (!isBrowser()) { + return; + } + + document.cookie = cookie.serialize(name, value, options); +} diff --git a/packages/react-network/src/server.ts b/packages/react-network/src/server.ts index 9b034c3619..5615d2498f 100644 --- a/packages/react-network/src/server.ts +++ b/packages/react-network/src/server.ts @@ -8,7 +8,7 @@ export function applyToContext( ctx: T, manager: NetworkManager, ) { - const {status, redirectUrl, headers} = manager.extract(); + const {status, redirectUrl, headers, cookies} = manager.extract(); if (redirectUrl) { ctx.redirect(redirectUrl); @@ -22,5 +22,15 @@ export function applyToContext( ctx.set(header, value); } + for (const [cookie, options] of cookies) { + const {value, ...rest} = options; + console.log('setting server ', cookie, value); + ctx.cookies.set(cookie, value, rest as any); + } + + // eslint-disable-next-line no-warning-comments + // TODO: Add a watcher function that reapplies the cookies + // to context when they change + return ctx; } diff --git a/packages/react-network/src/test/e2e.test.tsx b/packages/react-network/src/test/e2e.test.tsx index fa09d90422..8dbf8eb145 100644 --- a/packages/react-network/src/test/e2e.test.tsx +++ b/packages/react-network/src/test/e2e.test.tsx @@ -8,6 +8,7 @@ import { useCspDirective, useRedirect, useHeader, + useCookie, StatusCode, CspDirective, Header, @@ -23,10 +24,12 @@ describe('e2e', () => { if (pass > 0) { return; } + const [, setFooCookie] = useCookie('foo'); useStatus(StatusCode.NotFound); useCspDirective(CspDirective.ChildSrc, 'https://*'); useHeader(Header.CacheControl, 'no-cache'); + setFooCookie('bar'); }); await extract(, { @@ -40,6 +43,7 @@ describe('e2e', () => { const extracted = networkManager.extract(); expect(extracted).toHaveProperty('headers.size', 0); expect(extracted).toHaveProperty('status', undefined); + expect(extracted).toHaveProperty('cookies.size', 0); }); it('bails out when a redirect is set', async () => { diff --git a/packages/react-network/src/test/hooks.test.tsx b/packages/react-network/src/test/hooks.test.tsx index a6f046dada..4314439c25 100644 --- a/packages/react-network/src/test/hooks.test.tsx +++ b/packages/react-network/src/test/hooks.test.tsx @@ -6,45 +6,47 @@ import {NetworkManager} from '../manager'; import {NetworkContext} from '../context'; import {useAcceptLanguage} from '../hooks'; -describe('useAcceptLanguage()', () => { - function MockComponent({ - fallback, - }: { - fallback?: FirstArgument; - }) { - const locales = useAcceptLanguage(fallback); +describe('hooks', () => { + describe('useAcceptLanguage()', () => { + function MockComponent({ + fallback, + }: { + fallback?: FirstArgument; + }) { + const locales = useAcceptLanguage(fallback); - const localeCodes = locales.map(local => local.code).join(' '); - return <>{localeCodes}; - } + const localeCodes = locales.map(local => local.code).join(' '); + return <>{localeCodes}; + } - it('returns the locale from the language header', async () => { - const language = 'it'; - const wrapper = await mount(, {language}); + it('returns the locale from the language header', async () => { + const language = 'it'; + const wrapper = await mount(, {language}); - expect(wrapper).toContainReactText(language); - }); + expect(wrapper).toContainReactText(language); + }); - it('parses codes from multiple languages in various formats', async () => { - const language = 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'; - const wrapper = await mount(, {language}); + it('parses codes from multiple languages in various formats', async () => { + const language = 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'; + const wrapper = await mount(, {language}); - expect(wrapper).toContainReactText('fr fr en de *'); - }); + expect(wrapper).toContainReactText('fr fr en de *'); + }); - it('returns a fallback when no language header exists', async () => { - const fallback = 'fr'; - const wrapper = await mount( - , - ); + it('returns a fallback when no language header exists', async () => { + const fallback = 'fr'; + const wrapper = await mount( + , + ); - expect(wrapper).toContainReactText(fallback); - }); + expect(wrapper).toContainReactText(fallback); + }); - it('returns `en` if no fallback is set and no language header exists', async () => { - const wrapper = await mount(); + it('returns `en` if no fallback is set and no language header exists', async () => { + const wrapper = await mount(); - expect(wrapper).toContainReactText('en'); + expect(wrapper).toContainReactText('en'); + }); }); }); diff --git a/packages/react-network/src/test/manager.test.ts b/packages/react-network/src/test/manager.test.ts index 0a0e0d9515..723218054e 100644 --- a/packages/react-network/src/test/manager.test.ts +++ b/packages/react-network/src/test/manager.test.ts @@ -35,4 +35,42 @@ describe('NetworkManager', () => { expect(manager.getHeader('FoO')).toBe(headers.Foo); }); }); + + describe('cookies', () => { + it('returns undefined when getting a cookie that does not exist', () => { + const manager = new NetworkManager(); + + expect(manager.getCookie('foo')).toBeUndefined(); + }); + + it('sets initial cookies when set as a string', () => { + const manager = new NetworkManager({cookies: 'foo=bar'}); + + expect(manager.getCookie('foo')).toBe('bar'); + }); + + it('sets initial cookies when manually set as an object', () => { + const manager = new NetworkManager({cookies: {foo: 'bar'}}); + + expect(manager.getCookie('foo')).toBe('bar'); + }); + + it('returns cookies after they are set', () => { + const manager = new NetworkManager(); + + manager.setCookie('foo', 'bar'); + + expect(manager.getCookie('foo')).toBe('bar'); + }); + + it('removes cookies after they are set', () => { + const manager = new NetworkManager({cookies: {foo: 'bar'}}); + + expect(manager.getCookie('foo')).toBe('bar'); + + manager.removeCookie('foo'); + + expect(manager.getCookie('foo')).toBeUndefined(); + }); + }); }); diff --git a/packages/react-network/src/test/server.test.ts b/packages/react-network/src/test/server.test.ts index cf7c43361c..52e0ebff04 100644 --- a/packages/react-network/src/test/server.test.ts +++ b/packages/react-network/src/test/server.test.ts @@ -74,6 +74,23 @@ describe('server', () => { }); }); + describe('cookies', () => { + it('can set cookies', () => { + const manager = new NetworkManager(); + const ctx = createMockContext(); + const spy = jest.spyOn(ctx.cookies, 'set'); + + const cookie = 'foo'; + const value = 'bar'; + const options = {maxAge: 123456789}; + + manager.setCookie(cookie, value, options); + applyToContext(ctx, manager); + + expect(spy).toHaveBeenCalledWith(cookie, value, options); + }); + }); + describe('csp', () => { it('does not set a CSP header if no directives were set', () => { const manager = new NetworkManager(); diff --git a/packages/react-network/src/test/useCookie.test.tsx b/packages/react-network/src/test/useCookie.test.tsx new file mode 100644 index 0000000000..9dddf694d7 --- /dev/null +++ b/packages/react-network/src/test/useCookie.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {createMount} from '@shopify/react-testing'; +import {NetworkManager} from '../manager'; +import {NetworkContext} from '../context'; +import {useCookie} from '../hooks'; + +describe('hooks', () => { + describe('useCookie', () => { + function MockComponent({cookie}: {cookie: string}) { + const [value, setCookie] = useCookie(cookie); + + return ( + <> + + {value} + + ); + } + + it('gets a cookie', async () => { + const key = 'foo'; + const value = 'bar'; + const cookies = {[key]: value}; + + const wrapper = await mount(, { + manager: new NetworkManager({cookies}), + }); + + expect(wrapper).toContainReactText(value); + }); + + it('sets a cookie', async () => { + const key = 'foo'; + const value = 'bar'; + const cookies = {[key]: value}; + + const wrapper = await mount(, { + manager: new NetworkManager({cookies}), + }); + + wrapper + .find(MockComponent)! + .find('button')! + .trigger('onClick'); + + expect(wrapper).toContainReactText(`baz`); + }); + }); +}); + +const mount = createMount<{manager?: NetworkManager}>({ + render: (element, _, {manager = new NetworkManager()}) => { + return ( + + {element} + + ); + }, +}); diff --git a/packages/react-server/src/render/render.tsx b/packages/react-server/src/render/render.tsx index 0663aba28c..e653fe7233 100644 --- a/packages/react-server/src/render/render.tsx +++ b/packages/react-server/src/render/render.tsx @@ -41,9 +41,7 @@ export function createRender(render: RenderFunction, options: Options = {}) { return async function renderFunction(ctx: Context) { const logger = getLogger(ctx) || console; const assets = getAssets(ctx); - const networkManager = new NetworkManager({ - headers: ctx.headers, - }); + const networkManager = new NetworkManager(ctx); const htmlManager = new HtmlManager(); const asyncAssetManager = new AsyncAssetManager(); const hydrationManager = new HydrationManager(); diff --git a/yarn.lock b/yarn.lock index 66a2b1cc44..e02107aba7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1199,6 +1199,11 @@ dependencies: "@types/node" "*" +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/cookiejar@*": version "2.1.1" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80" @@ -3505,6 +3510,11 @@ convert-source-map@^1.4.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5" integrity sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU= +cookie@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" + integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== + cookiejar@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c"