├── .babelrc ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── README.md ├── example ├── gets.js └── observer.js ├── flow └── index.js.flow ├── package.json ├── src ├── index.js ├── traps.js ├── types.js └── utils.js └── test ├── helpers.js └── index.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-object-rest-spread", 4 | "transform-class-properties" 5 | ], 6 | "presets": [ 7 | "latest", 8 | "flow" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended" 4 | ], 5 | 6 | "rules": { 7 | "quotes": [ 8 | "warn", 9 | "single" 10 | ], 11 | "linebreak-style": [ 12 | "error", 13 | "unix" 14 | ], 15 | "semi": [ 16 | "warn", 17 | "always" 18 | ], 19 | "curly": "off", 20 | "no-use-before-define": [ 21 | "error", 22 | "nofunc" 23 | ], 24 | "keyword-spacing": "warn", 25 | "space-before-blocks": [ 26 | "warn", 27 | "always" 28 | ], 29 | "eol-last": [ 30 | "warn", 31 | "always" 32 | ], 33 | "semi-spacing": [ 34 | "warn", 35 | { 36 | "before": false, 37 | "after": true 38 | } 39 | ], 40 | "no-var": "error", 41 | "object-shorthand": [ 42 | "error", 43 | "always" 44 | ], 45 | "prefer-const": "error", 46 | "max-depth": [ 47 | "error", 48 | 5 49 | ], 50 | "max-len": [ 51 | "warn", 52 | 145, 53 | 2 54 | ], 55 | "max-statements": [ 56 | "error", 57 | 20 58 | ], 59 | "complexity": [ 60 | "warn", 61 | 8 62 | ], 63 | "object-curly-spacing": [ 64 | "warn", 65 | "always" 66 | ], 67 | "no-process-exit": "error", 68 | "no-console": "warn", 69 | "rest-spread-spacing": [ 70 | "warn", 71 | "never" 72 | ], 73 | "indent": [ 74 | "warn", 75 | 4, 76 | { 77 | "SwitchCase": 1 78 | } 79 | ], 80 | "comma-dangle": [ 81 | "warn", 82 | "never" 83 | ], 84 | "no-underscore-dangle": 0, 85 | 86 | "flowtype/boolean-style": [ 87 | "warn", 88 | "boolean" 89 | ], 90 | "flowtype/define-flow-type": "warn", 91 | "flowtype/delimiter-dangle": [ 92 | "warn", 93 | "never" 94 | ], 95 | "flowtype/generic-spacing": [ 96 | "warn", 97 | "never" 98 | ], 99 | "flowtype/no-primitive-constructor-types": "warn", 100 | "flowtype/no-types-missing-file-annotation": "warn", 101 | "flowtype/no-weak-types": 0, 102 | "flowtype/object-type-delimiter": [ 103 | "warn", 104 | "comma" 105 | ], 106 | "flowtype/require-parameter-type": "warn", 107 | "flowtype/require-return-type": [ 108 | "warn", 109 | "always" 110 | ], 111 | "flowtype/require-valid-file-annotation": 2, 112 | "flowtype/semi": [ 113 | "warn", 114 | "always" 115 | ], 116 | "flowtype/space-after-type-colon": [ 117 | "warn", 118 | "always" 119 | ], 120 | "flowtype/space-before-generic-bracket": [ 121 | "warn", 122 | "never" 123 | ], 124 | "flowtype/space-before-type-colon": [ 125 | "warn", 126 | "never" 127 | ], 128 | "flowtype/type-id-match": [ 129 | "warn", 130 | "^([A-Z][a-z0-9]+)+$" 131 | ], 132 | "flowtype/union-intersection-spacing": [ 133 | "warn", 134 | "always" 135 | ], 136 | "flowtype/use-flow-type": "warn", 137 | "flowtype/valid-syntax": "warn", 138 | 139 | "space-infix-ops": ["warn", {"int32Hint": false}], 140 | "no-only-tests/no-only-tests" : "warn" 141 | }, 142 | "env": { 143 | "browser": true, 144 | "es6": true, 145 | "node": true, 146 | "mocha": true 147 | }, 148 | "globals": { 149 | "React": true 150 | }, 151 | "plugins": [ 152 | "no-only-tests", 153 | "flowtype" 154 | ], 155 | "settings": { 156 | "flowtype": { 157 | "onlyFilesWithFlowAnnotation": true 158 | } 159 | }, 160 | "parser": "babel-eslint", 161 | "parserOptions": { 162 | "ecmaVersion": 6, 163 | "sourceType": "module", 164 | "ecmaFeatures": { 165 | "modules": true, 166 | "experimentalObjectRestSpread": true 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [include] 2 | src/ 3 | 4 | [ignore] 5 | /lib/.* 6 | /test/.* 7 | /flow/.* 8 | 9 | [libs] 10 | flow-typed/ 11 | 12 | [lints] 13 | 14 | [options] 15 | suppress_comment= \\(.\\|\n\\)*\\$FlowIgnore 16 | include_warnings=true 17 | munge_underscores=true 18 | 19 | [strict] 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | ### Example user template template 30 | ### Example user template 31 | 32 | # IntelliJ project files 33 | .idea 34 | *.iml 35 | out 36 | gen### Node template 37 | # Logs 38 | logs 39 | *.log 40 | npm-debug.log* 41 | yarn-debug.log* 42 | yarn-error.log* 43 | 44 | # Runtime data 45 | pids 46 | *.pid 47 | *.seed 48 | *.pid.lock 49 | 50 | # Directory for instrumented libs generated by jscoverage/JSCover 51 | lib-cov 52 | 53 | # Coverage directory used by tools like istanbul 54 | coverage 55 | 56 | # nyc test coverage 57 | .nyc_output 58 | 59 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 60 | .grunt 61 | 62 | # Bower dependency directory (https://bower.io/) 63 | bower_components 64 | 65 | # node-waf configuration 66 | .lock-wscript 67 | 68 | # Compiled binary addons (https://nodejs.org/api/addons.html) 69 | build/Release 70 | 71 | # Dependency directories 72 | node_modules/ 73 | jspm_packages/ 74 | 75 | # Typescript v1 declaration files 76 | typings/ 77 | 78 | # Optional npm cache directory 79 | .npm 80 | 81 | # Optional eslint cache 82 | .eslintcache 83 | 84 | # Optional REPL history 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | *.tgz 89 | 90 | # Yarn Integrity file 91 | .yarn-integrity 92 | 93 | # dotenv environment variables file 94 | .env 95 | 96 | yarn.lock 97 | package-lock.json 98 | 99 | lib/ 100 | 101 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Example user template template 3 | ### Example user template 4 | 5 | # IntelliJ project files 6 | .idea 7 | *.iml 8 | out 9 | gen### macOS template 10 | # General 11 | .DS_Store 12 | .AppleDouble 13 | .LSOverride 14 | 15 | # Icon must end with two \r 16 | Icon 17 | 18 | # Thumbnails 19 | ._* 20 | 21 | # Files that might appear in the root of a volume 22 | .DocumentRevisions-V100 23 | .fseventsd 24 | .Spotlight-V100 25 | .TemporaryItems 26 | .Trashes 27 | .VolumeIcon.icns 28 | .com.apple.timemachine.donotpresent 29 | 30 | # Directories potentially created on remote AFP share 31 | .AppleDB 32 | .AppleDesktop 33 | Network Trash Folder 34 | Temporary Items 35 | .apdisk 36 | ### Node template 37 | # Logs 38 | logs 39 | *.log 40 | npm-debug.log* 41 | yarn-debug.log* 42 | yarn-error.log* 43 | 44 | # Runtime data 45 | pids 46 | *.pid 47 | *.seed 48 | *.pid.lock 49 | 50 | # Directory for instrumented libs generated by jscoverage/JSCover 51 | lib-cov 52 | 53 | # Coverage directory used by tools like istanbul 54 | coverage 55 | 56 | # nyc test coverage 57 | .nyc_output 58 | 59 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 60 | .grunt 61 | 62 | # Bower dependency directory (https://bower.io/) 63 | bower_components 64 | 65 | # node-waf configuration 66 | .lock-wscript 67 | 68 | # Compiled binary addons (https://nodejs.org/api/addons.html) 69 | build/Release 70 | 71 | # Dependency directories 72 | node_modules/ 73 | jspm_packages/ 74 | 75 | # Typescript v1 declaration files 76 | typings/ 77 | 78 | # Optional npm cache directory 79 | .npm 80 | 81 | # Optional eslint cache 82 | .eslintcache 83 | 84 | # Optional REPL history 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | *.tgz 89 | 90 | # Yarn Integrity file 91 | .yarn-integrity 92 | 93 | # dotenv environment variables file 94 | .env 95 | test/ 96 | src/ 97 | .babelrc 98 | .gitignore 99 | .flowconfig 100 | yarn.lock 101 | flow/ 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # recursive-proxy 2 | 3 | Quite like native ES6 proxy, but recursive! 4 | 5 | ## Installation 6 | 7 | ```bash 8 | yarn add recursive-proxy 9 | ``` 10 | 11 | or via NPM 12 | 13 | ```bash 14 | npm install --save recursive-proxy 15 | ``` 16 | 17 | ## Examples 18 | 19 | For more details on how does it work check `examples` folder. 20 | 21 | To run examples type 22 | 23 | ```bash 24 | yarn babel-node -- example/gets.js 25 | ``` 26 | 27 | or 28 | 29 | ```bash 30 | yarn babel-node -- example/observer.js 31 | ``` 32 | 33 | ## Usage 34 | 35 | ```js 36 | import RProxy from 'recursive-proxy'; 37 | 38 | const target = {}; // proxy target 39 | 40 | const context = {/* context */}; 41 | 42 | 43 | const proxyConfig = { 44 | // Options 45 | 46 | // Whether apply (follow) proxy on nested functions 47 | // bool, default: true 48 | followFunction: true, 49 | 50 | // Whether apply (follow) proxy on nested arrays 51 | // bool, default: false 52 | followArray: false, 53 | 54 | // Whether apply (follow) proxy on nested, non plain objects 55 | // bool, default: true 56 | followNonPlainObject: false, 57 | 58 | // Whether proxy should be readOnly 59 | // values: "error", "silent", false (default) 60 | readOnly: false, 61 | 62 | // Recursive path separator 63 | // string, default: "." 64 | pathSeparator: '.', 65 | 66 | // Traps 67 | 68 | // Each trap definition is an object with path expression as 69 | // a key and trap specific value or callback. Every callback 70 | // function has context bound as this 71 | 72 | // Path expression (PE) starts with pathSeparator character (PS) 73 | // after which (PS) separated path is provided. 74 | // ex. ".a" is property "a" of target object 75 | // ex. ".a.b" is property "b" of "a" object of target 76 | // 77 | // Wildcards when (PE) doesn't start with (PS) and there was no 78 | // exact path match it can be matched to the property name itself 79 | // ex. "a" is property "a" of target object or any of its nodes 80 | // 81 | // When there is no wildcard match "" (empty) path is considered 82 | // as fallback (if present). 83 | 84 | // Replace value with provided static value 85 | value: { 86 | // (PE): value to be returned for that property 87 | }, 88 | 89 | // Replace value with value computed from callback 90 | // currentValue may come from value trap 91 | creator: { 92 | // (PE): (currentValue, target, propName, pathToTrappedItem) => newValue 93 | }, 94 | 95 | // Trap when prop value is changed 96 | setter: { 97 | // (PE): (target, propName, newValue, pathToTrappedItem) => changeWasAllowed? 98 | }, 99 | 100 | // Trap when target is a function and is called 101 | apply: { 102 | // (PE): (target, this, argsArray, pathToTrappedItem) => anyValue 103 | }, 104 | 105 | // Trap when target is a function and is called with "new" operator 106 | construct: { 107 | // (PE): (target, argsArray?, newTarget, pathToTrappedItem) => object 108 | } 109 | }; 110 | 111 | const proxy = RProxy(proxyConfig, target, context); 112 | // or with new operator 113 | const proxy = new RProxy(proxyConfig, target, context); 114 | 115 | ``` 116 | 117 | -------------------------------------------------------------------------------- /example/gets.js: -------------------------------------------------------------------------------- 1 | import RProxy from '../src'; 2 | 3 | const _gets = (obj, path, defaultValue) => { 4 | if (path.length === 0) { 5 | return obj; 6 | } 7 | 8 | const key = path.shift(); 9 | if (key in obj) { 10 | return _gets(obj[key], path, defaultValue); 11 | } 12 | 13 | return defaultValue; 14 | }; 15 | 16 | const gets = (subject) => new RProxy({ 17 | value: { 18 | '': () => {} 19 | }, 20 | apply: { 21 | '': (_, __, [defaultValue], path) => { 22 | return _gets(subject, path, defaultValue); 23 | } 24 | } 25 | }, subject); 26 | 27 | const target = { 28 | a: { 29 | b: { 30 | c: { 31 | d: 9876789 32 | } 33 | }, 34 | k: () => 'existing func result' 35 | } 36 | }; 37 | 38 | 39 | const proxy = gets(target); 40 | 41 | console.log(proxy.a.b.c({})); // { d: 9876789 } 42 | console.log(proxy.a.b.c.d(0)); // 9876789 43 | console.log(proxy.a.b.c.e(0)); // 0 44 | 45 | console.log(proxy.a.b.z({})); // {} 46 | target.a.b.z = { now: 7 }; 47 | console.log(proxy.a.b.z({})); // { now: 7 } 48 | 49 | console.log(proxy.x.y.z.e('!default!')); // !default! 50 | console.log(proxy.x.y.z.f()); // undefined 51 | 52 | 53 | // functions 54 | const fallbackFunc = () => 'fallback func result'; 55 | 56 | const func1 = proxy.a.k(fallbackFunc); 57 | console.log(func1()); // existing func result 58 | 59 | 60 | const func2 = proxy.a.l(fallbackFunc); 61 | console.log(func2()); // fallback func result 62 | -------------------------------------------------------------------------------- /example/observer.js: -------------------------------------------------------------------------------- 1 | import Multimap from 'multimap'; 2 | 3 | import RProxy from '../src'; 4 | 5 | const observable = (subject, notifyParents = false) => { 6 | const _subscribers = new Multimap(); 7 | 8 | return new RProxy({ 9 | value: { 10 | '.subscribe': (key, callback) => { 11 | _subscribers.set(key, callback); 12 | 13 | return () => { 14 | _subscribers.delete(key, callback) 15 | } 16 | } 17 | }, 18 | setter: { 19 | '': (target, key, value, path) => { 20 | const oldValue = target[key]; 21 | if (oldValue !== value) { 22 | target[key] = value; 23 | 24 | const fullPath = path.join('.'); 25 | 26 | do { 27 | const matchedSubscribers = _subscribers.get(path.join('.')); 28 | if (matchedSubscribers) { 29 | matchedSubscribers.forEach((callback) => { 30 | return callback({ from: oldValue, to: value }, { target, key, origin: subject, path: fullPath }); 31 | }); 32 | } 33 | } while (notifyParents && (path = path.slice(0, -1)).length > 0); 34 | } 35 | } 36 | } 37 | }, subject) 38 | }; 39 | 40 | const target = { 41 | a: { 42 | b: { 43 | c: { 44 | d: 9 45 | } 46 | } 47 | } 48 | }; 49 | 50 | const proxy = observable(target, true); 51 | proxy.subscribe('a.b.c.d', ({ from, to }, { key }) => console.log(`Sub1. Value changed from ${from} to ${to} at key ${key}.`)); 52 | proxy.subscribe('a.b.c', ({ from, to }, { key, origin, path }) => { 53 | console.log(`Sub2. Nested value changed from ${from} to ${to} at key ${key}, in object`, target); 54 | console.log('Sub2. Observed object', origin, `, path ${path}`); 55 | }); 56 | 57 | console.log(target.a.b.c.d); //9 58 | console.log(proxy.a.b.c.d); //9 59 | 60 | proxy.a.b.c.d = 11; 61 | // Sub1. Value changed from 9 to 11 at key d. 62 | // Sub2. Nested value changed from 9 to 11 at key d, in object { a: { b: { c: [Object] } } } 63 | // Sub2. Observed object { a: { b: { c: [Object] } } } , path a.b.c.d 64 | 65 | 66 | console.log(proxy.a.b.c.d); //11 67 | console.log(target.a.b.c.d); //11 68 | 69 | proxy.a.b.c.e = 22; 70 | // Sub2. Nested value changed from undefined to 22 at key e, in object { a: { b: { c: [Object] } } } 71 | // Sub2. Observed object { a: { b: { c: [Object] } } } , path a.b.c.e 72 | 73 | 74 | console.log(proxy.a.b.c); // { d: 11, e: 22 } 75 | console.log(target.a.b.c); // { d: 11, e: 22 } 76 | -------------------------------------------------------------------------------- /flow/index.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { RecursiveProxyOptions } from './types'; 3 | 4 | declare export default (opts: $Shape, target: S, context?: {}) => S; 5 | 6 | declare export function recursiveProxy(opts: $Shape, target: S, context?: {}): S; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recursive-proxy", 3 | "version": "1.1.0", 4 | "description": "JS Recursive proxy.", 5 | "main": "lib/index.js", 6 | "author": "Adam Makświej ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/vertexbz/recursive-proxy.git" 10 | }, 11 | "license": "MIT", 12 | "devDependencies": { 13 | "babel-cli": "^6.26.0", 14 | "babel-eslint": "^8.1.2", 15 | "babel-plugin-transform-class-properties": "^6.24.1", 16 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 17 | "babel-preset-flow": "^6.23.0", 18 | "babel-preset-latest": "^6.24.1", 19 | "eslint": "^4.14.0", 20 | "eslint-loader": "^1.9.0", 21 | "eslint-plugin-flowtype": "^2.40.1", 22 | "eslint-plugin-no-only-tests": "^2.0.0", 23 | "eslint-plugin-react": "^7.5.1", 24 | "flow": "^0.2.3", 25 | "flow-bin": "^0.62.0", 26 | "jest": "^22.0.4", 27 | "multimap": "^1.0.2" 28 | }, 29 | "scripts": { 30 | "build:flow": "cp flow/index.js.flow src/types.js lib/", 31 | "build:src": "babel src --out-dir lib -s", 32 | "build": "yarn lint && yarn flow && rm -rf ./lib/ && yarn build:src && yarn build:flow", 33 | "lint": "eslint src --ignore-path flow/* --ext .js --ext .jsx --cache", 34 | "lint:fix": "eslint src --ignore-path flow/* --ext .js --ext .jsx --cache --fix", 35 | "test": "jest", 36 | "test:cover": "jest --coverage" 37 | }, 38 | "dependencies": { 39 | "context-proxy": "^1.1.4", 40 | "is-plain-object": "^2.0.4", 41 | "isobject": "^3.0.1" 42 | }, 43 | "jest": { 44 | "collectCoverageFrom": [ 45 | "src/**/*.{js,jsx}", 46 | "!/node_modules/", 47 | "!/test/helpers.js" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import contextProxy from 'context-proxy'; 3 | import { triageTraps } from './traps'; 4 | 5 | import type { RecursiveProxyOptions } from './types'; 6 | import { shouldFollowValue } from './utils'; 7 | 8 | const defaultOpts = { 9 | value: {}, 10 | creator: {}, 11 | setter: {}, 12 | construct: {}, 13 | apply: {}, 14 | readonly: false, 15 | pathSeparator: '.', 16 | followFunction: true, 17 | followArray: false, 18 | followNonPlainObject: false 19 | }; 20 | 21 | export const recursiveProxy = (opts: $Shape, target: S, context: {} = {}): S => { 22 | const config: RecursiveProxyOptions = Object.assign({}, defaultOpts, opts); 23 | 24 | if (!shouldFollowValue(target, config)) { 25 | throw new Error('Cannot wrap provided target! Check proxy target and config.'); 26 | } 27 | 28 | const traps = triageTraps(config.readOnly); 29 | 30 | return contextProxy( 31 | target, 32 | traps, 33 | { origin: target, context, config, traps, target: null, path: [] } 34 | ); 35 | }; 36 | 37 | export default recursiveProxy; 38 | -------------------------------------------------------------------------------- /src/traps.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import contextProxy from 'context-proxy'; 3 | import { getLastPathElement, matchObjectPath, nope, pathBuilder, shh, shouldFollowValue, triageSet } from './utils'; 4 | 5 | import type { RecursiveContext } from './types'; 6 | 7 | const enhanceContext = (context: RecursiveContext, target: T, path: string): RecursiveContext => ({ 8 | config: context.config, 9 | context: context.context, 10 | origin: context.origin, 11 | traps: context.traps, 12 | path: context.path.concat(String(path)), 13 | target 14 | }); 15 | 16 | const traps = { 17 | get(target: Object, name: string): mixed { 18 | const ctx = enhanceContext(this, target, name); 19 | const builtPath = pathBuilder(ctx.config.pathSeparator, ctx.path); 20 | 21 | let value = matchObjectPath(ctx.config.value, builtPath, name); 22 | 23 | if (value === undefined) { 24 | value = target[name]; 25 | } 26 | 27 | const creator = matchObjectPath(ctx.config.creator, builtPath, name); 28 | if (creator) { 29 | value = creator.call(ctx.context, value, target, name, ctx.path); 30 | } 31 | 32 | if (shouldFollowValue(value, ctx.config)) { 33 | return contextProxy(value, ctx.traps, ctx); 34 | } 35 | 36 | return value; 37 | }, 38 | set(target: Object, name: string, value: any): boolean { 39 | const ctx = enhanceContext(this, target, name); 40 | 41 | const setter = matchObjectPath(ctx.config.setter, pathBuilder(ctx.config.pathSeparator, ctx.path), name); 42 | if (setter) { 43 | return setter.call(ctx.context, target, name, value, ctx.path) !== false; 44 | } 45 | 46 | target[name] = value; 47 | return true; 48 | }, 49 | apply(target: Function, thisArg: any, argArray: ?Array): any { 50 | const path = this.path; 51 | 52 | const apply = matchObjectPath(this.config.apply, pathBuilder(this.config.pathSeparator, path), getLastPathElement(path)); 53 | if (apply) { 54 | return apply.call(this.context, target, thisArg, argArray, path); 55 | } 56 | 57 | return Function.prototype.apply.call(target, thisArg, (argArray: any)); 58 | }, 59 | construct(target: Function, argArray: Array, newTarget?: any): Object { 60 | const path = this.path; 61 | 62 | const construct = matchObjectPath( 63 | this.config.construct, 64 | pathBuilder(this.config.pathSeparator, path), 65 | getLastPathElement(path) 66 | ); 67 | 68 | if (construct) { 69 | return construct.call(this.context, target, argArray, newTarget, path); 70 | } 71 | 72 | return new target(...argArray); 73 | } 74 | }; 75 | 76 | const readOnlyTrapsMixin = { 77 | set: nope, 78 | defineProperty: nope, 79 | deleteProperty: nope, 80 | preventExtensions: nope, 81 | setPrototypeOf: nope 82 | }; 83 | 84 | const silentReadOnlyTrapsMixin = { 85 | set: shh, 86 | defineProperty: shh, 87 | deleteProperty: shh, 88 | preventExtensions: shh, 89 | setPrototypeOf: shh 90 | }; 91 | 92 | 93 | export const triageTraps = triageSet({ 94 | error: Object.assign({}, traps, readOnlyTrapsMixin), 95 | silent: Object.assign({}, traps, silentReadOnlyTrapsMixin) 96 | }, traps); 97 | 98 | export default triageTraps; 99 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export type ObjectOf = { 3 | [key: string]: T 4 | }; 5 | 6 | export type Path = Array; 7 | 8 | export type RecursiveProxyOptions = { 9 | value: ObjectOf, 10 | creator: ObjectOf<(any, Object, string, Path) => any>, 11 | setter: ObjectOf<(Object, string, any, Path) => boolean>, 12 | apply: ObjectOf<(target: Function, any, argArray: ?Array, Path) => any>, 13 | construct: ObjectOf<(target: Function, Array, newTarget: any, Path) => Object>, 14 | readOnly: false | 'silent' | 'error', 15 | pathSeparator: string, 16 | followFunction: boolean, 17 | followArray: boolean, 18 | followNonPlainObject: boolean 19 | }; 20 | 21 | export type RecursiveContext = { 22 | config: N, 23 | context: C, 24 | origin: O, 25 | path: Path, 26 | target: T, 27 | traps: Proxy$traps<*> 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import isObject from 'isobject/index'; 3 | import isPlainObject from 'is-plain-object/index'; 4 | import type { ObjectOf, RecursiveProxyOptions } from './types'; 5 | 6 | export const triageSet = (sets: ObjectOf, fallback: T): * => (requested: string | false): T => { 7 | if (typeof requested === 'string' && requested in sets) { 8 | return sets[requested]; 9 | } 10 | 11 | return fallback; 12 | }; 13 | 14 | export const pathBuilder = (glue: string, path: Array): string => { 15 | return glue + path.join(glue); 16 | }; 17 | 18 | export const matchObjectPath = (haystack: ObjectOf, path: string, name: string): T | void => { 19 | for (const [key, value] of Object.entries(haystack)) { 20 | if (path === key) { 21 | return (value: any); 22 | } 23 | } 24 | 25 | for (const [key, value] of Object.entries(haystack)) { 26 | if (name === key) { 27 | return (value: any); 28 | } 29 | } 30 | 31 | if ('' in haystack) { 32 | return haystack['']; 33 | } 34 | 35 | return undefined; 36 | }; 37 | 38 | export const nope = () => { 39 | throw new Error('The object is read only!'); 40 | }; 41 | 42 | export const shh = (): * => true; 43 | 44 | export const shouldFollowValue = (value: any, config: RecursiveProxyOptions): boolean => { 45 | if (config.followFunction && typeof value === 'function') { 46 | return true; 47 | } 48 | 49 | if (config.followArray && Array.isArray(value)) { 50 | return true; 51 | } 52 | 53 | if (isObject(value)) { 54 | if (config.followNonPlainObject) { 55 | return true; 56 | } 57 | 58 | if (isPlainObject(value)) { 59 | return true; 60 | } 61 | } 62 | 63 | return false; 64 | }; 65 | 66 | export const getLastPathElement = (path: Array): string => { 67 | return path.length > 0 ? path[path.length - 1] : ''; 68 | }; 69 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | /* global expect */ 2 | export const expectLastCallParameter = (fn, param) => { 3 | return expect(fn.mock.calls[fn.mock.calls.length - 1][param]); 4 | }; 5 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | /* global jest, expect, describe, it */ 2 | import recursiveProxy from '../src/index'; 3 | import { expectLastCallParameter } from './helpers'; 4 | 5 | describe('Recursive Proxy', () => { 6 | it('handles invalid proxy target', () => { 7 | expect(() => { 8 | recursiveProxy( 9 | { 10 | followFunction: false 11 | }, 12 | function () { 13 | } 14 | ); 15 | }).toThrow(); 16 | 17 | expect(() => { 18 | recursiveProxy( 19 | {}, 20 | [] 21 | ); 22 | }).toThrow(); 23 | 24 | expect(() => { 25 | recursiveProxy( 26 | {}, 27 | new Map 28 | ); 29 | }).toThrow(); 30 | }); 31 | 32 | it('passes valid proxy target', () => { 33 | expect(recursiveProxy({}, function () {})).toBeTruthy(); 34 | expect(recursiveProxy({ followArray: true }, [])).toBeTruthy(); 35 | expect(recursiveProxy({ followNonPlainObject: true }, new Map)).toBeTruthy(); 36 | }); 37 | 38 | it('can do shallow value replace', () => { 39 | const proxy = recursiveProxy({ 40 | value: { 41 | '.a': 5, 42 | '.c': 15 43 | } 44 | }, { 45 | a: 1 46 | }); 47 | 48 | expect(proxy.a).toBe(5); 49 | expect(proxy.c).toBe(15); 50 | expect(proxy.b).toBeUndefined(); 51 | }); 52 | 53 | it('can do nested value replace', () => { 54 | const proxy = recursiveProxy({ 55 | value: { 56 | '.a.b.c': 5, 57 | '.a.b.d': 7 58 | } 59 | }, { 60 | a: { 61 | b: { 62 | c: 2, 63 | d: 1 64 | } 65 | } 66 | }); 67 | 68 | expect(proxy.a.b.c).toBe(5); 69 | expect(proxy.a.b.d).toBe(7); 70 | expect(proxy.b).toBeUndefined(); 71 | }); 72 | 73 | it('can do shallow value replace, not existing in target', () => { 74 | const proxy = recursiveProxy({ 75 | value: { 76 | '.a': 5 77 | } 78 | }, {}); 79 | 80 | expect(proxy.a).toBe(5); 81 | expect(proxy.b).toBeUndefined(); 82 | }); 83 | 84 | it('can do nested value replace, not existing in target', () => { 85 | const proxy = recursiveProxy({ 86 | value: { 87 | '.a.b.c': 5 88 | } 89 | }, { 90 | a: { 91 | b: {} 92 | } 93 | }); 94 | 95 | expect(proxy.a.b.c).toBe(5); 96 | expect(proxy.b).toBeUndefined(); 97 | }); 98 | 99 | it('can do nested value replace, always can enter', () => { 100 | const dummy = {}; 101 | 102 | const proxy = recursiveProxy({ 103 | value: { 104 | '.a.b.c': 5, 105 | '': dummy 106 | } 107 | }, {}); 108 | 109 | expect(proxy.a.b.c).toBe(5); 110 | expect(Object.getPrototypeOf(proxy.b) === Object.getPrototypeOf({})).toBeTruthy(); 111 | expect(Object.getPrototypeOf(proxy.b.a.d.f.g.h) === Object.getPrototypeOf({})).toBeTruthy(); 112 | }); 113 | 114 | it('can do nested value replace, always can enter, creator', () => { 115 | const dummy = {}; 116 | 117 | const proxy = recursiveProxy({ 118 | value: { 119 | '.a.b.c': 5, 120 | '': dummy 121 | }, 122 | creator: { 123 | '': (value) => { 124 | if (typeof value === 'number') { 125 | return value * 2; 126 | } 127 | 128 | return value; 129 | } 130 | } 131 | }, {}); 132 | 133 | expect(proxy.a.b.c).toBe(10); 134 | expect(Object.getPrototypeOf(proxy.b) === Object.getPrototypeOf({})).toBeTruthy(); 135 | expect(Object.getPrototypeOf(proxy.b.a.d.f.g.h) === Object.getPrototypeOf({})).toBeTruthy(); 136 | }); 137 | 138 | it('can do shallow value creator', () => { 139 | const proxy = recursiveProxy({ 140 | creator: { 141 | '.a': (value) => 6 * value 142 | } 143 | }, { 144 | a: 3 145 | }); 146 | 147 | expect(proxy.a).toBe(3 * 6); 148 | expect(proxy.b).toBeUndefined(); 149 | }); 150 | 151 | it('nested value creator', () => { 152 | const proxy = recursiveProxy({ 153 | creator: { 154 | '.a.b.c': (value) => 6 * value 155 | } 156 | }, { 157 | a: { 158 | b: { 159 | c: 2 160 | } 161 | } 162 | }); 163 | 164 | expect(proxy.a.b.c).toBe(6 * 2); 165 | expect(proxy.b).toBeUndefined(); 166 | }); 167 | 168 | it('shallow value creator, not existing in target', () => { 169 | const proxy = recursiveProxy({ 170 | creator: { 171 | '.a': () => 6 172 | } 173 | }, {}); 174 | 175 | expect(proxy.a).toBe(6); 176 | expect(proxy.b).toBeUndefined(); 177 | }); 178 | 179 | it('nested value creator, not existing in target', () => { 180 | const proxy = recursiveProxy({ 181 | creator: { 182 | '.a.b.c': () => 7 183 | } 184 | }, { 185 | a: { 186 | b: {} 187 | } 188 | }); 189 | 190 | expect(proxy.a.b.c).toBe(7); 191 | expect(proxy.b).toBeUndefined(); 192 | }); 193 | 194 | it('shallow read only, silent', () => { 195 | const proxy = recursiveProxy({ 196 | readOnly: 'silent' 197 | }, { 198 | a: 1 199 | }); 200 | 201 | expect(proxy.a).toBe(1); 202 | expect(proxy.b).toBeUndefined(); 203 | 204 | proxy.a = 2; 205 | proxy.b = 3; 206 | 207 | expect(proxy.a).toBe(1); 208 | expect(proxy.b).toBeUndefined(); 209 | }); 210 | 211 | it('shallow read only, error', () => { 212 | const proxy = recursiveProxy({ 213 | readOnly: 'error' 214 | }, { 215 | a: 1 216 | }); 217 | 218 | expect(proxy.a).toBe(1); 219 | expect(proxy.b).toBeUndefined(); 220 | 221 | expect(() => { 222 | proxy.a = 2; 223 | }).toThrow(); 224 | 225 | expect(() => { 226 | proxy.b = 3; 227 | }).toThrow(); 228 | 229 | expect(proxy.a).toBe(1); 230 | expect(proxy.b).toBeUndefined(); 231 | }); 232 | 233 | it('nested read only, silent', () => { 234 | const proxy = recursiveProxy({ 235 | readOnly: 'silent' 236 | }, { 237 | a: { 238 | b: { 239 | c: 2 240 | } 241 | } 242 | }); 243 | 244 | expect(proxy.a.b.c).toBe(2); 245 | expect(proxy.b).toBeUndefined(); 246 | 247 | proxy.a.b.c = 3; 248 | proxy.b = 4; 249 | 250 | expect(proxy.a.b.c).toBe(2); 251 | expect(proxy.b).toBeUndefined(); 252 | }); 253 | 254 | it('nested read only, error', () => { 255 | const proxy = recursiveProxy({ 256 | readOnly: 'error' 257 | }, { 258 | a: { 259 | b: { 260 | c: 2 261 | } 262 | } 263 | }); 264 | 265 | expect(proxy.a.b.c).toBe(2); 266 | expect(proxy.b).toBeUndefined(); 267 | 268 | expect(() => { 269 | proxy.a.b.c = 3; 270 | }).toThrow(); 271 | 272 | expect(() => { 273 | proxy.b = 3; 274 | }).toThrow(); 275 | 276 | expect(proxy.a.b.c).toBe(2); 277 | expect(proxy.b).toBeUndefined(); 278 | }); 279 | 280 | it('value wildcard replace', () => { 281 | const proxy = recursiveProxy({ 282 | value: { 283 | 'c': 5, 284 | 'f': 15 285 | } 286 | }, { 287 | a: { 288 | b: { 289 | c: 2 290 | } 291 | } 292 | }); 293 | 294 | expect(proxy.c).toBe(5); 295 | expect(proxy.a.c).toBe(5); 296 | expect(proxy.a.b.c).toBe(5); 297 | 298 | expect(proxy.f).toBe(15); 299 | expect(proxy.a.f).toBe(15); 300 | expect(proxy.a.b.f).toBe(15); 301 | 302 | expect(proxy.b).toBeUndefined(); 303 | }); 304 | 305 | it('wildcard value creator', () => { 306 | const proxy = recursiveProxy({ 307 | creator: { 308 | 'c': (value) => 10 * value 309 | } 310 | }, { 311 | a: { 312 | b: { 313 | c: 2 314 | }, 315 | c: 8 316 | }, 317 | c: 4 318 | }); 319 | 320 | expect(proxy.c).toBe(10 * 4); 321 | expect(proxy.a.c).toBe(10 * 8); 322 | expect(proxy.a.b.c).toBe(10 * 2); 323 | expect(proxy.b).toBeUndefined(); 324 | }); 325 | 326 | it('shallow value setter', () => { 327 | const proxy = recursiveProxy({ 328 | setter: { 329 | '.a': (target, name, value) => { 330 | target[name] = value * 2; 331 | return true; 332 | } 333 | } 334 | }, { 335 | a: 1 336 | }); 337 | 338 | expect(proxy.a).toBe(1); 339 | proxy.a = 3; 340 | expect(proxy.a).toBe(6); 341 | 342 | expect(proxy.b).toBeUndefined(); 343 | proxy.b = 3; 344 | expect(proxy.b).toBe(3); 345 | }); 346 | 347 | it('nested value setter', () => { 348 | const proxy = recursiveProxy({ 349 | setter: { 350 | '.a.b.c': (target, name, value) => { 351 | target[name] = value * 2; 352 | } 353 | } 354 | }, { 355 | a: { 356 | b: { 357 | c: 2 358 | } 359 | } 360 | }); 361 | 362 | expect(proxy.a.b.c).toBe(2); 363 | proxy.a.b.c = 5; 364 | expect(proxy.a.b.c).toBe(10); 365 | 366 | expect(proxy.b).toBeUndefined(); 367 | proxy.b = 3; 368 | expect(proxy.b).toBe(3); 369 | }); 370 | 371 | it('throwing value setter', () => { 372 | const proxy = recursiveProxy({ 373 | setter: { 374 | '.a': () => { 375 | return false; 376 | } 377 | } 378 | }, { 379 | a: 1 380 | }); 381 | 382 | expect(proxy.a).toBe(1); 383 | expect(() => proxy.a = 3).toThrow(); 384 | }); 385 | 386 | it('wildcard value setter', () => { 387 | const proxy = recursiveProxy({ 388 | setter: { 389 | 'c': (target, name, value) => { 390 | target[name] = value * 3; 391 | return true; 392 | } 393 | } 394 | }, { 395 | a: { 396 | b: { 397 | c: 2 398 | }, 399 | c: 1 400 | } 401 | }); 402 | 403 | expect(proxy.a.b.c).toBe(2); 404 | proxy.a.b.c = 5; 405 | expect(proxy.a.b.c).toBe(15); 406 | 407 | expect(proxy.a.c).toBe(1); 408 | proxy.a.c = 2; 409 | expect(proxy.a.c).toBe(6); 410 | 411 | expect(proxy.b).toBeUndefined(); 412 | proxy.b = 3; 413 | expect(proxy.b).toBe(3); 414 | 415 | expect(proxy.c).toBeUndefined(); 416 | proxy.c = 3; 417 | expect(proxy.c).toBe(9); 418 | }); 419 | 420 | it('shallow apply', () => { 421 | const proxy = recursiveProxy({ 422 | apply: { 423 | '': () => 10 424 | } 425 | }, function () { 426 | }); 427 | 428 | expect(proxy()).toBe(10); 429 | expect(proxy.b).toBeUndefined(); 430 | }); 431 | 432 | it('nested apply', () => { 433 | const proxy = recursiveProxy({ 434 | apply: { 435 | '.a.b.c': () => 10 436 | } 437 | }, { 438 | a: { 439 | b: { 440 | c: function () { 441 | }, 442 | x: function () { 443 | return 'apud'; 444 | } 445 | } 446 | } 447 | }); 448 | 449 | expect(proxy.a.b.c()).toBe(10); 450 | expect(proxy.a.b.x()).toBe('apud'); 451 | expect(proxy.b).toBeUndefined(); 452 | }); 453 | 454 | it('wildcard apply', () => { 455 | const proxy = recursiveProxy({ 456 | apply: { 457 | 'c': () => 10 458 | } 459 | }, { 460 | c: function () { 461 | }, 462 | a: { 463 | c: function () { 464 | }, 465 | b: { 466 | c: function () { 467 | }, 468 | x: function () { 469 | return 'apud'; 470 | } 471 | } 472 | } 473 | }); 474 | 475 | expect(proxy.c()).toBe(10); 476 | expect(proxy.a.c()).toBe(10); 477 | expect(proxy.a.b.c()).toBe(10); 478 | expect(proxy.a.b.x()).toBe('apud'); 479 | expect(proxy.b).toBeUndefined(); 480 | }); 481 | 482 | it('shallow construct', () => { 483 | const expectedResult = { u: 'la-la-la' }; 484 | 485 | const proxy = recursiveProxy({ 486 | construct: { 487 | '': () => expectedResult 488 | } 489 | }, function () { 490 | }); 491 | 492 | expect(new proxy).toBe(expectedResult); 493 | expect(proxy.b).toBeUndefined(); 494 | }); 495 | 496 | it('nested construct', () => { 497 | const expectedResult = { i: 'love you baby' }; 498 | const unexpectedResult = { u: 'la-la-la' }; 499 | 500 | const proxy = recursiveProxy({ 501 | construct: { 502 | '.a.b.c': () => expectedResult 503 | } 504 | }, { 505 | a: { 506 | b: { 507 | c: function () { 508 | }, 509 | x: function () { 510 | return unexpectedResult; 511 | } 512 | } 513 | } 514 | }); 515 | 516 | expect(new proxy.a.b.c).toBe(expectedResult); 517 | expect(new proxy.a.b.x).toBe(unexpectedResult); 518 | expect(proxy.b).toBeUndefined(); 519 | }); 520 | 521 | it('wildcard construct', () => { 522 | const expectedResult = { u: 'la-la-la' }; 523 | const unexpectedResult = { i: 'love you baby' }; 524 | 525 | const proxy = recursiveProxy({ 526 | construct: { 527 | 'c': () => expectedResult 528 | } 529 | }, { 530 | c: function () { 531 | }, 532 | a: { 533 | c: function () { 534 | }, 535 | b: { 536 | c: function () { 537 | }, 538 | x: function () { 539 | return unexpectedResult; 540 | } 541 | } 542 | } 543 | }); 544 | 545 | expect(new proxy.c).toBe(expectedResult); 546 | expect(new proxy.a.c).toBe(expectedResult); 547 | expect(new proxy.a.b.c).toBe(expectedResult); 548 | expect(proxy.a.b.x()).toBe(unexpectedResult); 549 | expect(proxy.b).toBeUndefined(); 550 | }); 551 | 552 | it('calls traps with correct arguments', () => { 553 | const context = {}; 554 | 555 | const target = { 556 | a: 3, 557 | b: function () {}, 558 | c: function () {}, 559 | d: 0 560 | }; 561 | 562 | const mock = { 563 | a: jest.fn().mockReturnValue(true), 564 | b: jest.fn().mockReturnValue(true), 565 | c: jest.fn().mockReturnValue({}), 566 | d: jest.fn().mockReturnValue(true) 567 | }; 568 | 569 | const proxy = recursiveProxy({ 570 | creator: { 571 | '.a': mock.a 572 | }, 573 | apply: { 574 | '.b': mock.b 575 | }, 576 | construct: { 577 | '.c': mock.c 578 | }, 579 | setter: { 580 | '.d': mock.d 581 | } 582 | }, target, context); 583 | 584 | expect(proxy.a).toBeTruthy(); 585 | expect(mock.a).toBeCalledWith(3, target, 'a', ['a']); 586 | expect(mock.a.mock.instances[0]).toBe(context); 587 | 588 | const args = ['arg1', 2, {}]; 589 | expect(proxy.b(...args)).toBeTruthy(); 590 | expectLastCallParameter(mock.b, 0).toBe(target.b); 591 | expectLastCallParameter(mock.b, 2).toEqual(expect.arrayContaining(args)); 592 | expectLastCallParameter(mock.b, 3).toEqual(expect.arrayContaining(['b'])); 593 | expect(mock.b.mock.instances[0]).toBe(context); 594 | 595 | const args2 = ['arg1wqe', 2343242, {}, []]; 596 | expect(new proxy.c(...args2)).toBeTruthy(); 597 | expectLastCallParameter(mock.c, 0).toBe(target.c); 598 | expectLastCallParameter(mock.c, 1).toEqual(expect.arrayContaining(args2)); 599 | expectLastCallParameter(mock.c, 3).toEqual(expect.arrayContaining(['c'])); 600 | expect(mock.c.mock.instances[0]).toBe(context); 601 | 602 | proxy.d = 123; 603 | expect(mock.d).toBeCalledWith(target, 'd', 123, ['d']); 604 | expect(mock.d.mock.instances[0]).toBe(context); 605 | }); 606 | }); 607 | --------------------------------------------------------------------------------