├── test ├── mocks │ ├── aSingleMethod.js │ ├── NestedEntity │ │ ├── ChildEntity │ │ │ └── childMethod.js │ │ └── aMethod.js │ ├── index.js │ ├── Entity │ │ ├── doSomething.js │ │ ├── returnValue.js │ │ ├── withValidation.js │ │ ├── doSomethingAsync.js │ │ ├── returnPromise.js │ │ ├── index.js │ │ └── withContext.js │ └── map.js ├── webpack.js ├── server.js └── browser.js ├── examples ├── isomorphic-react │ ├── .babelrc │ ├── gulpfile.js │ ├── api │ │ ├── index.js │ │ ├── User │ │ │ ├── create.js │ │ │ └── load.js │ │ └── Post │ │ │ └── create.js │ ├── tasks │ │ ├── start.js │ │ ├── nodemon.js │ │ └── webpack.js │ ├── README.md │ ├── index.js │ ├── server │ │ ├── app.js │ │ ├── render.js │ │ └── models.js │ ├── client │ │ ├── render.js │ │ ├── Layout.jsx │ │ └── App.jsx │ ├── .eslintrc │ ├── webpack.config.js │ └── package.json ├── isomorphic-todomvc │ └── README.md └── barebone │ ├── README.md │ ├── index.js │ ├── src │ ├── api │ │ ├── User │ │ │ ├── create.js │ │ │ └── load.js │ │ ├── Post │ │ │ └── create.js │ │ └── index.js │ ├── server.js │ ├── index.html │ ├── client.js │ └── db.js │ ├── webpack.config.js │ └── package.json ├── browser.js ├── index.js ├── .gitignore ├── .eslintrc ├── LICENSE ├── package.json ├── src ├── server │ ├── router.js │ ├── controllers.js │ └── factory.js ├── webpack-loader.js ├── client │ ├── factory.js │ └── createProxiedMethod.js └── util.js └── README.md /test/mocks/aSingleMethod.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function() {}; 3 | -------------------------------------------------------------------------------- /examples/isomorphic-react/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "optional": ["es7.functionBind"] 3 | } -------------------------------------------------------------------------------- /test/mocks/NestedEntity/ChildEntity/childMethod.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function() {}; 3 | -------------------------------------------------------------------------------- /test/mocks/NestedEntity/aMethod.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | default: function() {} 4 | } 5 | -------------------------------------------------------------------------------- /test/mocks/index.js: -------------------------------------------------------------------------------- 1 | var isomorphine = require('../../index'); 2 | 3 | module.exports = isomorphine.proxy(); 4 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Browser's API surface area 4 | */ 5 | exports.proxy = require('./src/client/factory'); 6 | -------------------------------------------------------------------------------- /examples/isomorphic-react/gulpfile.js: -------------------------------------------------------------------------------- 1 | var taskLoader = require('gulp-task-loader'); 2 | 3 | taskLoader('tasks'); 4 | -------------------------------------------------------------------------------- /test/mocks/Entity/doSomething.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function doSomething() { 3 | return 'You got it'; 4 | }; 5 | -------------------------------------------------------------------------------- /test/mocks/Entity/returnValue.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function returnValue() { 3 | return 'Sync value'; 4 | }; 5 | -------------------------------------------------------------------------------- /examples/isomorphic-react/api/index.js: -------------------------------------------------------------------------------- 1 | import isomorphine from 'isomorphine'; 2 | 3 | export default isomorphine.proxy(__dirname); 4 | -------------------------------------------------------------------------------- /examples/isomorphic-react/tasks/start.js: -------------------------------------------------------------------------------- 1 | module.exports = function() {}; 2 | module.exports.dependencies = ['webpack', 'nodemon']; 3 | -------------------------------------------------------------------------------- /examples/isomorphic-todomvc/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Go to [https://github.com/d-oliveros/isomorphic-todomvc](https://github.com/d-oliveros/isomorphic-todomvc) -------------------------------------------------------------------------------- /examples/isomorphic-react/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Basic Isomorphic React example 3 | 4 | ``` 5 | npm install 6 | gulp start 7 | // Go to localhost:8800 8 | ``` 9 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * API surface area 4 | */ 5 | module.exports = require('./src/webpack-loader'); 6 | module.exports.proxy = require('./src/server/factory'); 7 | 8 | -------------------------------------------------------------------------------- /examples/isomorphic-react/index.js: -------------------------------------------------------------------------------- 1 | require('babel/register'); 2 | 3 | require('./server/app').listen(8800, function() { 4 | console.log('-- Listening on http://127.0.0.1:8800'); 5 | }); 6 | -------------------------------------------------------------------------------- /test/mocks/Entity/withValidation.js: -------------------------------------------------------------------------------- 1 | var createError = require('http-errors'); 2 | 3 | module.exports = function withValidation(key, callback) { 4 | if (key !== 'thekey') return callback(createError(401)); 5 | 6 | callback(); 7 | }; 8 | -------------------------------------------------------------------------------- /examples/isomorphic-react/tasks/nodemon.js: -------------------------------------------------------------------------------- 1 | var nodemon = require('gulp-nodemon'); 2 | 3 | module.exports = function() { 4 | nodemon({ 5 | script: 'index.js', 6 | ext: 'js,jsx', 7 | ignore: ['node_modules'] 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /test/mocks/Entity/doSomethingAsync.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function doSomethingAsync(firstParam, secondParam, callback) { 3 | setTimeout(function() { 4 | callback(null, 'Sweet', { nested: { thing: ['true', 'dat'] }}); 5 | }, 300); 6 | }; 7 | -------------------------------------------------------------------------------- /examples/isomorphic-react/server/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import api from '../api'; 3 | import renderClient from './render'; 4 | 5 | let app = express(); 6 | app.use(api); 7 | app.get('/', renderClient); 8 | 9 | export default app; 10 | -------------------------------------------------------------------------------- /examples/barebone/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Barebone Isomorphine Example 3 | 4 | To start: 5 | 6 | ``` 7 | git clone git@github.com:d-oliveros/isomorphine.git 8 | cd isomorphine/examples/barebone 9 | npm install 10 | npm start 11 | // Go to localhost:3000 12 | ``` 13 | -------------------------------------------------------------------------------- /examples/barebone/index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | if (!fs.existsSync(path.resolve(__dirname, 'src', 'build', 'bundle.js'))) { 5 | return console.log('Please start the app with "npm start"') 6 | } 7 | 8 | require('./src/server'); 9 | -------------------------------------------------------------------------------- /examples/barebone/src/api/User/create.js: -------------------------------------------------------------------------------- 1 | var db = require('../../db'); 2 | 3 | module.exports = function createUser(user, callback) { 4 | 5 | // Do the DB query, etc 6 | console.log('Creating a new user...'); 7 | 8 | db.User.create(user, callback); 9 | } 10 | -------------------------------------------------------------------------------- /test/mocks/Entity/returnPromise.js: -------------------------------------------------------------------------------- 1 | var promisify = require('../../../src/util').promisify; 2 | 3 | module.exports = promisify(function returnPromise(firstParam, secondParam, callback) { 4 | setTimeout(function() { 5 | callback(null, 'Cool'); 6 | }, 300); 7 | }); 8 | -------------------------------------------------------------------------------- /examples/barebone/src/api/Post/create.js: -------------------------------------------------------------------------------- 1 | var db = require('../../db'); 2 | 3 | module.exports = function createPost(newPost, callback) { 4 | 5 | // Do the DB query, etc 6 | console.log('Creating a new post...'); 7 | 8 | db.Post.create(newPost, callback); 9 | } 10 | -------------------------------------------------------------------------------- /examples/barebone/src/api/User/load.js: -------------------------------------------------------------------------------- 1 | var db = require('../../db'); 2 | 3 | module.exports = function loadUser(user, options, callback) { 4 | 5 | // Do the DB query, etc 6 | console.log('Loading a user...'); 7 | 8 | db.User.load(user, options, callback); 9 | } 10 | -------------------------------------------------------------------------------- /examples/isomorphic-react/api/User/create.js: -------------------------------------------------------------------------------- 1 | import models from '../../server/models'; 2 | 3 | export default function create(user, callback) { 4 | 5 | // Do the DB query, etc 6 | console.log('Creating a new user...'); 7 | 8 | models.User.create(user, callback); 9 | } 10 | -------------------------------------------------------------------------------- /examples/isomorphic-react/api/Post/create.js: -------------------------------------------------------------------------------- 1 | import models from '../../server/models'; 2 | 3 | export default function createPost(newPost, callback) { 4 | 5 | // Do the DB query, etc 6 | console.log('Creating a new post...'); 7 | 8 | models.Post.create(newPost, callback); 9 | } 10 | -------------------------------------------------------------------------------- /examples/isomorphic-react/api/User/load.js: -------------------------------------------------------------------------------- 1 | import models from '../../server/models'; 2 | 3 | export default function loadUser(user, options, callback) { 4 | 5 | // Do the DB query, etc 6 | console.log('Loading a user...'); 7 | 8 | models.User.load(user, options, callback); 9 | } 10 | -------------------------------------------------------------------------------- /test/mocks/Entity/index.js: -------------------------------------------------------------------------------- 1 | 2 | exports.doSomething = require('./doSomething'); 3 | exports.doSomethingAsync = require('./doSomethingAsync'); 4 | exports.returnPromise = require('./returnPromise'); 5 | exports.withContext = require('./withContext'); 6 | exports.withValidation = require('./withValidation'); 7 | -------------------------------------------------------------------------------- /test/mocks/Entity/withContext.js: -------------------------------------------------------------------------------- 1 | var createError = require('http-errors'); 2 | 3 | module.exports = function withContext(callback) { 4 | if (!this.xhr) return callback(createError(400, 'XHR was not true')); 5 | if (!this.req) return callback(createError(400, 'Request was not present')); 6 | 7 | callback(); 8 | }; 9 | -------------------------------------------------------------------------------- /examples/isomorphic-react/client/render.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './App.jsx'; 3 | 4 | let state = window.__STATE__; 5 | 6 | /** 7 | * Renders the app in the container 8 | */ 9 | let container = document.getElementById('app-container'); 10 | React.render(, container); 11 | -------------------------------------------------------------------------------- /examples/barebone/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | entry: './src/client.js', 6 | module: { 7 | preLoaders: [ 8 | { 9 | loaders: ['isomorphine'] 10 | } 11 | ] 12 | }, 13 | output: { 14 | path: path.resolve('./src/build'), 15 | filename: 'bundle.js' 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /test/mocks/map.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | aSingleMethod: true, 4 | 5 | Entity: { 6 | doSomething: true, 7 | doSomethingAsync: true, 8 | returnPromise: true, 9 | returnValue: true, 10 | withContext: true, 11 | withValidation: true 12 | }, 13 | 14 | NestedEntity: { 15 | aMethod: true, 16 | ChildEntity: { 17 | childMethod: true 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /examples/barebone/src/api/index.js: -------------------------------------------------------------------------------- 1 | var isomorphine = require('isomorphine'); 2 | 3 | /** 4 | * This will provide the entities in this folder. 5 | * It's similar to 'require-all' but in a browser-compatible way. 6 | * 7 | * You should require this file directly from the browser, 8 | * as it will let you use server-side modules located in this folder remotely. 9 | */ 10 | module.exports = isomorphine.proxy(); 11 | -------------------------------------------------------------------------------- /examples/barebone/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isomorphic-react-example", 3 | "version": "2.0.0", 4 | "description": "Barebone Isomorphine example", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack && node index" 8 | }, 9 | "keywords": [ 10 | "isomorphine", 11 | "example" 12 | ], 13 | "author": "David Oliveros", 14 | "license": "MIT", 15 | "dependencies": { 16 | "express": "4.13.3", 17 | "isomorphine": "0.8.11", 18 | "webpack": "1.12.9" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/isomorphic-react/tasks/webpack.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | 4 | module.exports = function(callback) { 5 | var config = require('../webpack.config.js'); 6 | var compiler = webpack(config); 7 | 8 | var webpackDevServerConf = { 9 | hot: true, 10 | publicPath: config.output.publicPath 11 | }; 12 | 13 | var devServer = new WebpackDevServer(compiler, webpackDevServerConf); 14 | 15 | devServer.listen(config.devServer.port, config.devServer.host, function(err) { 16 | if (err) callback(err); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /examples/isomorphic-react/client/Layout.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Layout extends React.Component { 4 | render() { 5 | let { body, state } = this.props; 6 | 7 | state = JSON.stringify(state); 8 | 9 | return ( 10 | 11 | 12 | Isomorphine React Example 13 | 6 | 7 | 8 |
9 |

Isomorphine Barebone Example

10 | 11 |
12 | Load a user: 13 | 14 | 15 |
16 | 17 |
18 | Create a post: 19 | 20 | 21 |
22 | 23 |
24 |
Console messages:
25 |

26 |       
27 |
28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/isomorphic-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isomorphic-react-example", 3 | "version": "1.2.0", 4 | "description": "Example of an isomorphic app using Isomorphine, React and Express", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "gulp start", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [ 11 | "isomorphine", 12 | "example" 13 | ], 14 | "author": "David Oliveros", 15 | "license": "MIT", 16 | "dependencies": { 17 | "babel": "^5.8.3", 18 | "express": "^4.13.1", 19 | "isomorphine": "0.6.0", 20 | "react": "^0.13.3" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "^5.8.9", 24 | "babel-eslint": "^4.0.5", 25 | "babel-loader": "^5.3.2", 26 | "eslint": "^0.24.1", 27 | "eslint-plugin-react": "^3.0.0", 28 | "gulp": "^3.9.0", 29 | "gulp-task-loader": "^1.2.1", 30 | "gulp-nodemon": "^2.0.4", 31 | "webpack": "^1.10.5", 32 | "webpack-dev-server": "^1.10.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/webpack.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var loader = require('../src/webpack-loader'); 4 | var expect = require('chai').expect; 5 | 6 | describe('Webpack Loader', function() { 7 | var source; 8 | var sourcePath = path.resolve(__dirname, './mocks/index.js'); 9 | 10 | var webpackContext = { 11 | context: path.dirname(sourcePath) 12 | }; 13 | 14 | before(function(done) { 15 | fs.readFile(sourcePath, { encoding: 'utf8' }, function(err, file) { 16 | if (err) return done(err); 17 | source = file; 18 | done(); 19 | }); 20 | }); 21 | 22 | it('should correctly transform a file', function() { 23 | expect(source.indexOf('isomorphine.proxy()')).to.be.gt(-1); 24 | 25 | var result = loader.call(webpackContext, source); 26 | 27 | expect(result).to.be.a('string'); 28 | expect(result.indexOf('isomorphine.proxy()')).to.be.lt(0); 29 | expect(result.indexOf('isomorphine.proxy(__entityMap)')).to.be.gt(-1); 30 | expect(result.indexOf('function __isomorphicAPIFactory() {')).to.be.gt(-1); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 David Oliveros 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isomorphine", 3 | "version": "0.10.2", 4 | "description": "Access server-side modules from the browser, remotely.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node node_modules/.bin/mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/d-oliveros/isomorphine.git" 12 | }, 13 | "keywords": [ 14 | "rpc", 15 | "webpack", 16 | "loader", 17 | "remote", 18 | "proxy", 19 | "isomorphic", 20 | "morphic", 21 | "model" 22 | ], 23 | "author": "David Oliveros", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/d-oliveros/isomorphine/issues" 27 | }, 28 | "homepage": "https://github.com/d-oliveros/isomorphine#readme", 29 | "browser": { 30 | "./index.js": "./browser.js" 31 | }, 32 | "dependencies": { 33 | "body-parser": "^1.13.3", 34 | "debug": "^2.2.0", 35 | "es6-promise": "^3.0.2", 36 | "express": "^4.13.3", 37 | "http-errors": "^1.3.1", 38 | "superagent": "^2.0.0" 39 | }, 40 | "devDependencies": { 41 | "chai": "^3.2.0", 42 | "es6-promise": "^3.0.2", 43 | "mocha": "^2.2.5", 44 | "supertest": "^1.0.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/barebone/src/client.js: -------------------------------------------------------------------------------- 1 | var api = require('./api'); 2 | var User = api.User; 3 | var Post = api.Post; 4 | 5 | // Isomorphine is framework-agnostic, so you can use any framework you want 6 | // React, Angular, Backbone, Ember, etc... 7 | 8 | /** 9 | * Loads or creates a user, when clicking the load user button. 10 | */ 11 | $('#load-user').on('click', function() { 12 | var username = $('#username').val(); 13 | 14 | if (!username) { 15 | return alert('Please specify a username to load'); 16 | } 17 | 18 | log('Calling isomorphic method: User.load'); 19 | User.load({ name: username }, { select: ['name'] }, function(err, user) { 20 | if (err) return console.error(err); 21 | 22 | if (user) { 23 | return log('User found: ' + user.name); 24 | } 25 | 26 | log('User "' + username + '" not found. Creating a new user'); 27 | log('Calling isomorphic method: User.create'); 28 | 29 | User.create({ name: username }, function(err, user) { 30 | if (err) return console.error(err); 31 | 32 | log('User created: ' + user.name); 33 | }); 34 | }); 35 | }); 36 | 37 | /** 38 | * Creates a new post, when clicking the create post button. 39 | */ 40 | $('#create-post').on('click', function() { 41 | var title = $('#post-title').val(); 42 | 43 | if (!title) { 44 | return alert('Please specify the post\'s title'); 45 | } 46 | 47 | log('Calling isomorphic method: Post.create'); 48 | Post.create(title, function(err, post) { 49 | if (err) return console.error(err); 50 | log('Created post ' + post._id + ' - "' + post.title + '"'); 51 | }); 52 | }); 53 | 54 | function log(msg) { 55 | console.log(msg); 56 | $('#console').append(msg + '\n'); 57 | } 58 | -------------------------------------------------------------------------------- /examples/isomorphic-react/client/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { User, Post } from '../api'; 3 | 4 | export default class App extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = props.state || { 9 | user: null, 10 | post: null 11 | }; 12 | } 13 | 14 | componentDidMount() { 15 | this.createPost(); 16 | this.loadUser(); 17 | } 18 | 19 | createPost() { 20 | let newPost = { 21 | title: `Morphine #${Date.now()}`, 22 | body: 'This post was created from the browser.' 23 | }; 24 | 25 | console.log(`Calling isomorphic method: Post.create`); 26 | 27 | Post.create(newPost, (err, post) => { 28 | if (err) return console.error(err); 29 | this.setState({ post }); 30 | }); 31 | } 32 | 33 | loadUser() { 34 | console.log('Calling isomorphic method: User.load'); 35 | 36 | User.load({ _id: 3 }, { select: ['name'] }, (err, user) => { 37 | if (err) return console.error(err); 38 | 39 | if (user) { 40 | console.log(`User found: ${user.name}`); 41 | return this.setState({ user }); 42 | } 43 | 44 | else { 45 | console.log(`User not found. Creating a new user.`); 46 | console.log('Calling isomorphic method: User.create'); 47 | 48 | User.create({ _id: 3, name: 'somename' }, (err, user) => { 49 | if (err) return console.error(err); 50 | 51 | console.log(`User found: ${user.name}`); 52 | this.setState({ user }); 53 | }); 54 | } 55 | }); 56 | } 57 | 58 | render() { 59 | let {user, post} = this.state; 60 | 61 | return ( 62 |
63 |

Isomorphine example.

64 | 65 | { !user ? null :

User is loaded.

} 66 | { !post 67 | ?

No post loaded yet.

68 | : ( 69 |
70 |

{ post.title }

71 |

{ post.body }

72 |
73 | ) 74 | } 75 |
76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /examples/isomorphic-react/server/models.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file represents the database models. 3 | * You can use any database and any library you want, because the browser 4 | * never gets here. The browser, instead of requiring the database, is 5 | * proxying the remote procedure calls 6 | * 7 | * This is to demostrate that you can require browser-incompatible modules 8 | * in the API endpoints, without breaking the browser or bloating 9 | * the bundled file. 10 | */ 11 | import net from 'net'; // eslint-disable-line 12 | import fs from 'fs'; // eslint-disable-line 13 | 14 | // Eg. 15 | // import mongoose from 'mongoose'; 16 | // export default Model = mongoose.model('Model', modelSchema); 17 | 18 | let state = { 19 | posts: {}, 20 | users: {} 21 | }; 22 | 23 | let models = { 24 | Post: { 25 | create(newPost, callback) { 26 | newPost.created = Date.now(); 27 | state.posts[newPost.title] = newPost; 28 | 29 | console.log(`Post ${newPost.title} created.`); 30 | 31 | callback(null, newPost); 32 | } 33 | }, 34 | 35 | User: { 36 | load(query, options, callback) { 37 | if (!state.users[query._id]) return callback(); 38 | 39 | let user = state.users[query._id]; 40 | let ret = {}; 41 | 42 | options.select.forEach((select) => { 43 | ret[select] = user[select]; 44 | }); 45 | 46 | console.log(`Loaded user: ${user._id}`); 47 | 48 | callback(null, ret); 49 | }, 50 | 51 | create(user, callback) { 52 | user.created = Date.now(); 53 | state.users[user._id] = user; 54 | 55 | console.log(`User ${user._id} created.`); 56 | 57 | callback(null, user); 58 | } 59 | } 60 | }; 61 | 62 | /** 63 | * Adds latency to every async callback method in each model. 64 | */ 65 | for (let entity in models) { 66 | for (let method in models[entity]) { 67 | models[entity][method] = fakeLatency(models[entity][method]); 68 | } 69 | } 70 | 71 | function fakeLatency(cb) { 72 | return function() { 73 | let args = Array.prototype.slice.call(arguments); 74 | setTimeout(() => { 75 | cb.apply(this, args); 76 | }, Math.floor(Math.random() * 1400)); 77 | }; 78 | } 79 | 80 | export default models; 81 | -------------------------------------------------------------------------------- /examples/barebone/src/db.js: -------------------------------------------------------------------------------- 1 | 2 | // This file represents the database models. 3 | // I mocked up some dummy models for demostration purposes only. 4 | 5 | /** 6 | * You can use any database and any library you want, because the browser 7 | * never gets here. The browser, instead of requiring the database, is 8 | * proxying remote procedure calls to the server. 9 | * 10 | * This is to demostrate that you can require browser-incompatible modules 11 | * in the API endpoints, without breaking the browser or bloating 12 | * the bundled file. 13 | */ 14 | var net = require('net'); // eslint-disable-line 15 | var fs = require('fs'); // eslint-disable-line 16 | 17 | // Eg. 18 | // var mongoose = require('mongoose'); 19 | // module.exports = mongoose.model('Model', modelSchema); etc... 20 | 21 | var databaseState = { 22 | posts: {}, 23 | users: {} 24 | }; 25 | 26 | // Dummy models... 27 | var models = { 28 | Post: { 29 | create: function(postTitle, callback) { 30 | var newPost = { 31 | _id: Math.floor(Math.random() * 1000), 32 | title: postTitle, 33 | created: Date.now() 34 | }; 35 | 36 | databaseState.posts[newPost._id] = newPost; 37 | 38 | console.log('Post "' + newPost.title + '" created'); 39 | 40 | callback(null, newPost); 41 | } 42 | }, 43 | 44 | User: { 45 | load: function(query, options, callback) { 46 | if (!databaseState.users[query.name]) return callback(); 47 | 48 | var user = databaseState.users[query.name]; 49 | var ret = {}; 50 | 51 | options.select.forEach(function(select) { 52 | ret[select] = user[select]; 53 | }); 54 | 55 | console.log('Loaded user: ' + user.name); 56 | 57 | callback(null, ret); 58 | }, 59 | 60 | create: function(user, callback) { 61 | user.created = Date.now(); 62 | databaseState.users[user.name] = user; 63 | 64 | console.log('User "' + user.name + '" created.'); 65 | 66 | callback(null, user); 67 | } 68 | } 69 | }; 70 | 71 | /** 72 | * Adds latency to every async callback method in each model. 73 | */ 74 | for (var entity in models) { 75 | for (var method in models[entity]) { 76 | models[entity][method] = fakeLatency(models[entity][method]); 77 | } 78 | } 79 | 80 | function fakeLatency(cb) { 81 | return function() { 82 | var args = Array.prototype.slice.call(arguments); 83 | var delay = 50; 84 | var self = this; 85 | 86 | setTimeout(function() { 87 | cb.apply(self, args); 88 | }, delay); 89 | }; 90 | } 91 | 92 | module.exports = models; 93 | -------------------------------------------------------------------------------- /src/server/router.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var bodyParser = require('body-parser'); 3 | var createError = require('http-errors'); 4 | var debug = require('debug')('isomorphine:router'); 5 | var isObject = require('../util').isObject; 6 | var isES6Function = require('../util').isES6Function; 7 | var getES6Function = require('../util').getES6Function; 8 | var ctrls = require('./controllers'); 9 | 10 | var getPayload = ctrls.getPayload; 11 | var callEntityMethod = ctrls.callEntityMethod; 12 | var serve = ctrls.serve; 13 | 14 | /** 15 | * Creates an express or connect-styled router, 16 | * and exposes the provided modules in the router's API surface area. 17 | * 18 | * @param {Object} modules The entities to use. 19 | * @returns {Function} An express app instance. 20 | * 21 | * @providesModule router 22 | */ 23 | function createRouter(modules) { 24 | debug('Creating a new router. Modules: ' + JSON.stringify(modules, null, 3)); 25 | 26 | var router = express(); 27 | router.use(bodyParser.json()); 28 | 29 | // Map the requested entity path with the actual serverside entity 30 | router.use('/isomorphine', methodLoader(modules)); 31 | 32 | // Proxy request pipeline 33 | router.post('/isomorphine/*', getPayload, callEntityMethod, serve); 34 | 35 | return router; 36 | } 37 | 38 | /** 39 | * Maps the parameter "entity" to the actual server-side entity. 40 | * 41 | * @param {Object} modules The entities available in this api. 42 | * @returns {Function} Middleware function that maps the :entity param 43 | * with the real entity, and exposes it in req. 44 | */ 45 | function methodLoader(modules) { 46 | return function(req, res, next) { 47 | if (req.path === '/') return next(); 48 | 49 | var path = req.path.substr(1).split('/'); 50 | var currModule = modules; 51 | var isLastIndex, p, method; 52 | 53 | debug('Looking for isomorphine entity in: ' + path.join('.')); 54 | 55 | for (var i = 0, len = path.length; i < len; i++) { 56 | isLastIndex = i === (len - 1); 57 | p = path[i]; 58 | 59 | // Expect a function when last index 60 | if (isLastIndex && isES6Function(currModule[p])) { 61 | method = getES6Function(currModule[p]); 62 | } 63 | 64 | // Expect an object when not last index 65 | else if (!isLastIndex && isObject(currModule[p])) { 66 | currModule = currModule[p]; 67 | } 68 | 69 | // Return a 404 if the entity was not found 70 | else { 71 | return next(createError(404, 'No method found at this path')); 72 | } 73 | } 74 | 75 | // Reference the serverside method in req 76 | debug('Entity found'); 77 | req.serversideMethod = method; 78 | 79 | next(); 80 | }; 81 | } 82 | 83 | module.exports = createRouter; 84 | -------------------------------------------------------------------------------- /src/server/controllers.js: -------------------------------------------------------------------------------- 1 | var util = require('../util'); 2 | var debug = require('debug')('isomorphine:controllers'); 3 | 4 | /** 5 | * @providesModule controllers 6 | */ 7 | exports.getPayload = getPayload; 8 | exports.callEntityMethod = callEntityMethod; 9 | exports.serve = serve; 10 | 11 | /** 12 | * Processes the client-side payload, and transforms the client-side 13 | * callback function signal to an actual callback function 14 | */ 15 | function getPayload(req, res, next) { 16 | req.hasCallback = false; 17 | req.payload = req.body.payload || []; 18 | 19 | // Determines if the request is asynchronous or not 20 | req.payload.forEach(function(arg, i) { 21 | if (arg === '__clientCallback__') { 22 | req.hasCallback = true; 23 | req.clientCallbackIndex = i; 24 | } 25 | }); 26 | 27 | debug('Got payload' + (req.hasCallback ? ' with callback' : '') + ': ' + 28 | JSON.stringify(req.payload, null, 3)); 29 | 30 | next(); 31 | } 32 | 33 | /** 34 | * Calls the server-side entity, and returns the results to the client 35 | */ 36 | function callEntityMethod(req, res, next) { 37 | var payload = req.payload; 38 | var method = req.serversideMethod; 39 | 40 | if (req.hasCallback) { 41 | 42 | debug('Transforming callback function'); 43 | payload[req.clientCallbackIndex] = function(err) { 44 | if (err) { 45 | return next(err); 46 | } 47 | 48 | var values = Array.prototype.slice.call(arguments).slice(1); 49 | 50 | debug('Callback function called. Values are:', values); 51 | 52 | res.entityResponse = values; 53 | 54 | next(); 55 | }; 56 | } 57 | 58 | debug('Calling ' + req.path + ' with arguments:', payload); 59 | 60 | var context = { 61 | req: req, 62 | xhr: true, 63 | setCookie: res.cookie.bind(res), 64 | clearCookie: res.clearCookie.bind(res) 65 | }; 66 | 67 | var ret; 68 | 69 | // Calls the requested serverside method. 70 | // Applies the payload, and provides a context for validation purposes. 71 | // Caches errors in the method's scope, and sends it to the next error handler. 72 | try { 73 | ret = method.apply(context, payload); 74 | } catch(err) { 75 | return next(err); 76 | } 77 | 78 | if (util.isPromise(ret)) { 79 | ret.then(function(resolved) { 80 | res.entityResponse = [resolved]; 81 | next(); 82 | }) 83 | .catch(function(err) { 84 | next(err); 85 | }); 86 | } 87 | 88 | // If the request is not expecting the response from the entity, 89 | // send a generic 'Ok' response. 90 | else if (!req.hasCallback) { 91 | res.entityResponse = [ret]; 92 | debug('Not asynchronous. Returning value: ', res.entityResponse); 93 | next(); 94 | } 95 | } 96 | 97 | /** 98 | * Serves the value in req.entityResponse as a JSON object. 99 | */ 100 | function serve(req, res) { 101 | var responseIsArray = Array.isArray(res.entityResponse); 102 | util.invariant(responseIsArray, 'Response values are required.'); 103 | 104 | res.json({ values: res.entityResponse }); 105 | } 106 | -------------------------------------------------------------------------------- /src/webpack-loader.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | /** 5 | * Webpack loader for isomorphine. 6 | * @type {webpack.Loader} 7 | * 8 | * Transforms isomorphine.proxy({String}) to isomorphine.proxy({Object}). 9 | * It generates an entity map from the provided path passed to isomorphine.proxy 10 | * without requiring its modules. 11 | * 12 | * It doesn't require the module itself to prevent serverside entities 13 | * to be bundled together, thus avoiding leaking sensitive data 14 | * and non-browser-compatible libraries. 15 | * 16 | * @providesModule webpack-loader 17 | * 18 | * @param {String} source The file's source code. 19 | * @return {String} The transformed file's source code. 20 | */ 21 | module.exports = function webpackLoader(source) { 22 | if (this.cacheable) { 23 | this.cacheable(); 24 | } 25 | 26 | if (source.indexOf('isomorphine.proxy(') < 0) { 27 | return source; 28 | } 29 | 30 | var rootdir = getRootdir(this.context, source); 31 | var map = getModuleMapSync(rootdir); 32 | 33 | source = source.replace(/isomorphine\.proxy\(.*\)/, '__isomorphicAPIFactory()'); 34 | 35 | source += '\n' + ( 36 | 'function __isomorphicAPIFactory() {\n' + 37 | ' var __entityMap = ' + JSON.stringify(map) + ';\n' + 38 | ' return isomorphine.proxy(__entityMap);\n' + 39 | '}' 40 | ); 41 | 42 | return source; 43 | }; 44 | 45 | /** 46 | * Gets the absolute path to the rootdir argument of the isomorphic.proxy() call. 47 | * 48 | * @param {String} context The absolute path to the file's directory. 49 | * @param {String} source The file's source code. 50 | * 51 | * @return {String} The absolute path, as sent to isomorphine. 52 | */ 53 | function getRootdir(context, source) { 54 | var regexMatch = source.match(/isomorphine.proxy\((.*)\)/); 55 | var target = ''; 56 | 57 | if (regexMatch) { 58 | target = regexMatch[1]; 59 | 60 | if (target[0] === '\'' || target[0] === '"') { 61 | target = target.substr(0, target.length - 1).substr(1); 62 | } 63 | 64 | if (target === '__dirname') { 65 | target = context; 66 | } 67 | 68 | } 69 | 70 | return path.resolve(context, target); 71 | } 72 | 73 | /** 74 | * Maps a directory and generates the module interface map synchronously. 75 | * @todo Make this asynchronous. 76 | * 77 | * @param {dir} dir Absolute path to the directory. 78 | * @return {Object} Module interface map. 79 | */ 80 | function getModuleMapSync(dir) { 81 | var map = {}; 82 | 83 | fs 84 | .readdirSync(dir) 85 | .filter(function(filename) { 86 | return filename !== 'index.js'; 87 | }) 88 | .forEach(function(filename) { 89 | var filePath = path.join(dir, filename); 90 | var Stats = fs.lstatSync(filePath); 91 | var isLink = Stats.isSymbolicLink(); 92 | var isDir = Stats.isDirectory(); 93 | var isFile = Stats.isFile(); 94 | var isJS = filename.indexOf('.js') > -1; 95 | 96 | if (!isLink && isDir) { 97 | map[filename] = getModuleMapSync(filePath); 98 | } 99 | 100 | else if (!isLink && isFile && isJS) { 101 | var entityName = filename.replace('.js', ''); 102 | map[entityName] = true; 103 | } 104 | }); 105 | 106 | return map; 107 | } 108 | -------------------------------------------------------------------------------- /src/server/factory.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var createRouter = require('./router'); 4 | var util = require('../util'); 5 | 6 | var invariant = util.invariant; 7 | var emptyFunction = util.emptyFunction; 8 | var isObject = util.isObject; 9 | var isFunction = util.isFunction; 10 | 11 | /** 12 | * Creates an isomorphine endpoint router with entities loaded from 'baseDir'. 13 | * 14 | * @warn 15 | * Isomorphine determines your entities' methods by scanning the file structure 16 | * of the base directory. Every folder represents an entity, 17 | * whereas every file in each folder represents an API endpoint, or "route". 18 | * 19 | * @see /lib/server/router.js 20 | * 21 | * @providesModule serverFactory 22 | * 23 | * @param {String} baseDir Path to folder to require from. 24 | * @return {Object} Required modules. 25 | */ 26 | module.exports = function routerFactory(baseDir) { 27 | var morphine = requireMethods(baseDir); 28 | 29 | invariant(!morphine.hasOwnProperty('router'), 30 | 'You can\'t use an entity with the name "router"'); 31 | 32 | // Create the API endpoint that will listen for RPCs. 33 | morphine.router = createRouter(morphine); 34 | 35 | // Mocks the `morphine.config` method. The method does nothing in the server. 36 | // It is only used in the browser. 37 | morphine.config = emptyFunction; 38 | morphine.addErrorHandler = emptyFunction; 39 | morphine.removeErrorHandler = emptyFunction; 40 | 41 | return morphine; 42 | }; 43 | 44 | /** 45 | * Recursively requires the modules in current dir. 46 | * 47 | * @param {String} dir The base directory to require entities from. 48 | * @return {Object} An object with all the modules loaded. 49 | */ 50 | function requireMethods(dir) { 51 | if (!dir) dir = getCallerDirname(); 52 | 53 | var modules = {}; 54 | 55 | fs 56 | .readdirSync(dir) 57 | .filter(function(filename) { 58 | return filename !== 'index.js'; 59 | }) 60 | .forEach(function(filename) { 61 | var filePath = path.join(dir, filename); 62 | var Stats = fs.lstatSync(filePath); 63 | var isLink = Stats.isSymbolicLink(); 64 | var isDir = Stats.isDirectory(); 65 | var isFile = Stats.isFile(); 66 | var isJS = filename.indexOf('.js') > -1; 67 | 68 | if (!isLink && isDir) { 69 | modules[filename] = requireMethods(filePath); 70 | } 71 | 72 | else if (!isLink && isFile && isJS) { 73 | var entityName = filename.replace('.js', ''); 74 | modules[entityName] = require(filePath); 75 | 76 | var hasES6Default = isObject(modules[entityName]) 77 | && isFunction(modules[entityName].default); 78 | 79 | if (hasES6Default) { 80 | modules[entityName] = modules[entityName].default; 81 | } 82 | } 83 | }); 84 | 85 | return modules; 86 | } 87 | 88 | /** 89 | * Gets the dirname of the caller function that is calling this method. 90 | * @return {String} Absolute path to the caller's directory. 91 | */ 92 | function getCallerDirname() { 93 | var orig = Error.prepareStackTrace; 94 | Error.prepareStackTrace = function(_, stack){ return stack; }; 95 | var err = new Error(); 96 | Error.captureStackTrace(err, arguments.callee); 97 | var stack = err.stack; 98 | Error.prepareStackTrace = orig; 99 | var requester = stack[2].getFileName(); 100 | 101 | return path.dirname(requester); 102 | } 103 | -------------------------------------------------------------------------------- /src/client/factory.js: -------------------------------------------------------------------------------- 1 | var changeConfig = require('../util').changeConfig; 2 | var invariant = require('../util').invariant; 3 | var isObject = require('../util').isObject; 4 | var isBoolean = require('../util').isBoolean; 5 | var emptyFunction = require('../util').emptyFunction; 6 | var createProxiedMethod = require('./createProxiedMethod'); 7 | var debug = require('debug')('isomorphine:injector'); 8 | 9 | /** 10 | * Transforms the API surface area of the serverside modules 11 | * to Proxy instances that will transport the function calls to the server. 12 | * 13 | * Isomorphine determines the server methods by recursively 14 | * scanning the file structure of the base directory. 15 | * 16 | * Every file represents a serverside method, and every folder is 17 | * recursively scanned to map the server methods. 18 | * 19 | * @see /lib/client/createProxiedMethod.js 20 | * 21 | * @providesModule clientFactory 22 | * 23 | * @param {Object|String} entityMap 24 | * A map of the entities to proxy, or the absolute path to the entities' base dir. 25 | * 26 | * If a string is provided, Isomorphine's webpack loader should 27 | * generate a map of the entity's file structure automatically for you. 28 | * 29 | * @return {Object} A proxied mirror of the serverside entities. 30 | */ 31 | module.exports = function proxyFactory(entityMap) { 32 | invariant(typeof entityMap === 'object', 'Entity map is not an object. '+ 33 | '(Hint: Are you sure you are using the webpack loader?)'); 34 | 35 | var params = getConfigFromBrowser(); 36 | var morphine = createProxies(params, entityMap); 37 | 38 | morphine.config = changeConfig.bind(this, params); 39 | 40 | morphine.addErrorHandler = function(handler) { 41 | params.errorHandlers.push(handler); 42 | }; 43 | 44 | morphine.removeErrorHandler = function(handler) { 45 | var index = params.errorHandlers.indexOf(handler); 46 | if (index > -1) { 47 | params.errorHandlers.splice(index, 1); 48 | } 49 | }; 50 | 51 | // Mocks the `morphine.router` property. 52 | // This is only used in the server. 53 | morphine.router = { 54 | listen: emptyFunction 55 | }; 56 | 57 | debug('Loaded entity mirror proxies in the browser: ', morphine); 58 | 59 | return morphine; 60 | }; 61 | 62 | /** 63 | * Creates proxied methods using a provided map. If parentPath is provided, 64 | * it will be used to build the proxied method's endpoint. 65 | * @param {Object} map The entity map to use. 66 | * @param {Array} parentPath The path to the parent entity. 67 | */ 68 | function createProxies(config, map, parentPath) { 69 | parentPath = parentPath || []; 70 | 71 | var isBase = parentPath.length === 0; 72 | var proxies = {}; 73 | var path; 74 | 75 | for (var key in map) { 76 | if (map.hasOwnProperty(key)) { 77 | if (isObject(map[key])) { 78 | proxies[key] = createProxies(config, map[key], parentPath.concat([key])); 79 | } 80 | else if (isBoolean(map[key])) { 81 | path = parentPath.join('/') + (isBase ? '' : '/') + key; 82 | proxies[key] = createProxiedMethod(config, path); 83 | } 84 | } 85 | } 86 | 87 | return proxies; 88 | } 89 | 90 | /** 91 | * Gets the default configuration based on environmental variables 92 | * @return {Object} Initial config 93 | */ 94 | function getConfigFromBrowser() { 95 | var defaultLocation = { 96 | port: '80', 97 | hostname: 'localhost', 98 | protocol: 'http:' 99 | }; 100 | 101 | var wLocation = (global.location) 102 | ? global.location 103 | : defaultLocation; 104 | 105 | var location = { 106 | port: wLocation.port, 107 | host: wLocation.protocol + '//' + wLocation.hostname 108 | }; 109 | 110 | var config = { 111 | port: location.port, 112 | host: location.host, 113 | errorHandlers: [] 114 | }; 115 | 116 | return config; 117 | } 118 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var request = require('supertest'); 3 | var express = require('express'); 4 | var expect = require('chai').expect; 5 | var isomorphine = require('../index'); 6 | 7 | var mocksPath = path.resolve(__dirname, 'mocks'); 8 | 9 | describe('Server', function() { 10 | describe('Factory', function() { 11 | 12 | it('should load all the modules in a folder', function() { 13 | var api = isomorphine.proxy(mocksPath); 14 | 15 | // Isomorphine exports 16 | expect(api).to.be.an('object'); 17 | expect(api.router).to.be.a('function'); 18 | 19 | // Methods that were required by isomorphine 20 | expect(api.aSingleMethod).to.be.an('function'); 21 | 22 | // Support for es6 "export default" 23 | expect(api.NestedEntity).to.be.an('object') 24 | .with.property('aMethod').that.is.a('function'); 25 | 26 | expect(api.Entity.doSomething()).to.equal('You got it'); 27 | }); 28 | 29 | it('should load all the modules in a folder without dir param', function() { 30 | var api = isomorphine.proxy(); 31 | 32 | expect(api).to.be.a('object'); 33 | 34 | expect(api.mocks).to.be.an('object') 35 | .with.property('map').that.is.an('object') 36 | .that.include.keys(['Entity', 'NestedEntity']); 37 | 38 | expect(api.mocks.NestedEntity).to.be.an('object') 39 | .with.property('ChildEntity').that.is.an('object') 40 | .that.include.keys(['childMethod']); 41 | }); 42 | }); 43 | 44 | describe('Router', function() { 45 | var app; 46 | 47 | before(function() { 48 | var api = isomorphine.proxy(mocksPath); 49 | app = express(); 50 | 51 | app.use(api.router); 52 | app.use(function(err, req, res, next) { // eslint-disable-line 53 | res.sendStatus(err.statusCode || err.status || 500); 54 | }); 55 | }); 56 | 57 | it('should only accept post requests and return 404(express)', function(done) { 58 | request(app) 59 | .get('/isomorphine/Entity/doSomething') 60 | .expect(404) 61 | .end(done); 62 | }); 63 | 64 | it('should call a server-side method and return OK', function(done) { 65 | request(app) 66 | .post('/isomorphine/aSingleMethod') 67 | .expect(200) 68 | .expect('Content-Type', /json/) 69 | .expect({ values: [null] }) 70 | .end(done); 71 | }); 72 | 73 | it('should call a nested server-side method and return OK', function(done) { 74 | request(app) 75 | .post('/isomorphine/NestedEntity/ChildEntity/childMethod') 76 | .expect(200) 77 | .expect('Content-Type', /json/) 78 | .expect({ values: [null] }) 79 | .end(done); 80 | }); 81 | 82 | it('should call a server-side entity and return nested results', function(done) { 83 | request(app) 84 | .post('/isomorphine/Entity/doSomethingAsync') 85 | .send({ payload: ['oneParam', 'anotherParam', '__clientCallback__'] }) 86 | .expect(200) 87 | .expect('Content-Type', /json/) 88 | .expect({ values: ['Sweet', { nested: { thing: ['true', 'dat'] }}]}) 89 | .end(done); 90 | }); 91 | 92 | it('should call a server-side entity and provide a context', function(done) { 93 | request(app) 94 | .post('/isomorphine/Entity/withContext') 95 | .send({ payload: ['__clientCallback__'] }) 96 | .expect(200) 97 | .end(done); 98 | }); 99 | 100 | it('should call a server-side entity and pass the validation', function(done) { 101 | request(app) 102 | .post('/isomorphine/Entity/withValidation') 103 | .send({ payload: ['thekey', '__clientCallback__'] }) 104 | .expect(200) 105 | .end(done); 106 | }); 107 | 108 | it('should call a server-side entity and fail the validation', function(done) { 109 | request(app) 110 | .post('/isomorphine/Entity/withValidation') 111 | .send({ payload: ['incorrectkey', '__clientCallback__'] }) 112 | .expect(401) 113 | .end(done); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/client/createProxiedMethod.js: -------------------------------------------------------------------------------- 1 | var request = require('superagent'); 2 | var debug = require('debug')('isomorphine:createProxiedMethod'); 3 | var util = require('../util'); 4 | 5 | /** 6 | * Creates a new proxied method. 7 | * Serializes the parameters sent in the function's call, 8 | * and sends a POST request to isomorphine's endpoint in the server. 9 | * 10 | * @param {Object} params The server's configuration and error handlers. 11 | * @param {Object} path The path to the serverside method to be called. 12 | * 13 | * @return {Function} 14 | * A proxied method that when called, will serialize the parameters 15 | * in the function's call, and send a POST request to isomorphine's 16 | * endpoint in the server. 17 | * 18 | * @providesModule createProxiedMethod 19 | */ 20 | module.exports = function createProxiedMethod(params, path) { 21 | debug('Creating a new proxied method with path "' + path + '"'); 22 | return proxiedMethod.bind({}, params, path); 23 | }; 24 | 25 | /** 26 | * Serializes the parameters sent in the function's call, 27 | * and sends a POST request to isomorphine's endpoint in the server. 28 | * 29 | * @param {Object} params The server's configuration and error handlers. 30 | * @param {Object} path The path to the serverside method to be called. 31 | */ 32 | function proxiedMethod(params, path) { 33 | 34 | // Get the arguments that should be passed to the server 35 | var payload = Array.prototype.slice.call(arguments).slice(2); 36 | 37 | // Save the callback function for later use 38 | var callback = util.firstFunction(payload); 39 | 40 | // Transform the callback function in the arguments into a special key 41 | // that will be used in the server to signal the client-side callback call 42 | payload = util.serializeCallback(payload); 43 | 44 | var endpoint = buildEndpoint(params, path); 45 | 46 | if (callback) { 47 | return doRequest(endpoint, payload, params, callback); 48 | } else { 49 | return util.promisify(doRequest)(endpoint, payload, params); 50 | } 51 | } 52 | 53 | /** 54 | * Runs a request to an isomorphine's endpoint with the provided payload. 55 | * 56 | * @param {String} endpoint The endpoint to request. 57 | * @param {Array} payload The arguments to send. 58 | * @param {Object} params The server's configuration and error handlers. 59 | * @param {Function} callback The callback function to call afterwards. 60 | */ 61 | function doRequest(endpoint, payload, params, callback) { 62 | debug('Calling API endpoint: ' + endpoint + '.'); 63 | 64 | request 65 | .post(endpoint) 66 | .send({ payload: payload }) 67 | .set('Accept', 'application/json') 68 | .end(function(err, res) { 69 | if ((!res || !res.body) && !err) { 70 | err = new Error('No response from server. ' + 71 | '(Hint: Have you mounted isomorphine.router() in your app?)'); 72 | } 73 | 74 | if (err) { 75 | return handleError(err, params, callback); 76 | } 77 | 78 | var values = res.body.values; 79 | 80 | if (!values || values.constructor !== Array) { 81 | err = new Error('Fetched payload is not an array.'); 82 | return handleError(err, params, callback); 83 | } 84 | 85 | debug('Resolving callback with ' + JSON.stringify(values, null, 3)); 86 | 87 | // Sets the error argument to null 88 | values.unshift(null); 89 | 90 | callback.apply(this, values); 91 | }); 92 | } 93 | 94 | function handleError(err, params, callback) { 95 | util.invariant(typeof params === 'object', 'Params is required'); 96 | 97 | debug('API request failed.', err); 98 | if (params.errorHandlers instanceof Array) { 99 | params.errorHandlers.forEach(function(handler) { 100 | handler(err); 101 | }); 102 | } 103 | 104 | if (typeof callback === 'function') { 105 | callback(err); 106 | } 107 | } 108 | 109 | /** 110 | * Builds a method's API endpoint. 111 | * 112 | * @param {Object} config The host and port parameters to use. 113 | * @param {String} path The path to the serverside method. 114 | * @return {String} The endpoint to request. 115 | */ 116 | function buildEndpoint(config, path) { 117 | var host = config.host; 118 | var port = config.port; 119 | 120 | if (!host) throw new Error('No host is specified in proxied method config'); 121 | 122 | var base = host + (port ? ':' + port : ''); 123 | var fullpath = '/isomorphine/' + path; 124 | var endpoint = base + fullpath; 125 | 126 | debug('Built endpoint: ' + endpoint); 127 | 128 | return endpoint; 129 | } 130 | -------------------------------------------------------------------------------- /test/browser.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var path = require('path'); 3 | var isomorphine = require('../browser'); 4 | var createProxiedMethod = require('../src/client/createProxiedMethod'); 5 | var apiFactory = require('../src/server/factory'); 6 | var mapMock = require('./mocks/map'); 7 | 8 | describe('Browser', function() { 9 | describe('Factory', function() { 10 | it('should map the entity methods to proxy instances', function() { 11 | var api = isomorphine.proxy(mapMock); 12 | 13 | expect(api.index).to.not.be.a('function'); 14 | expect(api.aSingleMethod).to.be.a('function'); 15 | expect(api.Entity.doSomething).to.be.a('function'); 16 | expect(api.NestedEntity.ChildEntity.childMethod).to.be.a('function'); 17 | }); 18 | }); 19 | 20 | describe('Proxied Methods', function() { 21 | it('should proxy an entity method through the rest API', function(done) { 22 | 23 | var config = { 24 | host: 'http://127.0.0.1', 25 | port: 3000 26 | }; 27 | 28 | var methodPath = 'Entity/doSomethingAsync'; 29 | 30 | // Instanciates a new Proxy 31 | var proxiedMethod = createProxiedMethod(config, methodPath); 32 | 33 | // Creates a new API to listen to the clientside proxied function calls 34 | var api = apiFactory(path.join(__dirname, 'mocks')); 35 | 36 | // Starts the test's API server 37 | var server = api.router.listen(3000, function(err) { 38 | if (err) return done(err); 39 | 40 | proxiedMethod('something', { another: 'thing' }, function(err, firstRes, secondRes) { 41 | if (err) return done(err); 42 | expect(firstRes).to.equal('Sweet'); 43 | expect(secondRes).to.deep.equal({ nested: { thing: ['true', 'dat'] }}); 44 | server.close(done); 45 | }); 46 | }); 47 | }); 48 | 49 | it('should proxy an entity method with overridden host and port', function(done) { 50 | var clientApi = isomorphine.proxy(mapMock); 51 | 52 | var config = { 53 | host: 'http://127.0.0.1', 54 | port: 6689 55 | }; 56 | 57 | clientApi.config(config); 58 | 59 | // Creates a new API to listen to the clientside proxied function calls 60 | // In a real-world example, clientApi and serverApi will be the same code, 61 | // reused in an isomorphic fashion. 62 | var serverApi = apiFactory(path.join(__dirname, 'mocks')); 63 | 64 | // Starts the test's API in port 6689 65 | var server = serverApi.router.listen(6689, function(err) { 66 | if (err) return done(err); 67 | 68 | clientApi.Entity.doSomethingAsync('something', { another: 'thing' }, function(err, firstRes, secondRes) { 69 | if (err) return done(err); 70 | expect(firstRes).to.equal('Sweet'); 71 | expect(secondRes).to.deep.equal({ nested: { thing: ['true', 'dat'] }}); 72 | server.close(done); 73 | }); 74 | }); 75 | }); 76 | 77 | it('should return a promise if no callback is provided', function(done) { 78 | var methodPath = 'Entity/returnPromise'; 79 | 80 | var config = { 81 | host: 'http://127.0.0.1', 82 | port: 3000 83 | }; 84 | 85 | // Instanciates a new Proxy 86 | var proxiedMethod = createProxiedMethod(config, methodPath); 87 | 88 | // Creates a new API to listen to the clientside proxied function calls 89 | var api = apiFactory(path.join(__dirname, 'mocks')); 90 | 91 | // Starts the test's API server 92 | var server = api.router.listen(3000, function(err) { 93 | if (err) return done(err); 94 | 95 | proxiedMethod('something', { another: 'thing' }) 96 | .then(function(value) { 97 | expect(value).to.equal('Cool'); 98 | server.close(done); 99 | }) 100 | .catch(done); 101 | }); 102 | }); 103 | 104 | it('should resolve a value when entity returns a raw value', function(done) { 105 | var methodPath = 'Entity/returnValue'; 106 | 107 | var config = { 108 | host: 'http://127.0.0.1', 109 | port: 3000 110 | }; 111 | 112 | // Instanciates a new Proxy 113 | var proxiedMethod = createProxiedMethod(config, methodPath); 114 | 115 | // Creates a new API to listen to the clientside proxied function calls 116 | var api = apiFactory(path.join(__dirname, 'mocks')); 117 | 118 | // Starts the test's API server 119 | var server = api.router.listen(3000, function(err) { 120 | if (err) return done(err); 121 | 122 | proxiedMethod() 123 | .then(function(value) { 124 | expect(value).to.equal('Sync value'); 125 | server.close(done); 126 | }) 127 | .catch(done); 128 | }); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('isomorphine:util'); 2 | var Promise = global.Promise || require('es6-promise').Promise; 3 | 4 | /** 5 | * @providesModule util 6 | */ 7 | exports.emptyFunction = emptyFunction; 8 | exports.firstFunction = firstFunction; 9 | exports.serializeCallback = serializeCallback; 10 | exports.promisify = promisify; 11 | exports.isObject = isObject; 12 | exports.isBoolean = isBoolean; 13 | exports.isFunction = isFunction; 14 | exports.isES6Function = isES6Function; 15 | exports.getES6Function = getES6Function; 16 | exports.isPromise = isPromise; 17 | exports.changeConfig = changeConfig; 18 | exports.invariant = invariant; 19 | 20 | /** 21 | * An empty function. 22 | */ 23 | function emptyFunction() {} 24 | 25 | /** 26 | * Returns the first function in an array. 27 | * 28 | * @param {Array} args The array to take the function from. 29 | * @return {Function} The resulting function, or null. 30 | */ 31 | function firstFunction(args) { 32 | for (var i = 0, len = args.length; i < len; i++) { 33 | if (typeof args[i] === 'function') { 34 | return args[i]; 35 | } 36 | } 37 | 38 | return null; 39 | } 40 | 41 | /** 42 | * Transforms the client's callback function to a callback notice string. 43 | * 44 | * @param {Array} args Array of arguments to transform. 45 | * @return {Array} The transformed arguments array. 46 | */ 47 | function serializeCallback(args) { 48 | var callback; 49 | 50 | debug('Transforming callback in ', args); 51 | 52 | return args.map(function(arg) { 53 | if (typeof arg !== 'function') return arg; 54 | 55 | // It shouldn't be an argument after the callback function 56 | invariant(!callback, 'Only one callback function is allowed.'); 57 | 58 | callback = arg; 59 | 60 | return '__clientCallback__'; 61 | }); 62 | } 63 | 64 | /** 65 | * Transforms a callback-based function flow to a promise-based flow 66 | */ 67 | function promisify(func) { 68 | return function promisified() { 69 | var args = Array.prototype.slice.call(arguments); 70 | var context = this; 71 | 72 | return new Promise(function(resolve, reject) { 73 | try { 74 | func.apply(context, args.concat(function(err, data) { 75 | if (err) { 76 | return reject(err); 77 | } 78 | 79 | resolve(data); 80 | })); 81 | } catch(err) { 82 | reject(err); 83 | } 84 | }); 85 | }; 86 | } 87 | 88 | /** 89 | * Checks if the passed in variable is an object. 90 | * @param {Mixed} obj The variable to check. 91 | * @return {Boolean} True if the variable is an object. 92 | */ 93 | function isObject(obj) { 94 | return typeof obj === 'object' && obj !== null; 95 | } 96 | 97 | /** 98 | * Checks if the passed in variable is a boolean. 99 | * @param {Mixed} obj The variable to check. 100 | * @return {Boolean} True if the variable is a Boolean. 101 | */ 102 | function isBoolean(obj) { 103 | return typeof obj === 'boolean'; 104 | } 105 | 106 | /** 107 | * Checks if the passed in variable is a function. 108 | * @param {Mixed} obj The variable to check. 109 | * @return {Boolean} True if the variable is a Function. 110 | */ 111 | function isFunction(obj) { 112 | return typeof obj === 'function'; 113 | } 114 | 115 | /** 116 | * Checks if the passed variable is a function, 117 | * with support for es6 style imports and exports. 118 | * @param {Mixed} obj The variable to check. 119 | * @return {Boolean} True if the variable is a Function, 120 | * or an object with a function in the property "default". 121 | */ 122 | function isES6Function(obj) { 123 | return isFunction(obj) || (isObject(obj) && isFunction(obj.default)); 124 | } 125 | 126 | /** 127 | * Returns `obj` if `obj` is a function, or `obj.default` 128 | * if obj is a es6-style default function export. 129 | * @param {Mixed} obj The variable to get the function from. 130 | * @return {Function} The function exported from a es6-style module. 131 | */ 132 | function getES6Function(obj) { 133 | return isFunction(obj) 134 | ? obj 135 | : isES6Function(obj) 136 | ? obj.default 137 | : null; 138 | } 139 | 140 | /** 141 | * Checks if the passed in variable is a promise. 142 | * @param {Mixed} obj The variable to check. 143 | * @return {Boolean} True if the variable is a promise. 144 | */ 145 | function isPromise(obj) { 146 | return typeof obj === 'object' && typeof obj.then === 'function'; 147 | } 148 | 149 | /** 150 | * Updates a config object 151 | * @param {Object} oldConfig Old configuration object 152 | * @param {Object} newConfig New configuration object 153 | * @return {Undefined} 154 | */ 155 | function changeConfig(oldConfig, newConfig) { 156 | invariant(isObject(oldConfig), 'Old config is not valid'); 157 | invariant(isObject(newConfig), 'Config is not valid'); 158 | 159 | if (newConfig.host) { 160 | var host = newConfig.host; 161 | var prefix = ''; 162 | 163 | if (host.indexOf('http://') < 0 && host.indexOf('https://') < 0) { 164 | prefix += 'http://'; 165 | } 166 | 167 | oldConfig.host = prefix + newConfig.host; 168 | } 169 | 170 | if (newConfig.port) { 171 | oldConfig.port = newConfig.port; 172 | } 173 | } 174 | 175 | /** 176 | * Copyright (c) 2014-2015, Facebook, Inc. 177 | * All rights reserved. 178 | * 179 | * This source code is licensed under the BSD-style license found in the 180 | * LICENSE file in the root directory of this source tree. An additional grant 181 | * of patent rights can be found in the PATENTS file in the same directory. 182 | */ 183 | 184 | /** 185 | * Use invariant() to assert state which your program assumes to be true. 186 | * 187 | * Provide sprintf-style format (only %s is supported) and arguments 188 | * to provide information about what broke and what you were 189 | * expecting. 190 | * 191 | * The invariant message will be stripped in production, but the invariant 192 | * will remain to ensure logic does not differ in production. 193 | */ 194 | 195 | function invariant(condition, format, a, b, c, d, e, f) { 196 | if (format === undefined) { 197 | throw new Error('invariant requires an error message argument'); 198 | } 199 | 200 | if (!condition) { 201 | var error; 202 | if (format === undefined) { 203 | error = new Error( 204 | 'Minified exception occurred; use the non-minified dev environment ' + 205 | 'for the full error message and additional helpful warnings.' 206 | ); 207 | } else { 208 | var args = [a, b, c, d, e, f]; 209 | var argIndex = 0; 210 | error = new Error( 211 | 'Invariant Violation: ' + 212 | format.replace(/%s/g, function() { return args[argIndex++]; }) 213 | ); 214 | } 215 | 216 | error.framesToPop = 1; // we don't care about invariant's own frame 217 | throw error; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Isomorphine 2 | 3 | Isomorphine is a webpack loader that lets you access server-side modules in the browser. It works by injecting an rpc routing layer behind the scenes. This lets you require and run server-side code from the browser, without doing AJAX requests or express routes. 4 | 5 | When requiring a server-side entity from the browser, the module is provided as a mirror of the server-side entity. This mirror will automatically transport any method call to the server, and resolve the results in the browser seamlessly. When requiring a module from the server's execution context, the module is resolved as-is, without any mirroring or routing whatsover. 6 | 7 | You can securely share and use server-side code in the browser (for example your database models) and eliminate data fetching boilerplate. The server-side modules will not be required directly in the browser, so you can require modules containing browser-incompatible libraries. 8 | 9 | It does _not_ expose server-side code. It also provides a [security mechanism](#rpc-context) for remote procedure calls (RPCs), and supports promises & async/await. 10 | 11 | You don't need to do HTTP requests and endpoints anymore. You can skip your application's routing layer, and focus on your application's purpose. 12 | 13 | 14 | ### Summary 15 | 16 | * [Usage](#installation) 17 | * [How It Works](#how-it-works) 18 | * [Examples](#examples) 19 | * [Promise & Async/Await support](#promise--es7-support) 20 | * [Security & RPC context](#rpc-context) 21 | * [Caveats](#caveats) 22 | * [Comparison](#comparison) 23 | * [Philosophy](#philosophy) 24 | 25 | 26 | ### Requirements 27 | - Node 28 | - Webpack 29 | 30 | ### Installation 31 | 32 | ```bash 33 | npm install isomorphine 34 | ``` 35 | 36 | Then you must [add isomorphine as a webpack loader](#webpack-configuration). 37 | 38 | ### Usage 39 | 40 | Isomorphine has only one method: `isomorphine.proxy()` 41 | 42 | `isomorphine.proxy()` - Creates an object exposing the modules in the directory where it was called. This is similar to [require-all](https://github.com/felixge/node-require-all), but also enables each module to be used in the browser. 43 | 44 | ```js 45 | var isomorphine = require('isomorphine'); 46 | 47 | // This will provide the entities in this folder, 48 | // similar to 'require-all' but in a browser-compatible way. 49 | module.exports = isomorphine.proxy(); 50 | ``` 51 | 52 | Each file in the current directory represents a property in the resulting object. Each file must export a function either in `module.exports` or via `export default`. Directories are scanned recursively (See [require-all](https://github.com/felixge/node-require-all)). When using these modules from the browser through isomorphine, function calls will be proxied to the server. 53 | 54 | This will let you use any server-side entity remotely, through the object created with `isomorphine.proxy()`. Just require this `morphine` object from the browser or the server, and take anything you need out of it. For example: 55 | 56 | ```js 57 | /** 58 | * Suppose the file we defined above is in ./models/index.js, 59 | * and the User model is located in ./models/User. 60 | */ 61 | var User = require('./models').User; 62 | 63 | $('#button').on('click', function() { 64 | 65 | // When called from the browser, isomorphine will transport this function call 66 | // to the server, process the results, and resolve this callback function. 67 | // When called from the server, the function is called directly. 68 | User.create({ name: 'someone' }, function(err, user) { 69 | console.log('Im the browser, and I created a user in the db!'); 70 | }); 71 | }); 72 | ``` 73 | 74 | You also need to mount isomorphine's RPC interface on your current express-based app, by doing: 75 | 76 | ```js 77 | var express = require('express'); 78 | var morphine = require('./models'); // Suppose this is the file we created above 79 | 80 | var app = express(); 81 | 82 | // Mounts the rpc layer middleware. This will enable remote function calls 83 | app.use(morphine.router); 84 | 85 | app.listen(3000, function() { 86 | console.log('App listening at port 3000'); 87 | }); 88 | ``` 89 | 90 | Alternatively, you can start isomorphine's router as a stand-alone http server by doing: 91 | 92 | ```js 93 | var morphine = require('./models'); // Suppose this is the file we created above 94 | 95 | // This will enable remote function calls, and must be run in the server 96 | morphine.router.listen(3000, function() { 97 | console.log('RPC interface listening at port 3000'); 98 | }); 99 | ``` 100 | 101 | By default, isomorphine will make the remote procedure calls to the same host and port of the client's current `window.location`. To manually specify the host and port of your API server, you can do: 102 | 103 | ```js 104 | var morphine = isomorphine.proxy(); 105 | 106 | morphine.config({ 107 | host: 'api.mysite.com', // default is the current browser location hostname 108 | port: '3000' // default is the current browser location port 109 | }); 110 | ``` 111 | 112 | Remember to read the [caveats](#caveats) for common gotchas, and the section below for a more in-depth explanation. 113 | 114 | 115 | ### How It Works 116 | 117 | * Check the [barebone example](https://github.com/d-oliveros/isomorphine/tree/master/examples/barebone), and the [isomorphic todoMVC](https://github.com/d-oliveros/isomorphic-todomvc) for [full working examples](#examples). 118 | 119 | Isomorphine detects server-side entities by scanning the file structure recursively, and building objects whose methods represent files in the server. Each file has to export a function (via `module.exports` or `export default`). Read the [caveats](#caveats) for common gotchas. 120 | 121 | The internal behavior of `isomorphine.proxy()` differs depending on whether its being ran in the browser or the server: 122 | 123 | * When called from the server: `isomorphine.proxy()` requires all files in the current directory (similar to [require-all](https://github.com/felixge/node-require-all)) and also creates an express-based router that will handle remote procedure calls (RPCs) to the methods in these entities. 124 | 125 | * When called from the browser: `isomorphine.proxy()` creates a mirror to the server-side entities. The mirror is preprocessed and injected by webpack, so you must [add isomorphine as a webpack loader](#webpack-configuration). No router is created in the browser, and no server-side modules are actually `require()`'d in the browser. 126 | 127 | In this example, we will be using a fictitious server-side model called `User`, written in vanilla ES5. We will be splitting each model method in its own file. Please note that only files that export a function can be used in the browser. Promises and ES7 async/await are [also supported](#promise--es7-support). 128 | 129 | ```js 130 | // in /models/User/create.js 131 | 132 | module.exports = function createUser(username, callback) { 133 | var user = { 134 | _id: 123, 135 | type: 'user', 136 | name: username, 137 | created: new Date() 138 | }; 139 | 140 | // It doesn't matter how you handle your data layer. 141 | // Isomorphine is framework-agnostic, so you can use anything you want, like 142 | // mongoose, sequelize, direct db drivers, or whatever really. 143 | db.create(user, callback); 144 | } 145 | ``` 146 | 147 | ```js 148 | // in /models/User/delete.js 149 | 150 | module.exports = function deleteUser(userId, callback) { 151 | db.remove({ _id: userId }, callback); 152 | } 153 | ``` 154 | 155 | To make the these functions available in the browser, you have to create an isomorphic proxy to this folder by using `isomorphine.proxy()`. 156 | 157 | `isomorphine.proxy()` will make these modules available in the browser, without breaking your bundle due to incompatible server-only libraries, bloating your bundle's size, or exposing your server's code. In this example, we will create an isomorphic proxy with `isomorphine.proxy()`, and export it in the index file of the `/models` directory, thus exposing all the models in the browser. 158 | 159 | ```js 160 | // in /models/index.js 161 | var isomorphine = require('isomorphine'); 162 | 163 | // This will be our main isomorphic gateway to this folder 164 | module.exports = isomorphine.proxy(); 165 | ``` 166 | 167 | We are calling `isomorphine.proxy()` in the `index.js` file of the `./models` folder, thus providing all the models in the `./models` folder to the browser, as mirror entity maps. 168 | 169 | Based on this example structure, the object created by `isomorphine.proxy()` is: 170 | 171 | ```js 172 | // The browser gets this -> 173 | { 174 | User: { 175 | create: [func ProxiedMethod], 176 | delete: [func ProxiedMethod] 177 | }, 178 | config: [func] // This method sets the host and port of your API server 179 | } 180 | 181 | // Whereas the server gets this -> 182 | { 183 | User: { 184 | create: [func createUser], 185 | delete: [func deleteUser] 186 | }, 187 | config: [func emptyFunction] // This method does nothing in the server 188 | router: [func] // this is the RPC API router that must be mounted in your app 189 | } 190 | 191 | // /models/User/create.js and /models/User/delete.js are not actually being 192 | // required in the browser, so don't worry about browser-incompatible modules 193 | ``` 194 | 195 | You can use this fictitious `User` model in the browser by doing this, for example: 196 | 197 | ```js 198 | // in /client.js, running in the browser 199 | 200 | // We can interact with this server-side entity (User) 201 | // without manually having to do any HTTP requests. 202 | // 203 | // If you were to require this model directly from the browser, you'd be requiring 204 | // your whole data layer, probably including your database initialization files 205 | // and other modules that are *not* browser-compatible. 206 | // 207 | // By requiring 'User' through '../models/index.js', which is the file where 208 | // we put 'isomorphine.proxy()', we are actually requiring an object 209 | // which mirrors the methods in the real server-side model. 210 | // 211 | // No server-side modules are actually required. Webpack generates an entity 212 | // map before running this code, so it already knows the API surface area of 213 | // your server-side model. 214 | // 215 | // Remember not to require 'User' directly. You need to require User through 216 | // the gateway we created above. Otherwise, you'd be requiring all the model 217 | // dependencies in the browser. 218 | // 219 | var User = require('../models').User; 220 | 221 | // eg. using jquery 222 | $('#button').on('click', function() { 223 | 224 | // When called from the browser, the browser serialized each parameter sent 225 | // to the function call, and sends the serialized payload via a HTTP request 226 | // to Isomorphine's endpoint in the server. 227 | // 228 | // Isomorphine serializes the result of the function call in the server, 229 | // returns the resulting values, deserializes the values, and calls 230 | // this callback function with the resulting values. 231 | User.create('someUser99', function(err, user) { 232 | 233 | window.alert('User created'); 234 | 235 | // uhh, lets delete the user better! 236 | User.delete(user._id, function(err) { 237 | window.alert('User deleted'); 238 | }); 239 | }); 240 | }); 241 | ``` 242 | 243 | To make this work, you must mount the express-based router created by `isomorphine.proxy()` in your app: 244 | 245 | ```js 246 | // in /server.js 247 | var express = require('express'); 248 | 249 | // suppose the file we defined above is in ./models/index.js 250 | var morphine = require('./models'); 251 | 252 | var app = express(); 253 | 254 | // Mounts the rpc layer middleware. This will enable remote function calls 255 | app.use(morphine.router); 256 | 257 | // you can mount other middleware and routes in the same app 258 | // app.get('/login', mySecureLoginCtrl); 259 | 260 | app.listen(3000, function() { 261 | console.log('Server listening at port 3000'); 262 | }); 263 | 264 | // Alternatively, if you don't wish to mount isomorphine's middleware in your app, 265 | // you can just start listening for RPCs by doing 266 | var morphine = require('./models'); 267 | morphine.router.listen(3000, function() { 268 | console.log('Server listening at port 3000'); 269 | }); 270 | ``` 271 | 272 | Multiple arguments in exported functions are supported. You can define and use functions as you would normally do: 273 | 274 | ```js 275 | // in /client.js, running in the browser 276 | var User = require('./models').User; 277 | 278 | User.edit(user._id, { name: 'newName' }, 'moreargs', (err, editedUser, stats) => { 279 | console.log('User edited. Server said:'); 280 | console.log(editedUser); 281 | console.log(stats); 282 | }); 283 | ``` 284 | 285 | **Your server's files will _not_ be exposed in the browser, nor they will get added to the bundled file.** 286 | 287 | It also supports promises and ES7 async/await 288 | 289 | ```js 290 | // in /client.js, running in the browser 291 | import { User } from '../models'; 292 | 293 | $('#button').on('click', async () => { 294 | 295 | // using promise-based server-side entities, and async/await in the browser 296 | const user = await User.create('someUser99'); 297 | window.alert('User created'); 298 | 299 | // lets delete this user 300 | await User.delete(user._id); 301 | window.alert('User deleted'); 302 | }); 303 | ``` 304 | 305 | Other than reducing boilerplate code, it really shines in isomorphic applications, where you need the same piece of code running in the browser and the server, for example when doing server-side rendering of a react application. 306 | 307 | ```js 308 | import { User } from '../models'; 309 | 310 | // In the browser, calling 'User.get()' will actually make a HTTP request 311 | // to the server, which will make the actual function call, 312 | // serialize the results back to the browser, and resolve the promise with the value(s). 313 | // 314 | // In the server, 'User.get()' will be called directly 315 | // without doing any HTTP requests or any routing whatsoever. 316 | // 317 | // This same piece of code can be run seamlessly in the browser and the server 318 | export default class MyComponent extends React.Component { 319 | async componentDidMount() { 320 | const user = await User.get(123); 321 | this.setState({ user }); 322 | } 323 | } 324 | ``` 325 | 326 | Please read the [caveats](#caveats) for common gotchas, and the section below for working examples. 327 | 328 | 329 | ### Examples 330 | 331 | * [Barebone](https://github.com/d-oliveros/isomorphine/tree/master/examples/barebone) - Barebone example using express, jquery, webpack. 332 | * [Isomorphic React](https://github.com/d-oliveros/isomorphine/tree/master/examples/isomorphic-react) - Server-side rendered React example using React, [Baobab](https://github.com/Yomguithereal/baobab), [Babel](https://github.com/babel/babel). 333 | * [isomorphic TodoMVC](https://github.com/d-oliveros/isomorphic-todomvc) for a full isomorphic TodoMVC react example. 334 | 335 | Also go to [Wiselike](https://wiselike.com) to see it running in a production environment, and [ask me anything here!](https://wiselike.com/david) 336 | 337 | 338 | ### Webpack Configuration 339 | 340 | In order for Isomorphine to work, you need to specify Isomorphine as a webpack loader in your `webpack.config.js` file. The main `isomorphine` package contains the webpack loader so you don't need to install anything else. 341 | 342 | ```js 343 | module.exports = { 344 | entry: {...}, 345 | 346 | module: { 347 | preLoaders: [{ loaders: ['isomorphine'] }] 348 | }, 349 | ... 350 | }; 351 | ``` 352 | 353 | For instructions and a usage guide, please read [Usage](#usage) 354 | 355 | 356 | ### Promise / ES7 Support 357 | 358 | Isomorphine supports promises, async/await, and callback-based functions. 359 | 360 | ```js 361 | // Promise support 362 | module.exports = function getUser() { 363 | return db.findAsync({ _id: 123 }); // supposing this returns a promise 364 | } 365 | ``` 366 | 367 | ```js 368 | // Another promise support example 369 | module.exports = function readSomeFile() { 370 | return new Promise((resolve, reject) => { 371 | fs.readFile('somefile.txt', function(err, file) { 372 | if (err) return reject(err); 373 | resolve(file); 374 | }); 375 | }); 376 | } 377 | ``` 378 | 379 | ```js 380 | // ES7 async/await support 381 | export default async function getUser() { 382 | const user = await MyUserModel.find({ _id: 123 }); 383 | 384 | // await doMoreStuff()... 385 | 386 | return user; 387 | } 388 | ``` 389 | 390 | ```js 391 | // callback-based support 392 | module.exports = function getUser(uid, callback) { 393 | MyUserModel.find({ _id: uid }, (err, user) => { 394 | if (err) return callback(err); 395 | 396 | console.log('Got a user!'); 397 | 398 | callback(null, user); 399 | }); 400 | } 401 | ``` 402 | 403 | 404 | ### RPC Context 405 | 406 | Allowing any API endpoint to be called from the browser, needs a proper validation mechanism to avoid getting exploited easily. 407 | 408 | When a call to a server-side function is done from the browser, a special context is passed to the function call. A special `xhr` flag and the request object `req` are passed as the function's context, in `this.xhr` and `this.req`: 409 | 410 | ```js 411 | /** 412 | * When an endpoint is called from the browser, 'this.xhr' will be true, 413 | * and you'll be able to access the request object in 'this.req'. 414 | */ 415 | module.exports = function createUser(username, callback) { 416 | if (this.xhr) { 417 | console.log('This function is being called remotely!'); 418 | console.log('Request is', this.req); 419 | } 420 | 421 | myUserModel.create(username, callback); 422 | } 423 | ``` 424 | 425 | You can use this context to validate incoming requests. Please note, Isomorphine is unobtrusive and comes with no security middleware by default (other than this mechanism). 426 | 427 | You *must* implement your own security mechanism yourself in an earlier middleware stage (using cookies or redis sessions or JWT or w/e): 428 | 429 | ```js 430 | module.exports = function deleteUser(userId, callback) { 431 | 432 | // Suppose I have a previous middleware step that adds `isAdmin` 433 | // to the request object, based on the user's session or else 434 | if (this.xhr && !this.req.isAdmin) { 435 | return callback(401); 436 | } 437 | 438 | myUserModel.delete(userId, callback) 439 | } 440 | ``` 441 | 442 | If the function is not being called remotely, `this.req` will be null, so make sure to validate `this.xhr` before trying to do something with the request object `this.req`. 443 | 444 | 445 | ### Caveats 446 | 447 | * Your files must export a function in `module.exports` (or `export default` if using ES6 syntax) if you want to be able to call them from the browser. There is currently no support for exporting objects yet. If anyone figures out a way to determine the exports of a module without requiring its dependencies I'll be happy to merge the PR :D 448 | 449 | * Your modules have to be required through the isomorphine proxy. You can not require a server-side entity directly. If you do, you will be importing all the server-side code to the browser's bundle, and possibly breaking your app due to browser-incompatible modules, like `fs`, `express`, `mongo`, database drivers, etc. 450 | 451 | * When a function is called directly from the server (eg when called by a cron job), there's no `this.req` object being passed to the function calls, so you must validate sensitive paths in an earlier stage. 452 | 453 | * Also, please note that the file where you create the isomorphic proxy, has to be browser-compatible. Isomorphine works by proxying the methods contained in the specified directory, but the file itself will be required as-is in the browser. 454 | 455 | 456 | ### Comparison 457 | 458 | The two examples below achieve the same result. They both create a web server, register a route that returns a user by user ID, starts listening at port 3000, and call this endpoint from the browser. 459 | 460 | One example uses isomorphine, while the other one uses the common approach of building an API route, calling a model from a controller, and building a client-side wrapper for data-fetching logic. 461 | 462 | ##### With isomorphine: 463 | 464 | ```js 465 | // in ./api/User/get.js 466 | 467 | // Dummy User.get() method 468 | module.exports = function(id, callback) { 469 | var user = { 470 | id: id, 471 | name: 'Some User' 472 | }; 473 | 474 | callback(null, user); 475 | }; 476 | ``` 477 | 478 | ```js 479 | // in ./api/index.js 480 | var isomoprhine = require('isomorphine'); 481 | 482 | // make the API entities callable from the browser 483 | var morphine = isomorphine.proxy(); 484 | 485 | // start listening for RPC calls 486 | morphine.router.listen(3000, function() { 487 | console.log('Interface listening at port 3000'); 488 | }); 489 | 490 | module.exports = morphine; 491 | ``` 492 | 493 | ```js 494 | // in ./client.js 495 | var User = require('./api').User; 496 | var userId = 123; 497 | 498 | // just use the model and be happy 499 | User.get(userId, function(err, user) { 500 | console.log('User is', user); 501 | }); 502 | ``` 503 | 504 | 505 | ##### Without isomorphine: 506 | 507 | ```js 508 | // in ./api/User/get.js 509 | 510 | // Dummy User.get() method 511 | module.exports = function(id, callback) { 512 | var user = { 513 | id: id, 514 | name: 'Some User' 515 | }; 516 | 517 | callback(null, user); 518 | }; 519 | ``` 520 | 521 | ```js 522 | // in ./api/index.js 523 | var User = require('./User'); 524 | 525 | var express = require('express'); 526 | var bodyParser = require('body-parser'); 527 | var http = require('http'); 528 | 529 | var app = express(); 530 | 531 | app.use(bodyParser.urlencoded()); 532 | app.use(bodyParser.json()); 533 | 534 | app.get('/api/user/:id', function(req, res, next) { 535 | var userId = req.params.id || 123; 536 | 537 | User.get(userId, function(err, user) { 538 | if (err) { 539 | return next(err); 540 | } 541 | res.set('Content-Type', 'application/json'); 542 | res.send(user); 543 | }); 544 | }); 545 | 546 | var server = http.createServer(app); 547 | server.listen(3000, function() { 548 | console.log('Server listening at port 3000'); 549 | }); 550 | ``` 551 | 552 | ```js 553 | // in ./client.js 554 | var request = require('request'); 555 | var userId = 1; 556 | 557 | // unnecesary boilerplate code 558 | function getUser(id, callback) { 559 | request.get(`/api/user/${id}`, function(err, res) { 560 | if (err) { 561 | return callback(err); 562 | } 563 | var user = res.data; 564 | callback(null, user); 565 | }); 566 | } 567 | 568 | // gets the user 569 | getUser(userId, function(err, user) { 570 | console.log('User is', user); 571 | }); 572 | ``` 573 | 574 | 575 | ##### With Isomorphine (condensed): 576 | 577 | ```js 578 | var isomoprhine = require('isomorphine'); 579 | var morphine = isomorphine.proxy(); 580 | morphine.router.listen(3000, function() { 581 | console.log('Interface listening at port 3000'); 582 | }); 583 | module.exports = morphine; 584 | var User = require('./api').User; 585 | var userId = 123; 586 | User.get(userId, function(err, user) { 587 | console.log('User is', user); 588 | }); 589 | ``` 590 | 591 | 592 | ##### Without Isomorphine (condensed): 593 | 594 | ```js 595 | var User = require('./User'); 596 | var express = require('express'); 597 | var bodyParser = require('body-parser'); 598 | var http = require('http'); 599 | var app = express(); 600 | app.use(bodyParser.urlencoded()); 601 | app.use(bodyParser.json()); 602 | app.get('/api/user/:id', function(req, res, next) { 603 | var userId = req.params.id || 123; 604 | User.get(userId, function(err, user) { 605 | if (err) { 606 | return next(err); 607 | } 608 | res.set('Content-Type', 'application/json'); 609 | res.send(user); 610 | }); 611 | }); 612 | var server = http.createServer(app); 613 | server.listen(3000, function() { 614 | console.log('Server listening at port 3000'); 615 | }); 616 | var request = require('request'); 617 | var userId = 1; 618 | function getUser(id, callback) { 619 | request.get(`/api/user/${id}`, function(err, res) { 620 | if (err) { 621 | return callback(err); 622 | } 623 | var user = res.data; 624 | callback(null, user); 625 | }); 626 | } 627 | getUser(userId, function(err, user) { 628 | console.log('User is', user); 629 | }); 630 | ``` 631 | 632 | This example took 11 lines of code using Isomorphine. Doing this the traditional way took 35 lines. _And its only one route_ 633 | 634 | If we were to add more CRUD routes to our model, each route would require: 635 | 636 | 1. A controller 637 | 2. Some request validation 638 | 3. A method in your model (maybe) 639 | 4. Some wrapper in your client-side application 640 | 641 | Plus having to mantain these new components. Multiply this for each route you are currently mantaining, and you'll realize there has to be a better way to streamline your application's development. 642 | 643 | With isomorphine, you can just call the server-side model directly. The model is already being supplied to the browser, so you would only require: 644 | 645 | 1. A new method in your model 646 | 647 | No need to re-write the data-fetching layer in the client application, or get parameters out of request object. heck, you don't even need to define and mantain routes. If you need to access the request object for validation purposes or else, you can access it through `this.req` as specified [here](#rpc-context). 648 | 649 | _Disclaimer: I'm not saying Isomorphine is the best fit for every case. You should have your routes and middleware in place to serve the client and views, and to handle special routes like auth actions, and should be providing everything you need to correctly authenticate remote calls to your methods. Starting isomorphine directly from `morphine.router.listen()` is not recommended, as Isomorphine is only intended to handle RPCs to server methods. It is not meant to server as a full-blown HTTP server or application stack._ 650 | 651 | 652 | ### Philosophy 653 | 654 | Isomorphine proposes an endpoint-less API approach in an attempt to further abstract the barriers between the server and the browser. It is meant to increase code reusability between the server and the browser, specially in an isomorphic full-stack javascript environment. 655 | 656 | The idea is to encapsulate the routing layer within javascript's native syntax for importing and exporting modules, while providing a middleware interface to let you mount its RPC handler the way you want. This massively reduces development times, as you don't have to worry about connecting the browser and server together, so you can focus solely in your application's purpose. 657 | 658 | The original idea was to use ES6's `Proxy` in the browser, to proxy any function call from any property of any object existing in any file in the server. Unfortunately, I quickly found out that there was no out-of-the-box support for `Proxy` in most of the major browsers. This led to the idea of using Webpack to pre-generate a map of server-side entities based on filenames. While this work, full support for any type of export will probably come after a wider adoption of ES6's `Proxy` in the browser, or a more advanced webpack loader. 659 | 660 | 661 | ### Tests 662 | 663 | ```bash 664 | mocha test 665 | ``` 666 | 667 | Cheers. 668 | --------------------------------------------------------------------------------