diff --git a/integration/package.json b/integration/package.json index 4dc7804bf..645401d4a 100644 --- a/integration/package.json +++ b/integration/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "boardgame.io": "file:boardgame.io-0.43.2.tgz", "react": "^16.7.0", "react-dom": "^16.7.0", "react-scripts": "^2.1.3" diff --git a/package-lock.json b/package-lock.json index 1c31b9e4e..b2f487e1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12775,8 +12775,7 @@ "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" }, "lodash.memoize": { "version": "4.1.2", diff --git a/package.json b/package.json index 743c68903..d769b579c 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "koa-body": "^4.1.0", "koa-router": "^7.2.1", "koa-socket-2": "^1.0.17", + "lodash.isplainobject": "^4.0.6", "nanoid": "^3.1.20", "p-queue": "^6.6.2", "prop-types": "^15.5.10", diff --git a/src/plugins/main.ts b/src/plugins/main.ts index 368c8f1ab..31fb643f0 100644 --- a/src/plugins/main.ts +++ b/src/plugins/main.ts @@ -10,6 +10,7 @@ import PluginImmer from './plugin-immer'; import PluginRandom from './plugin-random'; import PluginEvents from './plugin-events'; import PluginLog from './plugin-log'; +import PluginSerializable from './plugin-serializable'; import type { AnyFn, PartialGameState, @@ -29,7 +30,13 @@ interface PluginOpts { /** * List of plugins that are always added. */ -const DEFAULT_PLUGINS = [PluginImmer, PluginRandom, PluginEvents, PluginLog]; +const DEFAULT_PLUGINS = [ + PluginImmer, + PluginRandom, + PluginEvents, + PluginLog, + PluginSerializable, +]; /** * Allow plugins to intercept actions and process them. diff --git a/src/plugins/plugin-serializable.test.ts b/src/plugins/plugin-serializable.test.ts new file mode 100644 index 000000000..85163b660 --- /dev/null +++ b/src/plugins/plugin-serializable.test.ts @@ -0,0 +1,39 @@ +import { Client } from '../client/client'; + +describe('plugin-serializable', () => { + let client; + + beforeAll(() => { + const game = { + moves: { + serializable: () => { + return { hello: 'world' }; + }, + + nonSerializable: () => { + class Foo { + a: number; + constructor(a: number) { + this.a = a; + } + } + return { hello: new Foo(1) }; + }, + }, + }; + + client = Client({ game }); + }); + + test('does not throw for serializable move', () => { + expect(() => { + client.moves.serializable(); + }).not.toThrow(); + }); + + test('throws for non-serializable move', () => { + expect(() => { + client.moves.nonSerializable(); + }).toThrow(); + }); +}); diff --git a/src/plugins/plugin-serializable.ts b/src/plugins/plugin-serializable.ts new file mode 100644 index 000000000..279b3cfac --- /dev/null +++ b/src/plugins/plugin-serializable.ts @@ -0,0 +1,53 @@ +import type { Plugin, AnyFn, Ctx } from '../types'; +import isPlainObject from 'lodash.isplainobject'; + +/** + * Check if a value can be serialized (e.g. using `JSON.stringify`). + * Adapted from: https://stackoverflow.com/a/30712764/3829557 + */ +function isSerializable(value: any) { + // Primitives are OK. + if ( + value === undefined || + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return true; + } + + // A non-primitive value that is neither a POJO or an array cannot be serialized. + if (!isPlainObject(value) && !Array.isArray(value)) { + return false; + } + + // Recurse entries if the value is an object or array. + for (const key in value) { + if (!isSerializable(value[key])) return false; + } + + return true; +} + +/** + * Plugin that checks whether state is serializable, in order to avoid + * network serialization bugs. + */ +const SerializablePlugin: Plugin = { + name: 'plugin-serializable', + + fnWrap: (move: AnyFn) => (G: unknown, ctx: Ctx, ...args: any[]) => { + const result = move(G, ctx, ...args); + // Check state in non-production environments. + if (process.env.NODE_ENV !== 'production' && !isSerializable(result)) { + throw new Error( + 'Move state is not JSON-serialiazable.\n' + + 'See https://boardgame.io/documentation/#/?id=state for more information.' + ); + } + return result; + }, +}; + +export default SerializablePlugin;