Repository: boardgameio/boardgame.io (ts)

Issue:
 localStorage as Local storage.
What do you think about idea to allow localStorage to be storage for localMaster? Sometimes it could be useful to be able to back to same game after accidental tab close or refresh without need of server in backend. 

Pull request:
Add localStorage support (close #679)
Closes #679 

* Local can now be called with Local({persist : true }) and use localStorage

* Add documentation note

#### Checklist

* [x] Use a separate branch in your local repo (not `master`).
* [x] Test coverage is 100% (or you have a story for why it's ok).

#### TODO : 

- In case of local master reset in debug UI doesn't work regardless of localStorage usage. Without localStorage is wasn't that bad as one can simply refresh the browser but with localStorage usage it can be problem. 

Patch:
diff --git a/docs/documentation/multiplayer.md b/docs/documentation/multiplayer.md
index 35fb842..4e2270e 100644
--- a/docs/documentation/multiplayer.md
+++ b/docs/documentation/multiplayer.md
@@ -127,6 +127,21 @@ In the example above you can play as Player 0 and Player 1 alternately
 on the two boards. Clicking on a particular board when it is not that
 player's turn has no effect.
 
+### Storing state in the browser
+
+If you want game state to be saved in the browser using `localStorage`,
+you can pass additional options when creating a local master:
+
+```js
+Local({
+  // Enable localStorage cache.
+  persist: true,
+
+  // Set custom prefix to store data under. Default: 'bgio'.
+  storageKey: 'bgio',
+});
+```
+
 ## Remote Master
 
 You can also run the game master on a separate server. Any boardgame.io
diff --git a/src/client/transport/local.ts b/src/client/transport/local.ts
index ffea3fc..9f875f0 100644
--- a/src/client/transport/local.ts
+++ b/src/client/transport/local.ts
@@ -8,6 +8,7 @@
 
 import * as ActionCreators from '../../core/action-creators';
 import { InMemory } from '../../server/db/inmemory';
+import { LocalStorage } from '../../server/db/localstorage';
 import { Master, TransportAPI } from '../../master/master';
 import { Transport, TransportOpts } from './transport';
 import {
@@ -41,11 +42,16 @@ export function GetBotPlayer(state: State, bots: Record<PlayerID, any>) {
   return null;
 }
 
-interface LocalMasterOpts {
-  game: Game;
+interface LocalOpts {
   bots?: Record<PlayerID, any>;
+  persist?: boolean;
+  storageKey?: string;
 }
 
+type LocalMasterOpts = LocalOpts & {
+  game: Game;
+};
+
 /**
  * Creates a local version of the master that the client
  * can interact with.
@@ -57,7 +63,7 @@ export class LocalMaster extends Master {
     callback: (...args: any[]) => void
   ) => void;
 
-  constructor({ game, bots }: LocalMasterOpts) {
+  constructor({ game, bots, storageKey, persist }: LocalMasterOpts) {
     const clientCallbacks: Record<PlayerID, (...args: any[]) => void> = {};
     const initializedBots = {};
 
@@ -88,8 +94,8 @@ export class LocalMaster extends Master {
         }
       },
     };
-
-    super(game, new InMemory(), transportAPI, false);
+    const storage = persist ? new LocalStorage(storageKey) : new InMemory();
+    super(game, storage, transportAPI, false);
 
     this.connect = (matchID, playerID, callback) => {
       clientCallbacks[playerID] = callback;
@@ -237,29 +243,29 @@ export class LocalTransport extends Transport {
 /**
  * Global map storing local master instances.
  */
-const localMasters: Map<
-  Game,
-  { master: LocalMaster; bots: LocalMasterOpts['bots'] }
-> = new Map();
+const localMasters: Map<Game, { master: LocalMaster } & LocalOpts> = new Map();
 
 /**
  * Create a local transport.
  */
-export function Local({ bots }: Pick<LocalMasterOpts, 'bots'> = {}) {
+export function Local({ bots, persist, storageKey }: LocalOpts = {}) {
   return (transportOpts: TransportOpts) => {
     const { gameKey, game } = transportOpts;
     let master: LocalMaster;
 
-    if (localMasters.has(gameKey)) {
-      const instance = localMasters.get(gameKey);
-      if (instance.bots === bots) {
-        master = instance.master;
-      }
+    const instance = localMasters.get(gameKey);
+    if (
+      instance &&
+      instance.bots === bots &&
+      instance.storageKey === storageKey &&
+      instance.persist === persist
+    ) {
+      master = instance.master;
     }
 
     if (!master) {
-      master = new LocalMaster({ game, bots });
-      localMasters.set(gameKey, { master, bots });
+      master = new LocalMaster({ game, bots, persist, storageKey });
+      localMasters.set(gameKey, { master, bots, persist, storageKey });
     }
 
     return new LocalTransport({ master, ...transportOpts });
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
index c296693..d203607 100644
--- a/src/server/db/index.ts
+++ b/src/server/db/index.ts
@@ -1,5 +1,6 @@
 import { InMemory } from './inmemory';
 import { FlatFile } from './flatfile';
+import { LocalStorage } from './localstorage';
 
 const DBFromEnv = () => {
   if (process.env.FLATFILE_DIR) {
@@ -11,4 +12,4 @@ const DBFromEnv = () => {
   }
 };
 
-export { InMemory, FlatFile, DBFromEnv };
+export { InMemory, FlatFile, LocalStorage, DBFromEnv };
diff --git a/src/server/db/inmemory.ts b/src/server/db/inmemory.ts
index 9960533..1669c49 100644
--- a/src/server/db/inmemory.ts
+++ b/src/server/db/inmemory.ts
@@ -13,10 +13,10 @@ import * as StorageAPI from './base';
  * InMemory data storage.
  */
 export class InMemory extends StorageAPI.Sync {
-  private state: Map<string, State>;
-  private initial: Map<string, State>;
-  private metadata: Map<string, Server.MatchData>;
-  private log: Map<string, LogEntry[]>;
+  protected state: Map<string, State>;
+  protected initial: Map<string, State>;
+  protected metadata: Map<string, Server.MatchData>;
+  protected log: Map<string, LogEntry[]>;
 
   /**
    * Creates a new InMemory storage.
diff --git a/src/server/db/localstorage.ts b/src/server/db/localstorage.ts
new file mode 100644
index 0000000..f4929fd
--- /dev/null
+++ b/src/server/db/localstorage.ts
@@ -0,0 +1,44 @@
+import { InMemory } from './inmemory';
+
+class WithLocalStorageMap<Key, Value> extends Map {
+  key: string;
+
+  constructor(key: string) {
+    super();
+    this.key = key;
+    const cache = JSON.parse(localStorage.getItem(this.key)) || [];
+    cache.forEach((entry: [Key, Value]) => this.set(...entry));
+  }
+
+  sync(): void {
+    const entries = [...this.entries()];
+    localStorage.setItem(this.key, JSON.stringify(entries));
+  }
+
+  set(key: Key, value: Value): this {
+    super.set(key, value);
+    this.sync();
+    return this;
+  }
+
+  delete(key: Key): boolean {
+    const result = super.delete(key);
+    this.sync();
+    return result;
+  }
+}
+
+/**
+ * locaStorage data storage.
+ */
+export class LocalStorage extends InMemory {
+  constructor(storagePrefix: string = 'bgio') {
+    super();
+    const StorageMap = (stateKey: string) =>
+      new WithLocalStorageMap(`${storagePrefix}_${stateKey}`);
+    this.state = StorageMap('state');
+    this.initial = StorageMap('initial');
+    this.metadata = StorageMap('metadata');
+    this.log = StorageMap('log');
+  }
+}


Test patch:
diff --git a/src/client/transport/local.test.js b/src/client/transport/local.test.js
index 1fa0ea2..6d91b28 100644
--- a/src/client/transport/local.test.js
+++ b/src/client/transport/local.test.js
@@ -147,6 +147,54 @@ describe('Local', () => {
     const transport2 = Local({ bots: {} })({ game, gameKey });
     expect(transport1.master).not.toBe(transport2.master);
   });
+
+  describe('with localStorage persistence', () => {
+    const game = {
+      setup: () => ({ count: 0 }),
+      moves: {
+        A: G => {
+          G.count++;
+        },
+      },
+    };
+
+    afterEach(() => {
+      localStorage.clear();
+    });
+
+    test('writes to localStorage', () => {
+      const matchID = 'persists-to-ls';
+      const multiplayer = Local({ persist: true });
+      const client = Client({ playerID: '0', matchID, game, multiplayer });
+      client.start();
+      expect(client.getState().G).toEqual({ count: 0 });
+      client.moves.A();
+      expect(client.getState().G).toEqual({ count: 1 });
+      client.stop();
+      const stored = JSON.parse(localStorage.getItem('bgio_state'));
+      const [id, state] = stored.find(([id]) => id === matchID);
+      expect(id).toBe(matchID);
+      expect(state.G).toEqual({ count: 1 });
+    });
+
+    test('reads from localStorage', () => {
+      const matchID = 'reads-from-ls';
+      const storageKey = 'rfls';
+      const stateMap = {
+        [matchID]: {
+          G: { count: 'foo' },
+          ctx: {},
+        },
+      };
+      const entriesString = JSON.stringify(Object.entries(stateMap));
+      localStorage.setItem(`${storageKey}_state`, entriesString);
+      const multiplayer = Local({ persist: true, storageKey });
+      const client = Client({ playerID: '0', matchID, game, multiplayer });
+      client.start();
+      expect(client.getState().G).toEqual({ count: 'foo' });
+      client.stop();
+    });
+  });
 });
 
 describe('LocalMaster', () => {
diff --git a/src/server/db/localstorage.test.ts b/src/server/db/localstorage.test.ts
new file mode 100644
index 0000000..5a7059a
--- /dev/null
+++ b/src/server/db/localstorage.test.ts
@@ -0,0 +1,73 @@
+import { generate as uuid } from 'shortid';
+import { LocalStorage } from './localstorage';
+import { State, Server } from '../../types';
+
+describe('LocaLStorage', () => {
+  let db: LocalStorage;
+
+  beforeAll(() => {
+    db = new LocalStorage(uuid());
+    db.connect();
+  });
+
+  // Must return undefined when no game exists.
+  test('must return undefined when no game exists', () => {
+    const { state } = db.fetch('gameID', { state: true });
+    expect(state).toEqual(undefined);
+  });
+
+  test('create game', () => {
+    let stateEntry: unknown = { a: 1 };
+
+    // Create game.
+    db.createMatch('gameID', {
+      metadata: {
+        gameName: 'tic-tac-toe',
+      } as Server.MatchData,
+      initialState: stateEntry as State,
+    });
+
+    // Must return created game.
+    const { state } = db.fetch('gameID', { state: true });
+    expect(state).toEqual(stateEntry);
+
+    // Fetch initial state.
+    const { initialState } = db.fetch('gameID', { initialState: true });
+    expect(initialState).toEqual(stateEntry);
+  });
+
+  test('listGames', () => {
+    let keys = db.listMatches({});
+    expect(keys).toEqual(['gameID']);
+    keys = db.listMatches({ gameName: 'tic-tac-toe' });
+    expect(keys).toEqual(['gameID']);
+    keys = db.listMatches({ gameName: 'chess' });
+    expect(keys).toEqual([]);
+  });
+
+  test('remove', () => {
+    // Must remove game from DB
+    db.wipe('gameID');
+    expect(db.fetch('gameID', { state: true })).toEqual({});
+    // Shall not return error
+    db.wipe('gameID');
+  });
+
+  test('must create new empty db if other localstorage key is used', () => {
+    // create another localstorage with anothr key
+    let db2 = new LocalStorage(uuid());
+    let stateEntry: unknown = { a: 1 };
+
+    // create game in db
+    db.createMatch('gameID', {
+      metadata: {
+        gameName: 'tic-tac-toe',
+      } as Server.MatchData,
+      initialState: stateEntry as State,
+    });
+
+    // game shouldnt be visible in db2
+    const { state } = db2.fetch('gameID', { state: true });
+    expect(state).toEqual(undefined);
+  });
+});