Skip to content

Commit

Permalink
feat: Implement configuration option to disable compression
Browse files Browse the repository at this point in the history
The optional `compress` option can be set to `false` to disable
compression (either globally, or for individual cookies).

Closes #18
  • Loading branch information
bjoluc committed Sep 24, 2021
1 parent dd0f4c6 commit 2cda67c
Show file tree
Hide file tree
Showing 12 changed files with 248 additions and 161 deletions.
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Hence, the first render on the client side may largely differ from the server-re
A solution to this is a storage method that is available to both the server and the client by default: Cookies.

This library started as a drop-in replacement for [next-redux-wrapper](https://github.com/kirill-konshin/next-redux-wrapper/) that built upon Redux Persist and a [storage adapter for cookies](https://github.com/abersager/redux-persist-cookie-storage).
However, in response to `getStaticProps()` and `getServerSideProps()` being introduced in Next.js, this library has been rewritten and the tooling has been simplified significantly.
However, in response to `getStaticProps()` and `getServerSideProps()` being introduced in Next.js, it has been rewritten and the tooling has been simplified significantly.
What remains is a single Redux middleware and a tiny wrapper around the `makeStore()` function.

## How does it work?
Expand All @@ -32,9 +32,8 @@ This way, incoming state updates from `getStaticProps()` do not overwrite the sy
You can opt out of this behavior on a per-state-subtree basis and instead always receive the server's state in the `HYDRATE` reducer if you wish to handle state portions from `getStaticProps()` on your own.

Some words about compression:
The serialized cookie state is compressed using [lz-string](https://github.com/pieroxy/lz-string) to keep the cookie size small.
Currently, there is no way to disable compression.
If you would like to see one implemented, please let me know.
By default, the serialized cookie state is compressed using [lz-string](https://github.com/pieroxy/lz-string) to keep the cookie size small.
You can disable compression globally or per state subtree by setting the `compress` option to `false`.

## Setup

Expand Down Expand Up @@ -105,4 +104,4 @@ Alternatively, you can also configure the serializability middleware to ignore t

## Configuration

For the configuration options of `nextReduxCookieMiddleware`, please refer to [the API documentation](https://next-redux-cookie-wrapper.js.org/interfaces/nextreduxcookiemiddlewareconfig.html).
For the configuration options of `nextReduxCookieMiddleware`, please refer to [the API documentation](https://next-redux-cookie-wrapper.js.org/interfaces/NextReduxCookieMiddlewareConfig.html).
58 changes: 42 additions & 16 deletions main/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {CookieSerializeOptions} from "cookie";
import {SetRequired} from "type-fest";
import {Except, SetRequired} from "type-fest";

export interface SubtreeConfig extends Omit<CookieSerializeOptions, "encode" | "httpOnly"> {
export type CookieOptions = Except<CookieSerializeOptions, "encode" | "httpOnly">;

export interface SubtreeConfig extends CookieOptions {
/**
* The path of a state subtree that shall be synced with cookies. If, for instance, the state
* object is of the form
Expand Down Expand Up @@ -32,10 +34,20 @@ export interface SubtreeConfig extends Omit<CookieSerializeOptions, "encode" | "
* value of the {@link subtree} option.
*/
cookieName?: string;

/**
* Whether or not to compress cookie values using lz-string. Defaults to `true`.
*/
compress?: boolean;
}

export interface DefaultedSubtreeConfig
extends SetRequired<SubtreeConfig, "cookieName" | "ignoreStateFromStaticProps"> {}
export interface InternalSubtreeConfig
extends Except<
SetRequired<SubtreeConfig, "cookieName" | "ignoreStateFromStaticProps" | "compress">,
keyof CookieOptions
> {
cookieOptions: CookieOptions;
}

/**
* The configuration options for {@link nextReduxCookieMiddleware}. The {@link subtrees} option
Expand All @@ -60,7 +72,7 @@ export interface DefaultedSubtreeConfig
* but `false` for `three`.
*/
export interface NextReduxCookieMiddlewareConfig
extends Omit<SubtreeConfig, "subtree" | "cookieName"> {
extends Except<SubtreeConfig, "subtree" | "cookieName"> {
/**
* Specifies which subtrees of the state shall be synced with cookies, and how. Takes a list of
* subtree paths (e.g. `my.subtree`) and/or {@link SubtreeConfig} objects.
Expand All @@ -70,27 +82,41 @@ export interface NextReduxCookieMiddlewareConfig

/**
* Given a `NextReduxCookieMiddlewareConfig` object, returns the corresponding list of
* `SubtreeConfig` objects.
* `InternalSubtreeConfig` objects.
*/
export function processMiddlewareConfig(
config: NextReduxCookieMiddlewareConfig
): DefaultedSubtreeConfig[] {
): InternalSubtreeConfig[] {
// Set defaults and destructure the config object
const {subtrees, ...globalSubtreeConfig} = {
ignoreStateFromStaticProps: true,
compress: true,
path: "/",
sameSite: true,
...config,
};

// Turn strings into `SubtreeConfig` objects, set a default for the cookieName option, and apply
// the global default config
return subtrees.map((current) => {
if (typeof current === "string") {
return {subtree: current, cookieName: current, ...globalSubtreeConfig};
}
return (
subtrees
// Turn strings into objects, set a default for the cookieName option, apply the global
// default config, and extract cookie options into separate objects
.map((current) => {
if (typeof current === "string") {
current = {subtree: current};
}

// `current` is a `SubtreeConfig` object
return {...globalSubtreeConfig, cookieName: current.subtree, ...current};
});
const {ignoreStateFromStaticProps, compress, subtree, cookieName, ...cookieOptions} = {
...globalSubtreeConfig,
cookieName: current.subtree,
...current,
};
return {
ignoreStateFromStaticProps,
compress,
subtree,
cookieName,
cookieOptions,
};
})
);
}
54 changes: 30 additions & 24 deletions main/src/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {CookieSerializeOptions} from "cookie";
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
import {GetServerSidePropsContext, NextPageContext} from "next";
import {parseCookies, setCookie} from "nookies";
import {Except, JsonValue, SetRequired} from "type-fest";
import {JsonValue, SetRequired} from "type-fest";

import {InternalSubtreeConfig} from "./config";
import {isClient} from "./util";

export type Cookies = Record<string, JsonValue>;
Expand All @@ -13,11 +13,13 @@ export type CookieContext = SetRequired<
"req" | "res"
>;

export type CookieConfig = Pick<InternalSubtreeConfig, "cookieName" | "compress" | "cookieOptions">;

/**
* An isomorphic class to set and get compressed state cookies.
* An isomorphic class to set and get (compressed) state cookies.
*/
export class StateCookies {
protected _allNames: string[] = [];
protected _config = new Map<string, CookieConfig>();

private readonly _context?: CookieContext;
private _cookies?: Cookies;
Expand All @@ -29,11 +31,25 @@ export class StateCookies {
this._context = context;
}

private static _encodeState(state: any) {
return encodeURIComponent(JSON.stringify(state));
}

private static _encodeStateCompressed(state: any) {
return compressToEncodedURIComponent(JSON.stringify(state));
}

private static _decodeState(state: string, compressed: boolean) {
return JSON.parse((compressed ? decompressFromEncodedURIComponent : decodeURIComponent)(state));
}

/**
* Set the names of all the cookies that shall be parsed via `getAll()`
* Set the configuration (@see CookieConfig) for each cookie
*/
public setAllNames(names: string[]) {
this._allNames = names;
public setConfigurations(configurations: CookieConfig[]) {
for (const config of configurations) {
this._config.set(config.cookieName, config);
}
}

public getAll() {
Expand All @@ -43,32 +59,22 @@ export class StateCookies {
for (const [name, value] of Object.entries(
parseCookies(this._context, {decode: (value: string) => value})
)) {
if (this._allNames.includes(name)) {
this._cookies[name] = this._decodeState(value);
const config = this._config.get(name);
if (config) {
this._cookies[name] = StateCookies._decodeState(value, config.compress);
}
}
}

return this._cookies;
}

public set(
name: string,
state: any,
options?: Except<CookieSerializeOptions, "encode" | "httpOnly">
) {
public set(name: string, state: any) {
const {cookieOptions, compress} = this._config.get(name)!;
setCookie(this._context, name, state, {
...options,
encode: this._encodeState,
...cookieOptions,
encode: compress ? StateCookies._encodeStateCompressed : StateCookies._encodeState,
httpOnly: false,
});
}

private _encodeState(state: any) {
return compressToEncodedURIComponent(JSON.stringify(state));
}

private _decodeState(state: string) {
return JSON.parse(decompressFromEncodedURIComponent(state));
}
}
36 changes: 17 additions & 19 deletions main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,19 @@ export const wrapMakeStore =

/**
* A Redux middleware that syncs user-defined subtrees of the Redux state with cookies – on the
* server and on the client. One cookie is used per state subtree and the serialized state is
* compressed using [lz-string](https://github.com/pieroxy/lz-string). The subtree paths, cookie
* names, and cookie options are configured via a {@link NextReduxCookieMiddlewareConfig} object.
* server and on the client. One cookie is used per state subtree and the serialized state is, by
* default, compressed using [lz-string](https://github.com/pieroxy/lz-string). The subtree paths,
* cookie names, cookie options, and compression are configured via a
* {@link NextReduxCookieMiddlewareConfig} object.
*/
export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig) => Middleware =
(config) => (store) => {
const subtrees = processMiddlewareConfig(config);
const cookieNames = subtrees.map((subtree) => subtree.cookieName);

let cookies: StateCookies;
if (isClient()) {
cookies = new StateCookies();
cookies.setAllNames(cookieNames);
cookies.setConfigurations(subtrees);
}
// On the server, we have to intercept the `SERVE_COOKIES` action to get the `cookies` object
// (we cannot directly set a property on the store, sadly, since the middleware does not have
Expand All @@ -74,7 +74,7 @@ export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig
case SERVE_COOKIES: {
// Handle the SERVE_COOKIES action (server-only):
cookies = action.payload;
cookies.setAllNames(cookieNames);
cookies.setConfigurations(subtrees);
// Console.log("Cookies received by middleware");

// We have access to the client's cookies now. Now we need to hydrate the store with their
Expand All @@ -98,15 +98,15 @@ export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig
const allCookies = cookies.getAll();
action.payload = walkState(
subtrees,
(subtreeConfig) => {
if (subtreeConfig.ignoreStateFromStaticProps) {
// `action.payload` holds the incoming server state. We overwrite that state with the
// state from the cookies: If the incoming state is from getServerSideProps, the
// cookies have also been updated to that state. If the incoming state is from
// getStaticProps, the cookies have remained unchanged and hence the server's state is
// ignored.

return allCookies[subtreeConfig.cookieName];
({ignoreStateFromStaticProps, cookieName}) => {
if (ignoreStateFromStaticProps) {
// `action.payload` holds the incoming server state. We overwrite that state with
// the state from the cookies: If the incoming state is from getServerSideProps,
// the cookies have also been updated to that state. If the incoming state is from
// getStaticProps, the cookies have remained unchanged and hence the server's
// state is ignored.

return allCookies[cookieName];
}
},
action.payload
Expand All @@ -124,11 +124,9 @@ export const nextReduxCookieMiddleware: (config: NextReduxCookieMiddlewareConfig
if (cookies) {
walkState(
subtrees,
(subtreeConfig, oldSubtreeState, newSubtreeState) => {
({cookieName}, oldSubtreeState, newSubtreeState) => {
if (!isEqual(oldSubtreeState, newSubtreeState)) {
const {cookieName, subtree, ignoreStateFromStaticProps, ...cookieOptions} =
subtreeConfig;
cookies.set(cookieName, newSubtreeState, cookieOptions);
cookies.set(cookieName, newSubtreeState);
}
},
oldState,
Expand Down
19 changes: 10 additions & 9 deletions main/src/state-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,29 @@ import get from "lodash/get";
import set from "lodash/set";
import {JsonObject} from "type-fest";

import {DefaultedSubtreeConfig} from "./config";
import {InternalSubtreeConfig} from "./config";

/**
* Given a list of subtree configurations, two state objects `stateA` and `stateB`, and a walker
* function, invokes the walker function for each `SubtreeConfig` object. The walker function
* receives a subtree config and the respective state subtrees from `stateA` and `stateB`. If a
* subtree does not exist in a state object, `undefined` is passed instead.
* function, invokes the walker function for each `InternalSubtreeConfig` object. The walker
* function receives a subtree config and the respective state subtrees from `stateA` and `stateB`.
* If a subtree does not exist in a state object, `undefined` is passed instead.
*
* @param subtrees A list of `SubtreeConfig` objects for the walker function
* @param subtrees A list of `InternalSubtreeConfig` objects for the walker function
* @param stateA First state object
* @param stateB Optional second state object. If omitted the walker function will always receive
* `undefined` as its last parameter.
* @param walker A function that will be invoked for each `SubtreeConfig` object from `subtrees`
* with the `SubtreeConfig` object and the respective state subtrees from `stateA` and `stateB`.
* @param walker A function that will be invoked for each `InternalSubtreeConfig` object from
* `subtrees` with the `InternalSubtreeConfig` object and the respective state subtrees from
* `stateA` and `stateB`.
*
* @returns A copy of `stateA` where those subtrees for which the walker function has returned a
* value have been replaced by that value.
*/
export const walkState = <State extends JsonObject>(
subtrees: DefaultedSubtreeConfig[],
subtrees: InternalSubtreeConfig[],
walker: (
subtreeConfig: DefaultedSubtreeConfig,
subtreeConfig: InternalSubtreeConfig,
subtreeA?: unknown,
subtreeB?: unknown
) => unknown | undefined,
Expand Down
Loading

0 comments on commit 2cda67c

Please sign in to comment.