├── .eslintrc.js ├── .gitignore ├── .npmrc ├── README.md ├── fixtures ├── data_clicks.json ├── data_dummy.json ├── echo_server.js ├── exports_hello_world_1.js ├── exports_hello_world_2.js ├── exports_hello_world_3.js ├── exports_hello_world_4.js ├── exports_junk.js ├── returns_hello_world_1.js ├── returns_hello_world_2.js ├── returns_hello_world_3.js ├── storage_set_query.js ├── use_npm_bogus.js ├── use_npm_expression.js └── use_npm_with_version.js ├── index.js ├── jsconfig.json ├── lib ├── compiler.js ├── context.js ├── errors.js ├── handler.js ├── request.js ├── runner.js ├── server.js ├── simulator.js └── storage.js ├── package.json └── test ├── compilation.js ├── model.js ├── serving.js ├── simulation.js └── storage.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | }, 5 | "env": { 6 | "node": true, 7 | }, 8 | "extends": ["eslint:recommended"], 9 | "rules": { 10 | "indent": ["warn", 4], 11 | "global-require": 0, 12 | "camelcase": 0, 13 | "curly": 0, 14 | "no-undef": ["error"], 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webtask runtime components 2 | 3 | A series of components that replicate the behaviour of the runtime component of https://webtask.io. 4 | 5 | ## Example 6 | 7 | **Given a sample webtask:** 8 | 9 | ```js 10 | function webtask(ctx, cb) { 11 | return cb(null, { 12 | secret: ctx.secrets.hello, 13 | }); 14 | } 15 | ``` 16 | 17 | **Sample of simulating a request to a webtask function:** 18 | 19 | ```js 20 | const Assert = require('assert'); 21 | const Runtime = require('webtask-runtime'); 22 | 23 | Runtime.simulate(webtask, { secrets: { hello: 'world' }}, function (res) { 24 | Assert.ok(res.statusCode === 200); 25 | Assert.ok(typeof res.payload === 'string'); 26 | }); 27 | ``` 28 | 29 | **Sample of creating a local http server that runs a webtask:** 30 | 31 | ```js 32 | const Assert = require('assert'); 33 | const Runtime = require('webtask-runtime'); 34 | 35 | const server = Runtime.createServer(webtask, { secrets: { hello: 'world' }}); 36 | 37 | server.listen(8080); 38 | ``` 39 | 40 | ## API 41 | 42 | TODO 43 | 44 | ## Contributing 45 | 46 | Just clone the repo, run `npm install` and then hack away. 47 | 48 | ## Issue reporting 49 | 50 | If you have found a bug or if you have a feature request, please report them at 51 | this repository issues section. Please do not report security vulnerabilities on 52 | the public GitHub issue tracker. The 53 | [Responsible Disclosure Program](https://auth0.com/whitehat) details the 54 | procedure for disclosing security issues. 55 | 56 | ## License 57 | 58 | MIT 59 | 60 | ## What is Auth0? 61 | 62 | Auth0 helps you to: 63 | 64 | * Add authentication with [multiple authentication sources](https://docs.auth0.com/identityproviders), either social like **Google, Facebook, Microsoft Account, LinkedIn, GitHub, Twitter, Box, Salesforce, amont others**, or enterprise identity systems like **Windows Azure AD, Google Apps, Active Directory, ADFS or any SAML Identity Provider**. 65 | * Add authentication through more traditional **[username/password databases](https://docs.auth0.com/mysql-connection-tutorial)**. 66 | * Add support for **[linking different user accounts](https://docs.auth0.com/link-accounts)** with the same user. 67 | * Support for generating signed [Json Web Tokens](https://docs.auth0.com/jwt) to call your APIs and **flow the user identity** securely. 68 | * Analytics of how, when and where users are logging in. 69 | * Pull data from other sources and add it to the user profile, through [JavaScript rules](https://docs.auth0.com/rules). 70 | 71 | ## Create a free account in Auth0 72 | 73 | 1. Go to [Auth0](https://auth0.com) and click Sign Up. 74 | 2. Use Google, GitHub or Microsoft Account to login. 75 | -------------------------------------------------------------------------------- /fixtures/data_clicks.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | "totalClicks": 12345 4 | } -------------------------------------------------------------------------------- /fixtures/data_dummy.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "b" 3 | } -------------------------------------------------------------------------------- /fixtures/echo_server.js: -------------------------------------------------------------------------------- 1 | module.exports = function (ctx, req, res) { 2 | res.writeHead(200, { 'Content-Type': 'application/json' }); 3 | res.end(JSON.stringify({ 4 | url: req.url, 5 | method: req.method, 6 | body: req.body, 7 | query: req.query, 8 | secrets: ctx.secrets, 9 | meta: ctx.meta, 10 | params: ctx.params, 11 | }, null, 2)); 12 | }; -------------------------------------------------------------------------------- /fixtures/exports_hello_world_1.js: -------------------------------------------------------------------------------- 1 | module.exports = function (cb) { 2 | cb(null, 'hello world'); 3 | }; -------------------------------------------------------------------------------- /fixtures/exports_hello_world_2.js: -------------------------------------------------------------------------------- 1 | module.exports = function (ctx, cb) { 2 | cb(null, 'hello world'); 3 | }; -------------------------------------------------------------------------------- /fixtures/exports_hello_world_3.js: -------------------------------------------------------------------------------- 1 | module.exports = function (ctx, req, res) { 2 | res.writeHead(200, { 3 | 'content-type': 'application/json', 4 | }); 5 | res.end(JSON.stringify('hello world', null, 2)); 6 | }; -------------------------------------------------------------------------------- /fixtures/exports_hello_world_4.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | module.exports = function (ctx, req, res, bogus) { 3 | res.writeHead(200, { 4 | 'content-type': 'application/json', 5 | }); 6 | res.end(JSON.stringify('hello world', null, 2)); 7 | }; -------------------------------------------------------------------------------- /fixtures/exports_junk.js: -------------------------------------------------------------------------------- 1 | module.exports = 'junk'; -------------------------------------------------------------------------------- /fixtures/returns_hello_world_1.js: -------------------------------------------------------------------------------- 1 | return function (cb) { 2 | cb(null, 'hello world'); 3 | }; -------------------------------------------------------------------------------- /fixtures/returns_hello_world_2.js: -------------------------------------------------------------------------------- 1 | return function (ctx, cb) { 2 | cb(null, 'hello world'); 3 | }; -------------------------------------------------------------------------------- /fixtures/returns_hello_world_3.js: -------------------------------------------------------------------------------- 1 | return function (ctx, req, res) { 2 | res.writeHead(200, { 3 | 'content-type': 'application/json', 4 | }); 5 | res.end(JSON.stringify('hello world', null, 2)); 6 | }; -------------------------------------------------------------------------------- /fixtures/storage_set_query.js: -------------------------------------------------------------------------------- 1 | module.exports = function (ctx, cb) { 2 | const initialEtag = ctx.storage.etag; 3 | 4 | ctx.storage.get((err, data) => { 5 | if (err) return cb(err); 6 | 7 | const afterReadEtag = ctx.storage.etag; 8 | 9 | ctx.storage.set(ctx.query, err => { 10 | if (err) return cb(err); 11 | 12 | cb(null, { 13 | data, 14 | etag: ctx.storage.etag, 15 | initialEtag, 16 | afterReadEtag, 17 | }); 18 | }); 19 | 20 | // Set storage after the request is sent 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /fixtures/use_npm_bogus.js: -------------------------------------------------------------------------------- 1 | "use npm"; 2 | 3 | 4 | const Bogus = require('bogus'); 5 | 6 | 7 | module.exports = function (cb) { 8 | cb(null, Bogus); 9 | }; -------------------------------------------------------------------------------- /fixtures/use_npm_expression.js: -------------------------------------------------------------------------------- 1 | "use npm"; 2 | 3 | /* eslint no-console:0 */ 4 | 5 | console.log(require('expression')); 6 | 7 | module.exports = function (cb) { 8 | cb(null, {}); 9 | }; 10 | -------------------------------------------------------------------------------- /fixtures/use_npm_with_version.js: -------------------------------------------------------------------------------- 1 | "use npm"; 2 | 3 | 4 | const Bogus = require('bogus@1.0.0'); 5 | 6 | 7 | module.exports = function (cb) { 8 | cb(null, Bogus); 9 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Compiler = require('./lib/compiler'); 5 | const Context = require('./lib/context'); 6 | const Errors = require('./lib/errors'); 7 | const Handler = require('./lib/handler'); 8 | const Request = require('./lib/request'); 9 | const Runner = require('./lib/runner'); 10 | const Server = require('./lib/server'); 11 | const Simulator = require('./lib/simulator'); 12 | const Storage = require('./lib/storage'); 13 | 14 | 15 | module.exports = { 16 | compile: Compiler.compile, 17 | createContext: Context.create, 18 | createError: Errors.create, 19 | createHandler: Handler.create, 20 | createServer: Server.create, 21 | createStorage: Storage.create, 22 | ensureRequestConsumed: Request.ensureRequestConsumed, 23 | readBody: Request.readBody, 24 | prepareRequest: Request.prepareRequest, 25 | runWebtaskFunction: Runner.run, 26 | simulate: Simulator.simulate, 27 | installEndListener: Request.installEndListener, 28 | PARSE_NEVER: 0, 29 | PARSE_ALWAYS: 1, 30 | PARSE_ON_ARITY: 2, 31 | }; 32 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6" 4 | }, 5 | "exclude": [ 6 | "node_modules" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /lib/compiler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Async = require('async'); 4 | const Errors = require('./errors'); 5 | const Path = require('path'); 6 | const _ = require('lodash'); 7 | 8 | 9 | module.exports = { 10 | compile, 11 | }; 12 | 13 | 14 | const RX_USE_DIRECTIVE = /^(?:[\n\s]*("|')use\s+(latest|npm)\1\s*(?:;|\n))*/g; 15 | 16 | 17 | /** 18 | * Complied a supplied webtask function's code, returning the webtask function. 19 | * 20 | * @param {String} script - The webtask code to be compiled. 21 | * @param {Object} [options] - Options object. 22 | * @param {String} [options.dirname] - The directory name relative to which the supplied webtask code will appear to run. 23 | * @param {String} [options.filename] - The filename under which the webtask code will appear to run. 24 | * @param {Object} [options.logger] - The logger object to use. This object is expected to expose standard log functions (`info`, `warn` and `error`). 25 | * @param {Function} [options.installModule] - A function that will be called for each missing module when the `"use npm"` directive is used. 26 | * @param {Object} [options.webtaskApi] - An object representing the api to be exposed for new webtask modules' as modules.webtask. 27 | * @param {Function} cb - The node-style callback with a signature of `function (err, webtaskFunction)`. 28 | */ 29 | function compile(code, options, cb) { 30 | if (typeof options === 'function') { 31 | cb = options; 32 | options = {}; 33 | } 34 | 35 | options = _.defaults({}, options, { 36 | dirname: process.cwd(), 37 | filename: 'webtask.js', 38 | logger: console, 39 | installModules: (specs, cb) => cb(new Error(`The code being compiled uses the "use npm" directive and the required modules '${specs.join(', ')}' are not available but no 'installModule' function was provided.`)), 40 | extraModulePaths: [], 41 | }); 42 | 43 | const logger = options.logger; 44 | const matches = code.match(RX_USE_DIRECTIVE); 45 | const use_latest = matches && matches[0].indexOf('latest') > 0; 46 | const use_npm = matches && matches[0].indexOf('npm') > 0; 47 | const pathname = Path.join(options.dirname, options.filename); 48 | 49 | // Check for UTF-8 BOM 50 | if (code.charCodeAt(0) === 0xFEFF) { 51 | code = code.slice(1); 52 | } 53 | 54 | if (use_latest) { 55 | try { 56 | code = require('babel-core').transform(code, { ast: false, plugins: ["transform-object-rest-spread"], presets:[ ["env", {"loose": true}] ] }).code; 57 | } catch (e) { 58 | const error = new Error(`Failed to compile code with "use latest" directive: ${e.toString()}`); 59 | 60 | error.code = 400; 61 | error.origin = e; 62 | 63 | return cb(error); 64 | } 65 | } 66 | 67 | const missing = []; 68 | const requireRe = /\brequire\b/; 69 | 70 | 71 | if (use_npm && requireRe.test(code)) { 72 | const Acorn = require('acorn'); 73 | const MagicString = require('magic-string'); 74 | const Walk = require('acorn/dist/walk'); 75 | 76 | const ast = Acorn.parse(code, { 77 | ecmaVersion: 6, 78 | allowHashBang: true, 79 | allowReserved: true, 80 | allowReturnOutsideFunction: true, 81 | }); 82 | const builder = new MagicString(code); 83 | const walker = Walk.make({ 84 | CallExpression: (node, state, recurse) => { 85 | Walk.base[node.type](node, state, recurse); 86 | 87 | if (node.callee.type === 'Identifier' 88 | && node.callee.name === 'require' 89 | && node.arguments.length 90 | && node.arguments[0].type === 'Literal' 91 | ) { 92 | const arg = node.arguments[0]; 93 | const spec = arg.value; 94 | 95 | if (spec[0] === '.' || spec[0] === '/') return; 96 | 97 | try { 98 | require(spec); 99 | } catch (__) { 100 | missing.push(spec); 101 | 102 | const versionStart = spec.indexOf('@'); 103 | 104 | if (versionStart >= 0) { 105 | builder.overwrite(arg.start, arg.end, `'${spec.slice(0, versionStart)}'`); 106 | } 107 | } 108 | } 109 | }, 110 | }); 111 | 112 | Walk.recursive(ast, null, walker); 113 | 114 | code = builder.toString(); 115 | } 116 | 117 | return Async.waterfall([ 118 | (next) => { 119 | if (!missing.length) return next(); 120 | 121 | logger.info('Your code requires the following modules that are not currently available: [' + missing.join(', ') + '].'); 122 | logger.info('Requiring modules that are not natively supported by the webtask platform will incur additional latency each time a new container is used. If you require low-latency and custom modules, we offer a managed webtask cluster option where you can customize the set of installed modules. Please contact us at support@webtask.io to discuss.'); 123 | logger.info(); 124 | logger.info('Installing: [' + missing.join(', ') + '] please wait...'); 125 | logger.info(); 126 | 127 | options.installModules(missing, (err) => { 128 | if (err) { 129 | logger.warn('An error was encountered while trying to install missing modules: ' + err.message); 130 | logger.warn('Attempting to run the webtask anyway...'); 131 | logger.warn(); 132 | } 133 | 134 | next(); 135 | }); 136 | }, 137 | (next) => { 138 | const Module = require('module'); 139 | const mod = new Module(pathname, module); 140 | let func; 141 | 142 | mod.webtask = options.webtaskApi; 143 | 144 | // Normally done as a part of Module#load 145 | mod.filename = pathname; 146 | mod.paths = module.constructor._nodeModulePaths(options.dirname) 147 | .concat(options.extraModulePaths); 148 | 149 | // Module#_compile will return the result of executing the code 150 | // which will be our function if the `return function () {}` syntax 151 | // is used. 152 | // Otherwise, we can look at `module.exports`. 153 | try { 154 | func = mod._compile(code, pathname) || mod.exports; 155 | 156 | mod.loaded = true; 157 | } catch (e) { 158 | if (e instanceof Error && e.message.indexOf('SyntaxError') === 0) { 159 | /* eslint no-console: 0 */ 160 | console.log(code); 161 | console.log(e.stack); 162 | } 163 | const error = Errors.create(400, `Unable to compile submitted JavaScript: ${e.toString()}`, e); 164 | 165 | return next(error); 166 | } 167 | 168 | return next(null, func); 169 | }, 170 | (webtaskFunction, next) => { 171 | let error; 172 | 173 | if (typeof webtaskFunction !== 'function') { 174 | error = new Error('Supplied code must return or export a function.'); 175 | } else if (webtaskFunction.length > 3 || webtaskFunction.length < 1) { 176 | error = new Error('The JavaScript function must have one of the following signatures: ([ctx], callback) or (ctx, req, res).'); 177 | } 178 | 179 | if (error) { 180 | error.message = `Unable to compile submitted JavaScript. ${error.toString()}`; 181 | error.code = 400; 182 | error.details = error.message; 183 | 184 | return next(error); 185 | } 186 | 187 | next(null, webtaskFunction); 188 | } 189 | ], cb); 190 | } 191 | -------------------------------------------------------------------------------- /lib/context.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Crypto = require('crypto'); 5 | const Jwt = require('jsonwebtoken'); 6 | const Querystring = require('querystring'); 7 | const Storage = require('./storage'); 8 | const Url = require('url'); 9 | const _ = require('lodash'); 10 | const Runtime = require('../'); 11 | 12 | 13 | module.exports = { 14 | create, 15 | prepareRequest, 16 | }; 17 | 18 | 19 | function create(options) { 20 | options = _.defaultsDeep({}, options, { 21 | body: undefined, 22 | container: 'webtask-local', 23 | createToken: not_implemented('create_token'), 24 | createTokenUrl: not_implemented('create_token_url'), 25 | headers: { 26 | 'content-type': 'application/json', 27 | }, 28 | initialStorageData: undefined, 29 | reqId: String(Date.now()), 30 | mergeBody: false, 31 | params: {}, 32 | parseBody: Runtime.PARSE_ON_ARITY, 33 | query: {}, 34 | secrets: {}, 35 | meta: {}, 36 | storage: undefined, 37 | token: undefined, 38 | signature: Crypto.randomBytes(32).toString('base64').slice(0, 32), 39 | }); 40 | 41 | if (options.headers) { 42 | options.headers = _.mapKeys(options.headers, (v, k) => k.toLowerCase()); 43 | } 44 | 45 | if (!options.storage) { 46 | options.storage = Storage.create(options.initialStorageData); 47 | } 48 | 49 | const context = { 50 | data: {}, 51 | headers: options.headers, 52 | id: options.reqId, 53 | params: {}, 54 | query: {}, 55 | secrets: {}, 56 | meta: {}, 57 | storage: options.storage, 58 | token: options.token, 59 | }; 60 | 61 | for (let i in options.query) { 62 | if (i.indexOf('webtask_') !== 0) { 63 | context.data[i] = options.query[i]; 64 | context.query[i] = options.query[i]; 65 | } 66 | } 67 | 68 | for (let k in options.params) { 69 | context.data[k] = context.params[k] = options.params[k]; 70 | } 71 | 72 | for (let k in options.secrets) { 73 | context.data[k] = context.secrets[k] = options.secrets[k]; 74 | } 75 | 76 | for (let k in options.meta) { 77 | context.meta[k] = options.meta[k]; 78 | } 79 | 80 | if (options.body) { 81 | context.body_raw = options.body; 82 | 83 | try { 84 | if (options.headers['content-type'].indexOf('application/x-www-form-urlencoded') >= 0) 85 | context.body = Querystring.parse(options.body); 86 | else if (options.headers['content-type'].indexOf('application/json') >= 0) 87 | context.body = JSON.parse(options.body); 88 | } 89 | catch (e) { 90 | // ignore 91 | } 92 | 93 | if (context.body && typeof context.body === 'object' && options.mergeBody) { 94 | for (let p in context.body) { 95 | if (!context.data[p]) { 96 | context.data[p] = context.body[p]; 97 | } 98 | } 99 | } 100 | } 101 | 102 | context.create_token = options.createToken; 103 | context.create_token_url = options.createTokenUrl; 104 | 105 | return context; 106 | } 107 | 108 | function prepareRequest(req, options) { 109 | options = _.defaultsDeep({}, options, { 110 | container: 'webtask-local', 111 | reqId: String(Date.now()), 112 | mergeBody: false, 113 | params: {}, 114 | parseBody: Runtime.PARSE_ON_ARITY, 115 | secrets: {}, 116 | meta: {}, 117 | token: undefined, 118 | signature: Crypto.randomBytes(32).toString('base64').slice(0, 32), 119 | url_format: 3, // Custom domain 120 | }); 121 | 122 | if (!options.token) { 123 | options.token = Jwt.sign({ 124 | ca: [], 125 | dd: 1, 126 | jti: Crypto.randomBytes(32).toString('base64').slice(0, 32), 127 | iat: Date.now(), 128 | ten: options.container, 129 | }, options.signature); 130 | } 131 | 132 | req.headers['x-wt-params'] = new Buffer.from(JSON.stringify({ 133 | container: options.container, 134 | mb: !!options.mergeBody, 135 | pctx: options.params, 136 | pb: +options.parseBody, // use integer or normalize boolean to 0 or 1 137 | req_id: options.reqId, 138 | ectx: options.secrets, 139 | meta: options.meta, 140 | token: options.token, 141 | url_format: options.url_format, 142 | })).toString('base64'); 143 | 144 | if (options.mergeBody || options.parseBody) { 145 | const parsedUrl = Url.parse(req.url, true); 146 | 147 | if (options.mergeBody) parsedUrl.query['webtask_mb'] = 1; 148 | if (options.parseBody) parsedUrl.query['webtask_pb'] = options.parseBody; 149 | 150 | // req.url = Url.format(parsedUrl).path; 151 | } 152 | } 153 | 154 | function not_implemented(api) { 155 | return function () { 156 | throw new Error(`The '${api}' method is not supported in this environment`); 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = { 5 | create, 6 | }; 7 | 8 | 9 | function create(code, description, source) { 10 | const error = new Error(); 11 | 12 | error.code = code; 13 | error.error = description; 14 | error.details = source instanceof Error ? source.toString() : source; 15 | error.name = source.name; 16 | error.message = source.message; 17 | error.stack = source.stack; 18 | 19 | return error; 20 | } 21 | -------------------------------------------------------------------------------- /lib/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Async = require('async'); 5 | const Request = require('./request'); 6 | const Runner = require('./runner'); 7 | const _ = require('lodash'); 8 | 9 | 10 | module.exports = { 11 | create, 12 | }; 13 | 14 | 15 | function create(webtaskFunction, options) { 16 | options = _.defaults({}, options, { 17 | container: undefined, // Use context default 18 | context: undefined, 19 | createStorage: undefined, 20 | logger: console, 21 | storage: undefined, 22 | token: undefined, // Use context default 23 | writeHead: (res, code, headers) => res.writeHead(code, headers), 24 | shortcutFavicon: false, 25 | }); 26 | 27 | return handleRequest; 28 | 29 | 30 | function handleRequest(req, res) { 31 | if (options.shortcutFavicon && req.url === '/favicon.ico') { 32 | options.writeHead(res, 404, {}); 33 | return res.end(); 34 | } 35 | 36 | // This is designed for local testing so we are not particularly concerned 37 | // about letting browsers cache pre-flight requests 38 | 39 | if (req.method.toLowerCase() === 'options' && req.headers['access-control-request-method']) { 40 | res.writeHead(200, { 41 | 'access-control-allow-origin': req.headers['origin'], 42 | 'access-control-allow-methods': req.headers['access-control-request-method'], 43 | 'access-control-allow-headers': req.headers['access-control-request-headers'], 44 | 'access-control-allow-credentials': true, 45 | 'access-control-max-age': 1000 * 60, 46 | }); 47 | 48 | return res.end(); 49 | } 50 | 51 | 52 | Async.series([ 53 | (next) => Request.installEndListener(req, next), 54 | (next) => Request.prepareRequest(req, next), 55 | (next) => maybeReadBody(req, options, next), 56 | (next) => Runner.run(webtaskFunction, req, res, options, next), 57 | ], err => { 58 | if (err) { 59 | if (!isNaN(err.code)) { 60 | const body = JSON.stringify(err, null, 2); 61 | 62 | options.logger.info(body); 63 | 64 | return Request.ensureRequestConsumed(req, () => { 65 | options.writeHead(res, err.code, { 66 | 'Content-Type': 'application/json', 67 | }); 68 | 69 | res.end(body); 70 | }); 71 | } 72 | 73 | const body = JSON.stringify({ 74 | code: 500, 75 | error: 'Server error', 76 | details: err.toString() 77 | }, null, 2); 78 | 79 | options.logger.info(body); 80 | 81 | return Request.ensureRequestConsumed(req, () => { 82 | options.writeHead(res, 500, { 83 | 'Content-Type': 'application/json', 84 | }); 85 | 86 | res.end(body); 87 | }); 88 | } 89 | }); 90 | } 91 | } 92 | 93 | 94 | function maybeReadBody(req, options, cb) { 95 | if (!req.x_wt.pb) { 96 | return cb(); 97 | } 98 | 99 | return Request.readBody(req, options, cb); 100 | } 101 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Errors = require('./errors'); 5 | const Url = require('url'); 6 | const _ = require('lodash'); 7 | 8 | 9 | module.exports = { 10 | ensureRequestConsumed, 11 | installEndListener, 12 | readBody, 13 | prepareRequest, 14 | }; 15 | 16 | 17 | function ensureRequestConsumed(req, cb) { 18 | if (req._ended) return cb(); 19 | 20 | req.once('end', cb); 21 | req.on('data', function () {}); 22 | req.resume(); 23 | } 24 | 25 | 26 | function installEndListener(req, cb) { 27 | req.once('end', () => { 28 | req._ended = true; 29 | }); 30 | 31 | cb && cb(); 32 | } 33 | 34 | function prepareRequest(req, cb) { 35 | req.query = Url.parse(req.url, true).query; 36 | 37 | req.x_wt = req.headers['x-wt-params'] 38 | ? JSON.parse((new Buffer.from(req.headers['x-wt-params'], 'base64'))) 39 | : { 40 | container: '', 41 | }; 42 | 43 | if (req.query.webtask_mb) 44 | req.x_wt.mb = 1; 45 | 46 | if (req.query.webtask_pb) 47 | req.x_wt.pb = 1; 48 | 49 | cb && cb(); 50 | } 51 | 52 | function readBody(req, options, cb) { 53 | options = _.defaults({}, { 54 | maxBodySize: undefined, 55 | }, options); 56 | 57 | const chunks = []; 58 | let length = 0; 59 | 60 | req.on('data', (chunk) => { 61 | chunks.push(chunk); 62 | length += chunk.length; 63 | 64 | if (typeof options.maxBodySize === 'number' && length > options.maxBodySize) { 65 | req.removeAllListeners('data'); 66 | req.removeAllListeners('end'); 67 | 68 | const error = new Error(`Script exceeds the size limit of ${options.maxBodySize}.`); 69 | 70 | return cb(Errors.create(400, 'Script exceeds the size limit.', error)); 71 | } 72 | }); 73 | 74 | req.on('end', () => { 75 | length; options; 76 | req.raw_body = Buffer.concat(chunks).toString('utf8'); 77 | 78 | cb(null, req.raw_body); 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /lib/runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Context = require('./context'); 5 | const Errors = require('./errors'); 6 | const Request = require('./request'); 7 | const Storage = require('./storage'); 8 | 9 | 10 | module.exports = { 11 | run, 12 | }; 13 | 14 | 15 | function run(webtaskFunction, req, res, options, cb) { 16 | const args = []; 17 | const webtaskContext = options.context || createContext(req, { 18 | body: req.body, 19 | container: options.container, 20 | headers: req.headers, 21 | reqId: req.x_wt.req_id, 22 | mergeBody: req.x_wt.mb, 23 | params: req.x_wt.pctx, 24 | parseBody: req.x_wt.pb, 25 | query: req.query, 26 | secrets: req.x_wt.ectx, 27 | meta: req.x_wt.meta, 28 | storage: options.storage, 29 | storageFile: options.storageFile, 30 | token: options.token, 31 | signature: options.signature, 32 | }); 33 | 34 | if (webtaskFunction.length === 3) { 35 | 36 | // (ctx, req, res) 37 | 38 | args.push(webtaskContext); 39 | args.push(req); 40 | args.push(res); 41 | } 42 | else { 43 | 44 | // ([ctx], callback) 45 | 46 | if (webtaskFunction.length === 2) { 47 | // Function accepts context parameter - create it and add to arguments 48 | args.push(webtaskContext); 49 | } 50 | 51 | args.push(function (err, data) { 52 | if (err) { 53 | return cb(Errors.create(400, 'Script returned an error.', err)); 54 | } 55 | 56 | try { 57 | const body = data ? JSON.stringify(data) : '{}'; 58 | 59 | return Request.ensureRequestConsumed(req, () => { 60 | options.writeHead(res, 200, { 61 | 'Content-Type': 'application/json', 62 | }); 63 | 64 | res.end(body); 65 | 66 | return cb(); 67 | }); 68 | } 69 | catch (e) { 70 | return cb(Errors.create(400, 'Error when JSON serializing the result of the JavaScript code.', e)); 71 | } 72 | }); 73 | } 74 | 75 | // Invoke the function 76 | 77 | try { 78 | return webtaskFunction.apply(this, args); 79 | } 80 | catch (e) { 81 | try { 82 | return cb(Errors.create(500, 'Script generated an unhandled synchronous exception.', e)); 83 | } 84 | catch (e1) { 85 | // ignore 86 | } 87 | 88 | // terminate the process 89 | throw e; 90 | } 91 | } 92 | 93 | function createContext(req, options) { 94 | // If no createStorage function is provided, use the default storage 95 | // provided by the context api 96 | const storage = typeof options.createStorage === 'function' 97 | ? options.createStorage(req, options) 98 | : options.storage || Storage.create(options.initialStorageData, options); 99 | 100 | return Context.create({ 101 | body: req.raw_body, 102 | container: options.container, 103 | headers: req.headers, 104 | mergeBody: req.x_wt.mb, 105 | params: req.x_wt.pctx, 106 | query: req.query, 107 | secrets: req.x_wt.ectx, 108 | meta: req.x_wt.meta, 109 | storage, 110 | token: options.token, 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Assert = require('assert'); 5 | const Compiler = require('./compiler'); 6 | const Context = require('./context'); 7 | const Handler = require('./handler'); 8 | const Http = require('http'); 9 | const Storage = require('./storage'); 10 | const _ = require('lodash'); 11 | const Runtime = require('../'); 12 | 13 | 14 | module.exports = { 15 | create, 16 | }; 17 | 18 | 19 | function create(codeOrWebtaskFunction, options) { 20 | options = _.defaults({}, options, { 21 | initialStorageData: undefined, 22 | logger: console, 23 | mergeBody: false, 24 | params: {}, 25 | parseBody: Runtime.PARSE_ON_ARITY, 26 | secrets: {}, 27 | signature: undefined, 28 | token: undefined, 29 | meta: {} 30 | }); 31 | 32 | if (!options.storage) { 33 | options.storage = Storage.create(options.initialStorageData, options); 34 | } 35 | 36 | // Queue to handle multiple requests coming in before the webtask 37 | // function is provisioned (async operation). 38 | const requestQueue = []; 39 | let requestHandler; 40 | let provisioningRequestHandler = false; 41 | 42 | return new Http.Server((req, res) => { 43 | Context.prepareRequest(req, options); 44 | 45 | if (!requestHandler) { 46 | if (!requestHandler) { 47 | requestQueue.push({ req, res }); 48 | } 49 | 50 | if (!provisioningRequestHandler) { 51 | provisioningRequestHandler = true; 52 | 53 | if (typeof codeOrWebtaskFunction === 'string') { 54 | const storage = Storage.create(options.initialStorageData); 55 | 56 | options.webtaskApi = { 57 | storage, 58 | meta: options.meta, 59 | params: options.params, 60 | secrets: options.secrets 61 | }; 62 | 63 | return Compiler.compile(codeOrWebtaskFunction, options, (err, webtaskFunction) => { 64 | // Failure to provision the webtask function is fatal 65 | if (err) { 66 | 67 | // Drain the queue 68 | while (requestQueue.length) { 69 | // Handle items in FIFO order 70 | const missed = requestQueue.shift(); 71 | 72 | missed.res.writeHead(err.code, { 'Content-Type': 'application/json' }); 73 | missed.res.end(err); 74 | } 75 | 76 | return; 77 | } 78 | 79 | requestHandler = Handler.create(webtaskFunction, options); 80 | 81 | // Drain the queue 82 | while (requestQueue.length) { 83 | // Handle items in FIFO order 84 | const missed = requestQueue.shift(); 85 | 86 | requestHandler(missed.req, missed.res); 87 | } 88 | }); 89 | } 90 | 91 | Assert.equal(typeof codeOrWebtaskFunction, 'function', 'A webtask function, or code that either exports or returns a webtask, is required.'); 92 | 93 | // Transform the webtask function into a handler 94 | requestHandler = Handler.create(codeOrWebtaskFunction, options); 95 | } 96 | } 97 | 98 | return requestHandler(req, res); 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /lib/simulator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Compiler = require('./compiler'); 5 | const Context = require('./context'); 6 | const Handler = require('./handler'); 7 | const Shot = require('shot'); 8 | const Storage = require('./storage'); 9 | const Url = require('url'); 10 | const _ = require('lodash'); 11 | 12 | 13 | module.exports = { 14 | simulate, 15 | }; 16 | 17 | 18 | function simulate(codeOrWebtaskFunction, options, cb) { 19 | if (typeof options === 'function') { 20 | cb = options; 21 | options = {}; 22 | } 23 | 24 | options = _.defaults({}, options, { 25 | method: 'GET', 26 | url: '/', 27 | query: {}, 28 | authority: undefined, 29 | headers: undefined, 30 | initialStorageData: undefined, 31 | logger: console, 32 | remoteAddress: undefined, 33 | meta: {}, 34 | params: {}, 35 | payload: undefined, 36 | secrets: {} 37 | }); 38 | 39 | const parsedUrl = Url.parse(options.url, true); 40 | 41 | _.extend(parsedUrl.query, options.query); 42 | 43 | const url = Url.format(parsedUrl); 44 | 45 | return Shot.inject(requestHandler, { 46 | url, 47 | method: options.method, 48 | authority: options.authority, 49 | headers: options.headers, 50 | remoteAddress: options.remoteAddress, 51 | payload: options.payload, 52 | }, cb); 53 | 54 | 55 | function requestHandler(req, res) { 56 | Context.prepareRequest(req, options); 57 | 58 | if (typeof codeOrWebtaskFunction === 'function') { 59 | const handleRequest = Handler.create(codeOrWebtaskFunction, options); 60 | 61 | return handleRequest(req, res); 62 | } 63 | 64 | if (typeof codeOrWebtaskFunction === 'string') { 65 | const storage = Storage.create(options.initialStorageData); 66 | 67 | options.webtaskApi = { 68 | storage, 69 | meta: options.meta, 70 | params: options.params, 71 | secrets: options.secrets 72 | }; 73 | 74 | return Compiler.compile(codeOrWebtaskFunction, options, (err, webtaskFunction) => { 75 | // Failure to provision the webtask function is fatal 76 | if (err) { 77 | res.writeHead(err.code, { 'Content-Type': 'application/json' }); 78 | res.end(JSON.stringify(err, null, 2)); 79 | 80 | return; 81 | } 82 | 83 | const handleRequest = Handler.create(webtaskFunction, options); 84 | 85 | return handleRequest(req, res); 86 | }); 87 | } 88 | 89 | throw new Error('The supplied webtaskFunction must be code that exports or returns a function or a function'); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/storage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Crypto = require('crypto'); 4 | const Fs = require('fs'); 5 | 6 | 7 | module.exports = { 8 | create, 9 | }; 10 | 11 | class Storage { 12 | constructor(initialData, runtimeOptions) { 13 | this._data = undefined; 14 | this._etag = undefined; 15 | 16 | if (!initialData && runtimeOptions && runtimeOptions.storageFile) { 17 | this._storageFile = runtimeOptions.storageFile; 18 | 19 | try { 20 | if (Fs.statSync(runtimeOptions.storageFile).isFile()) { 21 | initialData = JSON.parse(Fs.readFileSync(runtimeOptions.storageFile, 'utf8')); 22 | } 23 | } catch (e) { 24 | // File does not exist. 25 | } 26 | } 27 | 28 | if (initialData) { 29 | // The noop must be passed otherwise set will assume that that 2nd argument is the cb 30 | this.set(initialData, { 31 | force: true, 32 | }, function noop() {}); 33 | } 34 | } 35 | 36 | get data() { 37 | return this._data; 38 | } 39 | 40 | get etag() { 41 | return this._etag; 42 | } 43 | 44 | get(options, cb) { 45 | if (!cb) { 46 | cb = options; 47 | options = {}; 48 | } 49 | 50 | if (typeof cb !== 'function') { 51 | throw new Error('Callback must be a function.'); 52 | } 53 | 54 | if (typeof options !== 'object') { 55 | throw new Error('Options must be an object.'); 56 | } 57 | 58 | if (typeof this._data === 'undefined') { 59 | // Signal that the initial read has been done 60 | this._etag = null; 61 | } 62 | 63 | cb(null, this._data); 64 | } 65 | 66 | set(data, options, cb) { 67 | if (!cb) { 68 | cb = options; 69 | options = {}; 70 | } 71 | 72 | if (cb && typeof cb !== 'function') { 73 | throw new Error('Callback must be a function.'); 74 | } 75 | 76 | if (typeof options !== 'object') { 77 | throw new Error('Options must be an object.'); 78 | } 79 | 80 | if (data !== undefined && data !== null && this._etag === undefined && !options.force) { 81 | throw new Error('When calling storage.set without having called storage.get before, you must specify the .force option.'); 82 | } 83 | 84 | if (data === undefined || data === null) { 85 | this._data = undefined; 86 | this._etag = undefined; 87 | } 88 | else { 89 | if (options.etag !== undefined) { 90 | if (options.etag === null && typeof this._data !== 'undefined' || options.etag !== null && this._etag !== options.etag) { 91 | const err = new Error('Item was modified since it was read.'); 92 | err.conflict = this._data; 93 | return cb && cb(err); 94 | } 95 | } 96 | 97 | // TODO: Under what conditions is options.create_etag *NOT* set? 98 | this._data = data; 99 | this._etag = Crypto.createHash('md5').update(JSON.stringify(data)).digest('base64'); 100 | } 101 | 102 | if (this._storageFile) { 103 | return Fs.writeFile(this._storageFile, JSON.stringify(this._data, null, 2), function (err) { 104 | if (err) { 105 | return cb && cb(err); 106 | } 107 | 108 | return cb && cb(); 109 | }); 110 | } 111 | 112 | return cb && cb(); 113 | } 114 | } 115 | 116 | function create(initialData, runtimeOptions) { 117 | return new Storage(initialData, runtimeOptions); 118 | } 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webtask-runtime", 3 | "version": "2.0.0", 4 | "description": "Runtime components of the webtask.io sandbox", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "acorn": "^5.6.2", 11 | "async": "^2.0.0-rc.5", 12 | "babel-core": "6.26.3", 13 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 14 | "babel-preset-env": "^1.7.0", 15 | "jsonwebtoken": "^8.3.0", 16 | "lodash": "^4.17.10", 17 | "magic-string": "^0.25.0", 18 | "shot": "^3.4.2" 19 | }, 20 | "devDependencies": { 21 | "code": "^4.1.0", 22 | "eslint": "^4.19.1", 23 | "lab": "^14.3.4", 24 | "request": "^2.87.0" 25 | }, 26 | "scripts": { 27 | "test": "lab -cvL -t 0%" 28 | }, 29 | "engines": { 30 | "node": ">=6.9.0" 31 | }, 32 | "keywords": [], 33 | "author": "", 34 | "license": "ISC" 35 | } 36 | -------------------------------------------------------------------------------- /test/compilation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Code = require('code'); 5 | const Fs = require('fs'); 6 | const Lab = require('lab'); 7 | const Path = require('path'); 8 | const Runtime = require('../'); 9 | 10 | const lab = exports.lab = Lab.script(); 11 | const expect = Code.expect; 12 | 13 | 14 | lab.experiment('Webtask compilation', () => { 15 | 16 | lab.test('will compile an exported webtask (1-argument)', done => { 17 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_1.js'), 'utf8'); 18 | 19 | Runtime.compile(code, (err, webtaskFunction) => { 20 | expect(err).to.be.null(); 21 | expect(webtaskFunction).to.be.a.function(); 22 | expect(webtaskFunction.length).to.equal(1); 23 | 24 | done(); 25 | }); 26 | }); 27 | 28 | lab.test('will compile an exported webtask (2-argument)', done => { 29 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_2.js'), 'utf8'); 30 | 31 | Runtime.compile(code, (err, webtaskFunction) => { 32 | expect(err).to.be.null(); 33 | expect(webtaskFunction).to.be.a.function(); 34 | expect(webtaskFunction.length).to.equal(2); 35 | 36 | done(); 37 | }); 38 | }); 39 | 40 | lab.test('will compile an exported webtask (3-argument)', done => { 41 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_3.js'), 'utf8'); 42 | 43 | Runtime.compile(code, (err, webtaskFunction) => { 44 | expect(err).to.be.null(); 45 | expect(webtaskFunction).to.be.a.function(); 46 | expect(webtaskFunction.length).to.equal(3); 47 | 48 | done(); 49 | }); 50 | }); 51 | 52 | lab.test('will fail to compile an exported webtask (4-argument)', done => { 53 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_4.js'), 'utf8'); 54 | 55 | Runtime.compile(code, (err, webtaskFunction) => { 56 | expect(err).to.be.an.error(); 57 | expect(err.message).to.be.a.string().and.contain('The JavaScript function must have one of the following signatures'); 58 | expect(webtaskFunction).to.be.undefined(); 59 | 60 | done(); 61 | }); 62 | }); 63 | 64 | lab.test('will compile a returned webtask function (1-argument)', done => { 65 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'returns_hello_world_1.js'), 'utf8'); 66 | 67 | Runtime.compile(code, (err, webtaskFunction) => { 68 | expect(err).to.be.null(); 69 | expect(webtaskFunction).to.be.a.function(); 70 | expect(webtaskFunction.length).to.equal(1); 71 | 72 | done(); 73 | }); 74 | }); 75 | 76 | lab.test('will compile a returned webtask function (2-argument)', done => { 77 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'returns_hello_world_2.js'), 'utf8'); 78 | 79 | Runtime.compile(code, (err, webtaskFunction) => { 80 | expect(err).to.be.null(); 81 | expect(webtaskFunction).to.be.a.function(); 82 | expect(webtaskFunction.length).to.equal(2); 83 | 84 | done(); 85 | }); 86 | }); 87 | 88 | lab.test('will compile a returned webtask function (3-argument)', done => { 89 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'returns_hello_world_3.js'), 'utf8'); 90 | 91 | Runtime.compile(code, (err, webtaskFunction) => { 92 | expect(err).to.be.null(); 93 | expect(webtaskFunction).to.be.a.function(); 94 | expect(webtaskFunction.length).to.equal(3); 95 | 96 | done(); 97 | }); 98 | }); 99 | 100 | lab.test('will fail to compile a webtask exporting junk', done => { 101 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_junk.js'), 'utf8'); 102 | 103 | Runtime.compile(code, (err, webtaskFunction) => { 104 | expect(err).to.be.an.error(); 105 | expect(err.message).to.be.a.string().and.contain('Supplied code must return or export a function'); 106 | expect(webtaskFunction).to.be.undefined(); 107 | 108 | done(); 109 | }); 110 | }); 111 | 112 | lab.experiment('The "use npm" directive', () => { 113 | 114 | const logger = { 115 | info: () => null, 116 | warn: () => null, 117 | error: () => null, 118 | }; 119 | 120 | lab.test('will fail when the `installModule` callback is not provided', done => { 121 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'use_npm_bogus.js'), 'utf8'); 122 | 123 | Runtime.compile(code, { logger }, (err, webtaskFunction) => { 124 | expect(err).to.be.an.error(); 125 | expect(err.message).to.be.a.string().and.contain('Cannot find module \'bogus\''); 126 | expect(webtaskFunction).to.be.undefined(); 127 | 128 | done(); 129 | }); 130 | }); 131 | 132 | lab.test('will cause the `installModule` callback to be invoked', done => { 133 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'use_npm_bogus.js'), 'utf8'); 134 | const requested = []; 135 | const installModules = (specs, cb) => { 136 | requested.push.apply(requested, specs); 137 | 138 | cb(); 139 | }; 140 | 141 | Runtime.compile(code, { installModules, logger }, (err, webtaskFunction) => { 142 | expect(err).to.be.an.error(); 143 | expect(err.message).to.be.a.string().and.contain('Cannot find module \'bogus\''); 144 | expect(webtaskFunction).to.be.undefined(); 145 | expect(requested).to.be.an.array().and.contain('bogus'); 146 | 147 | done(); 148 | }); 149 | }); 150 | 151 | lab.test('will cause the `installModule` callback to be invoked for nested expressions', done => { 152 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'use_npm_expression.js'), 'utf8'); 153 | const requested = []; 154 | const installModules = (specs, cb) => { 155 | requested.push.apply(requested, specs); 156 | 157 | cb(); 158 | }; 159 | 160 | Runtime.compile(code, { installModules, logger }, (err, webtaskFunction) => { 161 | expect(err).to.be.an.error(); 162 | expect(err.message).to.be.a.string().and.contain('Cannot find module \'expression\''); 163 | expect(webtaskFunction).to.be.undefined(); 164 | expect(requested).to.be.an.array().and.contain('expression'); 165 | 166 | done(); 167 | }); 168 | }); 169 | 170 | lab.test('will not invoke the `installModule` callback if the directive is modified', done => { 171 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'use_npm_bogus.js'), 'utf8') 172 | .replace('"use npm"', '"use astrology"'); 173 | const requested = []; 174 | const installModule = (spec, cb) => { 175 | requested.push(spec); 176 | 177 | cb(); 178 | }; 179 | 180 | Runtime.compile(code, { installModule, logger }, (err, webtaskFunction) => { 181 | expect(err).to.be.an.error(); 182 | expect(err.message).to.be.a.string().and.contain('Cannot find module \'bogus\''); 183 | expect(webtaskFunction).to.be.undefined(); 184 | expect(requested).to.be.an.array().and.be.empty(); 185 | 186 | done(); 187 | }); 188 | }); 189 | 190 | lab.test('will rewrite the source code for version-specific requires', done => { 191 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'use_npm_with_version.js'), 'utf8'); 192 | const requested = []; 193 | const installModules = (specs, cb) => { 194 | requested.push.apply(requested, specs); 195 | 196 | cb(); 197 | }; 198 | 199 | Runtime.compile(code, { installModules, logger }, (err, webtaskFunction) => { 200 | expect(err).to.be.an.error(); 201 | expect(err.message).to.be.a.string().and.contain('Cannot find module \'bogus\''); 202 | expect(webtaskFunction).to.be.undefined(); 203 | expect(requested).to.be.an.array().and.contain('bogus@1.0.0'); 204 | 205 | done(); 206 | }); 207 | }); 208 | }); 209 | }); 210 | 211 | if (require.main === module) { 212 | Lab.report([lab], { output: process.stdout, progress: 2 }); 213 | } -------------------------------------------------------------------------------- /test/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Code = require('code'); 5 | const Lab = require('lab'); 6 | const Runtime = require('../'); 7 | 8 | const lab = exports.lab = Lab.script(); 9 | const describe = lab.describe; 10 | const it = lab.it; 11 | const expect = Code.expect; 12 | 13 | 14 | describe('Sandbox programming model', () => { 15 | 16 | const logger = { 17 | info: () => null, 18 | warn: () => null, 19 | error: () => null, 20 | }; 21 | 22 | it('will correctly respond to webtask_pb=1 in the query parameters', done => { 23 | const code = (ctx, cb) => cb(null, ctx.body); 24 | const payload = { hello: 'world' }; 25 | 26 | Runtime.simulate(code, { logger, parseBody: true, payload, method: 'POST' }, (res) => { 27 | expect(res.statusCode).to.equal(200); 28 | expect(res.payload).to.be.a.string(); 29 | 30 | const json = JSON.parse(res.payload); 31 | 32 | expect(json).to.equal({ hello: 'world' }); 33 | 34 | done(); 35 | }); 36 | }); 37 | 38 | it('fails with malformed javascript', done => { 39 | const code = 'malformed javascript'; 40 | 41 | Runtime.simulate(code, { logger }, (res) => { 42 | expect(res.statusCode).to.equal(400); 43 | expect(res.payload).to.be.a.string().and.contain('Unable to compile submitted JavaScript'); 44 | 45 | done(); 46 | }); 47 | }); 48 | 49 | it('fails with requests that are too large', done => { 50 | const code = (ctx, cb) => cb(null, ctx.raw_body); 51 | const maxBodySize = 100; 52 | const payload = '0'.repeat(500 * 1024); 53 | 54 | Runtime.simulate(code, { logger, maxBodySize, parseBody: true, payload, method: 'POST' }, (res) => { 55 | expect(res.statusCode).to.equal(400); 56 | expect(res.payload).to.be.a.string().and.contain('Script exceeds the size limit'); 57 | 58 | done(); 59 | }); 60 | }); 61 | 62 | it('exposes secrets using the module.webtask api', done => { 63 | const code = ` 64 | const secrets = module.webtask.secrets; 65 | 66 | module.exports = (cb) => cb(null, { secrets }); 67 | `; 68 | const secrets = { key: 'value' }; 69 | 70 | Runtime.simulate(code, { logger, secrets, parseBody: true }, (res) => { 71 | expect(res.statusCode).to.equal(200); 72 | expect(res.payload).to.equal(JSON.stringify({ secrets })); 73 | 74 | done(); 75 | }); 76 | }); 77 | 78 | it('exposes meta using the module.webtask api', done => { 79 | const code = ` 80 | const meta = module.webtask.meta; 81 | 82 | module.exports = (cb) => cb(null, { meta }); 83 | `; 84 | const meta = { key: 'value' }; 85 | 86 | Runtime.simulate(code, { logger, meta, parseBody: true }, (res) => { 87 | expect(res.statusCode).to.equal(200); 88 | expect(res.payload).to.equal(JSON.stringify({ meta })); 89 | 90 | done(); 91 | }); 92 | }); 93 | }); 94 | 95 | if (require.main === module) { 96 | Lab.report([lab], { output: process.stdout, progress: 2 }); 97 | } 98 | -------------------------------------------------------------------------------- /test/serving.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Code = require('code'); 5 | const Fs = require('fs'); 6 | const Lab = require('lab'); 7 | const Path = require('path'); 8 | const Request = require('request'); 9 | const Runtime = require('../'); 10 | 11 | const lab = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | lab.experiment('Local webtask server', () => { 16 | 17 | const logger = { 18 | info: () => null, 19 | warn: () => null, 20 | error: () => null, 21 | }; 22 | let server; 23 | 24 | lab.afterEach(done => { 25 | server && server.listening 26 | ? server.close(done) 27 | : done(); 28 | }); 29 | 30 | lab.test('will run an exported webtask (1-argument)', done => { 31 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_1.js'), 'utf8'); 32 | server = Runtime.createServer(code, { logger }); 33 | 34 | server.listen(3001); 35 | 36 | Request.get('http://localhost:3001', { json: false }, (err, res, body) => { 37 | server.close(done); 38 | 39 | expect(err).to.be.null(); 40 | expect(res.statusCode).to.equal(200); 41 | expect(body).to.equal('"hello world"'); 42 | }); 43 | }); 44 | 45 | lab.test('will run an exported webtask (2-argument)', done => { 46 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_2.js'), 'utf8'); 47 | server = Runtime.createServer(code, { logger }); 48 | 49 | server.listen(3001); 50 | 51 | Request.get('http://localhost:3001', { json: false }, (err, res, body) => { 52 | server.close(done); 53 | 54 | expect(err).to.be.null(); 55 | expect(res.statusCode).to.equal(200); 56 | expect(body).to.equal('"hello world"'); 57 | }); 58 | }); 59 | 60 | lab.test('will run an exported webtask (3-argument)', done => { 61 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_3.js'), 'utf8'); 62 | server = Runtime.createServer(code, { logger }); 63 | 64 | server.listen(3001); 65 | 66 | Request.get('http://localhost:3001', { json: false }, (err, res, body) => { 67 | server.close(done); 68 | 69 | expect(err).to.be.null(); 70 | expect(res.statusCode).to.equal(200); 71 | expect(body).to.equal('"hello world"'); 72 | }); 73 | }); 74 | 75 | lab.test('supports the module.webtask api when serving webtasks', done => { 76 | const code = ` 77 | const secrets = module.webtask.secrets; 78 | const meta = module.webtask.meta; 79 | 80 | module.exports = (cb) => cb(null, { secrets, meta }); 81 | `; 82 | const secrets = { key: 'value' }; 83 | const meta = { key: 'value' }; 84 | server = Runtime.createServer(code, { logger, secrets, meta }); 85 | 86 | server.listen(3001); 87 | 88 | Request.get('http://localhost:3001', { json: false }, (err, res, body) => { 89 | server.close(done); 90 | 91 | expect(err).to.be.null(); 92 | expect(res.statusCode).to.equal(200); 93 | expect(body).to.equal(JSON.stringify({ secrets, meta })); 94 | }); 95 | }); 96 | 97 | lab.test('will only run a webtask once per request', done => { 98 | let count = 0; 99 | 100 | const webtaskFn = (cb) => { count++; cb(null, count); }; 101 | server = Runtime.createServer(webtaskFn, { logger }); 102 | 103 | server.listen(3001); 104 | 105 | Request.get('http://localhost:3001', { json: false }, (err, res, body) => { 106 | server.close(done); 107 | 108 | expect(err).to.be.null(); 109 | expect(res.statusCode).to.equal(200); 110 | expect(body).to.equal("1"); 111 | expect(count).to.equal(1); 112 | }); 113 | }); 114 | }); 115 | 116 | if (require.main === module) { 117 | Lab.report([lab], { output: process.stdout, progress: 2 }); 118 | } 119 | -------------------------------------------------------------------------------- /test/simulation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Code = require('code'); 5 | const Fs = require('fs'); 6 | const Lab = require('lab'); 7 | const Path = require('path'); 8 | const Runtime = require('../'); 9 | 10 | const lab = exports.lab = Lab.script(); 11 | const expect = Code.expect; 12 | 13 | 14 | lab.experiment('Simulation of mock requests', () => { 15 | 16 | const logger = { 17 | info: () => null, 18 | warn: () => null, 19 | error: () => null, 20 | }; 21 | 22 | lab.test('will execute basic requests (1 argument)', done => { 23 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_1.js'), 'utf8'); 24 | 25 | Runtime.simulate(code, { logger }, (res) => { 26 | expect(res.statusCode).to.equal(200); 27 | expect(res.payload).to.equal('"hello world"'); 28 | 29 | done(); 30 | }); 31 | }); 32 | 33 | lab.test('will execute basic requests (2 argument)', done => { 34 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_2.js'), 'utf8'); 35 | 36 | Runtime.simulate(code, { logger }, (res) => { 37 | expect(res.statusCode).to.equal(200); 38 | expect(res.payload).to.equal('"hello world"'); 39 | 40 | done(); 41 | }); 42 | }); 43 | 44 | lab.test('will execute basic requests (3 argument)', done => { 45 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'exports_hello_world_3.js'), 'utf8'); 46 | 47 | Runtime.simulate(code, { logger }, (res) => { 48 | expect(res.statusCode).to.equal(200); 49 | expect(res.payload).to.equal('"hello world"'); 50 | 51 | done(); 52 | }); 53 | }); 54 | 55 | lab.test('will correctly simulate a POST request with a JSON body (parseBody: false)', done => { 56 | const code = Fs.readFileSync(Path.join(__dirname, '..', 'fixtures', 'echo_server.js'), 'utf8'); 57 | const options = { 58 | logger, 59 | url: '/simulation', 60 | method: 'POST', 61 | query: { hello: 'world' }, 62 | payload: { goodbye: 'moon' }, 63 | secrets: { something: 'private' }, 64 | params: { something: 'public' }, 65 | meta: { something: 'meta' } 66 | }; 67 | 68 | Runtime.simulate(code, options, (res) => { 69 | expect(res.statusCode).to.equal(200); 70 | expect(res.payload).to.be.a.string(); 71 | 72 | const payload = JSON.parse(res.payload); 73 | 74 | expect(payload.url).to.equal('/simulation?hello=world'); 75 | expect(payload.method).to.equal('POST'); 76 | expect(payload.query).to.equal({ hello: 'world' }); 77 | expect(payload.body).to.be.undefined(); // parseBody is false 78 | expect(payload.secrets).to.equal({ something: 'private' }); 79 | expect(payload.params).to.equal({ something: 'public' }); 80 | expect(payload.meta).to.equal({ something: 'meta' }); 81 | 82 | done(); 83 | }); 84 | }); 85 | }); 86 | 87 | if (require.main === module) { 88 | Lab.report([lab], { output: process.stdout, progress: 2 }); 89 | } -------------------------------------------------------------------------------- /test/storage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const Code = require('code'); 5 | const Fs = require('fs'); 6 | const Lab = require('lab'); 7 | const Path = require('path'); 8 | const Request = require('request'); 9 | const Runtime = require('../'); 10 | 11 | const lab = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | lab.experiment('Storage APIs', () => { 16 | 17 | const logger = { 18 | info: () => null, 19 | warn: () => null, 20 | error: () => null, 21 | }; 22 | 23 | let server; 24 | 25 | lab.afterEach(done => { 26 | Fs.writeFileSync(Path.join(__dirname, '../fixtures/data_clicks.json'), JSON.stringify({ 27 | "foo": "bar", 28 | "totalClicks": 12345 29 | }, null, 2), 'utf8'); 30 | 31 | server 32 | ? server.close(() => done()) 33 | : done(); 34 | }); 35 | 36 | lab.test('will start with undefined value and undefined etag', { timeout: 0 }, done => { 37 | server = Runtime.createServer(storageSetQuery, { logger }); 38 | 39 | server.listen(3001); 40 | 41 | Request.get('http://localhost:3001', { json: true, qs: { store: 'this' } }, (err, res, body) => { 42 | expect(err).to.be.null(); 43 | expect(res.statusCode).to.equal(200); 44 | expect(body).to.be.an.object(); 45 | expect(body.initialEtag).to.be.undefined(); 46 | expect(body.afterReadEtag).to.be.null(); 47 | expect(body.etag).to.be.a.string(); 48 | expect(body.data).to.be.undefined(); 49 | expect(body.initialEtag).to.not.equal(body.afterReadEtag); 50 | expect(body.afterReadEtag).to.not.equal(body.etag); 51 | 52 | const afterWriteEtag = body.etag; 53 | 54 | Request.get('http://localhost:3001', { json: true }, (err, res, body) => { 55 | expect(err).to.be.null(); 56 | expect(res.statusCode).to.equal(200); 57 | expect(body).to.be.an.object(); 58 | expect(body.initialEtag).to.be.a.string().and.to.equal(afterWriteEtag); 59 | expect(body.afterReadEtag).to.be.a.string().and.to.equal(afterWriteEtag); 60 | expect(body.etag).to.not.equal(body.initialEtag); 61 | 62 | expect(body.data).to.equal({ store: 'this' }); 63 | 64 | done(); 65 | }); 66 | }); 67 | 68 | function storageSetQuery(ctx, cb) { 69 | const initialEtag = ctx.storage.etag; 70 | 71 | ctx.storage.get((err, data) => { 72 | if (err) return cb(err); 73 | 74 | const afterReadEtag = ctx.storage.etag; 75 | 76 | ctx.storage.set(ctx.query, err => { 77 | if (err) return cb(err); 78 | 79 | cb(null, { 80 | data, 81 | etag: ctx.storage.etag, 82 | initialEtag, 83 | afterReadEtag, 84 | }); 85 | }); 86 | }); 87 | } 88 | }); 89 | 90 | lab.test('will not throw if storageFile is not found', { timeout: 0 }, done => { 91 | server = Runtime.createServer(storageSetQuery, { logger, storageFile: './foo/bar' }); 92 | 93 | server.listen(3001); 94 | 95 | Request.get('http://localhost:3001', { json: true }, (err, res, body) => { 96 | expect(err).to.be.null(); 97 | expect(res.statusCode).to.equal(200); 98 | expect(body).to.be.an.object(); 99 | expect(body.data).to.be.undefined(); 100 | done(); 101 | }); 102 | 103 | function storageSetQuery(ctx, cb) { 104 | ctx.storage.get((err, data) => { 105 | if (err) return cb(err); 106 | 107 | cb(null, { 108 | data 109 | }); 110 | }); 111 | } 112 | }); 113 | 114 | lab.test('will not use storageFile if initialData is set', { timeout: 0 }, done => { 115 | server = Runtime.createServer(storageGetQuery, { logger, initialStorageData: { a: 'b' }, storageFile: Path.join(__dirname, '../fixtures/data_dummy.json') }); 116 | 117 | server.listen(3001); 118 | 119 | Request.get('http://localhost:3001', { json: true }, (err, res, body) => { 120 | expect(err).to.be.null(); 121 | expect(res.statusCode).to.equal(200); 122 | expect(body).to.be.an.object(); 123 | expect(body.data).to.be.an.object(); 124 | expect(body.data.a).to.equal("b"); 125 | done(); 126 | }); 127 | 128 | function storageGetQuery(ctx, cb) { 129 | ctx.storage.get((err, data) => { 130 | if (err) return cb(err); 131 | 132 | cb(null, { 133 | data 134 | }); 135 | }); 136 | } 137 | }); 138 | 139 | lab.test('will use the storageFile to load the initial data', { timeout: 0 }, done => { 140 | server = Runtime.createServer(storageGetQuery, { logger, storageFile: Path.join(__dirname, '../fixtures/data_clicks.json') }); 141 | 142 | server.listen(3001); 143 | 144 | Request.get('http://localhost:3001', { json: true }, (err, res, body) => { 145 | expect(err).to.be.null(); 146 | expect(res.statusCode).to.equal(200); 147 | expect(body).to.be.an.object(); 148 | expect(body.data).to.be.an.object(); 149 | expect(body.data.foo).to.equal("bar"); 150 | expect(body.data.totalClicks).to.equal(12345); 151 | done(); 152 | }); 153 | 154 | function storageGetQuery(ctx, cb) { 155 | ctx.storage.get((err, data) => { 156 | if (err) return cb(err); 157 | 158 | cb(null, { 159 | data 160 | }); 161 | }); 162 | } 163 | }); 164 | 165 | lab.test('will persist data to the storageFile', { timeout: 0 }, done => { 166 | server = Runtime.createServer(storageSetQuery, { logger, storageFile: Path.join(__dirname, '../fixtures/data_clicks.json') }); 167 | server.listen(3001); 168 | 169 | Request.get('http://localhost:3001', { json: true, qs: { totalClicks: 20000 } }, (err, res, body) => { 170 | expect(err).to.be.null(); 171 | expect(res.statusCode).to.equal(200); 172 | expect(body).to.be.an.object(); 173 | expect(body.data).to.be.an.object(); 174 | expect(body.data.foo).to.equal("bar"); 175 | expect(body.data.totalClicks).to.equal(12345); 176 | 177 | Request.get('http://localhost:3001', { json: true }, (err, res, body) => { 178 | expect(err).to.be.null(); 179 | expect(res.statusCode).to.equal(200); 180 | expect(body).to.be.an.object(); 181 | expect(body.data).to.be.an.object(); 182 | expect(body.data.foo).to.equal("bar"); 183 | expect(body.data.totalClicks).to.equal(20000); 184 | 185 | const dataFile = JSON.parse(Fs.readFileSync(Path.join(__dirname, '../fixtures/data_clicks.json'), 'utf8')); 186 | expect(dataFile.totalClicks).to.equal(20000); 187 | 188 | done(); 189 | }); 190 | }); 191 | 192 | function storageSetQuery(ctx, cb) { 193 | ctx.storage.get((err, data) => { 194 | if (err) return cb(err); 195 | 196 | if (!ctx.query || !ctx.query.totalClicks) { 197 | return cb(null, { 198 | data 199 | }); 200 | } 201 | 202 | const update = { foo: data.foo, totalClicks: parseInt(ctx.query.totalClicks, 10) }; 203 | ctx.storage.set(update, err => { 204 | if (err) return cb(err); 205 | 206 | cb(null, { 207 | data 208 | }); 209 | }); 210 | }); 211 | } 212 | }); 213 | }); 214 | 215 | if (require.main === module) { 216 | Lab.report([lab], { output: process.stdout, progress: 2 }); 217 | } 218 | --------------------------------------------------------------------------------