Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Universal Cookies #935

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/react-network/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
48 changes: 46 additions & 2 deletions packages/react-network/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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];
}
2 changes: 2 additions & 0 deletions packages/react-network/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ export {
useRequestHeader,
useRedirect,
useAcceptLanguage,
useCookie,
useServerCookie,
} from './hooks';
73 changes: 63 additions & 10 deletions packages/react-network/src/manager.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import {Context} from 'koa';
import cookie, {CookieSerializeOptions} from 'cookie';
import {StatusCode, CspDirective, Header} from '@shopify/network';
import {EffectKind} from '@shopify/react-effect';

export {NetworkContext} from './context';

export const EFFECT_ID = Symbol('network');

interface Options {
headers?: Record<string, string>;
}

export class NetworkManager {
readonly effect: EffectKind = {
id: EFFECT_ID,
Expand All @@ -25,9 +23,24 @@ export class NetworkManager {
private readonly csp = new Map<CspDirective, string[] | boolean>();
private readonly headers = new Map<string, string>();
private readonly requestHeaders: Record<string, string>;

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() {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, string>) {
if (!headers) {
function lowercaseEntries(entries: undefined | Record<string, string>) {
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);
}
12 changes: 11 additions & 1 deletion packages/react-network/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function applyToContext<T extends Context>(
ctx: T,
manager: NetworkManager,
) {
const {status, redirectUrl, headers} = manager.extract();
const {status, redirectUrl, headers, cookies} = manager.extract();

if (redirectUrl) {
ctx.redirect(redirectUrl);
Expand All @@ -22,5 +22,15 @@ export function applyToContext<T extends Context>(
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we just used https://www.npmjs.com/package/universal-cookie it would handle updating the cookies for us

// to context when they change

return ctx;
}
4 changes: 4 additions & 0 deletions packages/react-network/src/test/e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useCspDirective,
useRedirect,
useHeader,
useCookie,
StatusCode,
CspDirective,
Header,
Expand All @@ -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(<TwoPass />, {
Expand All @@ -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 () => {
Expand Down
62 changes: 32 additions & 30 deletions packages/react-network/src/test/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,47 @@ import {NetworkManager} from '../manager';
import {NetworkContext} from '../context';
import {useAcceptLanguage} from '../hooks';

describe('useAcceptLanguage()', () => {
function MockComponent({
fallback,
}: {
fallback?: FirstArgument<typeof useAcceptLanguage>;
}) {
const locales = useAcceptLanguage(fallback);
describe('hooks', () => {
describe('useAcceptLanguage()', () => {
function MockComponent({
fallback,
}: {
fallback?: FirstArgument<typeof useAcceptLanguage>;
}) {
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(<MockComponent />, {language});
it('returns the locale from the language header', async () => {
const language = 'it';
const wrapper = await mount(<MockComponent />, {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(<MockComponent />, {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(<MockComponent />, {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(
<MockComponent fallback={{code: fallback, quality: 1.0}} />,
);
it('returns a fallback when no language header exists', async () => {
const fallback = 'fr';
const wrapper = await mount(
<MockComponent fallback={{code: fallback, quality: 1.0}} />,
);

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(<MockComponent />);
it('returns `en` if no fallback is set and no language header exists', async () => {
const wrapper = await mount(<MockComponent />);

expect(wrapper).toContainReactText('en');
expect(wrapper).toContainReactText('en');
});
});
});

Expand Down
38 changes: 38 additions & 0 deletions packages/react-network/src/test/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
17 changes: 17 additions & 0 deletions packages/react-network/src/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading