From ccf91480da401cb765bdad6be87bd59ddeca4fa4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 31 Aug 2018 15:09:00 +0200 Subject: [PATCH] Implement pym storage --- src/core/client/embed/Stream.ts | 3 + .../__snapshots__/withPymStorage.spec.ts.snap | 15 +++ src/core/client/embed/decorators/index.ts | 1 + .../embed/decorators/withPymStorage.spec.ts | 104 ++++++++++++++++++ .../client/embed/decorators/withPymStorage.ts | 52 +++++++++ .../framework/lib/bootstrap/TalkContext.tsx | 1 + .../framework/lib/bootstrap/createContext.tsx | 9 +- .../framework/lib/storage/InMemoryStorage.ts | 4 + .../framework/lib/storage/PymStorage.spec.ts | 100 +++++++++++++++++ .../framework/lib/storage/PymStorage.ts | 57 ++++++++++ .../__snapshots__/PymStorage.spec.ts.snap | 11 ++ .../client/framework/lib/storage/index.ts | 2 + .../client/framework/lib/storage/interface.ts | 14 +++ .../client/stream/local/initLocalState.ts | 4 +- 14 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap create mode 100644 src/core/client/embed/decorators/withPymStorage.spec.ts create mode 100644 src/core/client/embed/decorators/withPymStorage.ts create mode 100644 src/core/client/framework/lib/storage/PymStorage.spec.ts create mode 100644 src/core/client/framework/lib/storage/PymStorage.ts create mode 100644 src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap create mode 100644 src/core/client/framework/lib/storage/interface.ts diff --git a/src/core/client/embed/Stream.ts b/src/core/client/embed/Stream.ts index ae47e450e..5486ea8a3 100644 --- a/src/core/client/embed/Stream.ts +++ b/src/core/client/embed/Stream.ts @@ -7,6 +7,7 @@ import { withClickEvent, withEventEmitter, withIOSSafariWidthWorkaround, + withPymStorage, withSetCommentID, } from "./decorators"; import PymControl from "./PymControl"; @@ -29,6 +30,8 @@ export function createPymControl(config: CreatePymControlConfig) { withClickEvent, withSetCommentID, withEventEmitter(config.eventEmitter), + withPymStorage(localStorage, "localStorage"), + withPymStorage(sessionStorage, "sessionStorage"), ]; const query = qs.stringify({ diff --git a/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap b/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap new file mode 100644 index 000000000..6cc6e2817 --- /dev/null +++ b/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`withPymStorage should handle handle errors 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"error\\\\\\"}\\"}]"`; + +exports[`withPymStorage should handle unknown method 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"Unknown method unknown\\\\\\"}\\"}]"`; + +exports[`withPymStorage should set, get and remove item 1`] = ` +Object { + "talkPymStorage:key": "test", +} +`; + +exports[`withPymStorage should set, get and remove item 2`] = `Object {}`; + +exports[`withPymStorage should set, get and remove item 3`] = `"[{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\"}\\"},{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"1\\\\\\",\\\\\\"result\\\\\\":\\\\\\"test\\\\\\"}\\"},{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"2\\\\\\"}\\"}]"`; diff --git a/src/core/client/embed/decorators/index.ts b/src/core/client/embed/decorators/index.ts index 6e2d45195..5a90f0280 100644 --- a/src/core/client/embed/decorators/index.ts +++ b/src/core/client/embed/decorators/index.ts @@ -6,6 +6,7 @@ export { default as withAutoHeight } from "./withAutoHeight"; export { default as withClickEvent } from "./withClickEvent"; export { default as withSetCommentID } from "./withSetCommentID"; export { default as withEventEmitter } from "./withEventEmitter"; +export { default as withPymStorage } from "./withPymStorage"; export { default as withIOSSafariWidthWorkaround, } from "./withIOSSafariWidthWorkaround"; diff --git a/src/core/client/embed/decorators/withPymStorage.spec.ts b/src/core/client/embed/decorators/withPymStorage.spec.ts new file mode 100644 index 000000000..60ab2ed0f --- /dev/null +++ b/src/core/client/embed/decorators/withPymStorage.spec.ts @@ -0,0 +1,104 @@ +import sinon from "sinon"; + +import withPymStorage from "./withPymStorage"; + +// tslint:disable:max-classes-per-file + +class FakeStorage { + public store: Record = {}; + + public setItem(key: string, value: string) { + this.store[key] = value; + } + public removeItem(key: string) { + delete this.store[key]; + } + public getItem(key: string) { + return this.store[key]; + } +} + +class PymStub { + public listeners: Record void)> = {}; + public messages: Array<{ key: string; value: string }> = []; + public type: string; + + constructor(type: string) { + this.type = type; + } + + public onMessage(key: string, callback: (msg: string) => void) { + this.listeners[key] = callback; + } + public sendMessage(key: string, value: string) { + this.messages.push({ key, value }); + } +} + +describe("withPymStorage", () => { + it("should set, get and remove item", () => { + const pym = new PymStub("localStorage"); + const storage = new FakeStorage(); + withPymStorage(storage as any, "localStorage", "talkPymStorage:")( + pym as any + ); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "setItem", + parameters: { key: "key", value: "test" }, + }) + ); + expect(storage.store).toMatchSnapshot(); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "1", + method: "getItem", + parameters: { key: "key" }, + }) + ); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "2", + method: "removeItem", + parameters: { key: "key" }, + }) + ); + expect(storage.store).toMatchSnapshot(); + expect(JSON.stringify(pym.messages)).toMatchSnapshot(); + }); + it("should handle unknown method", () => { + const pym = new PymStub("localStorage"); + const storage = new FakeStorage(); + withPymStorage(storage as any, "localStorage", "talkPymStorage:")( + pym as any + ); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "unknown", + parameters: {}, + }) + ); + expect(JSON.stringify(pym.messages)).toMatchSnapshot(); + }); + it("should handle handle errors", () => { + const pym = new PymStub("localStorage"); + const storage = new FakeStorage(); + sinon + .mock(storage) + .expects("getItem") + .throws("error"); + withPymStorage(storage as any, "localStorage", "talkPymStorage:")( + pym as any + ); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "getItem", + parameters: {}, + }) + ); + expect(JSON.stringify(pym.messages)).toMatchSnapshot(); + }); +}); diff --git a/src/core/client/embed/decorators/withPymStorage.ts b/src/core/client/embed/decorators/withPymStorage.ts new file mode 100644 index 000000000..eab731d71 --- /dev/null +++ b/src/core/client/embed/decorators/withPymStorage.ts @@ -0,0 +1,52 @@ +import { Decorator } from "./"; + +const withPymStorage = ( + storage: Storage, + type: "localStorage" | "sessionStorage", + prefix = "talkPymStorage:" +): Decorator => pym => { + pym.onMessage(`pymStorage.${type}.request`, (msg: any) => { + const { id, method, parameters } = JSON.parse(msg); + const { key, value } = parameters; + const prefixedKey = `${prefix}${key}`; + + // Variable for the method return value. + let result; + + const sendError = (error: string) => { + // tslint:disable-next-line:no-console + console.error(error); + pym.sendMessage( + `pymStorage.${type}.error`, + JSON.stringify({ id, error }) + ); + }; + + try { + switch (method) { + case "setItem": + result = storage.setItem(prefixedKey, value); + break; + case "getItem": + result = storage.getItem(prefixedKey); + break; + case "removeItem": + result = storage.removeItem(prefixedKey); + break; + default: + sendError(`Unknown method ${method}`); + return; + } + } catch (err) { + sendError(err.toString()); + return; + } + + pym.sendMessage( + `pymStorage.${type}.response`, + JSON.stringify({ id, result }) + ); + }); +}; + +export default withPymStorage; diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index a77a14576..5b5880883 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -8,6 +8,7 @@ import { Environment } from "relay-runtime"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; +import { Storage } from "talk-framework/lib/storage"; import { UIContext } from "talk-ui/components"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 325c2e54e..64241dd5a 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -8,6 +8,7 @@ import { Environment, Network, RecordSource, Store } from "relay-runtime"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createLocalStorage, + createPymStorage, createSessionStorage, } from "talk-framework/lib/storage"; @@ -116,8 +117,12 @@ export default async function createContext({ registerClickFarAway, rest: new RestClient("/api", tokenGetter), postMessage: new PostMessageService(), - localStorage: createLocalStorage(), - sessionStorage: createSessionStorage(), + localStorage: pym + ? createPymStorage(pym, "localStorage") + : createLocalStorage(), + sessionStorage: pym + ? createPymStorage(pym, "sessionStorage") + : createSessionStorage(), }; // Run custom initializations. diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.ts b/src/core/client/framework/lib/storage/InMemoryStorage.ts index 3c5b15d7c..ec4838b20 100644 --- a/src/core/client/framework/lib/storage/InMemoryStorage.ts +++ b/src/core/client/framework/lib/storage/InMemoryStorage.ts @@ -38,6 +38,10 @@ class InMemoryStorage implements Storage { public removeItem(key: string) { delete this.storage[key]; } + + public toString() { + return JSON.stringify(this.storage); + } } export default function createInMemoryStorage() { diff --git a/src/core/client/framework/lib/storage/PymStorage.spec.ts b/src/core/client/framework/lib/storage/PymStorage.spec.ts new file mode 100644 index 000000000..7a9c9a805 --- /dev/null +++ b/src/core/client/framework/lib/storage/PymStorage.spec.ts @@ -0,0 +1,100 @@ +import createPymStorage from "./PymStorage"; + +class PymStub { + public listeners: Record void)> = {}; + public messages: Array<{ key: string; value: string }> = []; + public type: string; + + constructor(type: string) { + this.type = type; + } + + public onMessage(key: string, callback: (msg: string) => void) { + this.listeners[key] = callback; + } + public sendMessage(key: string, value: string) { + this.messages.push({ key, value }); + } +} + +describe("PymStorage", () => { + it("should set item", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.setItem("test", "value"); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id, method, parameters } = JSON.parse(value); + expect(method).toBe("setItem"); + expect(parameters).toEqual({ key: "test", value: "value" }); + pym.listeners["pymStorage.localStorage.response"](JSON.stringify({ id })); + expect(promise).resolves.toBeUndefined(); + }); + + it("should remove item", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.removeItem("test"); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id, method, parameters } = JSON.parse(value); + expect(method).toBe("removeItem"); + expect(parameters).toEqual({ key: "test" }); + pym.listeners["pymStorage.localStorage.response"](JSON.stringify({ id })); + expect(promise).resolves.toBeUndefined(); + }); + + it("should get item", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.getItem("test"); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id, method, parameters } = JSON.parse(value); + expect(method).toBe("getItem"); + expect(parameters).toEqual({ key: "test" }); + pym.listeners["pymStorage.localStorage.response"]( + JSON.stringify({ id, result: "value" }) + ); + expect(promise).resolves.toBe("value"); + }); + + describe("on error", () => { + it("should reject set item", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.setItem("test", "value"); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id } = JSON.parse(value); + pym.listeners["pymStorage.localStorage.error"]( + JSON.stringify({ id, error: "error" }) + ); + expect(promise).rejects.toThrow(new Error("error")); + }); + it("should reject remove item", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.removeItem("test"); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id } = JSON.parse(value); + pym.listeners["pymStorage.localStorage.error"]( + JSON.stringify({ id, error: "error" }) + ); + expect(promise).rejects.toThrow(new Error("error")); + }); + it("should reject get item", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.getItem("test"); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id } = JSON.parse(value); + pym.listeners["pymStorage.localStorage.error"]( + JSON.stringify({ id, error: "error" }) + ); + expect(promise).rejects.toThrow(new Error("error")); + }); + }); +}); diff --git a/src/core/client/framework/lib/storage/PymStorage.ts b/src/core/client/framework/lib/storage/PymStorage.ts new file mode 100644 index 000000000..5075e6505 --- /dev/null +++ b/src/core/client/framework/lib/storage/PymStorage.ts @@ -0,0 +1,57 @@ +import { Child, Parent } from "pym.js"; +import uuid from "uuid/v4"; +import { Storage } from "./interface"; + +type Pym = Child | Parent; + +/** + * Creates a storage that put requests onto pym. + * This is the counterpart of `connectStorageToPym`. + * @param {string} pym pym + * @return {Object} storage + */ +export default function createPymStorage( + pym: Pym, + type: "localStorage" | "sessionStorage" +): Storage { + // A Map of requestID => {resolve, reject} + const requests: Record< + string, + { resolve: ((v: any) => void); reject: ((v: any) => void) } + > = {}; + + // Requests method with parameters over pym. + const call = ( + method: string, + parameters: { key: string; value?: string } + ): Promise => { + const id = uuid(); + return new Promise((resolve, reject) => { + requests[id] = { resolve, reject }; + pym.sendMessage( + `pymStorage.${type}.request`, + JSON.stringify({ id, method, parameters }) + ); + }); + }; + + // Receive successful responses. + pym.onMessage(`pymStorage.${type}.response`, (msg: string) => { + const { id, result } = JSON.parse(msg); + requests[id].resolve(result); + delete requests[id]; + }); + + // Receive error responses. + pym.onMessage(`pymStorage.${type}.error`, (msg: string) => { + const { id, error } = JSON.parse(msg); + requests[id].reject(new Error(error)); + delete requests[id]; + }); + + return { + setItem: (key: string, value: string) => call("setItem", { key, value }), + getItem: (key: string) => call("getItem", { key }), + removeItem: (key: string) => call("removeItem", { key }), + }; +} diff --git a/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap b/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap new file mode 100644 index 000000000..715a8260a --- /dev/null +++ b/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`connectStorageToPym should handle handle errors 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"error\\\\\\"}\\"}]"`; + +exports[`connectStorageToPym should handle unknown method 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"Unknown method unknown\\\\\\"}\\"}]"`; + +exports[`connectStorageToPym should set, get and remove item 1`] = `"{\\"talkPymStorage:key\\":\\"test\\"}"`; + +exports[`connectStorageToPym should set, get and remove item 2`] = `"{}"`; + +exports[`connectStorageToPym should set, get and remove item 3`] = `"[{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\"}\\"},{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"1\\\\\\",\\\\\\"result\\\\\\":\\\\\\"test\\\\\\"}\\"},{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"2\\\\\\"}\\"}]"`; diff --git a/src/core/client/framework/lib/storage/index.ts b/src/core/client/framework/lib/storage/index.ts index a558f693b..82f213a46 100644 --- a/src/core/client/framework/lib/storage/index.ts +++ b/src/core/client/framework/lib/storage/index.ts @@ -1,3 +1,5 @@ export { default as createInMemoryStorage } from "./InMemoryStorage"; export { default as createLocalStorage } from "./LocalStorage"; export { default as createSessionStorage } from "./SessionStorage"; +export { default as createPymStorage } from "./PymStorage"; +export { Storage } from "./interface"; diff --git a/src/core/client/framework/lib/storage/interface.ts b/src/core/client/framework/lib/storage/interface.ts new file mode 100644 index 000000000..5a3a1b6c8 --- /dev/null +++ b/src/core/client/framework/lib/storage/interface.ts @@ -0,0 +1,14 @@ +export interface Storage { + /** + * value = storage[key] + */ + getItem(key: string): Promise | string | null; + /** + * delete storage[key] + */ + removeItem(key: string): Promise | void; + /** + * storage[key] = value + */ + setItem(key: string, value: string): Promise | void; +} diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 81ba25702..81b32a742 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -22,6 +22,8 @@ export default async function initLocalState( environment: Environment, { localStorage }: TalkContext ) { + const authToken = await localStorage.getItem("authToken"); + commitLocalUpdate(environment, s => { // TODO: (cvle) move local, auth token and network initialization to framework. const root = s.getRoot(); @@ -31,7 +33,7 @@ export default async function initLocalState( root.setLinkedRecord(localRecord, "local"); // Set auth token - localRecord.setValue(localStorage.getItem("authToken") || "", "authToken"); + localRecord.setValue(authToken || "", "authToken"); // Set initial auth revision, this is increment whenenver auth state might have changed. localRecord.setValue(0, "authRevision");