UNPKG

parasails

Version:

Lightweight structures for apps with more than one page.

1,045 lines (935 loc) 109 kB
/** * cloud.js * (high-level AJAX library) * * > This is now part of `parasails`. It was branched from the old "Cloud SDK" * > library at its v1.0.1 -- but from that point on, its versioning has been * > tied to the version of parasails it's bundled in. (All future development * > of Cloud SDK will be as part of parasails.) * * Copyright (c) 2014-present, Mike McNeil * MIT License * * - https://twitter.com/mikermcneil * - https://sailsjs.com/about * - https://sailsjs.com/support * - https://www.npmjs.com/package/parasails * * --------------------------------------------------------------------------------------------- * ## Basic Usage * * Step 1: * * ``` * Cloud.setup({ doSomething: 'POST /api/v1/somethings/:id/do' }); * ``` * ^^Note that this can also be compiled automatically from your Sails app's routes using a script. * * Step 2: * * ``` * var result = await Cloud.doSomething(8); * ``` * * Or: * ``` * var result = await Cloud.doSomething.with({id: 8, foo: ['bar', 'baz']}); * ``` * --------------------------------------------------------------------------------------------- */ (function(factory, exposeUMD){ exposeUMD(this, factory); })(function (_, io, $, SAILS_LOCALS, location, File, FileList, FormData){ // ██████╗ ██████╗ ██╗██╗ ██╗ █████╗ ████████╗███████╗ // ██╔══██╗██╔══██╗██║██║ ██║██╔══██╗╚══██╔══╝██╔════╝ // ██████╔╝██████╔╝██║██║ ██║███████║ ██║ █████╗ // ██╔═══╝ ██╔══██╗██║╚██╗ ██╔╝██╔══██║ ██║ ██╔══╝ // ██║ ██║ ██║██║ ╚████╔╝ ██║ ██║ ██║ ███████╗ // ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ // // ██╗ ██╗████████╗██╗██╗ ███████╗ // ██║ ██║╚══██╔══╝██║██║ ██╔════╝ // ██║ ██║ ██║ ██║██║ ███████╗ // ██║ ██║ ██║ ██║██║ ╚════██║ // ╚██████╔╝ ██║ ██║███████╗███████║ // ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ // Module utilities (private) /** * @param {Ref} that * * @throws {Error} If that is not a File instance, a FileList instance, an * array of File instances, a special File wrapper, or an * array of special File wrappers. (Note that, if an array is * provided, this function will only return true if the array * consists of ≥1 item.) */ function _representsOneOrMoreFiles(that) { // FUTURE: add support for Blobs return ( _.isObject(that) && ( (File? that instanceof File : false)|| (FileList? that instanceof FileList : false)|| (_.isArray(that) && that.length > 0 && _.all(that, function(item) { return File? _.isObject(item) && item instanceof File : false; }))|| (File? _.isObject(that) && _.isObject(that.file) && that.file instanceof File : false)|| (_.isArray(that) && that.length > 0 && _.all(that, function(item) { return File? _.isObject(item) && _.isObject(item.file) && item.file instanceof File : false; })) ) ); }//ƒ /** * @param {String} negotiationRule * * @throws {Error} If rule is invalid or absent */ function _verifyErrorNegotiationRule(negotiationRule) { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: add support for parley/flaverr/bluebird/lodash-style dictionary negotiation rules // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if (_.isNumber(negotiationRule) && Math.floor(negotiationRule) === negotiationRule) { if (negotiationRule > 599 || negotiationRule < 0) { throw new Error('Invalid error negotiation rule: `'+negotiationRule+'`. If a status code is provided, it must be between zero and 599.'); } } else if (_.isString(negotiationRule) && negotiationRule) { // Ok, we'll assume it's fine } else { var suffix = ''; if (negotiationRule === undefined || _.isFunction(negotiationRule)) { suffix = ' Looking to tolerate or intercept **EVERY** error? This usually isn\'t a good idea, because, just like some try/catch usage patterns, it could mean swallowing errors unexpectedly, which can make debugging a nightmare.'; } throw new Error('Invalid error negotiation rule: `'+negotiationRule+'`. Please pass in a valid intercept rule string. An intercept rule is either (A) the name of an exit or (B) a whole number representing the status code like `404` or `200`.'+suffix); } } // ███████╗██╗ ██╗██████╗ ██████╗ ██████╗ ████████╗███████╗ // ██╔════╝╚██╗██╔╝██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝ // █████╗ ╚███╔╝ ██████╔╝██║ ██║██████╔╝ ██║ ███████╗ // ██╔══╝ ██╔██╗ ██╔═══╝ ██║ ██║██╔══██╗ ██║ ╚════██║ // ███████╗██╔╝ ██╗██║ ╚██████╔╝██║ ██║ ██║ ███████║ // ╚══════╝╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ // Module exports: /** * Cloud (SDK) * * After setup, this dictionary will have a method for each declared endpoint. * Each key will be a function which sends an HTTP or socket request to a * particular endpoint. * * ### Setup * * ``` * Cloud.setup({ * apiBaseUrl: 'https://example.com', * usageOpts: { * arginStyle: 'serial' * }, * methods: { * doSomething: 'PUT /api/v1/projects/:id', * // ... * } * }); * ``` * * > Note that you should avoid having an endpoint method named "setup", for obvious reasons. * > (Technically, it should work anyway though. But yeah, no reason to tempt the fates.) * * ### Basic Usage * * ``` * var user = await Cloud.findOneUser(3); * ``` * * ``` * var user = await Cloud.findOneUser.with({ id: 3 }); * ``` * * ``` * Cloud.doSomething.with({ * someParam: ['things', 3235, null, true, false, {}, []] * someOtherParam: 2523, * etc: 'more things' * }).exec(function (err, responseBody, responseObjLikeJqXHR) { * if (err) { * // ... * return; * } * * // ... * }); * ``` * * ### Negotiating Errors * ``` * Cloud.signup.with({...}) * .switch({ * error: function (err) { ... }, * usernameAlreadyInUse: function (recommendedAlternativeUsernames) { ... }, * emailAddressAlreadyInUse: function () { ... }, * success: function () { ... } * }); * ``` * * ### Using WebSockets * ``` * Cloud.doSomething.with({...}) * .protocol('jQuery') * .exec(...); * ``` * * ``` * Cloud.doSomething.with({...}) * .protocol('io.socket') * .exec(...); * ``` * * ##### Providing a particular jQuery or SailsSocket instance * * ``` * Cloud.doSomething.with({...}) * .protocol(io.socket) * .exec(...); * ``` * * ``` * Cloud.doSomething.with({...}) * .protocol($) * .exec(...); * ``` * * ### Using Custom Headers * ``` * Cloud.doSomething.with({...}) * .headers({ * 'X-Auth': 'whatever' * }) * .exec(...); * ``` * * ### CSRF Protection * * It `SAILS_LOCALS._csrf` is defined, then it will be sent * as the "x-csrf-token" header for all Cloud.* requests, automatically. * */ var Cloud = {}; // FUTURE: Cloud.getUrlFor() // (similar to https://sailsjs.com/documentation/reference/application/sails-get-url-for) // (but would def need to provide a way of providing values for URL pattern variables like `:id`) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: finish this when time allows (might be better to have it work by attaching dedicated // nav methods rather than a generic nav method though) // ``` // // A mapping of names of view actions to URL // // > provided to `.setup()`, for use in .navigate() // var _navigableUrlsByViewActionName; // // // /** // * Cloud.navigate() // * // * Call this function to navigate to a different web page. // * (Be sure and call it *before* trying to use any of the endpoint methods!) // * // * @param {String} destination // * A URL or the name of a view action. // */ // Cloud.navigate = function(destination) { // var doesBeginWithSlash = _.isString(destination) && destination.match(/^\//); // var doesBeginWithHttp = _.isString(destination) && destination.match(/^http/); // var isProbablyTheNameOfAViewAction = _.isString(destination) && destination.match(/^view/); // if (!_.isString(destination) || !(doesBeginWithSlash || doesBeginWithHttp || isProbablyTheNameOfAViewAction)) { // throw new Error('Bad usage: Cloud.navigate() should be called with a URL or the name of a view action.'); // } // if (!_navigableUrlsByViewActionName) { // throw new Error('Cannot navigate to a view action because Cloud.setup() has not been called yet-- please do that first (or if that\'s not possible, just navigate directly to the URL)'); // } // }; // ``` // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /** * Cloud.setup() * * Call this function once, when the page loads. * (Be sure and call it *before* trying to use any of the endpoint methods!) * * @param {Dictionary} options * @required {Dictionary} methods * @optional {Dictionary} links * @optional {Dictionary} apiBaseUrl */ Cloud.setup = function(options) { options = options || {}; if (!_.isObject(options.methods) || _.isArray(options.methods) || _.isFunction(options.methods)) { throw new Error('Cannot .setup() Cloud SDK: `methods` must be provided as a dictionary of addresses and definitions.'); }//• // Determine the proper API base URL if (!options.apiBaseUrl) { if (location) { options.apiBaseUrl = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: ''); } else { throw new Error('Cannot .setup() Cloud SDK: Since a location cannot be determined, `apiBaseUrl` must be provided as a string (e.g. "https://example.com").'); } }//fi // Apply the base URL for the benefit of WebSockets (if relevant): if (io) { io.sails.url = options.apiBaseUrl; }//fi // The name of the default protocol. var DEFAULT_PROTOCOL_NAME = 'jQuery'; // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: finish this when time allows (would be better to have it work by attaching dedicated // nav methods rather than a generic nav method though) // ``` // // Save a reference to the mapping of navigable URLs by view action name (if provided). // _navigableUrlsByViewActionName = options.links || {}; // ``` // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if (options.methods.on) { throw new Error('Cannot .setup() Cloud SDK: `.on()` is reserved. It cannot be used as the name for a method.'); } if (options.methods.off) { throw new Error('Cannot .setup() Cloud SDK: `.off()` is reserved. It cannot be used as the name for a method.'); } // Interpret methods var methods = _.reduce(options.methods, function(memo, appLevelSdkEndpointDef, methodName) { if (methodName === 'setup') { console.warn('"setup" is a confusing name for a cloud action (it conflicts with a built-in feature of this SDK itself). Would "initialize()" work instead? (Continuing this time...)'); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: finish this when time allows (would be better to have it work by attaching dedicated // nav methods rather than a generic nav method though) // ``` // if (methodName === 'navigate') { // console.warn('"navigate" is a confusing name for a cloud action (it conflicts with a built-in feature of this SDK itself). Would "travel()" work instead? (Continuing this time...)'); // } // ``` // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Validate the endpoint definition. //////////////////////////////////////////////////////////////////////////////////////////////// var _verbToCheck; var _urlToCheck; if (typeof appLevelSdkEndpointDef === 'function') { // We can't really check functions, so we just let it through. } else { if (appLevelSdkEndpointDef && typeof appLevelSdkEndpointDef === 'object') { // Must have `verb` and `url` properties. _verbToCheck = appLevelSdkEndpointDef.verb; _urlToCheck = appLevelSdkEndpointDef.url; } else if (typeof appLevelSdkEndpointDef === 'string') { // Must be able to parse `verb` and `url`. _verbToCheck = appLevelSdkEndpointDef.replace(/^\s*([^\/\s]+)\s*\/.*$/, '$1'); _urlToCheck = appLevelSdkEndpointDef.replace(/^\s*[^\/\s]+\s*\/(.*)$/, '/$1'); } else { throw new Error('CloudSDK endpoint (`'+methodName+'`) is invalid: Endpoints should be defined as either (1) a string like "GET /foo", (2) a dictionary containing a `verb` and a `url`, or (3) a function that returns a dictionary like that.'); } // --• // `verb` must be valid. if (typeof _verbToCheck !== 'string' || _verbToCheck === '') { throw new Error('CloudSDK endpoint (`'+methodName+'`) is invalid: An endpoint\'s `verb` should be defined as a non-empty string.'); } // `url` must be valid. if (typeof _urlToCheck !== 'string' || _urlToCheck === '') { throw new Error('CloudSDK endpoint (`'+methodName+'`) is invalid: An endpoint\'s `url` should be defined as a non-empty string.'); } } // Build the actual method that will be called at runtime: //////////////////////////////////////////////////////////////////////// var _helpCallCloudMethod = function (argins) { //+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++// // There are 3 ways to define an SDK wrapper for a cloud endpoint. //+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++// var requestInfo = { // • the HTTP verb (aka HTTP "method" -- we're just using "verb" for clarity) verb: undefined, // • the path part of the URL url: undefined, // • a dictionary of request data // (depending on the circumstances, these params will be encoded directly // into either the url path, the querystring, or the request body) params: undefined, // • a dictionary of custom request headers headers: undefined, // • the protocol name (e.g. "jQuery" or "io.socket") protocolName: undefined, // • the protocol instance (e.g. actual reference to `$` or `io.socket`) protocolInstance: undefined, // • an array of conditional lifecycle instructions from userland .intercept() / .tolerate() calls, if any are configured lifecycleInstructions: [], }; // ██████╗ ██╗ ██╗██╗██╗ ██████╗ ██████╗ ███████╗███████╗███████╗██████╗ ██████╗ ███████╗██████╗ // ██╔══██╗██║ ██║██║██║ ██╔══██╗ ██╔══██╗██╔════╝██╔════╝██╔════╝██╔══██╗██╔══██╗██╔════╝██╔══██╗ // ██████╔╝██║ ██║██║██║ ██║ ██║ ██║ ██║█████╗ █████╗ █████╗ ██████╔╝██████╔╝█████╗ ██║ ██║ // ██╔══██╗██║ ██║██║██║ ██║ ██║ ██║ ██║██╔══╝ ██╔══╝ ██╔══╝ ██╔══██╗██╔══██╗██╔══╝ ██║ ██║ // ██████╔╝╚██████╔╝██║███████╗██████╔╝ ██████╔╝███████╗██║ ███████╗██║ ██║██║ ██║███████╗██████╔╝ // ╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═════╝ // // ██████╗ ██████╗ ██╗███████╗ ██████╗████████╗ // ██╔═══██╗██╔══██╗ ██║██╔════╝██╔════╝╚══██╔══╝ // ██║ ██║██████╔╝ ██║█████╗ ██║ ██║ // ██║ ██║██╔══██╗██ ██║██╔══╝ ██║ ██║ // ╚██████╔╝██████╔╝╚█████╔╝███████╗╚██████╗ ██║ // ╚═════╝ ╚═════╝ ╚════╝ ╚══════╝ ╚═════╝ ╚═╝ // // Used for avoiding accidentally creating multiple promises when // using .then() or .catch(). var _promise; // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: add support for omens so we get better stack traces, particularly // when running this in a Node.js environment. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Return a dictionary of functions (to allow for "deferred object" usage.) var deferred = { // Allow request headers to be configured. ///////////////////////////////////////////////////////////////////////////// headers: function (_customRequestHeaders){ if (!_.isObject(_customRequestHeaders)) { throw new Error('Invalid request headers: Must be specified as a dictionary, where each key has a string value.'); } requestInfo.headers = _.extend(requestInfo.headers||{}, _customRequestHeaders); return deferred; }, // Allow the protocol to be configured on a per-request basis. ///////////////////////////////////////////////////////////////////////////// protocol: function (_protocolNameOrInstance){ if (typeof _protocolNameOrInstance === 'string') { switch (_protocolNameOrInstance) { case 'jQuery': requestInfo.protocolName = 'jQuery'; if ($ === undefined) { throw new Error('Could not access jQuery: `$` is undefined.'); } else { requestInfo.protocolInstance = $; } break; case 'io.socket': requestInfo.protocolName = 'io.socket'; if (typeof io === 'undefined') { throw new Error('Could not access `io.socket`: `io` is undefined.'); } else if (typeof io !== 'function') { throw new Error('Could not access `io.socket`: `io` is invalid:' + io); } else if (typeof io.socket === 'undefined') { throw new Error('Could not access `io.socket`: `io` does not have a `socket` property. Make sure `sails.io.js` is being injected in a <script> tag!'); } else { requestInfo.protocolInstance = io.socket; } break; default: throw new Error('Unrecognized protocol: `'+_protocolNameOrInstance+'`. Use "jQuery" or "io.socket".'); } } else if (_.isObject(_protocolNameOrInstance) || _.isFunction(_protocolNameOrInstance)) { if (_protocolNameOrInstance.name === 'jQuery') { requestInfo.protocolName = 'jQuery'; requestInfo.protocolInstance = _protocolNameOrInstance; } else if (_protocolNameOrInstance.constructor.name === 'SailsSocket') { requestInfo.protocolName = 'io.socket'; requestInfo.protocolInstance = _protocolNameOrInstance; } else if (_protocolNameOrInstance.toString() === '[Package: machinepack-http]' || _protocolNameOrInstance.toString() === '[Package: sails.helpers.http]') { requestInfo.protocolName = 'machinepack-http'; requestInfo.protocolInstance = _protocolNameOrInstance; } // FUTURE: maybe native browser "fetch"? // FUTURE: maybe native Node "http"? else { throw new Error('Unrecognized instance provided to `.protocol()`: `'+_protocolNameOrInstance+'`'); } } else { throw new Error('Unrecognized protocol: `'+_protocolNameOrInstance+'`. Use "jQuery" or "io.socket".'); } return deferred; },//</ implementation of `.protocol()`> // Allow intercepting the response before resolution/rejection occurs. // (This is basically an "after receiving response" lifecycle callback.) ///////////////////////////////////////////////////////////////////////////// intercept: function (negotiationRule, handler) { _verifyErrorNegotiationRule(negotiationRule); if (!_.isFunction(handler)) { throw new Error('Invalid 2nd argument to `.intercept()`. Expecting a handler function.'); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: Add a best-effort check to make sure there is no pre-existing rule // that matches this one (i.e. already previously registered using .tolerate() // or .intercept()) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - requestInfo.lifecycleInstructions.push({ type: 'intercept', rule: negotiationRule, handler: handler }); return deferred; }, // Allow explicitly tolerating certain kinds of responses before resolution/rejection occurs. // (This causes control flow convergence by using `.intercept()` + throwing a special value) ///////////////////////////////////////////////////////////////////////////// tolerate: function (_negotiationRuleMaybe, _handlerMaybe) { var handler; var negotiationRule; if (_handlerMaybe === undefined && _.isFunction(_negotiationRuleMaybe)) { handler = _negotiationRuleMaybe; } else { negotiationRule = _negotiationRuleMaybe; handler = _handlerMaybe; } if (negotiationRule !== undefined) { _verifyErrorNegotiationRule(negotiationRule); } if (handler !== undefined && !_.isFunction(handler)) { throw new Error('Invalid 2nd argument. to `.tolerate()`. Expecting a handler function.'); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: Add a best-effort check to make sure there is no pre-existing rule // that matches this one (i.e. already previously registered using .tolerate() // or .intercept()) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - requestInfo.lifecycleInstructions.push({ type: 'tolerate', rule: negotiationRule, handler: handler? handler : function(){ return; } }); return deferred; }, // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Looking for the EarlyReturnSignal stuff? // // See https://stackoverflow.com/a/43402123/486547 and specifically also // https://stackoverflow.com/questions/29499582/how-to-properly-break-out-of-a-promise-chain#comment80446341_43402123 // (there may be a way to do this more elegantly without requiring the calling // code environment to be aware of our special Errors-- but it's not worth it // as-is. Too much black magic!) // // > More notes & background leading up to this: // > https://gist.github.com/mikermcneil/c1bc2d57f5bedae810295e5ed8c5f935 // > // > (Also check out the commit history of the original `caviar` repo.) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - then: function (){ // console.log('in implementation of `then()`...'); var promise = deferred.toPromise(); // console.log('obj:',promise); return promise.then.apply(promise, arguments); }, // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: use parley for all this instead, if we can find a way to keep it // from being too enormous when browserified // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - toPromise: function (){ if (typeof Promise === 'undefined') { throw new Error('Cannot use this approach: `Promise` constructor not available in current environment.'); } if (_promise) { // console.log('using catched promise...'); return _promise; } // console.log('instantiating new promise!'); _promise = new Promise(function(resolve, reject){// eslint-disable-line no-undef try { deferred.exec(function(err, resultMaybe) { if (err){ // console.log('calling reject..'); return reject(err); } // console.log('calling resolve..'); return resolve(resultMaybe); });//_∏_ } catch (err) { // console.log('EXEC THREW ERROR!',err); // console.log('CALLED REJECT IN NATIVE CATCH BLOCK!'); reject(err); } });//_∏_ return _promise; }, // Allow the AJAX request to actually be sent. ///////////////////////////////////////////////////////////////////////////// exec: function (exitCallbacks){ if (exitCallbacks) { if (!_.isObject(exitCallbacks) && !_.isFunction(exitCallbacks)) { throw new Error('If specified, the argument passed to `.exec()` must be a dictionary containing a `success` and `error` callback. Alternatively, you can use a Node.js-style callback.'); } else if (_.isObject(exitCallbacks) && exitCallbacks.success && !_.isFunction(exitCallbacks.success)) { throw new Error('If specified, `success` callback must be a function.'); } else if (_.isObject(exitCallbacks) && exitCallbacks.error && !_.isFunction(exitCallbacks.error)) { throw new Error('If specified, `error` callback must be a function.'); } } // Just in case, build an error instance beforehand. // (This ensures it has a good stack trace.) var errorInstance = new Error('Endpoint (`'+methodName+'`) responded with an error (or the request failed).'); // Give the error a special `name` property to ease negotiation // (vs. other unrelated things like typos in argins) errorInstance.name = 'CloudError'; // If present, use CSRF token from `SAILS_LOCALS` as the `x-csrf-token` // request header for all non-GET requests. // (Unless of course there's another x-csrf-token header already specified.) if (_.isObject(SAILS_LOCALS) && typeof SAILS_LOCALS._csrf !== 'undefined') { if (_.isUndefined(requestInfo.headers)) { requestInfo.headers = {}; }// >- if (!requestInfo.headers['x-csrf-token']) { requestInfo.headers['x-csrf-token'] = SAILS_LOCALS._csrf; } }//fi // Finally, use the appropriate protocol to actually send the request and // send back the response to the code that called this `Cloud.*()` method. (function _makeAjaxCallWithAppropriateProtocol(proceed){ // First, tease apart text params and file params. var textParamsByFieldName = requestInfo.params; // Check for file uploads. // // If `FormData` constructor is available, check to see if any // of the param values are File/FileList instances, or arrays of // File instances, or special File wrappers, or arrays of special // File wrappers. If they are, then remove them from a shallow // clone of the params dictionary, and set them up separately. // (The files will be attached to the request _after_ the text // parameters.) var uploadsByFieldName = {}; if (FormData && textParamsByFieldName) { textParamsByFieldName = _.extend({}, textParamsByFieldName); _.each(textParamsByFieldName, function(value, fieldName){ if (_representsOneOrMoreFiles(value)) { uploadsByFieldName[fieldName] = value; delete textParamsByFieldName[fieldName]; } });//∞ }//fi // Don't allow file uploads for GET requests, // or if the FormData constructor is somehow missing. if (_.keys(uploadsByFieldName).length > 0) { if (requestInfo.verb.match(/get/i)) { throw new Error( 'Detected File or FileList instance(s) provided for parameter(s): '+ _.keys(uploadsByFieldName)+'\n'+ 'But this is a nullipotent ('+requestInfo.verb.toUpperCase()+') '+ 'request, which does not support file uploads.' ); }//• if (!FormData) { throw new Error( 'Detected File or FileList instance(s) provided for parameter(s): '+ _.keys(uploadsByFieldName)+'\n'+ 'But the native FormData constructor does not exist!' ); } }//fi switch (requestInfo.protocolName) { // ▄▄███▄▄· █████╗ ██╗ █████╗ ██╗ ██╗ ██╗██╗ // ██╔════╝ ██╔══██╗ ██║██╔══██╗╚██╗██╔╝██╔╝╚██╗ // ███████╗ ███████║ ██║███████║ ╚███╔╝ ██║ ██║ // ╚════██║ ██╔══██║██ ██║██╔══██║ ██╔██╗ ██║ ██║ // ███████║██╗██║ ██║╚█████╔╝██║ ██║██╔╝ ██╗╚██╗██╔╝ // ╚═▀▀▀══╝╚═╝╚═╝ ╚═╝ ╚════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝ case 'jQuery': return (function _doAjaxWithJQuery(){ var thisJQuery = requestInfo.protocolInstance; // Build options for $.ajax(). var ajaxOpts = { url: requestInfo.url, method: requestInfo.verb }; // If GET request, encode params in querystring. if (requestInfo.verb.match(/get/i)) { ajaxOpts.data = textParamsByFieldName; } // Else if there are files, attach them properly, // alongside the other stuff in the form -- either // in the body or as querystring parameters, depending // on what kind of data they are. // // > Note that we include text params **FIRST**, // > in order to support order-aware body parsers // > that rely on pessimistic upstream awareness, // > optimizing uploads and preventing DDoS attacks. // > // > Also note that we skip text params and file fields w/ // > `undefined` values for consistency w/ Sails conventions. // // > Finally, one last thing to consider: // > If a value is NOT something that needs special encoding // > to accurately capture its meaning and data type (e.g. if // > it is a string), then we simply attach it to the body as // > form data. But otherwise, we have to do something fancy // > to get it to be losslessly encoded for use in backend code. else if (_.keys(uploadsByFieldName).length > 0){ ajaxOpts.processData = false; ajaxOpts.contentType = false; ajaxOpts.data = new FormData(); _.each(textParamsByFieldName, function(value, fieldName){ if (value === undefined) { return; }//• if (_.isString(value)) { ajaxOpts.data.append(fieldName, value); } else { // Use the "X-JSON-MPU-Params" header to signal to the // server that this text param is encoded as stringified // JSON, even though the request's content type would // suggest otherwise (because it's multipart/form-data // in order to handle file uploads). // // > This is "the new way" of solving this problem. // > For more info about "the old way" of "solving" this // > that didn't really work for everything (i.e. doing // > a recursive dive over the value and attempting to // > losslessly encode it in the URL query string), see: // > https://github.com/mikermcneil/parasails/commit/28732b1ed55eb4697de4bf4c559f0319cf773041 requestInfo.headers = requestInfo.headers||{}; if (requestInfo.headers['X-JSON-MPU-Params']) { requestInfo.headers['X-JSON-MPU-Params'] += ','+fieldName; } else { requestInfo.headers['X-JSON-MPU-Params'] = fieldName; } // FUTURE: do a deep-crawl to sanitize prior to stringification (as alluded to below) -- i.e. to strip undefined array items, etc var stringifiedValue; try { stringifiedValue = JSON.stringify(value); } catch (unusedErr) { var errMsgPrefix = 'Could not encode value provided for '+fieldName+' because the value is (or contains) '; var errMsgSuffix = '. In a request that contains one or more file uploads, any additional text parameter values need to be encoded in such a way that they can be losslessly parsed by the Sails framework.\n [?] Unsure? Reach out at https://sailsjs.com/support'; throw new Error(errMsgPrefix+'data that cannot be stringified as JSON (usually, this means it contains circular references-- i.e. its properties or array items are actually references to itself, or each other)'+errMsgSuffix); } ajaxOpts.data.append(fieldName, stringifiedValue); } });//∞ _.each(uploadsByFieldName, function(fileOrFileList, fieldName){ if (fileOrFileList === undefined) { return; } if (!_representsOneOrMoreFiles(fileOrFileList)) { throw new Error('Cannot upload as "'+fieldName+'" because the provided value is not a File instance, an array of File instances, a dictionary like `{file: someFileInstance, name: \'filename-override.png\'}`, or an array of such wrapper dictionaries. Instead, got: '+fileOrFileList+'\n\nNote that this can sometimes occur due to problems with code minification (e.g. uglify configuration).'); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: throw usage error if wrapper (i.e. with `.file`) has a `.name`, override, // but it isn't a valid string (i.e. truthy, decent chars, & not too long) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if (_.isArray(fileOrFileList) || (_.isObject(fileOrFileList) && _.isObject(fileOrFileList.constructor) && fileOrFileList.constructor.name === 'FileList')) { for (var i = 0; i < fileOrFileList.length; i++) { if (fileOrFileList[i] instanceof File) { ajaxOpts.data.append(fieldName, fileOrFileList[i], fileOrFileList[i].name); } else { ajaxOpts.data.append(fieldName, fileOrFileList[i].file, fileOrFileList[i].name||fileOrFileList[i].file.name); } }//∞ } else { if (fileOrFileList instanceof File) { ajaxOpts.data.append(fieldName, fileOrFileList, fileOrFileList.name); } else { ajaxOpts.data.append(fieldName, fileOrFileList.file, fileOrFileList.name||fileOrFileList.file.name); } } });//∞ } // Otherwise, attach params as a JSON-encoded request body. else { // If any of our text params are arrays, then before stringifying, // make a shallow clone and strip out any `undefined` values // that exist as items at the top level of the array. (This // prevents them from automatically being changed into `null` // by JSON.stringify().) // > (This behavior is a breaking change that was introduced // > in parasails@0.9.0) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // > FUTURE: Instead, do a deep crawl and mimic the behavior of RTTC: // > https://github.com/node-machine/rttc/blob/8a84191dc786e872a6c28b24566539573b2a2c4d/lib/helpers/rebuild-recursive.js#L77-L90 // > ^^That'll take care of several other common edge cases that // > are handled in a kinda strange way by JSON.stringify(), // > including `NaN`, etc. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var sanitizedTPBFN = _.mapValues(textParamsByFieldName, function(value) { var sanitizedValue; if (_.isArray(value)) { sanitizedValue = _.clone(value); _.remove(sanitizedValue, function(item){ return item === undefined; });//∞ } else { sanitizedValue = value; } return sanitizedValue; }); ajaxOpts.data = JSON.stringify(sanitizedTPBFN); ajaxOpts.processData = false; ajaxOpts.contentType = 'application/json; charset=UTF-8'; } // Attach headers so they'll be included in our $.ajax() call. if (requestInfo.headers !== undefined) { ajaxOpts.headers = requestInfo.headers; } // Dealing with jqXHR: // // To get status code: // console.log(jqXHR.statusCode); // // To get header(s): // console.log(jqXHR.getResponseHeader('foo')); // - or - // console.log(jqXHR.getAllResponseHeaders()); // ^^^ but this one gives it to you as a string. // ^^ // WARNING: if using a cross-domain request w/ CORS, this (^^^^^) // header grabbing may not work properly on some versions of firefox. More details: // http://stackoverflow.com/questions/5614735/jqxhr-getallresponseheaders-wont-return-all-headers thisJQuery.ajax(_.extend(ajaxOpts, { error: function (jqXHR) { return proceed(undefined, { body: jqXHR.responseJSON === undefined ? jqXHR.responseText : jqXHR.responseJSON, statusCode: jqXHR.status, headers: _.reduce(jqXHR.getAllResponseHeaders().split(/\n/), function (memo, pair) { var splitPair = pair.split(/:/); var headerName = splitPair[0]; if (headerName === '') { return memo; } // Note that we trim leading AND trailing whitespace. var headerVal = splitPair.slice(1).join('').replace(/^\s*/, '').replace(/\s*$/, ''); memo[headerName] = headerVal; // Also add an alias using the all-lowercased version of the header name // (if it's different) var allLowercaseHeaderName = headerName.toLowerCase(); if (allLowercaseHeaderName !== headerName) { memo[allLowercaseHeaderName] = headerVal; } return memo; }, {}) }); }, success: function (unused0, unused1, jqXHR) { return proceed(undefined, { body: jqXHR.responseJSON === undefined ? jqXHR.responseText : jqXHR.responseJSON, statusCode: jqXHR.status, headers: _.reduce(jqXHR.getAllResponseHeaders().split(/\n/), function (memo, pair) { var splitPair = pair.split(/:/); var headerName = splitPair[0]; if (headerName === '') { return memo; } // Note that we trim leading AND trailing whitespace. var headerVal = splitPair.slice(1).join('').replace(/^\s*/, '').replace(/\s*$/, ''); memo[headerName] = headerVal; // Also add an alias using the all-lowercased version of the header name // (if it's different) var allLowercaseHeaderName = headerName.toLowerCase(); if (allLowercaseHeaderName !== headerName) { memo[allLowercaseHeaderName] = headerVal; } return memo; }, {}) }); } }));//</ thisJQuery.ajax + _.extend() > })();//</self-calling function :: _doAjaxWithJQuery> // ██╗ ██████╗ ███████╗ ██████╗ ██████╗██╗ ██╗███████╗████████╗ ██╗██╗ // ██║██╔═══██╗ ██╔════╝██╔═══██╗██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝▄ ██╗▄██╔╝╚██╗ // ██║██║ ██║ ███████╗██║ ██║██║ █████╔╝ █████╗ ██║ ████╗██║ ██║ // ██║██║ ██║ ╚════██║██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ▀╚██╔▀██║ ██║ // ██║╚██████╔╝██╗███████║╚██████╔╝╚██████╗██║ ██╗███████╗ ██║██╗ ╚═╝ ╚██╗██╔╝ // ╚═╝ ╚═════╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝╚═╝ ╚═╝╚═╝ // case 'io.socket': return (function _doAjaxWithSocket(){ var socket = requestInfo.protocolInstance; // Check to be sure that none of the parameter values are // attempted file uploads. if (File && requestInfo.params) { _.each(requestInfo.params, function(value, fieldName){ if (_representsOneOrMoreFiles(value)) { throw new Error('Detected File-like data provided for the "'+fieldName+'" parameter -- but file uploads are not currently supported using WebSockets / Socket.io. Please call this method using a different request protocol (e.g. `protocol: \'jQuery\'`)'); } }); }//fi // Determine if the socket has been disconnected, or if it // has NEVER BEEN connected and is not CURRENTLY TRYING to // connect. var disconnectedOrWasNeverConnectedAndUnlikelyToTry = // => // If the socket is connected, cool, no problem. !socket.isConnected() && // => // If the socket is at least _attempting_ to connect, we'll go ahead // and let it try to do it's thing (i.e. queue and replay) !socket.isConnecting() && // => // If the socket hasn't even had the _chance_ to begin connecting // (because the one-tick auto-connect timer hasn't fired yet), // then we'll give it that chance. !socket.mightBeAboutToAutoConnect(); // If none of the above were true, then emulate a normal // offline AJAX response from jQuery. if (disconnectedOrWasNeverConnectedAndUnlikelyToTry) { return proceed(undefined, { body: null, statusCode: 0, headers: {} }); } // Otherwise the socket is either connected, in the process of connecting, // or in an indeterminate state where it has _never_ connected but _might_ // still connect (see above for details). // // In any of these cases, thanks largely to queuing, it is safe to continue // onwards, and to send the request! socket.request({ method: requestInfo.verb, url: requestInfo.url, data: requestInfo.params, headers: requestInfo.headers }, function (unused, jwres) { return proceed(undefined, { body: jwres.body, statusCode: jwres.statusCode, headers: jwres.headers }); });//</ socket.request() > })();//</self-calling function :: _doAjaxWithSocket> // ███╗ ███╗██████╗ ██╗ ██╗████████╗████████╗██████╗ // ████╗ ████║██╔══██╗ ██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗ // ██╔████╔██║██████╔╝█████╗███████║ ██║ ██║ ██████╔╝ // ██║╚██╔╝██║██╔═══╝ ╚════╝██╔══██║ ██║ ██║ ██╔═══╝ // ██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ██║ // ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ case 'machinepack-http': return (function _doAjaxWithMpHttp(){ // If there are request parameters, check to be sure // that none of the parameter values are File instances. if (requestInfo.params) { _.each(requestInfo.params, function(value, fieldName){ if (_representsOneOrMoreFiles(value)) { throw new Error('Detected File-like data provided for the "'+fieldName+'" parameter -- but file uploads are not currently supported in Cloud SDK when using "machinepack-http". Please call this method using a different request protocol.'); } });//∞ }//fi var mpHttpOpts = { url: requestInfo.url, method: requestInfo.verb }; // If GET request, encode params in querystring. if (requestInfo.verb.match(/get/i)) { mpHttpOpts.qs = textParamsByFieldName; } // Otherwise, attach params as the request body. // (it will be JSON-encoded automatically by default) else { mpHttpOpts.body = textParamsByFieldName; } if (typeof requestInfo.headers !== 'undefined') { mpHttpOpts.headers = requestInfo.headers; } requestInfo.protocolInstance.sendHttpRequest.with(mpHttpOpts) .switch({ error: function (err) { return proceed(err); }, requestFailed: function(err) { return proceed(undefined, { body: err.message, statusCode: 0, headers: {} }); }, non200Response: function(serverResponse) { return proceed(undefined, serverResponse); },