Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Serialization hooks #126

Merged
merged 18 commits into from
Apr 16, 2018
Merged
Show file tree
Hide file tree
Changes from 17 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
2 changes: 1 addition & 1 deletion .babelrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"plugins": ["transform-async-to-generator"],
"presets": ["es2015"]
"presets": ["es2015", "stage-1"]
}
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,84 @@ No changes are required to your actions, react-chrome-redux automatically adds t

`react-chrome-redux` supports `onMessageExternal` which is fired when a message is sent from another extension, app, or website. By default, if `externally_connectable` is not declared in your extension's manifest, all extensions or apps will be able to send messages to your extension, but no websites will be able to. You can follow [this](https://developer.chrome.com/extensions/manifest/externally_connectable) to address your needs appropriately.

## Custom Serialization

You may wish to implement custom serialization and deserialization logic for communication between the background store and your proxy store(s). Chrome's message passing (which is used to implement this library) automatically serializes messages when they are sent and deserializes them when they are received. In the case that you have non-JSON-ifiable information in your Redux state, like a circular reference or a `Date` object, you will lose information between the background store and the proxy store(s). To manage this, both `wrapStore` and `Store` accept `serializer` and `deserializer` options. These should be functions that take a single parameter, the payload of a message, and return a serialized and deserialized form, respectively. The `serializer` function will be called every time a message is sent, and the `deserializer` function will be called every time a message is received. Note that, in addition to state updates, action creators being passed from your content script(s) to your background page will be serialized and deserialized as well.

### Example
For example, consider the following `state` in your background page:

```js
{todos: [
{
id: 1,
text: 'Write a Chrome extension',
created: new Date(2018, 0, 1)
}
]}
```

With no custom serialization, the `state` in your proxy store will look like this:

```js
{todos: [
{
id: 1,
text: 'Write a Chrome extension',
created: {}
Copy link
Owner

Choose a reason for hiding this comment

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

Is this true? I swore it turned it into a string last time I checked... am I crazy?

Copy link
Contributor Author

@ethanroday ethanroday Mar 23, 2018

Choose a reason for hiding this comment

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

Vanilla JSON.parse(JSON.stringify(new Date())) will give you a string, but Chrome's default serialization/deserialization for messaging gives you an empty object for some reason or another.

Copy link
Owner

Choose a reason for hiding this comment

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

Got it - must have been confusing the two. Thanks

Copy link
Collaborator

Choose a reason for hiding this comment

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

Wow this is really good to know. I could see us having a serializers dir with defaults for things like dates (not necessary for this PR ofc).

}
]}
```

As you can see, Chrome's message passing has caused your date to disappear. You can pass a custom `serializer` and `deserializer` to both `wrapStore` and `Store` to make sure your dates get preserved:

```js
// background.js

import {wrapStore} from 'react-chrome-redux';

const store; // a normal Redux store

wrapStore(store, {
portName: 'MY_APP',
serializer: payload => JSON.stringify(payload, dateReplacer),
Copy link
Owner

Choose a reason for hiding this comment

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

I like that these were added as options rather than additional parameters to wrap store 👍

Copy link
Collaborator

Choose a reason for hiding this comment

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

I like the names too (dateReplacer and dateReviver). Makes it clear what's going on

deserializer: payload => JSON.parse(payload, dateReviver)
});
```

```js
// content.js

import {Store} from 'react-chrome-redux';

const store = new Store({
portName: 'MY_APP',
serializer: payload => JSON.stringify(payload, dateReplacer),
deserializer: payload => JSON.parse(payload, dateReviver)
});
```

In this example, `dateReplacer` and `dateReviver` are a custom JSON [replacer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) and [reviver](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) function, respectively. They are defined as such:

```js
function dateReplacer (key, value) {
// Put a custom flag on dates instead of relying on JSON's native
// stringification, which would force us to use a regex on the other end
return this[key] instanceof Date ? {"_RECOVER_DATE": this[key].getTime()} : value
};

function dateReviver (key, value) {
// Look for the custom flag and revive the date
return value && value["_RECOVER_DATE"] ? new Date(value["_RECOVER_DATE"]) : value
};

const stringified = JSON.stringify(state, dateReplacer)
//"{"todos":[{"id":1,"text":"Write a Chrome extension","created":{"_RECOVER_DATE":1514793600000}}]}"

JSON.parse(stringified, dateReviver)
// {todos: [{ id: 1, text: 'Write a Chrome extension', created: new Date(2018, 0, 1) }]}
```

## Docs

* [Introduction](https://github.com/tshaddix/react-chrome-redux/wiki/Introduction)
Expand Down
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export class Store<S = any, A extends redux.Action = redux.Action> {
portName: string,
state?: any,
extensionId?: string,
serializer?: Function,
Copy link
Collaborator

Choose a reason for hiding this comment

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

thank you for adding the TS defs

deserializer?: Function
});

/**
Expand Down Expand Up @@ -72,6 +74,8 @@ export function wrapStore<S>(
configuration: {
portName: string,
dispatchResponder?(dispatchResult: any, send: (response: any) => void): void,
serializer?: Function,
deserializer?: Function
},
): void;

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"babel-plugin-transform-async-to-generator": "^6.22.0",
"babel-polyfill": "^6.22.0",
"babel-preset-es2015": "^6.3.13",
"babel-preset-stage-1": "^6.24.1",
"eslint": "^3.17.1",
"mocha": "^2.3.4",
"should": "^8.2.0",
Expand Down
91 changes: 91 additions & 0 deletions src/serialization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export const noop = (payload) => payload;

const transformPayload = (message, transformer = noop) => ({
Copy link
Owner

Choose a reason for hiding this comment

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

Might be good to mention here why a new object is required rather than mutating the message. It took me a second to realize that manipulations here would manipulate the original action object which could cause unintentional side-effects.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will add a comment.

...message,
// If the message has a payload, transform it. Otherwise,
// just return a copy of the message.
...(message.payload ? {payload: transformer(message.payload)} : {})
Copy link
Collaborator

Choose a reason for hiding this comment

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

Very nice - love the cascading spread

});

const deserializeListener = (listener, deserializer = noop, shouldDeserialize) => {
// If a shouldDeserialize function is passed, return a function that uses it
// to check if any given message payload should be deserialized
if (shouldDeserialize) {
return (message, ...args) => {
if (shouldDeserialize(message, ...args)) {
return listener(transformPayload(message, deserializer), ...args);
} else {
return listener(message, ...args);
}
};
}
// Otherwise, return a function that tries to deserialize on every message
return (message, ...args) => listener(transformPayload(message, deserializer), ...args);
};

/**
* A function returned from withDeserializer that, when called, wraps addListenerFn with the
* deserializer passed to withDeserializer.
* @name AddListenerDeserializer
* @function
* @param {Function} addListenerFn The add listener function to wrap.
* @returns {DeserializedAddListener}
*/

/**
* A wrapped add listener function that registers the given listener.
* @name DeserializedAddListener
* @function
* @param {Function} listener The listener function to register. It should expect the (optionally)
* deserialized message as its first argument.
* @param {Function} [shouldDeserialize] A function that takes the arguments passed to the listener
* and returns whether the message payload should be deserialized. Not all messages (notably, messages
* this listener doesn't care about) should be attempted to be deserialized.
*/

/**
* Given a deserializer, returns an AddListenerDeserializer function that that takes an add listener
* function and returns a DeserializedAddListener that automatically deserializes message payloads.
* Each message listener is expected to take the message as its first argument.
* @param {Function} deserializer A function that deserializes a message payload.
* @returns {AddListenerDeserializer}
* Example Usage:
* const withJsonDeserializer = withDeserializer(payload => JSON.parse(payload));
* const deserializedChromeListener = withJsonDeserializer(chrome.runtime.onMessage.addListener);
* const shouldDeserialize = (message) => message.type === 'DESERIALIZE_ME';
* deserializedChromeListener(message => console.log("Payload:", message.payload), shouldDeserialize);
* chrome.runtime.sendMessage("{'type:'DESERIALIZE_ME','payload':{'prop':4}}");
* //Payload: { prop: 4 };
* chrome.runtime.sendMessage("{'payload':{'prop':4}}");
* //Payload: "{'prop':4}";
*/
export const withDeserializer = (deserializer = noop) =>
(addListenerFn) =>
(listener, shouldDeserialize) =>
addListenerFn(deserializeListener(listener, deserializer, shouldDeserialize));

/**
* Given a serializer, returns a function that takes a message sending
* function as its sole argument and returns a wrapped message sender that
* automaticaly serializes message payloads. The message sender
* is expected to take the message as its first argument, unless messageArgIndex
* is nonzero, in which case it is expected in the position specified by messageArgIndex.
* @param {Function} serializer A function that serializes a message payload
* Example Usage:
* const withJsonSerializer = withSerializer(payload => JSON.stringify(payload))
* const serializedChromeSender = withJsonSerializer(chrome.runtime.sendMessage)
* chrome.runtime.addListener(message => console.log("Payload:", message.payload))
* serializedChromeSender({ payload: { prop: 4 }})
* //Payload: "{'prop':4}"
*/
export const withSerializer = (serializer = noop) =>
(sendMessageFn, messageArgIndex = 0) => {
return (...args) => {
if (args.length <= messageArgIndex) {
throw new Error(`Message in request could not be serialized. ` +
`Expected message in position ${messageArgIndex} but only received ${args.length} args.`);
}
args[messageArgIndex] = transformPayload(args[messageArgIndex], serializer);
return sendMessageFn(...args);
};
};
18 changes: 14 additions & 4 deletions src/store/Store.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,40 @@ import {
DIFF_STATUS_UPDATED,
DIFF_STATUS_REMOVED,
} from '../constants';
import { withSerializer, withDeserializer, noop } from "../serialization";

const backgroundErrPrefix = '\nLooks like there is an error in the background page. ' +
'You might want to inspect your background page for more details.\n';

class Store {
/**
* Creates a new Proxy store
* @param {object} options An object of form {portName, state, extensionId}, where `portName` is a required string and defines the name of the port for state transition changes, `state` is the initial state of this store (default `{}`) `extensionId` is the extension id as defined by chrome when extension is loaded (default `''`)
* @param {object} options An object of form {portName, state, extensionId, serializer, deserializer}, where `portName` is a required string and defines the name of the port for state transition changes, `state` is the initial state of this store (default `{}`) `extensionId` is the extension id as defined by chrome when extension is loaded (default `''`), `serializer` is a function to serialize outgoing message payloads (default is passthrough), and `deserializer` is a function to deserialize incoming message payloads (default is passthrough)
*/
constructor({portName, state = {}, extensionId = null}) {
constructor({portName, state = {}, extensionId = null, serializer = noop, deserializer = noop}) {
if (!portName) {
throw new Error('portName is required in options');
}
if (typeof serializer !== 'function') {
throw new Error('serializer must be a function');
}
if (typeof deserializer !== 'function') {
throw new Error('deserializer must be a function');
}

this.portName = portName;
this.readyResolved = false;
this.readyPromise = new Promise(resolve => this.readyResolve = resolve);

this.extensionId = extensionId; // keep the extensionId as an instance variable
this.port = chrome.runtime.connect(this.extensionId, {name: portName});
this.serializedPortListener = withDeserializer(deserializer)((...args) => this.port.onMessage.addListener(...args));
this.serializedMessageSender = withSerializer(serializer)((...args) => chrome.runtime.sendMessage(...args), 1);
this.listeners = [];
this.state = state;

this.port.onMessage.addListener(message => {
// Don't use shouldDeserialize here, since no one else should be using this port
this.serializedPortListener(message => {
switch (message.type) {
case STATE_TYPE:
this.replaceState(message.payload);
Expand Down Expand Up @@ -138,7 +148,7 @@ class Store {
*/
dispatch(data) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
this.serializedMessageSender(
this.extensionId,
{
type: DISPATCH_TYPE,
Expand Down
29 changes: 24 additions & 5 deletions src/wrap-store/wrapStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
STATE_TYPE,
PATCH_STATE_TYPE,
} from '../constants';
import { withSerializer, withDeserializer, noop } from "../serialization";

import shallowDiff from './shallowDiff';

Expand Down Expand Up @@ -30,13 +31,26 @@ const promiseResponder = (dispatchResult, send) => {
});
};

/**
* Wraps a Redux store so that proxy stores can connect to it.
* @param {Object} store A Redux store
* @param {Object} options An object of form {portName, dispatchResponder, serializer, deserializer}, where `portName` is a required string and defines the name of the port for state transition changes, `dispatchResponder` is a function that takes the result of a store dispatch and optionally implements custom logic for responding to the original dispatch message,`serializer` is a function to serialize outgoing message payloads (default is passthrough), and `deserializer` is a function to deserialize incoming message payloads (default is passthrough)
*/
export default (store, {
portName,
dispatchResponder
dispatchResponder,
serializer = noop,
deserializer = noop
}) => {
if (!portName) {
throw new Error('portName is required in options');
}
if (typeof serializer !== 'function') {
throw new Error('serializer must be a function');
}
if (typeof deserializer !== 'function') {
throw new Error('deserializer must be a function');
}

// set dispatch responder as promise responder
if (!dispatchResponder) {
Expand Down Expand Up @@ -74,6 +88,8 @@ export default (store, {
return;
}

const serializedMessagePoster = withSerializer(serializer)((...args) => port.postMessage(...args));

let prevState = store.getState();

const patchState = () => {
Expand All @@ -83,7 +99,7 @@ export default (store, {
if (diff.length) {
prevState = state;

port.postMessage({
serializedMessagePoster({
type: PATCH_STATE_TYPE,
payload: diff,
});
Expand All @@ -97,22 +113,25 @@ export default (store, {
port.onDisconnect.addListener(unsubscribe);

// Send store's initial state through port
port.postMessage({
serializedMessagePoster({
type: STATE_TYPE,
payload: prevState,
});
};

const withPayloadDeserializer = withDeserializer(deserializer);
const shouldDeserialize = (request) => request.type === DISPATCH_TYPE && request.portName === portName;

/**
* Setup action handler
*/
chrome.runtime.onMessage.addListener(dispatchResponse);
withPayloadDeserializer((...args) => chrome.runtime.onMessage.addListener(...args))(dispatchResponse, shouldDeserialize);

/**
* Setup external action handler
*/
if (chrome.runtime.onMessageExternal) {
chrome.runtime.onMessageExternal.addListener(dispatchResponse);
withPayloadDeserializer((...args) => chrome.runtime.onMessageExternal.addListener(...args))(dispatchResponse, shouldDeserialize);
} else {
console.warn('runtime.onMessageExternal is not supported');
}
Expand Down
Loading