diff --git a/.eslintignore b/.eslintignore index 5228e27ede..3653ab7c32 100644 --- a/.eslintignore +++ b/.eslintignore @@ -67,7 +67,6 @@ tools/runners/run-app.js tools/runners/run-mongo.js tools/runners/run-proxy.js tools/runners/run-selenium.js -tools/runners/run-updater.js tools/packaging/package-client.js tools/packaging/package-map.js diff --git a/docs/history.md b/docs/history.md index 7c1bceec6a..c748fda441 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,16 @@ +## 2.8.1, Unreleased + +#### Highlights + +#### Breaking Changes + +#### Migration Steps + +#### Meteor Version Release +* `facebook-oauth@1.12.0` + - Updated default version of Facebook GraphAPI to v15 + + ## v2.8, 2022-10-19 #### Highlights @@ -77,7 +90,7 @@ Read our [Migration Guide](https://guide.meteor.com/2.8-migration.html) for this For making this great framework even better! -## v2.7.3, 2022-05-31 +## v2.7.3, 2022-05-3 #### Highlights * `accounts-passwordless@2.1.2`: diff --git a/docs/source/api/accounts-multi.md b/docs/source/api/accounts-multi.md index 644a28970f..8689b15859 100644 --- a/docs/source/api/accounts-multi.md +++ b/docs/source/api/accounts-multi.md @@ -315,6 +315,15 @@ Accounts.setAdditionalFindUserOnExternalLogin(({serviceName, serviceData}) => { } }) ``` +{% apibox "AccountsServer#registerLoginHandler" %} + +Use this to register your own custom authentication method. This is also used by all of the other inbuilt accounts packages to integrate with the accounts system. + +There can be multiple login handlers that are registered. When a login request is made, it will go through all these handlers to find its own handler. + +The registered handler callback is called with a single argument, the `options` object which comes from the login method. For example, if you want to login with a plaintext password, `options` could be `{ user: { username: }, password: }`,or `{ user: { email: }, password: }`. + +The login handler should return `undefined` if it's not going to handle the login request or else the login result object.

Rate Limiting

diff --git a/docs/source/api/email.md b/docs/source/api/email.md index e801e6a709..a4dc913954 100644 --- a/docs/source/api/email.md +++ b/docs/source/api/email.md @@ -83,6 +83,28 @@ Meteor.call( 'This is a test of Email.send.' ); ``` +{% apibox "Email.sendAsync" %} + +`sendAsync` only works on the server. It has the same behavior as `Email.send`, but returns a Promise. +If you defined `Email.customTransport`, the `callAsync` method returns the return value from the `customTransport` method or a Promise, if this method is async. + +```js +// Server: Define a method that the client can call. +Meteor.methods({ + sendEmail(to, from, subject, text) { + // Make sure that all arguments are strings. + check([to, from, subject, text], [String]); + + // Let other method calls from the same client start running, without + // waiting for the email sending to complete. + this.unblock(); + + return Email.sendAsync({ to, from, subject, text }).catch(err => { + // + }); + } +}); +``` {% apibox "Email.hookSend" %} diff --git a/npm-packages/eslint-plugin-meteor/.eslintignore b/npm-packages/eslint-plugin-meteor/.eslintignore index 5228e27ede..3653ab7c32 100644 --- a/npm-packages/eslint-plugin-meteor/.eslintignore +++ b/npm-packages/eslint-plugin-meteor/.eslintignore @@ -67,7 +67,6 @@ tools/runners/run-app.js tools/runners/run-mongo.js tools/runners/run-proxy.js tools/runners/run-selenium.js -tools/runners/run-updater.js tools/packaging/package-client.js tools/packaging/package-map.js diff --git a/npm-packages/meteor-installer/README.md b/npm-packages/meteor-installer/README.md index 4d164e7194..b240fe71b2 100644 --- a/npm-packages/meteor-installer/README.md +++ b/npm-packages/meteor-installer/README.md @@ -57,3 +57,8 @@ npm install -g meteor --ignore-meteor-setup-exec-path ``` (or by setting the environment variable `npm_config_ignore_meteor_setup_exec_path=true`) + +### Proxy configuration + +Setting the `https_proxy` or `HTTPS_PROXY` environment variable to a valid proxy URL will cause the +downloader to use the configured proxy to retrieve the Meteor files. \ No newline at end of file diff --git a/npm-packages/meteor-installer/install.js b/npm-packages/meteor-installer/install.js index c495d31999..6f52bfb93b 100644 --- a/npm-packages/meteor-installer/install.js +++ b/npm-packages/meteor-installer/install.js @@ -1,4 +1,5 @@ const { DownloaderHelper } = require('node-downloader-helper'); +const HttpsProxyAgent = require('https-proxy-agent'); const cliProgress = require('cli-progress'); const Seven = require('node-7z'); const path = require('path'); @@ -143,6 +144,15 @@ try { download(); +function generateProxyAgent() { + const proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY; + if (!proxyUrl) { + return undefined; + } + + return new HttpsProxyAgent(proxyUrl); +} + function download() { const start = Date.now(); const downloadProgress = new cliProgress.SingleBar( @@ -158,6 +168,9 @@ function download() { retry: { maxRetries: 5, delay: 5000 }, override: true, fileName: tarGzName, + httpsRequestOptions: { + agent: generateProxyAgent() + } }); dl.on('progress', ({ progress }) => { diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 9088bbbea9..8c472e1b5e 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -318,7 +318,7 @@ export class AccountsServer extends AccountsCommon { // If user is not found, try a case insensitive lookup if (!user) { selector = this._selectorForFastCaseInsensitiveLookup(fieldName, fieldValue); - const candidateUsers = Meteor.users.find(selector, options).fetch(); + const candidateUsers = Meteor.users.find(selector, { ...options, limit: 2 }).fetch(); // No match if multiple candidates are found if (candidateUsers.length === 1) { user = candidateUsers[0]; @@ -434,7 +434,7 @@ export class AccountsServer extends AccountsCommon { // If the login is allowed and isn't aborted by a validate login hook // callback, log in the user. // - _attemptLogin( + async _attemptLogin( methodInvocation, methodName, methodArgs, @@ -494,18 +494,18 @@ export class AccountsServer extends AccountsCommon { // Ensure that thrown exceptions are caught and that login hook // callbacks are still called. // - _loginMethod( + async _loginMethod( methodInvocation, methodName, methodArgs, type, fn ) { - return this._attemptLogin( + return await this._attemptLogin( methodInvocation, methodName, methodArgs, - tryLoginMethod(type, fn) + await tryLoginMethod(type, fn) ); }; @@ -547,19 +547,14 @@ export class AccountsServer extends AccountsCommon { /// LOGIN HANDLERS /// - // The main entry point for auth packages to hook in to login. - // - // A login handler is a login method which can return `undefined` to - // indicate that the login request is not handled by this handler. - // - // @param name {String} Optional. The service name, used by default - // if a specific service name isn't returned in the result. - // - // @param handler {Function} A function that receives an options object - // (as passed as an argument to the `login` method) and returns one of: - // - `undefined`, meaning don't handle; - // - a login method result object - + /** + * @summary Registers a new login handler. + * @locus Server + * @param {String} [name] The type of login method like oauth, password, etc. + * @param {Function} handler A function that receives an options object + * (as passed as an argument to the `login` method) and returns one of + * `undefined`, meaning don't handle or a login method result object. + */ registerLoginHandler(name, handler) { if (! handler) { handler = name; @@ -587,11 +582,10 @@ export class AccountsServer extends AccountsCommon { // Try all of the registered login handlers until one of them doesn't // return `undefined`, meaning it handled this call to `login`. Return // that return value. - _runLoginHandlers(methodInvocation, options) { + async _runLoginHandlers(methodInvocation, options) { for (let handler of this._loginHandlers) { - const result = tryLoginMethod( - handler.name, - () => handler.handler.call(methodInvocation, options) + const result = await tryLoginMethod(handler.name, async () => + await handler.handler.call(methodInvocation, options) ); if (result) { @@ -599,7 +593,10 @@ export class AccountsServer extends AccountsCommon { } if (result !== undefined) { - throw new Meteor.Error(400, "A login handler should return a result or undefined"); + throw new Meteor.Error( + 400, + 'A login handler should return a result or undefined' + ); } } @@ -644,14 +641,15 @@ export class AccountsServer extends AccountsCommon { // If successful, returns {token: reconnectToken, id: userId} // If unsuccessful (for example, if the user closed the oauth login popup), // throws an error describing the reason - methods.login = function (options) { + methods.login = async function (options) { // Login handlers should really also check whatever field they look at in // options, but we don't enforce it. check(options, Object); - const result = accounts._runLoginHandlers(this, options); + const result = await accounts._runLoginHandlers(this, options); + //console.log({result}); - return accounts._attemptLogin(this, "login", arguments, result); + return await accounts._attemptLogin(this, "login", arguments, result); }; methods.logout = function () { @@ -1512,10 +1510,10 @@ const cloneAttemptWithConnection = (connection, attempt) => { return clonedAttempt; }; -const tryLoginMethod = (type, fn) => { +const tryLoginMethod = async (type, fn) => { let result; try { - result = fn(); + result = await fn(); } catch (e) { result = {error: e}; diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index ea1236313c..812bf848d1 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -1,8 +1,5 @@ -import bcrypt from 'bcrypt' -import {Accounts} from "meteor/accounts-base"; - -const bcryptHash = Meteor.wrapAsync(bcrypt.hash); -const bcryptCompare = Meteor.wrapAsync(bcrypt.compare); +import { hash as bcryptHash, compare as bcryptCompare } from 'bcrypt'; +import { Accounts } from "meteor/accounts-base"; // Utility for grabbing user const getUserById = (id, options) => Meteor.users.findOne(id, Accounts._addDefaultFieldSelector(options)); @@ -48,9 +45,9 @@ const getPasswordString = password => { // SHA256 before bcrypt) or an object with properties `digest` and // `algorithm` (in which case we bcrypt `password.digest`). // -const hashPassword = password => { +const hashPassword = async password => { password = getPasswordString(password); - return bcryptHash(password, Accounts._bcryptRounds()); + return await bcryptHash(password, Accounts._bcryptRounds()); }; // Extract the number of rounds used in the specified bcrypt hash. @@ -74,7 +71,7 @@ const getRoundsFromBcryptHash = hash => { // The user parameter needs at least user._id and user.services Accounts._checkPasswordUserFields = {_id: 1, services: 1}; // -Accounts._checkPassword = (user, password) => { +const checkPasswordAsync = async (user, password) => { const result = { userId: user._id }; @@ -83,15 +80,16 @@ Accounts._checkPassword = (user, password) => { const hash = user.services.password.bcrypt; const hashRounds = getRoundsFromBcryptHash(hash); - if (! bcryptCompare(formattedPassword, hash)) { + if (! await bcryptCompare(formattedPassword, hash)) { result.error = Accounts._handleError("Incorrect password", false); } else if (hash && Accounts._bcryptRounds() != hashRounds) { // The password checks out, but the user's bcrypt hash needs to be updated. - Meteor.defer(() => { + + Meteor.defer(async () => { Meteor.users.update({ _id: user._id }, { $set: { 'services.password.bcrypt': - bcryptHash(formattedPassword, Accounts._bcryptRounds()) + await bcryptHash(formattedPassword, Accounts._bcryptRounds()) } }); }); @@ -99,7 +97,13 @@ Accounts._checkPassword = (user, password) => { return result; }; -const checkPassword = Accounts._checkPassword; + +const checkPassword = async (user, password) => { + return Promise.await(checkPasswordAsync(user, password)); +}; + +Accounts._checkPassword = checkPassword; +Accounts._checkPasswordAsync = checkPasswordAsync; /// /// LOGIN @@ -163,7 +167,7 @@ const passwordValidator = Match.OneOf( // // Note that neither password option is secure without SSL. // -Accounts.registerLoginHandler("password", options => { +Accounts.registerLoginHandler("password", async options => { if (!options.password) return undefined; // don't handle @@ -188,7 +192,7 @@ Accounts.registerLoginHandler("password", options => { Accounts._handleError("User has no password set"); } - const result = checkPassword(user, options.password); + const result = await checkPasswordAsync(user, options.password); // This method is added by the package accounts-2fa // First the login is validated, then the code situation is checked if ( @@ -258,7 +262,7 @@ Accounts.setUsername = (userId, newUsername) => { // Let the user change their own password if they know the old // password. `oldPassword` and `newPassword` should be objects with keys // `digest` and `algorithm` (representing the SHA256 of the password). -Meteor.methods({changePassword: function (oldPassword, newPassword) { +Meteor.methods({changePassword: async function (oldPassword, newPassword) { check(oldPassword, passwordValidator); check(newPassword, passwordValidator); @@ -278,12 +282,12 @@ Meteor.methods({changePassword: function (oldPassword, newPassword) { Accounts._handleError("User has no password set"); } - const result = checkPassword(user, oldPassword); + const result = await checkPasswordAsync(user, oldPassword); if (result.error) { throw result.error; } - const hashed = hashPassword(newPassword); + const hashed = await hashPassword(newPassword); // It would be better if this removed ALL existing tokens and replaced // the token for the current connection with a new one, but that would @@ -316,10 +320,10 @@ Meteor.methods({changePassword: function (oldPassword, newPassword) { * @param {Object} options.logout Logout all current connections with this userId (default: true) * @importFromPackage accounts-base */ -Accounts.setPassword = (userId, newPlaintextPassword, options) => { - check(userId, String) - check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)) - check(options, Match.Maybe({ logout: Boolean })) +Accounts.setPasswordAsync = async (userId, newPlaintextPassword, options) => { + check(userId, String); + check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)); + check(options, Match.Maybe({ logout: Boolean })); options = { logout: true , ...options }; const user = getUserById(userId, {fields: {_id: 1}}); @@ -331,7 +335,7 @@ Accounts.setPassword = (userId, newPlaintextPassword, options) => { $unset: { 'services.password.reset': 1 }, - $set: {'services.password.bcrypt': hashPassword(newPlaintextPassword)} + $set: {'services.password.bcrypt': await hashPassword(newPlaintextPassword)} }; if (options.logout) { @@ -341,6 +345,19 @@ Accounts.setPassword = (userId, newPlaintextPassword, options) => { Meteor.users.update({_id: user._id}, update); }; +/** + * @summary Forcibly change the password for a user. + * @locus Server + * @param {String} userId The id of the user to update. + * @param {String} newPassword A new password for the user. + * @param {Object} [options] + * @param {Object} options.logout Logout all current connections with this userId (default: true) + * @importFromPackage accounts-base + */ +Accounts.setPassword = (userId, newPlaintextPassword, options) => { + return Promise.await(Accounts.setPasswordAsync(userId, newPlaintextPassword, options)); +}; + /// /// RESETTING VIA EMAIL @@ -560,15 +577,15 @@ Accounts.sendEnrollmentEmail = (userId, email, extraTokenData, extraParams) => { // Take token from sendResetPasswordEmail or sendEnrollmentEmail, change // the users password, and log them in. -Meteor.methods({resetPassword: function (...args) { +Meteor.methods({resetPassword: async function (...args) { const token = args[0]; const newPassword = args[1]; - return Accounts._loginMethod( + return await Accounts._loginMethod( this, "resetPassword", args, "password", - () => { + async () => { check(token, String); check(newPassword, passwordValidator); @@ -617,7 +634,7 @@ Meteor.methods({resetPassword: function (...args) { error: new Meteor.Error(403, "Token has invalid email address") }; - const hashed = hashPassword(newPassword); + const hashed = await hashPassword(newPassword); // NOTE: We're about to invalidate tokens on the user, who we might be // logged in as. Make sure to avoid logging ourselves out if this @@ -712,9 +729,9 @@ Accounts.sendVerificationEmail = (userId, email, extraTokenData, extraParams) => // Take token from sendVerificationEmail, mark the email as verified, // and log them in. -Meteor.methods({verifyEmail: function (...args) { +Meteor.methods({verifyEmail: async function (...args) { const token = args[0]; - return Accounts._loginMethod( + return await Accounts._loginMethod( this, "verifyEmail", args, @@ -888,7 +905,7 @@ Accounts.removeEmail = (userId, email) => { // does the actual user insertion. // // returns the user id -const createUser = options => { +const createUser = async options => { // Unknown keys allowed, because a onCreateUserHook can take arbitrary // options. check(options, Match.ObjectIncluding({ @@ -903,22 +920,22 @@ const createUser = options => { const user = {services: {}}; if (password) { - const hashed = hashPassword(password); + const hashed = await hashPassword(password); user.services.password = { bcrypt: hashed }; } - return Accounts._createUserCheckingDuplicates({ user, email, username, options }) + return Accounts._createUserCheckingDuplicates({ user, email, username, options }); }; // method for create user. Requests come from the client. -Meteor.methods({createUser: function (...args) { +Meteor.methods({createUser: async function (...args) { const options = args[0]; - return Accounts._loginMethod( + return await Accounts._loginMethod( this, "createUser", args, "password", - () => { + async () => { // createUser() above does more checking. check(options, Object); if (Accounts._options.forbidClientAccountCreation) @@ -926,7 +943,7 @@ Meteor.methods({createUser: function (...args) { error: new Meteor.Error(403, "Signups forbidden") }; - const userId = Accounts.createUserVerifyingEmail(options); + const userId = await Accounts.createUserVerifyingEmail(options); // client gets logged in as the new user afterwards. return {userId: userId}; @@ -948,10 +965,10 @@ Meteor.methods({createUser: function (...args) { * @param {Object} options.profile The user's profile, typically including the `name` field. * @importFromPackage accounts-base * */ -Accounts.createUserVerifyingEmail = (options) => { +Accounts.createUserVerifyingEmail = async (options) => { options = { ...options }; // Create user. result contains id and token. - const userId = createUser(options); + const userId = await createUser(options); // safety belt. createUser is supposed to throw on error. send 500 error // instead of sending a verification email with empty userid. if (! userId) @@ -976,14 +993,15 @@ Accounts.createUserVerifyingEmail = (options) => { // Unlike the client version, this does not log you in as this user // after creation. // -// returns userId or throws an error if it can't create +// returns Promise or throws an error if it can't create // // XXX add another argument ("server options") that gets sent to onCreateUser, // which is always empty when called from the createUser method? eg, "admin: // true", which we want to prevent the client from setting, but which a custom // method calling Accounts.createUser could set? // -Accounts.createUser = (options, callback) => { + +Accounts.createUserAsync = async (options, callback) => { options = { ...options }; // XXX allow an optional callback? @@ -994,6 +1012,23 @@ Accounts.createUser = (options, callback) => { return createUser(options); }; +// Create user directly on the server. +// +// Unlike the client version, this does not log you in as this user +// after creation. +// +// returns userId or throws an error if it can't create +// +// XXX add another argument ("server options") that gets sent to onCreateUser, +// which is always empty when called from the createUser method? eg, "admin: +// true", which we want to prevent the client from setting, but which a custom +// method calling Accounts.createUser could set? +// + +Accounts.createUser = (options, callback) => { + return Promise.await(Accounts.createUserAsync(options, callback)); +}; + /// /// PASSWORD-SPECIFIC INDEXES ON USERS /// diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 23e7e6ca8c..0266c977f2 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1747,7 +1747,7 @@ if (Meteor.isServer) (() => { Tinytest.addAsync( 'passwords - allow custom bcrypt rounds', - (test, done) => { + async (test, done) => { const getUserHashRounds = user => Number(user.services.password.bcrypt.substring(4, 6)); @@ -1768,7 +1768,7 @@ if (Meteor.isServer) (() => { const defaultRounds = Accounts._bcryptRounds(); const customRounds = 11; Accounts._options.bcryptRounds = customRounds; - Accounts._checkPassword(user1, password); + await Accounts._checkPasswordAsync(user1, password); Meteor.setTimeout(() => { user1 = Meteor.users.findOne(userId1); rounds = getUserHashRounds(user1); diff --git a/packages/browser-policy-framing/browser-policy-framing.js b/packages/browser-policy-framing/browser-policy-framing.js index eb90a3778b..1027492c57 100644 --- a/packages/browser-policy-framing/browser-policy-framing.js +++ b/packages/browser-policy-framing/browser-policy-framing.js @@ -12,7 +12,7 @@ var xFrameOptions = defaultXFrameOptions; const BrowserPolicy = require("meteor/browser-policy-common").BrowserPolicy; BrowserPolicy.framing = {}; -_.extend(BrowserPolicy.framing, { +Object.assign(BrowserPolicy.framing, { // Exported for tests and browser-policy-common. _constructXFrameOptions: function () { return xFrameOptions; diff --git a/packages/browser-policy-framing/package.js b/packages/browser-policy-framing/package.js index 4b982f0967..7dc9041568 100644 --- a/packages/browser-policy-framing/package.js +++ b/packages/browser-policy-framing/package.js @@ -5,7 +5,7 @@ Package.describe({ Package.onUse(function (api) { api.use("modules"); - api.use(["underscore", "browser-policy-common"], "server"); + api.use(["browser-policy-common"], "server"); api.imply(["browser-policy-common"], "server"); api.mainModule("browser-policy-framing.js", "server"); }); diff --git a/packages/browser-policy/browser-policy-test.js b/packages/browser-policy/browser-policy-test.js index cd2e453a74..4ba437f761 100644 --- a/packages/browser-policy/browser-policy-test.js +++ b/packages/browser-policy/browser-policy-test.js @@ -1,17 +1,34 @@ BrowserPolicy._setRunningTest(); +var toObject = function(list, values) { + if (list == null) return {}; + var result = {}; + for (var i = 0, length = list.length; i < length; i++) { + if (values) { + result[list[i]] = values[i]; + } else { + result[list[i][0]] = list[i][1]; + } + } + return result; +}; + var cspsEqual = function (csp1, csp2) { var cspToObj = function (csp) { csp = csp.substring(0, csp.length - 1); - var parts = _.map(csp.split("; "), function (part) { + var parts = csp.split("; ").map(function (part) { return part.split(" "); }); - var keys = _.map(parts, _.first); - var values = _.map(parts, _.rest); - _.each(values, function (value) { + var keys = parts.map(part => part[0]); + var values = parts.map((part) => { + const [head, ...tail] = part; + return tail; + }); + values.forEach(function (value) { value.sort(); }); - return _.object(keys, values); + + return toObject(keys, values); }; return EJSON.equals(cspToObj(csp1), cspToObj(csp2)); @@ -137,11 +154,11 @@ Tinytest.add("browser-policy - csp", function (test) { "default-src 'none'; frame-src https://foo.com; " + "object-src http://foo.com https://foo.com;")); - // Check that frame-ancestors property is set correctly.
 - BrowserPolicy.content.allowFrameAncestorsOrigin("https://foo.com/");
 - test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
 - "default-src 'none'; frame-src https://foo.com; " +
 - "object-src http://foo.com https://foo.com; " +
 + // Check that frame-ancestors property is set correctly. + BrowserPolicy.content.allowFrameAncestorsOrigin("https://foo.com/"); + test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), + "default-src 'none'; frame-src https://foo.com; " + + "object-src http://foo.com https://foo.com; " + "frame-ancestors https://foo.com;")); // CSP2 options: nonce @@ -188,4 +205,4 @@ Tinytest.add("browser-policy - X-Content-Type-Options", function (test) { test.equal(BrowserPolicy.content._xContentTypeOptions(), "nosniff"); BrowserPolicy.content.allowContentTypeSniffing(); test.equal(BrowserPolicy.content._xContentTypeOptions(), undefined); -}); +}); \ No newline at end of file diff --git a/packages/browser-policy/package.js b/packages/browser-policy/package.js index 8d370afc66..a44f8ba6b4 100644 --- a/packages/browser-policy/package.js +++ b/packages/browser-policy/package.js @@ -11,6 +11,6 @@ Package.onUse(function (api) { }); Package.onTest(function (api) { - api.use(["tinytest", "browser-policy", "ejson", "underscore"], "server"); + api.use(["tinytest", "browser-policy", "ejson"], "server"); api.addFiles("browser-policy-test.js", "server"); }); diff --git a/packages/diff-sequence/package.js b/packages/diff-sequence/package.js index 18c673049b..ff143f3e8a 100644 --- a/packages/diff-sequence/package.js +++ b/packages/diff-sequence/package.js @@ -14,7 +14,6 @@ Package.onUse(function (api) { Package.onTest(function (api) { api.use([ 'tinytest', - 'underscore', 'ejson' ]); diff --git a/packages/diff-sequence/tests.js b/packages/diff-sequence/tests.js index a8cc9c3c58..8c593baee0 100644 --- a/packages/diff-sequence/tests.js +++ b/packages/diff-sequence/tests.js @@ -1,6 +1,6 @@ Tinytest.add("diff-sequence - diff changes ordering", function (test) { var makeDocs = function (ids) { - return _.map(ids, function (id) { return {_id: id};}); + return ids.map(function (id) { return {_id: id};}); }; var testMutation = function (a, b) { var aa = makeDocs(a); @@ -10,12 +10,12 @@ Tinytest.add("diff-sequence - diff changes ordering", function (test) { addedBefore: function (id, doc, before) { if (before === null) { - aaCopy.push( _.extend({_id: id}, doc)); + aaCopy.push( Object.assign({_id: id}, doc)); return; } for (var i = 0; i < aaCopy.length; i++) { if (aaCopy[i]._id === before) { - aaCopy.splice(i, 0, _.extend({_id: id}, doc)); + aaCopy.splice(i, 0, Object.assign({_id: id}, doc)); return; } } @@ -29,12 +29,12 @@ Tinytest.add("diff-sequence - diff changes ordering", function (test) { } } if (before === null) { - aaCopy.push( _.extend({_id: id}, found)); + aaCopy.push( Object.assign({_id: id}, found)); return; } for (i = 0; i < aaCopy.length; i++) { if (aaCopy[i]._id === before) { - aaCopy.splice(i, 0, _.extend({_id: id}, found)); + aaCopy.splice(i, 0, Object.assign({_id: id}, found)); return; } } @@ -75,7 +75,7 @@ Tinytest.add("diff-sequence - diff", function (test) { for (var i = 1; i <= origLen; i++) oldResults[i-1] = {_id: i}; - var newResults = _.map(newOldIdx, function(n) { + var newResults = newOldIdx.map(function(n) { var doc = {_id: Math.abs(n)}; if (n < 0) doc.changed = true; @@ -89,7 +89,7 @@ Tinytest.add("diff-sequence - diff", function (test) { return -1; }; - var results = _.clone(oldResults); + var results = [...oldResults]; var observer = { addedBefore: function(id, fields, before) { var before_idx; @@ -97,7 +97,7 @@ Tinytest.add("diff-sequence - diff", function (test) { before_idx = results.length; else before_idx = find (results, before); - var doc = _.extend({_id: id}, fields); + var doc = Object.assign({_id: id}, fields); test.isFalse(before_idx < 0 || before_idx > results.length); results.splice(before_idx, 0, doc); }, @@ -157,4 +157,3 @@ Tinytest.add("diff-sequence - diff", function (test) { diffTest(3, [-3, -2, -1]); diffTest(10, [-2, 7, 4, 6, 11, -3, -8, 9]); }); - diff --git a/packages/ecmascript/package.js b/packages/ecmascript/package.js index eed53fefd0..37378b5163 100644 --- a/packages/ecmascript/package.js +++ b/packages/ecmascript/package.js @@ -31,7 +31,7 @@ Package.onUse(function(api) { }); Package.onTest(function(api) { - api.use(['tinytest', 'underscore']); + api.use(['tinytest']); api.use(['es5-shim', 'ecmascript', 'babel-compiler']); api.addFiles('runtime-tests.js'); api.addFiles('transpilation-tests.js', 'server'); diff --git a/packages/ecmascript/runtime-tests.js b/packages/ecmascript/runtime-tests.js index f0cb308a97..c4ddfe227d 100644 --- a/packages/ecmascript/runtime-tests.js +++ b/packages/ecmascript/runtime-tests.js @@ -216,7 +216,7 @@ Tinytest.add('ecmascript - runtime - block scope', test => { }); } - _.each(thunks, f => f()); + thunks.forEach(f => f()); test.equal(buf, [0, 1, 2]); } }); diff --git a/packages/ejson/ejson.d.ts b/packages/ejson/ejson.d.ts new file mode 100644 index 0000000000..61c3a7674c --- /dev/null +++ b/packages/ejson/ejson.d.ts @@ -0,0 +1,71 @@ +export interface EJSONableCustomType { + clone?(): EJSONableCustomType; + equals?(other: Object): boolean; + toJSONValue(): JSONable; + typeName(): string; +} + +export type EJSONableProperty = + | number + | string + | boolean + | Object + | number[] + | string[] + | Object[] + | Date + | Uint8Array + | EJSONableCustomType + | undefined + | null; + +export interface EJSONable { + [key: string]: EJSONableProperty; +} + +export interface JSONable { + [key: string]: + | number + | string + | boolean + | Object + | number[] + | string[] + | Object[] + | undefined + | null; +} + +export interface EJSON extends EJSONable {} + +export namespace EJSON { + function addType( + name: string, + factory: (val: JSONable) => EJSONableCustomType + ): void; + + function clone(val: T): T; + + function equals( + a: EJSON, + b: EJSON, + options?: { keyOrderSensitive?: boolean | undefined } + ): boolean; + + function fromJSONValue(val: JSONable): any; + + function isBinary(x: Object): x is Uint8Array; + function newBinary(size: number): Uint8Array; + + function parse(str: string): EJSON; + + function stringify( + val: EJSON, + options?: { + indent?: boolean | number | string | undefined; + canonical?: boolean | undefined; + } + ): string; + + function toJSONValue(val: EJSON): JSONable; +} diff --git a/packages/ejson/package-types.json b/packages/ejson/package-types.json new file mode 100644 index 0000000000..3f2149b4c0 --- /dev/null +++ b/packages/ejson/package-types.json @@ -0,0 +1,3 @@ +{ + "typesEntry": "ejson.d.ts" +} diff --git a/packages/ejson/package.js b/packages/ejson/package.js index 2a10d49443..654f16c568 100644 --- a/packages/ejson/package.js +++ b/packages/ejson/package.js @@ -5,6 +5,7 @@ Package.describe({ Package.onUse(function onUse(api) { api.use(['ecmascript', 'base64']); + api.addAssets('ejson.d.ts', 'server'); api.mainModule('ejson.js'); api.export('EJSON'); }); diff --git a/packages/email/email.js b/packages/email/email.js index 3f64e23692..eed8dbd9b3 100644 --- a/packages/email/email.js +++ b/packages/email/email.js @@ -2,7 +2,6 @@ import { Meteor } from 'meteor/meteor'; import { Log } from 'meteor/logging'; import { Hook } from 'meteor/callback-hook'; -import Future from 'fibers/future'; import url from 'url'; import nodemailer from 'nodemailer'; import wellKnow from 'nodemailer/lib/well-known'; @@ -25,7 +24,7 @@ export const EmailInternals = { const MailComposer = EmailInternals.NpmModules.mailcomposer.module; -const makeTransport = function(mailUrlString) { +const makeTransport = function (mailUrlString) { const mailUrl = new URL(mailUrlString); if (mailUrl.protocol !== 'smtp:' && mailUrl.protocol !== 'smtps:') { @@ -60,7 +59,7 @@ const makeTransport = function(mailUrlString) { }; // More info: https://nodemailer.com/smtp/well-known/ -const knownHostsTransport = function(settings = undefined, url = undefined) { +const knownHostsTransport = function (settings = undefined, url = undefined) { let service, user, password; const hasSettings = settings && Object.keys(settings).length; @@ -110,7 +109,7 @@ const knownHostsTransport = function(settings = undefined, url = undefined) { }; EmailTest.knowHostsTransport = knownHostsTransport; -const getTransport = function() { +const getTransport = function () { const packageSettings = Meteor.settings.packages?.email || {}; // We delay this check until the first call to Email.send, in case someone // set process.env.MAIL_URL in startup code. Then we store in a cache until @@ -138,40 +137,40 @@ const getTransport = function() { }; let nextDevModeMailId = 0; -let output_stream = process.stdout; + +EmailTest._getAndIncNextDevModeMailId = function () { + return nextDevModeMailId++; +}; // Testing hooks -EmailTest.overrideOutputStream = function(stream) { +EmailTest.resetNextDevModeMailId = function () { nextDevModeMailId = 0; - output_stream = stream; }; -EmailTest.restoreOutputStream = function() { - output_stream = process.stdout; -}; +const devModeSendAsync = function (mail, options) { + const stream = options?.stream || process.stdout; + return new Promise((resolve, reject) => { + let devModeMailId = EmailTest._getAndIncNextDevModeMailId(); -const devModeSend = function(mail) { - let devModeMailId = nextDevModeMailId++; - - const stream = output_stream; - - // This approach does not prevent other writers to stdout from interleaving. - stream.write('====== BEGIN MAIL #' + devModeMailId + ' ======\n'); - stream.write( - '(Mail not sent; to enable sending, set the MAIL_URL ' + + // This approach does not prevent other writers to stdout from interleaving. + const output = ['====== BEGIN MAIL #' + devModeMailId + ' ======\n']; + output.push( + '(Mail not sent; to enable sending, set the MAIL_URL ' + 'environment variable.)\n' - ); - const readStream = new MailComposer(mail).compile().createReadStream(); - readStream.pipe(stream, { end: false }); - const future = new Future(); - readStream.on('end', function() { - stream.write('====== END MAIL #' + devModeMailId + ' ======\n'); - future.return(); + ); + const readStream = new MailComposer(mail).compile().createReadStream(); + readStream.on('data', buffer => { + output.push(buffer.toString()); + }); + readStream.on('end', function () { + output.push('====== END MAIL #' + devModeMailId + ' ======\n'); + stream.write(output.join(''), () => resolve()); + }); + readStream.on('error', (err) => reject(err)); }); - future.wait(); }; -const smtpSend = function(transport, mail) { +const smtpSend = function (transport, mail) { transport._syncSendMail(mail); }; @@ -186,7 +185,7 @@ const sendHooks = new Hook(); * false to skip sending. * @returns {{ stop: function, callback: function }} */ -Email.hookSend = function(f) { +Email.hookSend = function (f) { return sendHooks.register(f); }; @@ -231,25 +230,77 @@ Email.customTransport = undefined; * You can create a `MailComposer` object via * `new EmailInternals.NpmModules.mailcomposer.module`. */ -Email.send = function(options) { - if (options.mailComposer) { - options = options.mailComposer.mail; +Email.send = function (options) { + if (Email.customTransport) { + // Preserve current behavior + const email = options.mailComposer ? options.mailComposer.mail : options; + let send = true; + sendHooks.forEach((hook) => { + send = hook(email); + return send; + }); + if (!send) { + return; + } + const packageSettings = Meteor.settings.packages?.email || {}; + Email.customTransport({ packageSettings, ...email }); + return; } + // Using Fibers Promise.await + return Promise.await(Email.sendAsync(options)); +}; + +/** + * @summary Send an email with asyncronous method. Capture Throws an `Error` on failure to contact mail server + * or if mail server returns an error. All fields should match + * [RFC5322](http://tools.ietf.org/html/rfc5322) specification. + * + * If the `MAIL_URL` environment variable is set, actually sends the email. + * Otherwise, prints the contents of the email to standard out. + * + * Note that this package is based on **nodemailer**, so make sure to refer to + * [the documentation](http://nodemailer.com/) + * when using the `attachments` or `mailComposer` options. + * + * @locus Server + * @return {Promise} + * @param {Object} options + * @param {String} [options.from] "From:" address (required) + * @param {String|String[]} options.to,cc,bcc,replyTo + * "To:", "Cc:", "Bcc:", and "Reply-To:" addresses + * @param {String} [options.inReplyTo] Message-ID this message is replying to + * @param {String|String[]} [options.references] Array (or space-separated string) of Message-IDs to refer to + * @param {String} [options.messageId] Message-ID for this message; otherwise, will be set to a random value + * @param {String} [options.subject] "Subject:" line + * @param {String} [options.text|html] Mail body (in plain text and/or HTML) + * @param {String} [options.watchHtml] Mail body in HTML specific for Apple Watch + * @param {String} [options.icalEvent] iCalendar event attachment + * @param {Object} [options.headers] Dictionary of custom headers - e.g. `{ "header name": "header value" }`. To set an object under a header name, use `JSON.stringify` - e.g. `{ "header name": JSON.stringify({ tracking: { level: 'full' } }) }`. + * @param {Object[]} [options.attachments] Array of attachment objects, as + * described in the [nodemailer documentation](https://nodemailer.com/message/attachments/). + * @param {MailComposer} [options.mailComposer] A [MailComposer](https://nodemailer.com/extras/mailcomposer/#e-mail-message-fields) + * object representing the message to be sent. Overrides all other options. + * You can create a `MailComposer` object via + * `new EmailInternals.NpmModules.mailcomposer.module`. + */ +Email.sendAsync = async function (options) { + + const email = options.mailComposer ? options.mailComposer.mail : options; let send = true; - sendHooks.forEach(hook => { - send = hook(options); + sendHooks.forEach((hook) => { + send = hook(email); return send; }); - if (!send) return; - - const customTransport = Email.customTransport; - if (customTransport) { - const packageSettings = Meteor.settings.packages?.email || {}; - customTransport({ packageSettings, ...options }); + if (!send) { return; } + if (Email.customTransport) { + const packageSettings = Meteor.settings.packages?.email || {}; + return Email.customTransport({ packageSettings, ...email }); + } + const mailUrlEnv = process.env.MAIL_URL; const mailUrlSettings = Meteor.settings.packages?.email; @@ -263,8 +314,8 @@ Email.send = function(options) { if (mailUrlEnv || mailUrlSettings) { const transport = getTransport(); - smtpSend(transport, options); + smtpSend(transport, email); return; } - devModeSend(options); + return devModeSendAsync(email, options); }; diff --git a/packages/email/email_test_helpers.js b/packages/email/email_test_helpers.js new file mode 100644 index 0000000000..a8706ab1c9 --- /dev/null +++ b/packages/email/email_test_helpers.js @@ -0,0 +1,21 @@ +import streamBuffers from 'stream-buffers'; + +export const devWarningBanner = + '(Mail not sent; to enable ' + + 'sending, set the MAIL_URL environment variable.)\n'; + +export const smokeEmailTest = (testFunction) => { + // This only tests dev mode, so don't run the test if this is deployed. + if (process.env.MAIL_URL) return; + const stream = new streamBuffers.WritableStreamBuffer(); + EmailTest.resetNextDevModeMailId(); + testFunction(stream); +}; + +export const canonicalize = (string) => { + // Remove generated content for test.equal to succeed. + return string + .replace(/Message-ID: <[^<>]*>\r\n/, 'Message-ID: <...>\r\n') + .replace(/Date: (?!dummy).*\r\n/, 'Date: ...\r\n') + .replace(/(boundary="|^--)--[^\s"]+?(-Part|")/gm, '$1--...$2'); +}; diff --git a/packages/email/email_tests.js b/packages/email/email_tests.js index 877264ce95..6f016f26b9 100644 --- a/packages/email/email_tests.js +++ b/packages/email/email_tests.js @@ -1,304 +1,85 @@ -import streamBuffers from 'stream-buffers'; +import { Email } from 'meteor/email'; +import { smokeEmailTest } from './email_test_helpers'; +import { TEST_CASES } from './email_tests_data'; -const devWarningBanner = "(Mail not sent; to enable " + - "sending, set the MAIL_URL environment variable.)\n"; +const CUSTOM_TRANSPORT_SETTINGS = { + email: { service: '1on1', user: 'test', password: 'pwd' }, +}; -function smokeEmailTest(testFunction) { - // This only tests dev mode, so don't run the test if this is deployed. - if (process.env.MAIL_URL) return; +const sleep = (ms) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; - try { - const stream = new streamBuffers.WritableStreamBuffer; - EmailTest.overrideOutputStream(stream); +// Create dynamic sync tests +TEST_CASES.forEach(({ title, options, testCalls }) => { + Tinytest.add(`[Sync] ${title}`, function (test) { + smokeEmailTest((stream) => { + Object.entries(options).forEach(([key, option]) => { + const testCall = testCalls[key]; + Email.send({ ...option, stream }); + testCall(test, stream); + }); + }); + }); +}); - testFunction(stream); +// Create dynamic async tests +TEST_CASES.forEach(({ title, options, testCalls }) => { + Tinytest.addAsync(`[Async] ${title}`, function (test, onComplete) { + smokeEmailTest((stream) => { + const allPromises = Object.entries(options).map(([key, option]) => { + const testCall = testCalls[key]; + return Email.sendAsync({ ...option, stream }).then(() => { + testCall(test, stream); + }); + }); + Promise.all(allPromises).then(() => onComplete()); + }); + }); +}); - } finally { - EmailTest.restoreOutputStream(); +// Individual sync tests + +Tinytest.add( + '[Sync] email - alternate API is used for sending gets data', + function (test) { + smokeEmailTest(function (stream) { + Email.customTransport = (options) => { + test.equal(options.from, 'foo@example.com'); + }; + Email.send({ + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + stream, + }); + test.equal(stream.getContentsAsString('utf8'), false); + }); + + smokeEmailTest(function (stream) { + Meteor.settings.packages = CUSTOM_TRANSPORT_SETTINGS; + Email.customTransport = (options) => { + test.equal(options.from, 'foo@example.com'); + test.equal(options.packageSettings?.service, '1on1'); + }; + + Email.send({ + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + stream, + }); + + test.equal(stream.getContentsAsString('utf8'), false); + }); + Email.customTransport = undefined; + Meteor.settings.packages = undefined; } -} +); -function canonicalize(string) { - // Remove generated content for test.equal to succeed. - return string.replace(/Message-ID: <[^<>]*>\r\n/, "Message-ID: <...>\r\n") - .replace(/Date: (?!dummy).*\r\n/, "Date: ...\r\n") - .replace(/(boundary="|^--)--[^\s"]+?(-Part|")/mg, "$1--...$2"); -} - -Tinytest.add("email - fully customizable", function (test) { - smokeEmailTest(function(stream) { - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - cc: ["friends@example.com", "enemies@example.com"], - subject: "This is the subject", - text: "This is the body\nof the message\nFrom us.", - headers: { - 'X-Meteor-Test': 'a custom header', - 'Date': 'dummy', - }, - }); - // XXX brittle if mailcomposer changes header order, etc - test.equal(canonicalize(stream.getContentsAsString("utf8")), - "====== BEGIN MAIL #0 ======\n" + - devWarningBanner + - "Content-Type: text/plain; charset=utf-8\r\n" + - "X-Meteor-Test: a custom header\r\n" + - "Date: dummy\r\n" + - "From: foo@example.com\r\n" + - "To: bar@example.com\r\n" + - "Cc: friends@example.com, enemies@example.com\r\n" + - "Subject: This is the subject\r\n" + - "Message-ID: <...>\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "MIME-Version: 1.0\r\n" + - "\r\n" + - "This is the body\n" + - "of the message\n" + - "From us.\r\n" + - "====== END MAIL #0 ======\n"); - }); -}); - -Tinytest.add("email - undefined headers sends properly", function (test) { - smokeEmailTest(function (stream) { - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - subject: "This is the subject", - text: "This is the body\nof the message\nFrom us.", - }); - - test.matches(canonicalize(stream.getContentsAsString("utf8")), - /^====== BEGIN MAIL #0 ======$[\s\S]+^To: bar@example.com$/m); - }); -}); - -Tinytest.add("email - multiple e-mails same stream", function (test) { - smokeEmailTest(function (stream) { - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - subject: "This is the subject", - text: "This is the body\nof the message\nFrom us.", - }); - - const contents = canonicalize(stream.getContentsAsString("utf8")); - test.matches(contents, /^====== BEGIN MAIL #0 ======$/m); - test.matches(contents, /^From: foo@example.com$/m); - test.matches(contents, /^To: bar@example.com$/m); - - Email.send({ - from: "qux@example.com", - to: "baz@example.com", - subject: "This is important", - text: "This is another message\nFrom Qux.", - }); - - const contents2 = canonicalize(stream.getContentsAsString("utf8")); - test.matches(contents2, /^====== BEGIN MAIL #1 ======$/m); - test.matches(contents2, /^From: qux@example.com$/m); - test.matches(contents2, /^To: baz@example.com$/m); - - }); -}); - -Tinytest.add("email - using mail composer", function (test) { - smokeEmailTest(function (stream) { - // Test direct MailComposer usage. - const mc = new EmailInternals.NpmModules.mailcomposer.module({ - from: "a@b.com", - text: "body" - }); - Email.send({mailComposer: mc}); - test.equal(canonicalize(stream.getContentsAsString("utf8")), - "====== BEGIN MAIL #0 ======\n" + - devWarningBanner + - "Content-Type: text/plain; charset=utf-8\r\n" + - "From: a@b.com\r\n" + - "Message-ID: <...>\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "Date: ...\r\n" + - "MIME-Version: 1.0\r\n" + - "\r\n" + - "body\r\n" + - "====== END MAIL #0 ======\n"); - }); -}); - -Tinytest.add("email - date auto generated", function (test) { - smokeEmailTest(function (stream) { - // Test if date header is automatically generated, if not specified - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - subject: "This is the subject", - text: "This is the body\nof the message\nFrom us.", - headers: { - 'X-Meteor-Test': 'a custom header', - }, - }); - - test.matches(canonicalize(stream.getContentsAsString("utf8")), - /^Date: .+$/m); - }); -}); - -Tinytest.add("email - long lines", function (test) { - smokeEmailTest(function (stream) { - // Test that long header lines get wrapped with single leading whitespace, - // and that long body lines get wrapped with quoted-printable conventions. - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - subject: "This is a very very very very very very very very very very very very long subject", - text: "This is a very very very very very very very very very very very very long text", - }); - - test.equal(canonicalize(stream.getContentsAsString("utf8")), - "====== BEGIN MAIL #0 ======\n" + - devWarningBanner + - "Content-Type: text/plain; charset=utf-8\r\n" + - "From: foo@example.com\r\n" + - "To: bar@example.com\r\n" + - "Subject: This is a very very very very very very very very " + - "very very very\r\n very long subject\r\n" + - "Message-ID: <...>\r\n" + - "Content-Transfer-Encoding: quoted-printable\r\n" + - "Date: ...\r\n" + - "MIME-Version: 1.0\r\n" + - "\r\n" + - "This is a very very very very very very very very very very " + - "very very long =\r\ntext\r\n" + - "====== END MAIL #0 ======\n"); - }); -}); - -Tinytest.add("email - unicode", function (test) { - smokeEmailTest(function (stream) { - // Test that unicode characters in header and body get encoded. - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - subject: "\u263a", - text: "I \u2665 Meteor", - }); - - test.equal(canonicalize(stream.getContentsAsString("utf8")), - "====== BEGIN MAIL #0 ======\n" + - devWarningBanner + - "Content-Type: text/plain; charset=utf-8\r\n" + - "From: foo@example.com\r\n" + - "To: bar@example.com\r\n" + - "Subject: =?UTF-8?B?4pi6?=\r\n" + - "Message-ID: <...>\r\n" + - "Content-Transfer-Encoding: quoted-printable\r\n" + - "Date: ...\r\n" + - "MIME-Version: 1.0\r\n" + - "\r\n" + - "I =E2=99=A5 Meteor\r\n" + - "====== END MAIL #0 ======\n"); - }); -}); - -Tinytest.add("email - text and html", function (test) { - smokeEmailTest(function (stream) { - // Test including both text and HTML versions of message. - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - text: "*Cool*, man", - html: "Cool, man", - }); - - test.equal(canonicalize(stream.getContentsAsString("utf8")), - "====== BEGIN MAIL #0 ======\n" + - devWarningBanner + - "Content-Type: multipart/alternative;\r\n" + - ' boundary="--...-Part_1"\r\n' + - "From: foo@example.com\r\n" + - "To: bar@example.com\r\n" + - "Message-ID: <...>\r\n" + - "Date: ...\r\n" + - "MIME-Version: 1.0\r\n" + - "\r\n" + - "----...-Part_1\r\n" + - "Content-Type: text/plain; charset=utf-8\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "*Cool*, man\r\n" + - "----...-Part_1\r\n" + - "Content-Type: text/html; charset=utf-8\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "Cool, man\r\n" + - "----...-Part_1--\r\n" + - "====== END MAIL #0 ======\n"); - }); -}); - -Tinytest.add("email - alternate API is used for sending gets data", function(test) { - smokeEmailTest(function(stream) { - Email.customTransport = (options) => { - test.equal(options.from, 'foo@example.com'); - }; - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - text: "*Cool*, man", - html: "Cool, man", - }); - test.equal(stream.getContentsAsString("utf8"), false); - }); - - smokeEmailTest(function(stream) { - Meteor.settings.packages = { email: { service: '1on1', user: 'test', password: 'pwd' } }; - Email.customTransport = (options) => { - test.equal(options.from, 'foo@example.com'); - test.equal(options.packageSettings?.service, '1on1'); - }; - - Email.send({ - from: "foo@example.com", - to: "bar@example.com", - text: "*Cool*, man", - html: "Cool, man", - }); - - test.equal(stream.getContentsAsString("utf8"), false); - }); - Email.customTransport = undefined; - Meteor.settings.packages = undefined; -}); - -Tinytest.add("email - URL string for known hosts", function(test) { - const oneTransport = EmailTest.knowHostsTransport({ service: '1und1', user: 'test', password: 'pwd' }); - test.equal(oneTransport.transporter.auth.type, 'LOGIN'); - test.equal(oneTransport.transporter.auth.user, 'test'); - - const aolUrlTransport = EmailTest.knowHostsTransport(null, 'AOL://test:pwd@aol.com'); - test.equal(aolUrlTransport.transporter.auth.user, 'test'); - test.equal(aolUrlTransport.transporter.auth.type, 'LOGIN'); - - const outlookTransport = EmailTest.knowHostsTransport(null, 'Outlook365://firstname.lastname%40hotmail.com:password@hotmail.com'); - const outlookTransport2 = EmailTest.knowHostsTransport(undefined, 'Outlook365://firstname.lastname@hotmail.com:password@hotmail.com'); - test.equal(outlookTransport.transporter.auth.user, 'firstname.lastname%40hotmail.com'); - test.equal(outlookTransport.options.auth.user, 'firstname.lastname%40hotmail.com'); - test.equal(outlookTransport.transporter.options.service, 'outlook365'); - test.equal(outlookTransport2.transporter.auth.user, 'firstname.lastname%40hotmail.com'); - test.equal(outlookTransport2.transporter.options.service, 'outlook365'); - - const hotmailTransport = EmailTest.knowHostsTransport(undefined, 'Hotmail://firstname.lastname@hotmail.com:password@hotmail.com'); - console.dir(hotmailTransport); - test.equal(hotmailTransport.transporter.options.service, 'hotmail'); - - const falseService = { service: '1on1', user: 'test', password: 'pwd' }; - const errorMsg = 'Could not recognize e-mail service. See list at https://nodemailer.com/smtp/well-known/ for services that we can configure for you.'; - test.throws(() => EmailTest.knowHostsTransport(falseService), errorMsg); - test.throws(() => EmailTest.knowHostsTransport(null, 'smtp://bbb:bb@bb.com'), errorMsg); -}); - -Tinytest.add("email - hooks stop the sending", function(test) { +Tinytest.add('[Sync] email - hooks stop the sending', function (test) { // Register hooks const hook1 = Email.hookSend((options) => { // Test that we get options through @@ -313,17 +94,218 @@ Tinytest.add("email - hooks stop the sending", function(test) { const hook3 = Email.hookSend(() => { console.log('FAIL'); }); - smokeEmailTest(function(stream) { + smokeEmailTest(function (stream) { Email.send({ - from: "foo@example.com", - to: "bar@example.com", - text: "*Cool*, man", - html: "Cool, man", + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + stream, }); - test.equal(stream.getContentsAsString("utf8"), false); + test.equal(stream.getContentsAsString('utf8'), false); }); hook1.stop(); hook2.stop(); hook3.stop(); }); + +// Individual Async tests + +Tinytest.addAsync( + '[Async] email - alternate API is used for sending gets data', + function (test, onComplete) { + const allPromises = []; + smokeEmailTest((stream) => { + Email.customTransport = (options) => { + test.equal(options.from, 'foo@example.com'); + }; + allPromises.push( + Email.sendAsync({ + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + stream, + }).then(() => { + test.equal(stream.getContentsAsString('utf8'), false); + }) + ); + }); + + smokeEmailTest(function (stream) { + Meteor.settings.packages = CUSTOM_TRANSPORT_SETTINGS; + Email.customTransport = (options) => { + test.equal(options.from, 'foo@example.com'); + test.equal(options.packageSettings?.service, '1on1'); + }; + + allPromises.push( + Email.sendAsync({ + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + stream, + }).then(() => { + test.equal(stream.getContentsAsString('utf8'), false); + }) + ); + }); + Promise.all(allPromises).then(() => { + Email.customTransport = undefined; + Meteor.settings.packages = undefined; + onComplete(); + }); + } +); + +Tinytest.addAsync( + '[Async] email - hooks stop the sending', + function (test, onComplete) { + // Register hooks + const hook1 = Email.hookSend((options) => { + // Test that we get options through + test.equal(options.from, 'foo@example.com'); + console.log('EXECUTE'); + return true; + }); + const hook2 = Email.hookSend(() => { + console.log('STOP'); + return false; + }); + const hook3 = Email.hookSend(() => { + console.log('FAIL'); + }); + smokeEmailTest((stream) => { + Email.sendAsync({ + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + stream, + }).then(() => { + test.equal(stream.getContentsAsString('utf8'), false); + hook1.stop(); + hook2.stop(); + hook3.stop(); + onComplete(); + }); + }); + } +); + +// Another tests + +Tinytest.add('[Sync] email - URL string for known hosts', function (test) { + const oneTransport = EmailTest.knowHostsTransport({ + service: '1und1', + user: 'test', + password: 'pwd', + }); + test.equal(oneTransport.transporter.auth.type, 'LOGIN'); + test.equal(oneTransport.transporter.auth.user, 'test'); + + const aolUrlTransport = EmailTest.knowHostsTransport( + null, + 'AOL://test:pwd@aol.com' + ); + test.equal(aolUrlTransport.transporter.auth.user, 'test'); + test.equal(aolUrlTransport.transporter.auth.type, 'LOGIN'); + + const outlookTransport = EmailTest.knowHostsTransport( + null, + 'Outlook365://firstname.lastname%40hotmail.com:password@hotmail.com' + ); + const outlookTransport2 = EmailTest.knowHostsTransport( + undefined, + 'Outlook365://firstname.lastname@hotmail.com:password@hotmail.com' + ); + test.equal( + outlookTransport.transporter.auth.user, + 'firstname.lastname%40hotmail.com' + ); + test.equal( + outlookTransport.options.auth.user, + 'firstname.lastname%40hotmail.com' + ); + test.equal(outlookTransport.transporter.options.service, 'outlook365'); + test.equal( + outlookTransport2.transporter.auth.user, + 'firstname.lastname%40hotmail.com' + ); + test.equal(outlookTransport2.transporter.options.service, 'outlook365'); + + const hotmailTransport = EmailTest.knowHostsTransport( + undefined, + 'Hotmail://firstname.lastname@hotmail.com:password@hotmail.com' + ); + console.dir(hotmailTransport); + test.equal(hotmailTransport.transporter.options.service, 'hotmail'); + + const falseService = CUSTOM_TRANSPORT_SETTINGS.email; + const errorMsg = + 'Could not recognize e-mail service. See list at https://nodemailer.com/smtp/well-known/ for services that we can configure for you.'; + test.throws(() => EmailTest.knowHostsTransport(falseService), errorMsg); + test.throws( + () => EmailTest.knowHostsTransport(null, 'smtp://bbb:bb@bb.com'), + errorMsg + ); +}); + +Tinytest.addAsync( + '[Async] email - with custom transport exception', + async function (test) { + Meteor.settings.packages = CUSTOM_TRANSPORT_SETTINGS; + Email.customTransport = (options) => { + test.equal(options.from, 'foo@example.com'); + test.equal(options.packageSettings?.service, '1on1'); + throw new Meteor.Error('Expected error'); + }; + await Email.sendAsync({ + from: 'foo@example.com', + to: 'bar@example.com', + }).catch((err) => { + test.equal(err.error, 'Expected error'); + }); + Meteor.settings.packages = undefined; + Email.customTransport = undefined; + } +); + +Tinytest.addAsync( + '[Async] email - with custom transport long time running', + async function (test) { + Meteor.settings.packages = CUSTOM_TRANSPORT_SETTINGS; + Email.customTransport = async (options) => { + await sleep(3000); + test.equal(options.from, 'foo@example.com'); + test.equal(options.packageSettings?.service, '1on1'); + }; + await Email.sendAsync({ + from: 'foo@example.com', + to: 'bar@example.com', + }); + Meteor.settings.packages = undefined; + Email.customTransport = undefined; + } +); + +Tinytest.addAsync( + '[Sync] email - with custom transport long time running', + function (test, onComplete) { + Meteor.settings.packages = CUSTOM_TRANSPORT_SETTINGS; + Email.customTransport = async (options) => { + await sleep(3000); + test.equal(options.from, 'foo@example.com'); + test.equal(options.packageSettings?.service, '1on1'); + Meteor.settings.packages = undefined; + Email.customTransport = undefined; + onComplete(); + }; + Email.send({ + from: 'foo@example.com', + to: 'bar@example.com', + }); + } +); diff --git a/packages/email/email_tests_data.js b/packages/email/email_tests_data.js new file mode 100644 index 0000000000..095c1fb9d2 --- /dev/null +++ b/packages/email/email_tests_data.js @@ -0,0 +1,254 @@ +import { canonicalize, devWarningBanner } from './email_test_helpers'; + +export const TEST_CASES = [ + { + title: 'email - fully customizable', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + cc: ['friends@example.com', 'enemies@example.com'], + subject: 'This is the subject', + text: 'This is the body\nof the message\nFrom us.', + headers: { + 'X-Meteor-Test': 'a custom header', + Date: 'dummy', + }, + }, + }, + testCalls: { + 0: (test, stream) => { + // XXX brittle if mailcomposer changes header order, etc + test.equal( + canonicalize(stream.getContentsAsString('utf8')), + '====== BEGIN MAIL #0 ======\n' + + devWarningBanner + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'X-Meteor-Test: a custom header\r\n' + + 'Date: dummy\r\n' + + 'From: foo@example.com\r\n' + + 'To: bar@example.com\r\n' + + 'Cc: friends@example.com, enemies@example.com\r\n' + + 'Subject: This is the subject\r\n' + + 'Message-ID: <...>\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + 'MIME-Version: 1.0\r\n' + + '\r\n' + + 'This is the body\n' + + 'of the message\n' + + 'From us.\r\n' + + '====== END MAIL #0 ======\n' + ); + }, + }, + }, + { + title: 'email - undefined headers sends properly', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + subject: 'This is the subject', + text: 'This is the body\nof the message\nFrom us.', + }, + }, + testCalls: { + 0: (test, stream) => { + test.matches( + canonicalize(stream.getContentsAsString('utf8')), + /^====== BEGIN MAIL #0 ======$[\s\S]+^To: bar@example.com$/m + ); + }, + }, + }, + { + title: 'email - multiple e-mails same stream', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + subject: 'This is the subject', + text: 'This is the body\nof the message\nFrom us.', + }, + 1: { + from: 'qux@example.com', + to: 'baz@example.com', + subject: 'This is important', + text: 'This is another message\nFrom Qux.', + }, + }, + + testCalls: { + 0: (test, stream) => { + const contents = canonicalize(stream.getContentsAsString('utf8')); + test.matches(contents, /^====== BEGIN MAIL #0 ======$/m); + test.matches(contents, /^From: foo@example.com$/m); + test.matches(contents, /^To: bar@example.com$/m); + }, + 1: (test, stream) => { + const contents2 = canonicalize(stream.getContentsAsString('utf8')); + test.matches(contents2, /^====== BEGIN MAIL #1 ======$/m); + test.matches(contents2, /^From: qux@example.com$/m); + test.matches(contents2, /^To: baz@example.com$/m); + }, + }, + }, + { + title: 'email - using mail composer', + options: { + 0: { + mailComposer: new EmailInternals.NpmModules.mailcomposer.module({ + from: 'a@b.com', + text: 'body', + }), + }, + }, + + testCalls: { + 0: (test, stream) => { + test.equal( + canonicalize(stream.getContentsAsString('utf8')), + '====== BEGIN MAIL #0 ======\n' + + devWarningBanner + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'From: a@b.com\r\n' + + 'Message-ID: <...>\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + 'Date: ...\r\n' + + 'MIME-Version: 1.0\r\n' + + '\r\n' + + 'body\r\n' + + '====== END MAIL #0 ======\n' + ); + }, + }, + }, + { + title: 'email - date auto generated', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + subject: 'This is the subject', + text: 'This is the body\nof the message\nFrom us.', + headers: { + 'X-Meteor-Test': 'a custom header', + }, + }, + }, + testCalls: { + 0: (test, stream) => { + test.matches( + canonicalize(stream.getContentsAsString('utf8')), + /^Date: .+$/m + ); + }, + }, + }, + { + title: 'email - long lines', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + subject: + 'This is a very very very very very very very very very very very very long subject', + text: 'This is a very very very very very very very very very very very very long text', + }, + }, + testCalls: { + 0: (test, stream) => { + test.equal( + canonicalize(stream.getContentsAsString('utf8')), + '====== BEGIN MAIL #0 ======\n' + + devWarningBanner + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'From: foo@example.com\r\n' + + 'To: bar@example.com\r\n' + + 'Subject: This is a very very very very very very very very ' + + 'very very very\r\n very long subject\r\n' + + 'Message-ID: <...>\r\n' + + 'Content-Transfer-Encoding: quoted-printable\r\n' + + 'Date: ...\r\n' + + 'MIME-Version: 1.0\r\n' + + '\r\n' + + 'This is a very very very very very very very very very very ' + + 'very very long =\r\ntext\r\n' + + '====== END MAIL #0 ======\n' + ); + }, + }, + }, + { + title: 'email - unicode', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + subject: '\u263a', + text: 'I \u2665 Meteor', + }, + }, + testCalls: { + 0: (test, stream) => { + test.equal( + canonicalize(stream.getContentsAsString('utf8')), + '====== BEGIN MAIL #0 ======\n' + + devWarningBanner + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'From: foo@example.com\r\n' + + 'To: bar@example.com\r\n' + + 'Subject: =?UTF-8?B?4pi6?=\r\n' + + 'Message-ID: <...>\r\n' + + 'Content-Transfer-Encoding: quoted-printable\r\n' + + 'Date: ...\r\n' + + 'MIME-Version: 1.0\r\n' + + '\r\n' + + 'I =E2=99=A5 Meteor\r\n' + + '====== END MAIL #0 ======\n' + ); + }, + }, + }, + { + title: 'email - text and html', + options: { + 0: { + from: 'foo@example.com', + to: 'bar@example.com', + text: '*Cool*, man', + html: 'Cool, man', + }, + }, + testCalls: { + 0: (test, stream) => { + test.equal( + canonicalize(stream.getContentsAsString('utf8')), + '====== BEGIN MAIL #0 ======\n' + + devWarningBanner + + 'Content-Type: multipart/alternative;\r\n' + + ' boundary="--...-Part_1"\r\n' + + 'From: foo@example.com\r\n' + + 'To: bar@example.com\r\n' + + 'Message-ID: <...>\r\n' + + 'Date: ...\r\n' + + 'MIME-Version: 1.0\r\n' + + '\r\n' + + '----...-Part_1\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + '\r\n' + + '*Cool*, man\r\n' + + '----...-Part_1\r\n' + + 'Content-Type: text/html; charset=utf-8\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + '\r\n' + + 'Cool, man\r\n' + + '----...-Part_1--\r\n' + + '====== END MAIL #0 ======\n' + ); + }, + }, + }, +]; + diff --git a/packages/facebook-oauth/CHANGELOG.md b/packages/facebook-oauth/CHANGELOG.md index a772af6e78..b492fe1f09 100644 --- a/packages/facebook-oauth/CHANGELOG.md +++ b/packages/facebook-oauth/CHANGELOG.md @@ -1,10 +1,26 @@ # Changelog -## 1.8.0 - unreleased -### Breaking changes -- N/A - +## 1.12.0 - UNRELEASED +### Changes +- Updated default version of Facebook GraphAPI to v15 + +## 1.11.0 - 2022-03-24 +### Changes +- Updated default version of Facebook GraphAPI to v12 + +## 1.10.0 - 2021-09-14 +### Changes +- Added login handler hook, like in the Google package for easier management in React Native and similar apps. [PR](https://github.com/meteor/meteor/pull/11603) + +## 1.9.1 - 2021-08-12 +### Changes +- Allow usage of `http` package both v1 and v2 for backward compatibility + +## 1.9.0 - 2021-06-24 +### Changes +- Upgrade default Facebook API to v10 [#11362](https://github.com/meteor/meteor/pull/11362) + +## 1.8.0 - 2021-04-15 ### Changes -- Updated to use Facebook GraphAPI v10 - You can now override the default API version by setting `Meteor.settings.public.packages.facebook-oauth.apiVersion` to for example `8.0` ## 1.7.3 - 2020-10-05 diff --git a/packages/facebook-oauth/facebook_client.js b/packages/facebook-oauth/facebook_client.js index 771e1d6828..582d63bf04 100644 --- a/packages/facebook-oauth/facebook_client.js +++ b/packages/facebook-oauth/facebook_client.js @@ -30,7 +30,7 @@ Facebook.requestCredential = (options, credentialRequestCompleteCallback) => { const loginStyle = OAuth._loginStyle('facebook', config, options); - const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '13.0'; + const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '15.0'; let loginUrl = `https://www.facebook.com/v${API_VERSION}/dialog/oauth?client_id=${config.appId}` + diff --git a/packages/facebook-oauth/facebook_server.js b/packages/facebook-oauth/facebook_server.js index c2964cf842..d9c824f27f 100644 --- a/packages/facebook-oauth/facebook_server.js +++ b/packages/facebook-oauth/facebook_server.js @@ -4,13 +4,13 @@ import { Accounts } from 'meteor/accounts-base'; const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '13.0'; -Facebook.handleAuthFromAccessToken = (accessToken, expiresAt) => { +Facebook.handleAuthFromAccessToken = async (accessToken, expiresAt) => { // include basic fields from facebook // https://developers.facebook.com/docs/facebook-login/permissions/ const whitelisted = ['id', 'email', 'name', 'first_name', 'last_name', 'middle_name', 'name_format', 'picture', 'short_name']; - const identity = getIdentity(accessToken, whitelisted); + const identity = await getIdentity(accessToken, whitelisted); const fields = {}; whitelisted.forEach(field => fields[field] = identity[field]); @@ -34,8 +34,8 @@ Accounts.registerLoginHandler(request => { return Accounts.updateOrCreateUserFromExternalService('facebook', facebookData.serviceData, facebookData.options); }); -OAuth.registerService('facebook', 2, null, query => { - const response = getTokenResponse(query); +OAuth.registerService('facebook', 2, null, async query => { + const response = await getTokenResponse(query); const { accessToken } = response; const { expiresIn } = response; @@ -52,7 +52,7 @@ function getAbsoluteUrlOptions(query) { const redirectUrl = new URL(state.redirectUrl); return { rootUrl: redirectUrl.origin, - } + }; } catch (e) { console.error( `Failed to complete OAuth handshake with Facebook because it was not able to obtain the redirect url from the state and you are using overrideRootUrlFromStateRedirectUrl.`, e @@ -61,73 +61,86 @@ function getAbsoluteUrlOptions(query) { } } -// returns an object containing: -// - accessToken -// - expiresIn: lifetime of token in seconds -const getTokenResponse = query => { - const config = ServiceConfiguration.configurations.findOne({service: 'facebook'}); - if (!config) - throw new ServiceConfiguration.ConfigError(); +/** + * @typedef {Object} UserAccessToken + * @property {string} accessToken - User access Token + * @property {number} expiresIn - lifetime of token in seconds + */ +/** + * @async + * @function getTokenResponse + * @param {Object} query - An object with the code. + * @returns {Promise} - Promise with an Object containing the accessToken and expiresIn (lifetime of token in seconds) + */ +const getTokenResponse = async (query) => { + const config = ServiceConfiguration.configurations.findOne({ + service: 'facebook', + }); + if (!config) throw new ServiceConfiguration.ConfigError(); - let responseContent; - try { + const absoluteUrlOptions = getAbsoluteUrlOptions(query); + const redirectUri = OAuth._redirectUri('facebook', config, undefined, absoluteUrlOptions); - const absoluteUrlOptions = getAbsoluteUrlOptions(query); - const redirectUri = OAuth._redirectUri('facebook', config, undefined, absoluteUrlOptions); - // Request an access token - responseContent = HTTP.get( - `https://graph.facebook.com/v${API_VERSION}/oauth/access_token`, { - params: { - client_id: config.appId, - redirect_uri: redirectUri, - client_secret: OAuth.openSecret(config.secret), - code: query.code - } - }).data; - } catch (err) { - throw Object.assign( - new Error(`Failed to complete OAuth handshake with Facebook. ${err.message}`), - { response: err.response }, - ); - } - - const fbAccessToken = responseContent.access_token; - const fbExpires = responseContent.expires_in; - - if (!fbAccessToken) { - throw new Error("Failed to complete OAuth handshake with facebook " + - `-- can't find access token in HTTP response. ${responseContent}`); - } - return { - accessToken: fbAccessToken, - expiresIn: fbExpires - }; + return OAuth._fetch( + `https://graph.facebook.com/v${API_VERSION}/oauth/access_token`, + 'GET', + { + queryParams: { + client_id: config.appId, + redirect_uri: redirectUri, + client_secret: OAuth.openSecret(config.secret), + code: query.code, + }, + } + ) + .then((res) => res.json()) + .then(data => { + const fbAccessToken = data.access_token; + const fbExpires = data.expires_in; + if (!fbAccessToken) { + throw new Error("Failed to complete OAuth handshake with facebook " + + `-- can't find access token in HTTP response. ${data}`); + } + return { + accessToken: fbAccessToken, + expiresIn: fbExpires + }; + }) + .catch((err) => { + throw Object.assign( + new Error( + `Failed to complete OAuth handshake with Facebook. ${err.message}` + ), + { response: err.response } + ); + }); }; -const getIdentity = (accessToken, fields) => { - const config = ServiceConfiguration.configurations.findOne({service: 'facebook'}); - if (!config) - throw new ServiceConfiguration.ConfigError(); +const getIdentity = async (accessToken, fields) => { + const config = ServiceConfiguration.configurations.findOne({ + service: 'facebook', + }); + if (!config) throw new ServiceConfiguration.ConfigError(); // Generate app secret proof that is a sha256 hash of the app access token, with the app secret as the key // https://developers.facebook.com/docs/graph-api/securing-requests#appsecret_proof const hmac = crypto.createHmac('sha256', OAuth.openSecret(config.secret)); hmac.update(accessToken); - try { - return HTTP.get(`https://graph.facebook.com/v${API_VERSION}/me`, { - params: { - access_token: accessToken, - appsecret_proof: hmac.digest('hex'), - fields: fields.join(",") - } - }).data; - } catch (err) { - throw Object.assign( - new Error(`Failed to fetch identity from Facebook. ${err.message}`), - { response: err.response }, - ); - } + return OAuth._fetch(`https://graph.facebook.com/v${API_VERSION}/me`, 'GET', { + queryParams: { + access_token: accessToken, + appsecret_proof: hmac.digest('hex'), + fields: fields.join(','), + }, + }) + .then((res) => res.json()) + .catch((err) => { + throw Object.assign( + new Error(`Failed to fetch identity from Facebook. ${err.message}`), + { response: err.response } + ); + }); }; Facebook.retrieveCredential = (credentialToken, credentialSecret) => diff --git a/packages/facebook-oauth/package.js b/packages/facebook-oauth/package.js index 67536065cb..de073e9b0c 100644 --- a/packages/facebook-oauth/package.js +++ b/packages/facebook-oauth/package.js @@ -7,7 +7,6 @@ Package.onUse(api => { api.use('ecmascript', ['client', 'server']); api.use('oauth2', ['client', 'server']); api.use('oauth', ['client', 'server']); - api.use('http@1.4.4 || 2.0.0', ['server']); api.use('random', 'client'); api.use('service-configuration', ['client', 'server']); diff --git a/packages/facts-ui/facts_ui_client.js b/packages/facts-ui/facts_ui_client.js index 9542c5f5f9..6731cfe466 100644 --- a/packages/facts-ui/facts_ui_client.js +++ b/packages/facts-ui/facts_ui_client.js @@ -6,7 +6,7 @@ Template.serverFacts.helpers({ factsByPackage: () => Facts.server.find(), facts: function () { const factArray = []; - _.each(this, function (value, name) { + Object.entries(this).forEach(function ([name, value]) { if (name !== '_id') factArray.push({name: name, value: value}); }); diff --git a/packages/facts-ui/package.js b/packages/facts-ui/package.js index 20d72c3b10..5f367eb09d 100644 --- a/packages/facts-ui/package.js +++ b/packages/facts-ui/package.js @@ -8,8 +8,7 @@ Package.onUse(function (api) { 'ecmascript', 'facts-base', 'mongo', - 'templating@1.2.13', - 'underscore', + 'templating@1.2.13' ], 'client'); api.imply('facts-base'); diff --git a/packages/fetch/fetch.d.ts b/packages/fetch/fetch.d.ts new file mode 100644 index 0000000000..8d6eb289ad --- /dev/null +++ b/packages/fetch/fetch.d.ts @@ -0,0 +1,4 @@ +export declare function fetch(): typeof globalThis.fetch; +export declare var Headers: typeof globalThis.Headers; +export declare var Request: typeof globalThis.Request; +export declare var Response: typeof globalThis.Response; diff --git a/packages/fetch/package-types.json b/packages/fetch/package-types.json new file mode 100644 index 0000000000..3fcb42c31a --- /dev/null +++ b/packages/fetch/package-types.json @@ -0,0 +1,3 @@ +{ + "typesEntry": "fetch.d.ts" +} diff --git a/packages/fetch/package.js b/packages/fetch/package.js index 9c3969ff7b..dcba22f913 100644 --- a/packages/fetch/package.js +++ b/packages/fetch/package.js @@ -19,6 +19,7 @@ Package.onUse(function(api) { api.mainModule("legacy.js", "legacy"); api.mainModule("server.js", "server"); + api.addAssets("fetch.d.ts", "server"); // The other exports (Headers, Request, Response) can be imported // explicitly from the "meteor/fetch" package. api.export("fetch"); diff --git a/packages/geojson-utils/geojson-utils.tests.js b/packages/geojson-utils/geojson-utils.tests.js index ea7ab39462..2d97d7a472 100644 --- a/packages/geojson-utils/geojson-utils.tests.js +++ b/packages/geojson-utils/geojson-utils.tests.js @@ -85,8 +85,8 @@ Tinytest.add("geojson-utils - points distance generated tests", function (test) 6846704.0253010122, 1368055.9401701286, 14041503.0409814864, 18560499.7346975356, 3793112.6186894816]; - _.each(tests, function (pair, testN) { - var distance = GeoJSON.pointDistance.apply(this, _.map(pair, toGeoJSONPoint)); + tests.forEach(function (pair, testN) { + var distance = GeoJSON.pointDistance.apply(this, pair.map(toGeoJSONPoint)); test.isTrue(Math.abs(distance - answers[testN]) < 0.000001, "Wrong distance between points " + JSON.stringify(pair) + ": " + distance + ", " + Math.abs(distance - answers[testN]) + " differenc"); }); diff --git a/packages/geojson-utils/package.js b/packages/geojson-utils/package.js index 5ae045b046..052cdc64bc 100644 --- a/packages/geojson-utils/package.js +++ b/packages/geojson-utils/package.js @@ -11,7 +11,6 @@ Package.onUse(function (api) { Package.onTest(function (api) { api.use('tinytest'); - api.use('underscore'); api.use('geojson-utils'); api.addFiles(['geojson-utils.tests.js'], 'client'); }); diff --git a/packages/github-oauth/github_server.js b/packages/github-oauth/github_server.js index b71995d1c0..7b4f36f5f6 100644 --- a/packages/github-oauth/github_server.js +++ b/packages/github-oauth/github_server.js @@ -1,12 +1,9 @@ Github = {}; -OAuth.registerService('github', 2, null, (query) => { - const accessTokenCall = Meteor.wrapAsync(getAccessToken); - const accessToken = accessTokenCall(query); - const identityCall = Meteor.wrapAsync(getIdentity); - const identity = identityCall(accessToken); - const emailsCall = Meteor.wrapAsync(getEmails); - const emails = emailsCall(accessToken); +OAuth.registerService('github', 2, null, async (query) => { + const accessToken = await getAccessToken(query); + const identity = await getIdentity(accessToken); + const emails = await getEmails(accessToken); const primaryEmail = emails.find((email) => email.primary); return { @@ -31,7 +28,7 @@ OAuth.registerService('github', 2, null, (query) => { let userAgent = 'Meteor'; if (Meteor.release) userAgent += `/${Meteor.release}`; -const getAccessToken = async (query, callback) => { +const getAccessToken = async (query) => { const config = ServiceConfiguration.configurations.findOne({ service: 'github' }); @@ -68,18 +65,16 @@ const getAccessToken = async (query, callback) => { ); } if (response.error) { - callback(response.error); // if the http response was a json object with an error attribute throw new Error( `Failed to complete OAuth handshake with GitHub. ${response.error}` ); } else { - callback(null, response.access_token); return response.access_token; } }; -const getIdentity = async (accessToken, callback) => { +const getIdentity = async (accessToken) => { try { const request = await fetch('https://api.github.com/user', { method: 'GET', @@ -89,11 +84,8 @@ const getIdentity = async (accessToken, callback) => { Authorization: `token ${accessToken}` } // http://developer.github.com/v3/#user-agent-required }); - const response = await request.json(); - callback(null, response); - return response; + return await request.json(); } catch (err) { - callback(err.message); throw Object.assign( new Error(`Failed to fetch identity from Github. ${err.message}`), { response: err.response } @@ -101,7 +93,7 @@ const getIdentity = async (accessToken, callback) => { } }; -const getEmails = async (accessToken, callback) => { +const getEmails = async (accessToken) => { try { const request = await fetch('https://api.github.com/user/emails', { method: 'GET', @@ -111,11 +103,8 @@ const getEmails = async (accessToken, callback) => { Authorization: `token ${accessToken}` } // http://developer.github.com/v3/#user-agent-required }); - const response = await request.json(); - callback(null, response); - return response; + return await request.json(); } catch (err) { - callback(err.message, []); return []; } }; diff --git a/packages/google-oauth/google_server.js b/packages/google-oauth/google_server.js index d13c285914..a25637be75 100644 --- a/packages/google-oauth/google_server.js +++ b/packages/google-oauth/google_server.js @@ -5,40 +5,46 @@ import { fetch } from 'meteor/fetch'; const hasOwn = Object.prototype.hasOwnProperty; // https://developers.google.com/accounts/docs/OAuth2Login#userinfocall -Google.whitelistedFields = ['id', 'email', 'verified_email', 'name', 'given_name', - 'family_name', 'picture', 'locale', 'timezone', 'gender']; +Google.whitelistedFields = [ + 'id', + 'email', + 'verified_email', + 'name', + 'given_name', + 'family_name', + 'picture', + 'locale', + 'timezone', + 'gender', +]; -const getServiceDataFromTokens = tokens => { +const getServiceDataFromTokens = async (tokens, callback) => { const { accessToken, idToken } = tokens; - const scopesCall = Meteor.wrapAsync(getScopes); - let scopes; - try { - scopes = scopesCall(accessToken); - } catch (err) { - throw Object.assign( + const scopes = await getScopes(accessToken).catch((err) => { + const error = Object.assign( new Error(`Failed to fetch tokeninfo from Google. ${err.message}`), { response: err.response } ); - } - const identityCall = Meteor.wrapAsync(getIdentity); - let identity; - try { - identity = identityCall(accessToken); - } catch (err) { - throw Object.assign( + callback && callback(error); + throw error; + }); + + let identity = await getIdentity(accessToken).catch((err) => { + const error = Object.assign( new Error(`Failed to fetch identity from Google. ${err.message}`), { response: err.response } ); - } + callback && callback(error); + throw error; + }); const serviceData = { accessToken, idToken, - scope: scopes + scope: scopes, }; - if (hasOwn.call(tokens, "expiresIn")) { - serviceData.expiresAt = - Date.now() + 1000 * parseInt(tokens.expiresIn, 10); + if (hasOwn.call(tokens, 'expiresIn')) { + serviceData.expiresAt = Date.now() + 1000 * parseInt(tokens.expiresIn, 10); } const fields = Object.create(null); @@ -56,22 +62,25 @@ const getServiceDataFromTokens = tokens => { if (tokens.refreshToken) { serviceData.refreshToken = tokens.refreshToken; } - - return { + const returnValue = { serviceData, options: { profile: { - name: identity.name - } - } + name: identity.name, + }, + }, }; + + callback && callback(undefined, returnValue); + + return returnValue; }; -Accounts.registerLoginHandler(request => { +Accounts.registerLoginHandler(async (request) => { if (request.googleSignIn !== true) { return; } - + console.log({ request }); const tokens = { accessToken: request.accessToken, refreshToken: request.refreshToken, @@ -79,29 +88,38 @@ Accounts.registerLoginHandler(request => { }; if (request.serverAuthCode) { - Object.assign(tokens, getTokens({ - code: request.serverAuthCode - })); + Object.assign( + tokens, + await getTokens({ + code: request.serverAuthCode, + }) + ); } let result; try { - result = getServiceDataFromTokens(tokens); + result = await getServiceDataFromTokens(tokens); } catch (err) { throw Object.assign( - new Error(`Failed to complete OAuth handshake with Google. ${err.message}`), + new Error( + `Failed to complete OAuth handshake with Google. ${err.message}` + ), { response: err.response } ); } - - return Accounts.updateOrCreateUserFromExternalService("google", { - id: request.userId, - idToken: request.idToken, - accessToken: request.accessToken, - email: request.email, - picture: request.imageUrl, - ...result.serviceData, - }, result.options); + console.log({ result }); + return Accounts.updateOrCreateUserFromExternalService( + 'google', + { + id: request.userId, + idToken: request.idToken, + accessToken: request.accessToken, + email: request.email, + picture: request.imageUrl, + ...result.serviceData, + }, + result.options + ); }); // returns an object containing: @@ -109,45 +127,48 @@ Accounts.registerLoginHandler(request => { // - expiresIn: lifetime of token in seconds // - refreshToken, if this is the first authorization request const getTokens = async (query, callback) => { - const config = ServiceConfiguration.configurations.findOne({service: 'google'}); - if (!config) - throw new ServiceConfiguration.ConfigError(); + const config = ServiceConfiguration.configurations.findOne({ + service: 'google', + }); + if (!config) throw new ServiceConfiguration.ConfigError(); const content = new URLSearchParams({ code: query.code, client_id: config.clientId, client_secret: OAuth.openSecret(config.secret), redirect_uri: OAuth._redirectUri('google', config), - grant_type: 'authorization_code' + grant_type: 'authorization_code', + }); + const request = await fetch('https://accounts.google.com/o/oauth2/token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: content, }); - const request = await fetch( - "https://accounts.google.com/o/oauth2/token", { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: content, - }); const response = await request.json(); - if (response.error) { // if the http response was a json object with an error attribute - callback(response.error); - throw new Meteor.Error(`Failed to complete OAuth handshake with Google. ${response.error}`); + if (response.error) { + // if the http response was a json object with an error attribute + callback && callback(response.error); + throw new Meteor.Error( + `Failed to complete OAuth handshake with Google. ${response.error}` + ); } else { const data = { accessToken: response.access_token, refreshToken: response.refresh_token, expiresIn: response.expires_in, - idToken: response.id_token + idToken: response.id_token, }; - callback(undefined, data); + callback && callback(undefined, data); return data; } }; -const getTokensCall = Meteor.wrapAsync(getTokens); -const getServiceData = query => getServiceDataFromTokens(getTokensCall(query)); +const getServiceData = async (query) => + getServiceDataFromTokens(await getTokens(query)); OAuth.registerService('google', 2, null, getServiceData); @@ -159,14 +180,15 @@ const getIdentity = async (accessToken, callback) => { `https://www.googleapis.com/oauth2/v1/userinfo?${content.toString()}`, { method: 'GET', - headers: { Accept: 'application/json' } - }); + headers: { Accept: 'application/json' }, + } + ); response = await request.json(); } catch (e) { - callback(e); + callback && callback(e); throw new Meteor.Error(e.reason); } - callback(undefined, response); + callback && callback(undefined, response); return response; }; @@ -178,14 +200,15 @@ const getScopes = async (accessToken, callback) => { `https://www.googleapis.com/oauth2/v1/tokeninfo?${content.toString()}`, { method: 'GET', - headers: { Accept: 'application/json' } - }); + headers: { Accept: 'application/json' }, + } + ); response = await request.json(); } catch (e) { - callback(e); + callback && callback(e); throw new Meteor.Error(e.reason); } - callback(undefined, response.scope.split(' ')); + callback && callback(undefined, response.scope.split(' ')); return response.scope.split(' '); }; diff --git a/packages/meetup-oauth/meetup_server.js b/packages/meetup-oauth/meetup_server.js index cffa8da9e5..bfc465c7b3 100644 --- a/packages/meetup-oauth/meetup_server.js +++ b/packages/meetup-oauth/meetup_server.js @@ -1,10 +1,10 @@ Meetup = {}; -OAuth.registerService('meetup', 2, null, query => { - const response = getAccessToken(query); +OAuth.registerService('meetup', 2, null, async query => { + const response = await getAccessToken(query); const accessToken = response.access_token; const expiresAt = (+new Date) + (1000 * response.expires_in); - const identity = getIdentity(accessToken); + const identity = await getIdentity(accessToken); const { id, name, @@ -33,50 +33,63 @@ OAuth.registerService('meetup', 2, null, query => { }; }); -const getAccessToken = query => { +const getAccessToken = async query => { const config = ServiceConfiguration.configurations.findOne({service: 'meetup'}); if (!config) throw new ServiceConfiguration.ConfigError(); - let response; - try { - response = HTTP.post( - "https://secure.meetup.com/oauth2/access", {headers: {Accept: 'application/json'}, params: { - code: query.code, - client_id: config.clientId, - client_secret: OAuth.openSecret(config.secret), - grant_type: 'authorization_code', - redirect_uri: OAuth._redirectUri('meetup', config), - state: query.state - }}); - } catch (err) { - throw Object.assign( - new Error(`Failed to complete OAuth handshake with Meetup. ${err.message}`), - { response: err.response } - ); - } + const body = OAuth._addValuesToQueryParams({ + code: query.code, + client_id: config.clientId, + client_secret: OAuth.openSecret(config.secret), + grant_type: 'authorization_code', + redirect_uri: OAuth._redirectUri('meetup', config), + state: query.state + }); - if (response.data.error) { // if the http response was a json object with an error attribute - throw new Error(`Failed to complete OAuth handshake with Meetup. ${response.data.error}`); - } else { - return response.data; - } + return OAuth._fetch('https://secure.meetup.com/oauth2/access', 'POST', { + headers: { + Accept: 'application/json', + 'Content-type': 'application/x-www-form-urlencoded', + }, + body, + }) + .then(data => data.json()) + .then(data => { + if (data.error) { + throw new Error(`Failed to complete OAuth handshake with Meetup. ${data.error.message}`); + } + return data; + }) + .catch(err => { + throw Object.assign( + new Error(`Failed to complete OAuth handshake with Meetup. ${err.message}`), + { response: err.response }, + ); + }); }; -const getIdentity = accessToken => { - try { - const response = HTTP.get( - "https://api.meetup.com/2/members", - {params: {member_id: 'self', access_token: accessToken}}); - return response.data.results && response.data.results[0]; - } catch (err) { +const getIdentity = async accessToken => { + const body = OAuth._addValuesToQueryParams({ + member_id: 'self', + access_token: accessToken + }); + + return OAuth._fetch('https://api.meetup.com/2/members', 'POST', { + headers: { + Accept: 'application/json', + 'Content-type': 'application/x-www-form-urlencoded', + }, + body, + }).then(data => data.json()) + .then(({results = []}) => results.length && results[0]) + .catch(err => { throw Object.assign( new Error(`Failed to fetch identity from Meetup. ${err.message}`), { response: err.response } ); - } + }); }; - Meetup.retrieveCredential = (credentialToken, credentialSecret) => OAuth.retrieveCredential(credentialToken, credentialSecret); diff --git a/packages/meetup-oauth/package.js b/packages/meetup-oauth/package.js index 83df9f74a3..9691528e9f 100644 --- a/packages/meetup-oauth/package.js +++ b/packages/meetup-oauth/package.js @@ -7,7 +7,6 @@ Package.onUse(api => { api.use('ecmascript'); api.use('oauth2', ['client', 'server']); api.use('oauth', ['client', 'server']); - api.use('http@1.4.4 || 2.0.0', 'server'); api.use('random', 'client'); api.use('service-configuration', ['client', 'server']); diff --git a/packages/meteor-developer-oauth/meteor_developer_server.js b/packages/meteor-developer-oauth/meteor_developer_server.js index c563ba47e8..57dd193ae1 100644 --- a/packages/meteor-developer-oauth/meteor_developer_server.js +++ b/packages/meteor-developer-oauth/meteor_developer_server.js @@ -1,7 +1,7 @@ -OAuth.registerService("meteor-developer", 2, null, query => { - const response = getTokens(query); +OAuth.registerService("meteor-developer", 2, null, async query => { + const response = await getTokens(query); const { accessToken } = response; - const identity = getIdentity(accessToken); + const identity = await getIdentity(accessToken); const serviceData = { accessToken: OAuth.sealSecret(accessToken), @@ -28,69 +28,77 @@ OAuth.registerService("meteor-developer", 2, null, query => { // - expiresIn: lifetime of token in seconds // - refreshToken, if this is the first authorization request and we got a // refresh token from the server -const getTokens = query => { +const getTokens = async (query) => { const config = ServiceConfiguration.configurations.findOne({ - service: 'meteor-developer' + service: 'meteor-developer', }); - if (!config) + if (!config) { throw new ServiceConfiguration.ConfigError(); + } - let response; - try { - response = HTTP.post( - MeteorDeveloperAccounts._server + "/oauth2/token", { - params: { - grant_type: "authorization_code", - code: query.code, - client_id: config.clientId, - client_secret: OAuth.openSecret(config.secret), - redirect_uri: OAuth._redirectUri('meteor-developer', config) - } + const body = OAuth._addValuesToQueryParams({ + grant_type: 'authorization_code', + code: query.code, + client_id: config.clientId, + client_secret: OAuth.openSecret(config.secret), + redirect_uri: OAuth._redirectUri('meteor-developer', config), + }).toString(); + + return OAuth._fetch( + MeteorDeveloperAccounts._server + '/oauth2/token', + 'POST', + { + headers: { + Accept: 'application/json', + 'Content-type': 'application/x-www-form-urlencoded', + }, + body, + } + ) + .then((data) => data.json()) + .then((data) => { + if (data.error) { + throw new Error( + 'Failed to complete OAuth handshake with Meteor developer accounts. ' + + (data ? data.error : 'No response data') + ); } - ); - } catch (err) { - throw Object.assign( - new Error( - "Failed to complete OAuth handshake with Meteor developer accounts. " - + err.message - ), - {response: err.response} - ); - } - - if (! response.data || response.data.error) { - // if the http response was a json object with an error attribute - throw new Error( - "Failed to complete OAuth handshake with Meteor developer accounts. " + - (response.data ? response.data.error : - "No response data") - ); - } else { - return { - accessToken: response.data.access_token, - refreshToken: response.data.refresh_token, - expiresIn: response.data.expires_in - }; - } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + }; + }) + .catch((err) => { + throw Object.assign( + new Error( + `Failed to complete OAuth handshake with Meteor developer accounts. ${err.message}` + ), + { response: err.response } + ); + }); }; -const getIdentity = accessToken => { - try { - return HTTP.get( - `${MeteorDeveloperAccounts._server}/api/v1/identity`, - { - headers: { Authorization: `Bearer ${accessToken}`} - } - ).data; - } catch (err) { - throw Object.assign( - new Error("Failed to fetch identity from Meteor developer accounts. " + - err.message), - {response: err.response} - ); - } +const getIdentity = async (accessToken) => { + return OAuth._fetch( + `${MeteorDeveloperAccounts._server}/api/v1/identity`, + 'GET', + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + .then((data) => data.json()) + .catch((err) => { + throw Object.assign( + new Error( + 'Failed to fetch identity from Meteor developer accounts. ' + + err.message + ), + { response: err.response } + ); + }); }; -MeteorDeveloperAccounts.retrieveCredential = - (credentialToken, credentialSecret) => +MeteorDeveloperAccounts.retrieveCredential = + (credentialToken, credentialSecret) => OAuth.retrieveCredential(credentialToken, credentialSecret); diff --git a/packages/meteor-developer-oauth/package.js b/packages/meteor-developer-oauth/package.js index b1463542d5..db38232d52 100644 --- a/packages/meteor-developer-oauth/package.js +++ b/packages/meteor-developer-oauth/package.js @@ -6,7 +6,6 @@ Package.describe({ Package.onUse(api => { api.use('oauth2', ['client', 'server']); api.use('oauth', ['client', 'server']); - api.use('http@1.4.4 || 2.0.0', ['server']); api.use(['ecmascript', 'service-configuration'], ['client', 'server']); api.use('random', 'client'); diff --git a/packages/meteor/package.js b/packages/meteor/package.js index 4999fd6859..504a2231e7 100644 --- a/packages/meteor/package.js +++ b/packages/meteor/package.js @@ -2,7 +2,7 @@ Package.describe({ summary: "Core Meteor environment", - version: '1.10.1' + version: '1.10.1-beta280.2' }); Package.registerBuildPlugin({ diff --git a/packages/minifier-css/minifier-async-tests.js b/packages/minifier-css/minifier-async-tests.js new file mode 100644 index 0000000000..755595ba6e --- /dev/null +++ b/packages/minifier-css/minifier-async-tests.js @@ -0,0 +1,51 @@ +import { CssTools } from './minifier'; +const TEST_CASES = [ + ['a \t\n{ color: red } \n', 'a{color:red}', 'whitespace check'], + [ + 'a \t\n{ color: red; margin: 1; } \n', + 'a{color:red;margin:1}', + 'only last one loses semicolon', + ], + [ + 'a \t\n{ color: red;;; margin: 1;;; } \n', + 'a{color:red;margin:1}', + 'more semicolons than needed', + ], + ['a , p \t\n{ color: red; } \n', 'a,p{color:red}', 'multiple selectors'], + ['body {}', '', 'removing empty rules'], + [ + '*.my-class { color: #fff; }', + '.my-class{color:#fff}', + 'removing universal selector', + ], + [ + 'p > *.my-class { color: #fff; }', + 'p>.my-class{color:#fff}', + 'removing optional whitespace around ">" in selector', + ], + [ + 'p + *.my-class { color: #fff; }', + 'p+.my-class{color:#fff}', + 'removing optional whitespace around "+" in selector', + ], + [ + 'a {\n\ + font:12px \'Helvetica\',"Arial",\'Nautica\';\n\ + background:url("/some/nice/picture.png");\n}', + 'a{font:12px Helvetica,Arial,Nautica;background:url(/some/nice/picture.png)}', + 'removing quotes in font and url (if possible)', + ], + ['/* no comments */ a { color: red; }', 'a{color:red}', 'remove comments'], +]; + +Tinytest.addAsync( + '[Async] minifier-css - simple CSS minification', + async (test) => { + const promises = TEST_CASES.map(([css, expected, desc]) => + CssTools.minifyCssAsync(css).then((minifiedCss) => { + test.equal(minifiedCss[0], expected, desc); + }) + ); + return Promise.all(promises); + } +); diff --git a/packages/minifier-css/minifier.js b/packages/minifier-css/minifier.js index 174452f1ee..a4c662e9e5 100644 --- a/packages/minifier-css/minifier.js +++ b/packages/minifier-css/minifier.js @@ -1,6 +1,5 @@ import path from 'path'; import url from 'url'; -import Future from 'fibers/future'; import postcss from 'postcss'; import cssnano from 'cssnano'; @@ -65,23 +64,21 @@ const CssTools = { * @return {String[]} Array containing the minified CSS. */ minifyCss(cssText) { - const f = new Future; - postcss([ - cssnano({ safe: true }), - ]).process(cssText, { - from: void 0, - }).then(result => { - f.return(result.css); - }).catch(error => { - f.throw(error); - }); - const minifiedCss = f.wait(); + return Promise.await(CssTools.minifyCssAsync(cssText)); + }, - // Since this function has always returned an array, we'll wrap the - // minified css string in an array before returning, even though we're - // only ever returning one minified css string in that array (maintaining - // backwards compatibility). - return [minifiedCss]; + /** + * Minify the passed in CSS string. + * + * @param {string} cssText CSS string to minify. + * @return {Promise} Array containing the minified CSS. + */ + async minifyCssAsync(cssText) { + return await postcss([cssnano({ safe: true })]) + .process(cssText, { + from: void 0, + }) + .then((result) => [result.css]); }, /** @@ -187,6 +184,7 @@ if (typeof Profile !== 'undefined') { 'parseCss', 'stringifyCss', 'minifyCss', + 'minifyCssAsync', 'mergeCssAsts', 'rewriteCssUrls', ].forEach(funcName => { diff --git a/packages/minifier-css/package.js b/packages/minifier-css/package.js index 022ed4c78c..057a6f2104 100644 --- a/packages/minifier-css/package.js +++ b/packages/minifier-css/package.js @@ -19,6 +19,7 @@ Package.onTest(function (api) { api.use('tinytest'); api.addFiles([ 'minifier-tests.js', + 'minifier-async-tests.js', 'urlrewriting-tests.js' ], 'server'); }); diff --git a/packages/modules-runtime-hot/installer.js b/packages/modules-runtime-hot/installer.js index 3c7c7df494..6ad56981a4 100644 --- a/packages/modules-runtime-hot/installer.js +++ b/packages/modules-runtime-hot/installer.js @@ -220,8 +220,8 @@ makeInstaller = function (options) { var file = fileResolve(filesByModuleId[this.id], id); if (file) return file.module.id; var error = makeMissingError(id); - if (fallback && isFunction(fallback.resolve)) { - return fallback.resolve(id, this.id, error); + if (fallback && isFunction(fallback)) { + return fallback(id, this.id, error); } throw error; }; diff --git a/packages/modules-runtime-hot/legacy.js b/packages/modules-runtime-hot/legacy.js index 77ef37d5c6..c4b1263ec4 100644 --- a/packages/modules-runtime-hot/legacy.js +++ b/packages/modules-runtime-hot/legacy.js @@ -1,3 +1,5 @@ +let verifyErrors = Package['modules-runtime'].verifyErrors; + meteorInstall = makeInstaller({ // On the client, make package resolution prefer the "browser" field of // package.json over the "module" field over the "main" field. @@ -8,15 +10,7 @@ meteorInstall = makeInstaller({ mainFields: ["browser", "main", "module"], fallback: function (id, parentId, error) { - if (id && id.startsWith('meteor/')) { - var packageName = id.split('/', 2)[1]; - throw new Error( - 'Cannot find package "' + packageName + '". ' + - 'Try "meteor add ' + packageName + '".' - ); - } - - throw error; + verifyErrors(id, parentId, error); } }); diff --git a/packages/modules-runtime-hot/modern.js b/packages/modules-runtime-hot/modern.js index 2445856d2d..48282a28f3 100644 --- a/packages/modules-runtime-hot/modern.js +++ b/packages/modules-runtime-hot/modern.js @@ -1,3 +1,5 @@ +let verifyErrors = Package['modules-runtime'].verifyErrors; + meteorInstall = makeInstaller({ // On the client, make package resolution prefer the "browser" field of // package.json over the "module" field over the "main" field. @@ -5,15 +7,7 @@ meteorInstall = makeInstaller({ mainFields: ["browser", "module", "main"], fallback: function (id, parentId, error) { - if (id && id.startsWith('meteor/')) { - var packageName = id.split('/', 2)[1]; - throw new Error( - 'Cannot find package "' + packageName + '". ' + - 'Try "meteor add ' + packageName + '".' - ); - } - - throw error; + verifyErrors(id, parentId, error); } }); diff --git a/packages/modules-runtime/errors/cannotFindMeteorPackage.js b/packages/modules-runtime/errors/cannotFindMeteorPackage.js new file mode 100644 index 0000000000..fd94ab813a --- /dev/null +++ b/packages/modules-runtime/errors/cannotFindMeteorPackage.js @@ -0,0 +1,12 @@ +/** + * @description Default error message for when a package is not found + * @param id{string} + * @return {Error} + */ +cannotFindMeteorPackage = function(id) { + var packageName = id.split('/', 2)[1]; + return new Error( + 'Cannot find package "' + packageName + '". ' + + 'Try "meteor add ' + packageName + '".' + ); +}; diff --git a/packages/modules-runtime/errors/importsErrors.js b/packages/modules-runtime/errors/importsErrors.js new file mode 100644 index 0000000000..d82f62cb1f --- /dev/null +++ b/packages/modules-runtime/errors/importsErrors.js @@ -0,0 +1,46 @@ +/** + * + * @param id{string} + * @return {{fromServer: (function(): Error), from: (function(location: string): boolean), fromClient: (function(): Error)}} + */ +imports = function (id) { + /** + * + * @param location{string} + * @return {boolean} + */ + var from = + function (location) { + if (!id) { + return false; + } + + // XXX: removed last part of path so that it does not trigger false positives + var path = String(id).split('/').slice(0, -1); + + return path.some(function (subPath) { + return subPath === location; + }); + }; + + var fromClientError = + function () { + return new Error( + 'Unable to import on the server a module from a client directory: "' + id + '" \n (cross-boundary import) see: https://guide.meteor.com/structure.html#special-directories' + ); + }; + + + var fromServerError = + function () { + return new Error( + 'Unable to import on the client a module from a server directory: "' + id + '" \n (cross-boundary import) see: https://guide.meteor.com/structure.html#special-directories' + ); + }; + + return { + from: from, + fromClientError: fromClientError, + fromServerError: fromServerError + }; +}; diff --git a/packages/modules-runtime/legacy.js b/packages/modules-runtime/legacy.js index b76f1d36b3..b75822a116 100644 --- a/packages/modules-runtime/legacy.js +++ b/packages/modules-runtime/legacy.js @@ -5,17 +5,9 @@ meteorInstall = makeInstaller({ // The difference between legacy.js and modern.js is that this module // prefers "main" over "module" (see issue #10658). - mainFields: ["browser", "main", "module"], + mainFields: ['browser', 'main', 'module'], - fallback: function(id, parentId, error) { - if (id && id.startsWith('meteor/')) { - var packageName = id.split('/', 2)[1]; - throw new Error( - 'Cannot find package "' + packageName + '". ' + - 'Try "meteor add ' + packageName + '".' - ); - } - - throw error; + fallback: function (id, parentId, error) { + verifyErrors(id, parentId, error); } }); diff --git a/packages/modules-runtime/modern.js b/packages/modules-runtime/modern.js index c26a129da0..06b6390a6f 100644 --- a/packages/modules-runtime/modern.js +++ b/packages/modules-runtime/modern.js @@ -2,17 +2,9 @@ meteorInstall = makeInstaller({ // On the client, make package resolution prefer the "browser" field of // package.json over the "module" field over the "main" field. browser: true, - mainFields: ["browser", "module", "main"], + mainFields: ['browser', 'module', 'main'], - fallback: function(id, parentId, error) { - if (id && id.startsWith('meteor/')) { - var packageName = id.split('/', 2)[1]; - throw new Error( - 'Cannot find package "' + packageName + '". ' + - 'Try "meteor add ' + packageName + '".' - ); - } - - throw error; + fallback: function (id, parentId, error) { + verifyErrors(id, parentId, error); } }); diff --git a/packages/modules-runtime/modules-runtime-tests.js b/packages/modules-runtime/modules-runtime-tests.js index cebc27a5c7..78abd74037 100644 --- a/packages/modules-runtime/modules-runtime-tests.js +++ b/packages/modules-runtime/modules-runtime-tests.js @@ -1,5 +1,76 @@ Tinytest.add('modules', function (test) { - test.equal(typeof meteorInstall, "function"); + test.equal(typeof meteorInstall, 'function'); var require = meteorInstall(); - test.equal(typeof require, "function"); + test.equal(typeof require, 'function'); }); + +Tinytest.add('errors - standard', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('meteor/foo'); + }, 'Cannot find package "foo". Try "meteor add foo".'); +}); + +Tinytest.add('errors - node_modules', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('./node_modules/foo'); + }, "Cannot find module './node_modules/foo'"); +}); + +if (Meteor.isServer) { + Tinytest.add('server - throwClientError', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('./../server/main.js'); + }, "Cannot find module './../server/main.js'" + ); + }); + Tinytest.add('server - client and server in path', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('/client/graphql/client'); + }, + 'Unable to import on the server a module from a client directory: "/client/graphql/client" \n' + + ' (cross-boundary import) see: https://guide.meteor.com/structure.html#special-directories' + ); + }); + Tinytest.add('server - throwServerError', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('./../client/main.js'); + }, + 'Unable to import on the server a module from a client directory: "./../client/main.js" \n' + + ' (cross-boundary import) see: https://guide.meteor.com/structure.html#special-directories' + ); + }); +} + +if (Meteor.isClient) { + Tinytest.add('client - throwClientError', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('./../server/main.js'); + }, + 'Unable to import on the client a module from a server directory: "./../server/main.js" \n' + + ' (cross-boundary import) see: https://guide.meteor.com/structure.html#special-directories' + ); + }); + Tinytest.add('client - client and server in path', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('/server/graphql/client'); + }, + 'Unable to import on the client a module from a server directory: "/server/graphql/client" \n' + + ' (cross-boundary import) see: https://guide.meteor.com/structure.html#special-directories' + ); + }); + Tinytest.add('client - throwServerError', function (test) { + var require = meteorInstall(); + test.throws(() => { + require('./../client/main.js'); + }, "Cannot find module './../client/main.js'"); + }); +} + + diff --git a/packages/modules-runtime/package.js b/packages/modules-runtime/package.js index 7afb76434b..f7ba69f134 100644 --- a/packages/modules-runtime/package.js +++ b/packages/modules-runtime/package.js @@ -18,12 +18,16 @@ Package.onUse(function(api) { bare: true }); - api.addFiles("modern.js", "modern"); - api.addFiles("legacy.js", "legacy"); - api.addFiles("server.js", "server"); - api.addFiles("profile.js"); + api.addFiles(['./errors/importsErrors.js', + './errors/cannotFindMeteorPackage.js']); + api.addFiles('modern.js', 'modern'); + api.addFiles('legacy.js', 'legacy'); + api.addFiles('server.js', 'server'); + api.addFiles('profile.js'); + api.addFiles('verifyErrors.js'); - api.export("meteorInstall"); + api.export('meteorInstall'); + api.export('verifyErrors'); }); Package.onTest(function(api) { diff --git a/packages/modules-runtime/server.js b/packages/modules-runtime/server.js index 018a61e49b..b2d66af281 100644 --- a/packages/modules-runtime/server.js +++ b/packages/modules-runtime/server.js @@ -28,7 +28,7 @@ makeInstallerOptions.fallback = function (id, parentId, error) { return Npm.require(id, error); } } - + verifyErrors(id, parentId, error); throw error; }; diff --git a/packages/modules-runtime/verifyErrors.js b/packages/modules-runtime/verifyErrors.js new file mode 100644 index 0000000000..d0fe827e8d --- /dev/null +++ b/packages/modules-runtime/verifyErrors.js @@ -0,0 +1,34 @@ + +/** + * + * @param id{string} + * @param parentId{string} + * @param err {Error} + */ +verifyErrors = function (id, parentId,err) { + + if (id && id.startsWith('meteor/')) { + throw cannotFindMeteorPackage(id); + } + + if(!(id.startsWith('.') || id.startsWith('/'))) { + throw err; + } + + if (imports(id).from('node_modules')) { + // Problem with node modules + throw err; + } + + // custom errors + if (Meteor.isServer && imports(id).from('client')) { + throw imports(id).fromClientError(); + } + if (Meteor.isClient && imports(id).from('server')) { + throw imports(id).fromServerError(); + } + + if (err) { + throw err; + } +}; diff --git a/packages/mongo/collection_tests.js b/packages/mongo/collection_tests.js index 96b953617e..78da9a1f18 100644 --- a/packages/mongo/collection_tests.js +++ b/packages/mongo/collection_tests.js @@ -1,3 +1,6 @@ + +var MongoDB = NpmModuleMongodb; + Tinytest.add( 'collection - call Mongo.Collection without new', function (test) { @@ -203,3 +206,181 @@ Tinytest.add('collection - calling find with an invalid readPreference', } } ); + +Tinytest.add('collection - inserting a document with a binary should return a document with a binary', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary1'); + const _id = Random.id(); + collection.insert({ + _id, + binary: new MongoDB.Binary(Buffer.from('hello world'), 6) + }); + + const doc = collection.findOne({ _id }); + test.ok( + doc.binary instanceof MongoDB.Binary + ); + test.equal( + doc.binary.buffer, + Buffer.from('hello world') + ); + } + } +); + +Tinytest.add('collection - inserting a document with a binary (sub type 0) should return a document with a uint8array', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary8'); + const _id = Random.id(); + collection.insert({ + _id, + binary: new MongoDB.Binary(Buffer.from('hello world'), 0) + }); + + const doc = collection.findOne({ _id }); + test.ok( + doc.binary instanceof Uint8Array + ); + test.equal( + doc.binary, + new Uint8Array(Buffer.from('hello world')) + ); + } + } +); + +Tinytest.add('collection - updating a document with a binary should return a document with a binary', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary2'); + const _id = Random.id(); + collection.insert({ + _id + }); + + collection.update({ _id }, { $set: { binary: new MongoDB.Binary(Buffer.from('hello world'), 6) } }); + + const doc = collection.findOne({ _id }); + test.ok( + doc.binary instanceof MongoDB.Binary + ); + test.equal( + doc.binary.buffer, + Buffer.from('hello world') + ); + } + } +); + +Tinytest.add('collection - updating a document with a binary (sub type 0) should return a document with a uint8array', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary7'); + const _id = Random.id(); + collection.insert({ + _id + }); + + collection.update({ _id }, { $set: { binary: new MongoDB.Binary(Buffer.from('hello world'), 0) } }); + + const doc = collection.findOne({ _id }); + test.ok( + doc.binary instanceof Uint8Array + ); + test.equal( + doc.binary, + new Uint8Array(Buffer.from('hello world')) + ); + } + } +); + +Tinytest.add('collection - inserting a document with a uint8array should return a document with a uint8array', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary3'); + const _id = Random.id(); + collection.insert({ + _id, + binary: new Uint8Array(Buffer.from('hello world')) + }); + + const doc = collection.findOne({ _id }); + test.ok( + doc.binary instanceof Uint8Array + ); + test.equal( + doc.binary, + new Uint8Array(Buffer.from('hello world')) + ); + } + } +); + +Tinytest.add('collection - updating a document with a uint8array should return a document with a uint8array', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary4'); + const _id = Random.id(); + collection.insert({ + _id + }); + + collection.update( + { _id }, + { $set: { binary: new Uint8Array(Buffer.from('hello world')) } } + ) + + const doc = collection.findOne({ _id }); + test.ok( + doc.binary instanceof Uint8Array + ); + test.equal( + doc.binary, + new Uint8Array(Buffer.from('hello world')) + ); + } + } +); + +Tinytest.add('collection - finding with a query with a uint8array field should return the correct document', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary5'); + const _id = Random.id(); + collection.insert({ + _id, + binary: new Uint8Array(Buffer.from('hello world')) + }); + + const doc = collection.findOne({ binary: new Uint8Array(Buffer.from('hello world')) }); + test.equal( + doc._id, + _id + ); + collection.remove({}); + } + } +); + +Tinytest.add('collection - finding with a query with a binary field should return the correct document', + function(test) { + if (Meteor.isServer) { + const collection = new Mongo.Collection('testBinary6'); + const _id = Random.id(); + collection.insert({ + _id, + binary: new MongoDB.Binary(Buffer.from('hello world'), 6) + }); + + const doc = collection.findOne({ binary: new MongoDB.Binary(Buffer.from('hello world'), 6) }); + test.equal( + doc._id, + _id + ); + collection.remove({}); + } + } +); diff --git a/packages/mongo/mongo_driver.js b/packages/mongo/mongo_driver.js index b8fa60d531..5d653636a8 100644 --- a/packages/mongo/mongo_driver.js +++ b/packages/mongo/mongo_driver.js @@ -69,6 +69,10 @@ var unmakeMongoLegal = function (name) { return name.substr(5); }; var replaceMongoAtomWithMeteor = function (document) { if (document instanceof MongoDB.Binary) { + // for backwards compatibility + if (document.sub_type !== 0) { + return document; + } var buffer = document.value(true); return new Uint8Array(buffer); } @@ -98,6 +102,9 @@ var replaceMeteorAtomWithMongo = function (document) { // serialize it correctly). return new MongoDB.Binary(Buffer.from(document)); } + if (document instanceof MongoDB.Binary) { + return document; + } if (document instanceof Mongo.ObjectID) { return new MongoDB.ObjectID(document.toHexString()); } diff --git a/packages/mongo/package.js b/packages/mongo/package.js index 628baf804b..f31b7efe27 100644 --- a/packages/mongo/package.js +++ b/packages/mongo/package.js @@ -88,6 +88,7 @@ Package.onTest(function (api) { api.use('mongo'); api.use('check'); api.use('ecmascript'); + api.use('npm-mongo', 'server'); api.use(['tinytest', 'underscore', 'test-helpers', 'ejson', 'random', 'ddp', 'base64']); // XXX test order dependency: the allow_tests "partial allow" test diff --git a/packages/npm-mongo/.npm/package/npm-shrinkwrap.json b/packages/npm-mongo/.npm/package/npm-shrinkwrap.json index bdf03af949..11662ebe99 100644 --- a/packages/npm-mongo/.npm/package/npm-shrinkwrap.json +++ b/packages/npm-mongo/.npm/package/npm-shrinkwrap.json @@ -1,15 +1,355 @@ { "lockfileVersion": 1, "dependencies": { + "@aws-crypto/ie11-detection": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-2.0.2.tgz", + "integrity": "sha512-5XDMQY98gMAf/WRTic5G++jfmS/VLM0rwpiOpaainKi4L0nqWMSB1SzsrEG5rjFZGYN6ZAefO+/Yta2dFM0kMw==", + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/sha256-browser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-2.0.0.tgz", + "integrity": "sha512-rYXOQ8BFOaqMEHJrLHul/25ckWH6GTJtdLSajhlqGMx0PmSueAuvboCuZCTqEKlxR8CQOwRarxYMZZSYlhRA1A==", + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/sha256-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-2.0.0.tgz", + "integrity": "sha512-VZY+mCY4Nmrs5WGfitmNqXzaE873fcIZDu54cbaDaaamsaTOP1DBImV9F4pICc3EHjQXujyE8jig+PFCaew9ig==", + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/supports-web-crypto": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-2.0.2.tgz", + "integrity": "sha512-6mbSsLHwZ99CTOOswvCRP3C+VCWnzBf+1SnbWxzzJ9lR0mA0JnY2JEAhp8rqmTE0GPFy88rrM27ffgp62oErMQ==", + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/util": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-2.0.2.tgz", + "integrity": "sha512-Lgu5v/0e/BcrZ5m/IWqzPUf3UYFTy/PpeED+uc9SWUR1iZQL8XXbGQg10UfllwwBryO3hFF5dizK+78aoXC1eA==", + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-sdk/abort-controller": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.190.0.tgz", + "integrity": "sha512-M6qo2exTzEfHT5RuW7K090OgesUojhb2JyWiV4ulu7ngY4DWBUBMKUqac696sHRUZvGE5CDzSi0606DMboM+kA==" + }, + "@aws-sdk/client-cognito-identity": { + "version": "3.192.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.192.0.tgz", + "integrity": "sha512-nIRmiv5JY8wWGUadhG7yLx8o8aVETj5CAgO8e8UJIwwqfue/Yv9bHi2mvkUphO1pj0TeBatAtvu79neJQtsR5g==" + }, + "@aws-sdk/client-sso": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.190.0.tgz", + "integrity": "sha512-joEKRjJEzgvXnEih/x2UDDCPlvXWCO3MAHmqi44yJ36Ph4YsFS299mOjPdVLuzUtpQ+cST1nRO7hXNFrulW2jQ==" + }, + "@aws-sdk/client-sts": { + "version": "3.192.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.192.0.tgz", + "integrity": "sha512-iv72dmRxbZ1cN5jGn4KIVzzu11eduS2fXHbNgd7JsFd5hLBV5TvJaugQzUdXNmy2gN4HiRJr+qa9WkD5b39lsA==" + }, + "@aws-sdk/config-resolver": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.190.0.tgz", + "integrity": "sha512-K+VnDtjTgjpf7yHEdDB0qgGbHToF0pIL0pQMSnmk2yc8BoB3LGG/gg1T0Ki+wRlrFnDCJ6L+8zUdawY2qDsbyw==" + }, + "@aws-sdk/credential-provider-cognito-identity": { + "version": "3.192.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.192.0.tgz", + "integrity": "sha512-CWo+KyHCGyYtvjlmDIGtnwBEkdiondergZADiStbFFvie8pPI7IsdTXNVssQQ1VxKIBGGHVebgZGSklHBqthwA==" + }, + "@aws-sdk/credential-provider-env": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.190.0.tgz", + "integrity": "sha512-GTY7l3SJhTmRGFpWddbdJOihSqoMN8JMo3CsCtIjk4/h3xirBi02T4GSvbrMyP7FP3Fdl4NAdT+mHJ4q2Bvzxw==" + }, + "@aws-sdk/credential-provider-imds": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.190.0.tgz", + "integrity": "sha512-gI5pfBqGYCKdmx8igPvq+jLzyE2kuNn9Q5u73pdM/JZxiq7GeWYpE/MqqCubHxPtPcTFgAwxCxCFoXlUTBh/2g==" + }, + "@aws-sdk/credential-provider-ini": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.190.0.tgz", + "integrity": "sha512-Z7NN/evXJk59hBQlfOSWDfHntwmxwryu6uclgv7ECI6SEVtKt1EKIlPuCLUYgQ4lxb9bomyO5lQAl/1WutNT5w==" + }, + "@aws-sdk/credential-provider-node": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.190.0.tgz", + "integrity": "sha512-ctCG5+TsIK2gVgvvFiFjinPjc5nGpSypU3nQKCaihtPh83wDN6gCx4D0p9M8+fUrlPa5y+o/Y7yHo94ATepM8w==" + }, + "@aws-sdk/credential-provider-process": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.190.0.tgz", + "integrity": "sha512-sIJhICR80n5XY1kW/EFHTh5ZzBHb5X+744QCH3StcbKYI44mOZvNKfFdeRL2fQ7yLgV7npte2HJRZzQPWpZUrw==" + }, + "@aws-sdk/credential-provider-sso": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.190.0.tgz", + "integrity": "sha512-uarU9vk471MHHT+GJj3KWFSmaaqLNL5n1KcMer2CCAZfjs+mStAi8+IjZuuKXB4vqVs5DxdH8cy5aLaJcBlXwQ==" + }, + "@aws-sdk/credential-provider-web-identity": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.190.0.tgz", + "integrity": "sha512-nlIBeK9hGHKWC874h+ITAfPZ9Eaok+x/ydZQVKsLHiQ9PH3tuQ8AaGqhuCwBSH0hEAHZ/BiKeEx5VyWAE8/x+Q==" + }, + "@aws-sdk/credential-providers": { + "version": "3.192.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.192.0.tgz", + "integrity": "sha512-iBTrEPkfOHlfgQyk7EeUCmZnhUKXsGcc/hhxBbc6Z/Xc7Y8LqRVLbEmHq9lruXraFuvs26xV9oZi1s1UMXneQA==" + }, + "@aws-sdk/fetch-http-handler": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.190.0.tgz", + "integrity": "sha512-5riRpKydARXAPLesTZm6eP6QKJ4HJGQ3k0Tepi3nvxHVx3UddkRNoX0pLS3rvbajkykWPNC2qdfRGApWlwOYsA==" + }, + "@aws-sdk/hash-node": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.190.0.tgz", + "integrity": "sha512-DNwVT3O8zc9Jk/bXiXcN0WsD98r+JJWryw9F1/ZZbuzbf6rx2qhI8ZK+nh5X6WMtYPU84luQMcF702fJt/1bzg==" + }, + "@aws-sdk/invalid-dependency": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.190.0.tgz", + "integrity": "sha512-crCh63e8d/Uw9y3dQlVTPja7+IZiXpNXyH6oSuAadTDQwMq6KK87Av1/SDzVf6bAo2KgAOo41MyO2joaCEk0dQ==" + }, + "@aws-sdk/is-array-buffer": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.188.0.tgz", + "integrity": "sha512-n69N4zJZCNd87Rf4NzufPzhactUeM877Y0Tp/F3KiHqGeTnVjYUa4Lv1vLBjqtfjYb2HWT3NKlYn5yzrhaEwiQ==" + }, + "@aws-sdk/middleware-content-length": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.190.0.tgz", + "integrity": "sha512-sSU347SuC6I8kWum1jlJlpAqeV23KP7enG+ToWcEcgFrJhm3AvuqB//NJxDbkKb2DNroRvJjBckBvrwNAjQnBQ==" + }, + "@aws-sdk/middleware-host-header": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.190.0.tgz", + "integrity": "sha512-cL7Vo/QSpGx/DDmFxjeV0Qlyi1atvHQDPn3MLBBmi1icu+3GKZkCMAJwzsrV3U4+WoVoDYT9FJ9yMQf2HaIjeQ==" + }, + "@aws-sdk/middleware-logger": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.190.0.tgz", + "integrity": "sha512-rrfLGYSZCBtiXNrIa8pJ2uwUoUMyj6Q82E8zmduTvqKWviCr6ZKes0lttGIkWhjvhql2m4CbjG5MPBnY7RXL4A==" + }, + "@aws-sdk/middleware-recursion-detection": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.190.0.tgz", + "integrity": "sha512-5tc1AIIZe5jDNdyuJW+7vIFmQOxz3q031ZVrEtUEIF7cz2ySho2lkOWziz+v+UGSLhjHGKMz3V26+aN1FLZNxQ==" + }, + "@aws-sdk/middleware-retry": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.190.0.tgz", + "integrity": "sha512-h1bPopkncf2ue/erJdhqvgR2AEh0bIvkNsIHhx93DckWKotZd/GAVDq0gpKj7/f/7B+teHH8Fg5GDOwOOGyKcg==" + }, + "@aws-sdk/middleware-sdk-sts": { + "version": "3.192.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.192.0.tgz", + "integrity": "sha512-xzTV7MyG5ipWYTvekWX1tQc5ExsUvCYsDTBCD3LR5hBrP8assUDPo52zGSe+QMcjgnQv7BcYIzeikTkLEG0dUw==" + }, + "@aws-sdk/middleware-serde": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.190.0.tgz", + "integrity": "sha512-S132hEOK4jwbtZ1bGAgSuQ0DMFG4TiD4ulAwbQRBYooC7tiWZbRiR0Pkt2hV8d7WhOHgUpg7rvqlA7/HXXBAsA==" + }, + "@aws-sdk/middleware-signing": { + "version": "3.192.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.192.0.tgz", + "integrity": "sha512-qTRIU/TL/dvtTrNj+AkZkgYeTIFslib3Y3XnQNNM6RCm4cMxIgs2K/lnhaUmLdbzHrpOQb4cISkY8yiHo+pNsw==" + }, + "@aws-sdk/middleware-stack": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.190.0.tgz", + "integrity": "sha512-h1mqiWNJdi1OTSEY8QovpiHgDQEeRG818v8yShpqSYXJKEqdn54MA3Z1D2fg/Wv/8ZJsFrBCiI7waT1JUYOmCg==" + }, + "@aws-sdk/middleware-user-agent": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.190.0.tgz", + "integrity": "sha512-y/2cTE1iYHKR0nkb3DvR3G8vt12lcTP95r/iHp8ZO+Uzpc25jM/AyMHWr2ZjqQiHKNlzh8uRw1CmQtgg4sBxXQ==" + }, + "@aws-sdk/node-config-provider": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.190.0.tgz", + "integrity": "sha512-TJPUchyeK5KeEXWrwb6oW5/OkY3STCSGR1QIlbPcaTGkbo4kXAVyQmmZsY4KtRPuDM6/HlfUQV17bD716K65rQ==" + }, + "@aws-sdk/node-http-handler": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.190.0.tgz", + "integrity": "sha512-3Klkr73TpZkCzcnSP+gmFF0Baluzk3r7BaWclJHqt2LcFUWfIJzYlnbBQNZ4t3EEq7ZlBJX85rIDHBRlS+rUyA==" + }, + "@aws-sdk/property-provider": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.190.0.tgz", + "integrity": "sha512-uzdKjHE2blbuceTC5zeBgZ0+Uo/hf9pH20CHpJeVNtrrtF3GALtu4Y1Gu5QQVIQBz8gjHnqANx0XhfYzorv69Q==" + }, + "@aws-sdk/protocol-http": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.190.0.tgz", + "integrity": "sha512-s5MVfeONpfZYRzCSbqQ+wJ3GxKED+aSS7+CQoeaYoD6HDTDxaMGNv9aiPxVCzW02sgG7py7f29Q6Vw+5taZXZA==" + }, + "@aws-sdk/querystring-builder": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.190.0.tgz", + "integrity": "sha512-w9mTKkCsaLIBC8EA4RAHrqethNGbf60CbpPzN/QM7yCV3ZZJAXkppFfjTVVOMbPaI8GUEOptJtzgqV68CRB7ow==" + }, + "@aws-sdk/querystring-parser": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.190.0.tgz", + "integrity": "sha512-vCKP0s33VtS47LSYzEWRRr2aTbi3qNkUuQyIrc5LMqBfS5hsy79P1HL4Q7lCVqZB5fe61N8fKzOxDxWRCF0sXg==" + }, + "@aws-sdk/service-error-classification": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.190.0.tgz", + "integrity": "sha512-g+s6xtaMa5fCMA2zJQC4BiFGMP7FN5/L1V/UwxCnKy8skCwaN0K5A1tFffBjjbYiPI7Gu7LVorWD2A0Y4xl01Q==" + }, + "@aws-sdk/shared-ini-file-loader": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.190.0.tgz", + "integrity": "sha512-CZC/xsGReUEl5w+JgfancrxfkaCbEisyIFy6HALUYrioWQe80WMqLAdUMZSXHWjIaNK9mH0J/qvcSV2MuIoMzQ==" + }, + "@aws-sdk/signature-v4": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.190.0.tgz", + "integrity": "sha512-L/R/1X2T+/Kg2k/sjoYyDFulVUGrVcRfyEKKVFIUNg0NwUtw5UKa1/gS7geTKcg4q8M2pd/v+OCBrge2X7phUw==" + }, + "@aws-sdk/smithy-client": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.190.0.tgz", + "integrity": "sha512-f5EoCwjBLXMyuN491u1NmEutbolL0cJegaJbtgK9OJw2BLuRHiBknjDF4OEVuK/WqK0kz2JLMGi9xwVPl4BKCA==" + }, + "@aws-sdk/types": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.190.0.tgz", + "integrity": "sha512-mkeZ+vJZzElP6OdRXvuLKWHSlDQxZP9u8BjQB9N0Rw0pCXTzYS0vzIhN1pL0uddWp5fMrIE68snto9xNR6BQuA==" + }, + "@aws-sdk/url-parser": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.190.0.tgz", + "integrity": "sha512-FKFDtxA9pvHmpfWmNVK5BAVRpDgkWMz3u4Sg9UzB+WAFN6UexRypXXUZCFAo8S04FbPKfYOR3O0uVlw7kzmj9g==" + }, + "@aws-sdk/util-base64-browser": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64-browser/-/util-base64-browser-3.188.0.tgz", + "integrity": "sha512-qlH+5NZBLiyKziL335BEPedYxX6j+p7KFRWXvDQox9S+s+gLCayednpK+fteOhBenCcR9fUZOVuAPScy1I8qCg==" + }, + "@aws-sdk/util-base64-node": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64-node/-/util-base64-node-3.188.0.tgz", + "integrity": "sha512-r1dccRsRjKq+OhVRUfqFiW3sGgZBjHbMeHLbrAs9jrOjU2PTQ8PSzAXLvX/9lmp7YjmX17Qvlsg0NCr1tbB9OA==" + }, + "@aws-sdk/util-body-length-browser": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.188.0.tgz", + "integrity": "sha512-8VpnwFWXhnZ/iRSl9mTf+VKOX9wDE8QtN4bj9pBfxwf90H1X7E8T6NkiZD3k+HubYf2J94e7DbeHs7fuCPW5Qg==" + }, + "@aws-sdk/util-body-length-node": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.188.0.tgz", + "integrity": "sha512-XwqP3vxk60MKp4YDdvDeCD6BPOiG2e+/Ou4AofZOy5/toB6NKz2pFNibQIUg2+jc7mPMnGnvOW3MQEgSJ+gu/Q==" + }, + "@aws-sdk/util-buffer-from": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.188.0.tgz", + "integrity": "sha512-NX1WXZ8TH20IZb4jPFT2CnLKSqZWddGxtfiWxD9M47YOtq/SSQeR82fhqqVjJn4P8w2F5E28f+Du4ntg/sGcxA==" + }, + "@aws-sdk/util-config-provider": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.188.0.tgz", + "integrity": "sha512-LBA7tLbi7v4uvbOJhSnjJrxbcRifKK/1ZVK94JTV2MNSCCyNkFotyEI5UWDl10YKriTIUyf7o5cakpiDZ3O4xg==" + }, + "@aws-sdk/util-defaults-mode-browser": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.190.0.tgz", + "integrity": "sha512-FKxTU4tIbFk2pdUbBNneStF++j+/pB4NYJ1HRSEAb/g4D2+kxikR/WKIv3p0JTVvAkwcuX/ausILYEPUyDZ4HQ==" + }, + "@aws-sdk/util-defaults-mode-node": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.190.0.tgz", + "integrity": "sha512-qBiIMjNynqAP7p6urG1+ZattYkFaylhyinofVcLEiDvM9a6zGt6GZsxru2Loq0kRAXXGew9E9BWGt45HcDc20g==" + }, + "@aws-sdk/util-hex-encoding": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.188.0.tgz", + "integrity": "sha512-QyWovTtjQ2RYxqVM+STPh65owSqzuXURnfoof778spyX4iQ4z46wOge1YV2ZtwS8w5LWd9eeVvDrLu5POPYOnA==" + }, + "@aws-sdk/util-locate-window": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.188.0.tgz", + "integrity": "sha512-SxobBVLZkkLSawTCfeQnhVX3Azm9O+C2dngZVe1+BqtF8+retUbVTs7OfYeWBlawVkULKF2e781lTzEHBBjCzw==" + }, + "@aws-sdk/util-middleware": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.190.0.tgz", + "integrity": "sha512-qzTJ/qhFDzHZS+iXdHydQ/0sWAuNIB5feeLm55Io/I8Utv3l3TKYOhbgGwTsXY+jDk7oD+YnAi7hLN5oEBCwpg==" + }, + "@aws-sdk/util-uri-escape": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.188.0.tgz", + "integrity": "sha512-4Y6AYZMT483Tiuq8dxz5WHIiPNdSFPGrl6tRTo2Oi2FcwypwmFhqgEGcqxeXDUJktvaCBxeA08DLr/AemVhPCg==" + }, + "@aws-sdk/util-user-agent-browser": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.190.0.tgz", + "integrity": "sha512-c074wjsD+/u9vT7DVrBLkwVhn28I+OEHuHaqpTVCvAIjpueZ3oms0e99YJLfpdpEgdLavOroAsNFtAuRrrTZZw==" + }, + "@aws-sdk/util-user-agent-node": { + "version": "3.190.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.190.0.tgz", + "integrity": "sha512-R36BMvvPX8frqFhU4lAsrOJ/2PJEHH/Jz1WZzO3GWmVSEAQQdHmo8tVPE3KOM7mZWe5Hj1dZudFAIxWHHFYKJA==" + }, + "@aws-sdk/util-utf8-browser": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.188.0.tgz", + "integrity": "sha512-jt627x0+jE+Ydr9NwkFstg3cUvgWh56qdaqAMDsqgRlKD21md/6G226z/Qxl7lb1VEW2LlmCx43ai/37Qwcj2Q==" + }, + "@aws-sdk/util-utf8-node": { + "version": "3.188.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-node/-/util-utf8-node-3.188.0.tgz", + "integrity": "sha512-hCgP4+C0Lekjpjt2zFJ2R/iHes5sBGljXa5bScOFAEkRUc0Qw0VNgTv7LpEbIOAwGmqyxBoCwBW0YHPW1DfmYQ==" + }, "@types/node": { - "version": "18.7.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.13.tgz", - "integrity": "sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw==" + "version": "18.11.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.2.tgz", + "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==" }, "@types/webidl-conversions": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz", - "integrity": "sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q==" + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==" }, "@types/whatwg-url": { "version": "8.2.2", @@ -21,6 +361,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" + }, "bson": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.0.tgz", @@ -36,6 +381,11 @@ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" }, + "fast-xml-parser": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.0.11.tgz", + "integrity": "sha512-4aUg3aNRR/WjQAcpceODG1C3x3lFANXRo8+1biqfieHmg9pyMt7qB4lQV/Ta6sJCTbA5vfD8fnA8S54JATiFUA==" + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -52,14 +402,14 @@ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "mongodb": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.9.0.tgz", - "integrity": "sha512-tJJEFJz7OQTQPZeVHZJIeSOjMRqc5eSyXTt86vSQENEErpkiG7279tM/GT5AVZ7TgXNh9HQxoa2ZkbrANz5GQw==" + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", + "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==" }, "mongodb-connection-string-url": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.3.tgz", - "integrity": "sha512-f+/WsED+xF4B74l3k9V/XkTVj5/fxFH2o5ToKXd8Iyi5UhM+sO9u0Ape17Mvl/GkZaFtM0HQnzAG5OTmhKw+tQ==" + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.4.tgz", + "integrity": "sha512-SeAxuWs0ez3iI3vvmLk/j2y+zHwigTDKQhtdxTgt5ZCOQQS5+HW4g45/Xw5vzzbn7oQXCNQ24Z40AkJsizEy7w==" }, "punycode": { "version": "2.1.1", @@ -77,20 +427,35 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" }, "socks": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.0.tgz", - "integrity": "sha512-scnOe9y4VuiNUULJN72GrM26BNOjVsfPXI+j+98PkyEfsIXroa5ofyjT+FzGvn/xHs73U2JtoBYAVx9Hl4quSA==" + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==" }, "sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==" }, + "strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==" }, + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/packages/npm-mongo/package.js b/packages/npm-mongo/package.js index b8519fa86a..2222c52f7a 100644 --- a/packages/npm-mongo/package.js +++ b/packages/npm-mongo/package.js @@ -3,12 +3,12 @@ Package.describe({ summary: "Wrapper around the mongo npm package", - version: "4.9.0", + version: "4.11.0", documentation: null }); Npm.depends({ - mongodb: "4.9.0" + mongodb: "4.11.0" }); Package.onUse(function (api) { diff --git a/packages/oauth/oauth_server.js b/packages/oauth/oauth_server.js index 6d7b0cb578..1b591a455b 100644 --- a/packages/oauth/oauth_server.js +++ b/packages/oauth/oauth_server.js @@ -136,7 +136,7 @@ OAuth._checkRedirectUrlOrigin = redirectUrl => { ); }; -const middleware = (req, res, next) => { +const middleware = async (req, res, next) => { let requestData; // Make sure to catch any exceptions because otherwise we'd crash @@ -168,7 +168,7 @@ const middleware = (req, res, next) => { requestData = req.body; } - handler(service, requestData, res); + await handler(service, requestData, res); } catch (err) { // if we got thrown an error, save it off, it will get passed to // the appropriate login call (if any) and reported there. @@ -473,3 +473,31 @@ OAuth.openSecrets = (serviceData, userId) => { ); return result; }; + +OAuth._addValuesToQueryParams = ( + values = {}, + queryParams = new URLSearchParams() +) => { + Object.entries(values).forEach(([key, value]) => { + queryParams.set(key, `${value}`); + }); + return queryParams; +}; + +OAuth._fetch = async ( + url, + method = 'GET', + { headers = {}, queryParams = {}, body, ...options } = {} +) => { + const urlWithParams = new URL(url); + + OAuth._addValuesToQueryParams(queryParams, urlWithParams.searchParams); + + const requestOptions = { + method: method.toUpperCase(), + headers, + ...(body ? { body } : {}), + ...options, + }; + return fetch(urlWithParams.toString(), requestOptions); +}; diff --git a/packages/oauth/package.js b/packages/oauth/package.js index 8962aeb282..421be1d506 100644 --- a/packages/oauth/package.js +++ b/packages/oauth/package.js @@ -11,6 +11,7 @@ Package.onUse(api => { api.use(['reload', 'base64'], 'client'); api.use('oauth-encryption', 'server', {weak: true}); + api.use('fetch', 'server'); api.export('OAuth'); diff --git a/packages/oauth1/oauth1_binding.js b/packages/oauth1/oauth1_binding.js index aab9629605..015553611e 100644 --- a/packages/oauth1/oauth1_binding.js +++ b/packages/oauth1/oauth1_binding.js @@ -19,12 +19,12 @@ export class OAuth1Binding { this._urls = urls; } - prepareRequestToken(callbackUrl) { + async prepareRequestToken(callbackUrl) { const headers = this._buildHeader({ oauth_callback: callbackUrl }); - const response = this._call('POST', this._urls.requestToken, headers); + const response = await this._call({method: 'POST', url: this._urls.requestToken, headers}); const tokens = querystring.parse(response.content); if (! tokens.oauth_callback_confirmed) @@ -35,7 +35,7 @@ export class OAuth1Binding { this.requestTokenSecret = tokens.oauth_token_secret; } - prepareAccessToken(query, requestTokenSecret) { + async prepareAccessToken(query, requestTokenSecret) { // support implementations that use request token secrets. This is // read by this._call. // @@ -50,7 +50,7 @@ export class OAuth1Binding { oauth_verifier: query.oauth_verifier }); - const response = this._call('POST', this._urls.accessToken, headers); + const response = await this._call({ method: 'POST', url: this._urls.accessToken, headers }); const tokens = querystring.parse(response.content); if (! tokens.oauth_token || ! tokens.oauth_token_secret) { @@ -66,7 +66,7 @@ export class OAuth1Binding { this.accessTokenSecret = tokens.oauth_token_secret; } - call(method, url, params, callback) { + async callAsync(method, url, params, callback) { const headers = this._buildHeader({ oauth_token: this.accessToken }); @@ -75,14 +75,29 @@ export class OAuth1Binding { params = {}; } - return this._call(method, url, headers, params, callback); + return this._call({ method, url, headers, params, callback }); + } + + async getAsync(url, params, callback) { + return this.callAsync('GET', url, params, callback); + } + + async postAsync(url, params, callback) { + return this.callAsync('POST', url, params, callback); + } + + call(method, url, params, callback) { + // Require changes when remove Fibers. Exposed to public api. + return Promise.await(this.callAsync(method, url, params, callback)); } get(url, params, callback) { + // Require changes when remove Fibers. Exposed to public api. return this.call('GET', url, params, callback); } post(url, params, callback) { + // Require changes when remove Fibers. Exposed to public api. return this.call('POST', url, params, callback); } @@ -118,7 +133,7 @@ export class OAuth1Binding { return crypto.createHmac('SHA1', signingKey).update(signatureBase).digest('base64'); }; - _call(method, url, headers = {}, params = {}, callback) { + async _call({method, url, headers = {}, params = {}, callback}) { // all URLs to be functions to support parameters/customization if(typeof url === "function") { url = url(this); @@ -141,29 +156,52 @@ export class OAuth1Binding { // Make a authorization string according to oauth1 spec const authString = this._getAuthHeaderString(headers); - // Make signed request - try { - const response = HTTP.call(method, url, { - params, - headers: { - Authorization: authString + return OAuth._fetch(url, method, { + headers: { + Authorization: authString, + ...(method.toUpperCase() === 'POST' ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}) + }, + ...(method.toUpperCase() === 'POST' ? + { body: OAuth._addValuesToQueryParams(params).toString() } + : { queryParams: params }) + }).then((res) => + res.text().then((content) => { + const responseHeaders = Array.from(res.headers.entries()).reduce( + (acc, [key, val]) => { + return { ...acc, [key.toLowerCase()]: val }; + }, + {} + ); + const data = responseHeaders['content-type'].includes('application/json') ? + JSON.parse(content) : undefined; + return { + content: data ? '' : content, + data, + headers: { ...responseHeaders, nonce: headers.oauth_nonce }, + redirected: res.redirected, + ok: res.ok, + statusCode: res.status, + }; + }) + ) + .then((response) => { + if (callback) { + callback(undefined, response); } - }, callback && ((error, response) => { - if (! error) { - response.nonce = headers.oauth_nonce; + return response; + }) + .catch((err) => { + if (callback) { + callback(err); } - callback(error, response); - })); - // We store nonce so that JWTs can be validated - if (response) - response.nonce = headers.oauth_nonce; - return response; - } catch (err) { - throw Object.assign(new Error(`Failed to send OAuth1 request to ${url}. ${err.message}`), - {response: err.response}); - } - }; + console.log({ err }); + throw Object.assign( + new Error(`Failed to send OAuth1 request to ${url}. ${err.message}`), + { response: err.response } + ); + }); + } _encodeHeader(header) { return Object.keys(header).reduce((memo, key) => { diff --git a/packages/oauth1/oauth1_server.js b/packages/oauth1/oauth1_server.js index eb54458825..d0c8e3732a 100644 --- a/packages/oauth1/oauth1_server.js +++ b/packages/oauth1/oauth1_server.js @@ -6,7 +6,7 @@ OAuth._queryParamsWithAuthTokenUrl = (authUrl, oauthBinding, params = {}, whitel Object.assign( redirectUrlObj.query, - whitelistedQueryParams.reduce((prev, param) => + whitelistedQueryParams.reduce((prev, param) => params.query[param] ? { ...prev, param: params.query[param] } : prev, {} ), @@ -25,7 +25,7 @@ OAuth._queryParamsWithAuthTokenUrl = (authUrl, oauthBinding, params = {}, whitel }; // connect middleware -OAuth._requestHandlers['1'] = (service, query, res) => { +OAuth._requestHandlers['1'] = async (service, query, res) => { const config = ServiceConfiguration.configurations.findOne({service: service.serviceName}); if (! config) { throw new ServiceConfiguration.ConfigError(service.serviceName); @@ -45,7 +45,7 @@ OAuth._requestHandlers['1'] = (service, query, res) => { }); // Get a request token to start auth process - oauthBinding.prepareRequestToken(callbackUrl); + await oauthBinding.prepareRequestToken(callbackUrl); // Keep track of request token so we can verify it on the next step OAuth._storeRequestToken( @@ -91,10 +91,10 @@ OAuth._requestHandlers['1'] = (service, query, res) => { // subsequent call to the `login` method will be immediate. // Get the access token for signing requests - oauthBinding.prepareAccessToken(query, requestTokenInfo.requestTokenSecret); + await oauthBinding.prepareAccessToken(query, requestTokenInfo.requestTokenSecret); // Run service-specific handler. - const oauthResult = service.handleOauthRequest( + const oauthResult = await service.handleOauthRequest( oauthBinding, { query: query }); const credentialToken = OAuth._credentialTokenFromQuery(query); diff --git a/packages/oauth1/oauth1_tests.js b/packages/oauth1/oauth1_tests.js index a9f266af02..d4b283a97a 100644 --- a/packages/oauth1/oauth1_tests.js +++ b/packages/oauth1/oauth1_tests.js @@ -1,7 +1,7 @@ import http from 'http'; import { OAuth1Binding } from './oauth1_binding'; -const testPendingCredential = (test, method) => { +const testPendingCredential = async (test, method) => { const twitterfooId = Random.id(); const twitterfooName = `nickname${Random.id()}`; const twitterfooAccessToken = Random.id(); @@ -17,8 +17,8 @@ const testPendingCredential = (test, method) => { authenticate: "https://example.com/oauth/authenticate" }; - OAuth1Binding.prototype.prepareRequestToken = () => {}; - OAuth1Binding.prototype.prepareAccessToken = function() { + OAuth1Binding.prototype.prepareRequestToken = async () => {}; + OAuth1Binding.prototype.prepareAccessToken = async function() { this.accessToken = twitterfooAccessToken; this.accessTokenSecret = twitterfooAccessTokenSecret; }; @@ -27,7 +27,7 @@ const testPendingCredential = (test, method) => { try { // register a fake login service - OAuth.registerService(serviceName, 1, urls, query => ({ + OAuth.registerService(serviceName, 1, urls, async query => ({ serviceData: { id: twitterfooId, screenName: twitterfooName, @@ -71,7 +71,7 @@ const testPendingCredential = (test, method) => { respData += args[0]; return end.apply(this, arguments); }; - OAuthTest.middleware(req, res); + await OAuthTest.middleware(req, res); const credentialSecret = respData; // Test that the result for the token is available @@ -94,17 +94,17 @@ const testPendingCredential = (test, method) => { } }; -Tinytest.add("oauth1 - pendingCredential is stored and can be retrieved (without oauth encryption)", test => { +Tinytest.addAsync("oauth1 - pendingCredential is stored and can be retrieved (without oauth encryption)", async test => { OAuthEncryption.loadKey(null); - testPendingCredential(test, "GET"); - testPendingCredential(test, "POST"); + await testPendingCredential(test, "GET"); + await testPendingCredential(test, "POST"); }); -Tinytest.add("oauth1 - pendingCredential is stored and can be retrieved (with oauth encryption)", test => { +Tinytest.addAsync("oauth1 - pendingCredential is stored and can be retrieved (with oauth encryption)", async test => { try { OAuthEncryption.loadKey(Buffer.from([1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]).toString("base64")); - testPendingCredential(test, "GET"); - testPendingCredential(test, "POST"); + await testPendingCredential(test, "GET"); + await testPendingCredential(test, "POST"); } finally { OAuthEncryption.loadKey(null); } diff --git a/packages/oauth1/package.js b/packages/oauth1/package.js index 550fdc2448..bb07c66774 100644 --- a/packages/oauth1/package.js +++ b/packages/oauth1/package.js @@ -8,10 +8,7 @@ Package.onUse(api => { api.use('random'); api.use('service-configuration', ['client', 'server']); api.use('oauth', ['client', 'server']); - api.use([ - 'check', - 'http@1.4.4 || 2.0.0' - ], 'server'); + api.use('check', 'server'); api.use('mongo'); diff --git a/packages/oauth2/oauth2_server.js b/packages/oauth2/oauth2_server.js index cf990d8691..86eead93ba 100644 --- a/packages/oauth2/oauth2_server.js +++ b/packages/oauth2/oauth2_server.js @@ -1,5 +1,5 @@ // connect middleware -OAuth._requestHandlers['2'] = (service, query, res) => { +OAuth._requestHandlers['2'] = async (service, query, res) => { let credentialSecret; // check if user authorized access @@ -7,7 +7,7 @@ OAuth._requestHandlers['2'] = (service, query, res) => { // Prepare the login results before returning. // Run service-specific handler. - const oauthResult = service.handleOauthRequest(query); + const oauthResult = await service.handleOauthRequest(query); credentialSecret = Random.secret(); const credentialToken = OAuth._credentialTokenFromQuery(query); diff --git a/packages/oauth2/oauth2_tests.js b/packages/oauth2/oauth2_tests.js index 1ce47813b4..49b94f4eb0 100644 --- a/packages/oauth2/oauth2_tests.js +++ b/packages/oauth2/oauth2_tests.js @@ -1,6 +1,6 @@ import http from 'http'; -const testPendingCredential = function (test, method) { +const testPendingCredential = async function (test, method) { const foobookId = Random.id(); const foobookOption1 = Random.id(); const credentialToken = Random.id(); @@ -51,7 +51,7 @@ const testPendingCredential = function (test, method) { return end.apply(this, args); }; - OAuthTest.middleware(req, res); + await OAuthTest.middleware(req, res); const credentialSecret = respData; // Test that the result for the token is available @@ -72,17 +72,17 @@ const testPendingCredential = function (test, method) { } }; -Tinytest.add("oauth2 - pendingCredential is stored and can be retrieved (without oauth encryption)", test => { +Tinytest.addAsync("oauth2 - pendingCredential is stored and can be retrieved (without oauth encryption)", async test => { OAuthEncryption.loadKey(null); - testPendingCredential(test, "GET"); - testPendingCredential(test, "POST"); + await testPendingCredential(test, "GET"); + await testPendingCredential(test, "POST"); }); -Tinytest.add("oauth2 - pendingCredential is stored and can be retrieved (with oauth encryption)", test => { +Tinytest.addAsync("oauth2 - pendingCredential is stored and can be retrieved (with oauth encryption)", async test => { try { OAuthEncryption.loadKey(Buffer.from([1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]).toString("base64")); - testPendingCredential(test, "GET"); - testPendingCredential(test, "POST"); + await testPendingCredential(test, "GET"); + await testPendingCredential(test, "POST"); } finally { OAuthEncryption.loadKey(null); } diff --git a/packages/standard-minifier-css/plugin/minify-css.js b/packages/standard-minifier-css/plugin/minify-css.js index 2b8c4d5e44..8ac2b0db75 100644 --- a/packages/standard-minifier-css/plugin/minify-css.js +++ b/packages/standard-minifier-css/plugin/minify-css.js @@ -60,7 +60,7 @@ class CssToolsMinifier { path: 'merged-stylesheets.css' }]; } else { - const minifiedFiles = CssTools.minifyCss(merged.code); + const minifiedFiles = await CssTools.minifyCssAsync(merged.code); result = minifiedFiles.map(minified => ({ data: minified diff --git a/packages/test-helpers/async_multi.js b/packages/test-helpers/async_multi.js index e5ec3cb43c..04be6aedfe 100644 --- a/packages/test-helpers/async_multi.js +++ b/packages/test-helpers/async_multi.js @@ -142,8 +142,13 @@ testAsyncMulti = function (name, funcs, { isOnly = false } = {}) { test.extraDetails.asyncBlock = i++; new Promise(resolve => { - resolve(func.apply(context, [test, _.bind(em.expect, em)])); - }).then(result => { + const result = func.apply(context, [test, _.bind(em.expect, em)]); + if (result && typeof result.then === "function") { + return result.then((r) => resolve(r)) + } + + return resolve(result); + }).then(() => { em.done(); }, exception => { if (em.cancel()) { @@ -191,3 +196,24 @@ pollUntil = function (expect, f, timeout, step, noFail) { step ); }; + +/** + * Helper that is used on the async tests. + * Just run the function and assert if we have an error or not. + * @param fn + * @param test + * @param shouldErrorOut + * @returns {Promise<*>} + */ +runAndThrowIfNeeded = async (fn, test, shouldErrorOut) => { + let err, result; + try { + result = await fn(); + } catch (e) { + err = e; + } + + test[shouldErrorOut ? "isTrue" : "isFalse"](err); + + return result; +}; diff --git a/packages/test-helpers/package.js b/packages/test-helpers/package.js index 17b6e0f37a..399e768cbe 100644 --- a/packages/test-helpers/package.js +++ b/packages/test-helpers/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "Utility functions for tests", - version: '1.3.0' + version: '1.3.1' }); Package.onUse(function (api) { @@ -28,7 +28,8 @@ Package.onUse(function (api) { 'SeededRandom', 'clickElement', 'blurElement', 'focusElement', 'simulateEvent', 'getStyleProperty', 'canonicalizeHtml', 'renderToDiv', 'clickIt', - 'withCallbackLogger', 'testAsyncMulti', 'simplePoll', + 'withCallbackLogger', 'testAsyncMulti', + 'simplePoll', 'runAndThrowIfNeeded', 'makeTestConnection', 'DomUtils']); api.addFiles('try_all_permutations.js'); diff --git a/packages/test-in-browser/driver.js b/packages/test-in-browser/driver.js index 5e6120d04a..d0d5fa4423 100644 --- a/packages/test-in-browser/driver.js +++ b/packages/test-in-browser/driver.js @@ -88,7 +88,7 @@ var reportResults = function(results) { } // Now process the current report - if (_.isArray(results.events)) { + if (Array.isArray(results.events)) { // append events, if present Array.prototype.push.apply((test.events || (test.events = [])), results.events); @@ -97,7 +97,7 @@ var reportResults = function(results) { return a.sequence - b.sequence; }); var out = []; - _.each(test.events, function (e) { + test.events.forEach(function (e) { if (out.length === 0 || out[out.length - 1].sequence !== e.sequence) out.push(e); }); @@ -110,7 +110,7 @@ var reportResults = function(results) { // test name yet). if (test.expanded === undefined) test.expanded = true; - if (!_.contains(failedTests, test.fullName)) + if (!failedTests.includes(test.fullName)) failedTests.push(test.fullName); countDep.changed(); @@ -147,16 +147,16 @@ var forgetEvents = function (results) { // possibly 'events'. var _findTestForResults = function (results) { var groupPath = results.groupPath; // array - if ((! _.isArray(groupPath)) || (groupPath.length < 1)) { + if ((! Array.isArray(groupPath)) || (groupPath.length < 1)) { throw new Error("Test must be part of a group"); } var group; var i = 0; - _.each(groupPath, function(gname) { + groupPath.forEach(function(gname) { var array = (group ? (group.groups || (group.groups = [])) : resultTree); - var newGroup = _.find(array, function(g) { return g.name === gname; }); + var newGroup = array.find(function(g) { return g.name === gname; }); if (! newGroup) { newGroup = { name: gname, @@ -177,12 +177,12 @@ var _findTestForResults = function (results) { var testName = results.test; var server = !!results.server; - var test = _.find(group.tests || (group.tests = []), + var test = (group.tests || (group.tests = [])).find( function(t) { return t.name === testName && t.server === server; }); if (! test) { // create test - var nameParts = _.clone(groupPath); + var nameParts = [...groupPath]; nameParts.push(testName); var fullName = nameParts.join(' - '); test = { @@ -209,7 +209,7 @@ var _findTestForResults = function (results) { var _testTime = function(t) { if (t.events && t.events.length > 0) { - var lastEvent = _.last(t.events); + var lastEvent = t.events[t.events.length - 1]; if (lastEvent.type === "finish") { if ((typeof lastEvent.timeMs) === "number") { return lastEvent.timeMs; @@ -221,15 +221,15 @@ var _testTime = function(t) { var _testStatus = function(t) { var events = t.events || []; - if (_.find(events, function(x) { return x.type === "exception"; })) { + if (events.find(function(x) { return x.type === "exception"; })) { // "exception" should be last event, except race conditions on the // server can make this not the case. Technically we can't tell // if the test is still running at this point, but it can only // result in FAIL. return "failed"; - } else if (events.length == 0 || (_.last(events).type != "finish")) { + } else if (events.length == 0 || (events[events.length - 1].type != "finish")) { return "running"; - } else if (_.any(events, function(e) { + } else if (events.some(function(e) { return e.type == "fail" || e.type == "exception"; })) { return "failed"; } else { @@ -261,8 +261,8 @@ Template.navBar.helpers({ var walk = function (groups) { var total = 0; - _.each(groups || [], function (group) { - _.each(group.tests || [], function (t) { + (groups || []).forEach(function (group) { + (group.tests || []).forEach(function (t) { total += _testTime(t); }); @@ -450,14 +450,14 @@ Template.test.helpers({ }, eventsArray: function() { - var events = _.filter(this.events, function(e) { - return e.type != "finish"; + var events = this.events.filter(function(e) { + return e[type] != "finish"; }); var partitionBy = function(seq, func) { var result = []; var lastValue = {}; - _.each(seq, function(x) { + seq.forEach(function(x) { var newValue = func(x); if (newValue === lastValue) { result[result.length-1].push(x); @@ -470,17 +470,17 @@ Template.test.helpers({ }; var dupLists = partitionBy( - _.map(events, function(e) { + events.map(function(e) { // XXX XXX We need something better than stringify! // stringify([undefined]) === "[null]" - e = _.clone(e); + e = Object.assign({}, e); delete e.sequence; return {obj: e, str: JSON.stringify(e)}; }), function(x) { return x.str; }); - return _.map(dupLists, function(L) { + return dupLists.map(function(L) { var obj = L[0].obj; - return (L.length > 1) ? _.extend({times: L.length}, obj) : obj; + return (L.length > 1) ? Object.assign({times: L.length}, obj) : obj; }); } }); @@ -525,7 +525,7 @@ Template.event.helpers({ var type = details.type; var stack = details.stack; - details = _.clone(details); + details = Array.isArray(details) && [...details] || Object.assign({}, details); delete details.type; delete details.stack; @@ -535,14 +535,14 @@ Template.event.helpers({ details.expected); } - return _.compact(_.map(details, function(val, key) { + return Object.entries(details).map(function([key, val]) { // make test._stringEqual results print nicely, // in particular for multiline strings if (type === 'string_equal' && (key === 'actual' || key === 'expected')) { var html = '
';
-            _.each(diff, function (piece) {
+            diff.forEach(function (piece) {
               var which = piece[0];
               var text = piece[1];
               if (which === 0 ||
@@ -561,7 +561,7 @@ Template.event.helpers({
           // You can end up with a an undefined value, e.g. using
           // isNull without providing a message attribute: isNull(1).
           // No need to display those.
-          if (!_.isUndefined(val)) {
+          if (typeof val !== 'undefined') {
             return {
               key: key,
               val: val
@@ -569,7 +569,7 @@ Template.event.helpers({
           } else {
             return undefined;
           }
-        }));
+        }).filter(Boolean);
       };
 
       return {
@@ -583,4 +583,4 @@ Template.event.helpers({
   is_debuggable: function() {
     return !!this.cookie;
   }
-});
+});
\ No newline at end of file
diff --git a/packages/test-in-browser/package.js b/packages/test-in-browser/package.js
index a92fb4206a..7bc88d5cf7 100644
--- a/packages/test-in-browser/package.js
+++ b/packages/test-in-browser/package.js
@@ -13,7 +13,6 @@ Package.onUse(function (api) {
   // XXX this should go away, and there should be a clean interface
   // that tinytest and the driver both implement?
   api.use('tinytest');
-  api.use('underscore');
 
   api.use('session');
   api.use('reload');
diff --git a/packages/tinytest/tinytest.js b/packages/tinytest/tinytest.js
index 045548c6de..f5cd025b98 100644
--- a/packages/tinytest/tinytest.js
+++ b/packages/tinytest/tinytest.js
@@ -1,5 +1,3 @@
-const Future = Meteor.isServer && require('fibers/future');
-
 /******************************************************************************/
 /* TestCaseResults                                                            */
 /******************************************************************************/
@@ -186,6 +184,43 @@ export class TestCaseResults {
       this.ok();
   }
 
+  _assertActual(actual, predicate, message) {
+    if (actual && predicate(actual))
+      this.ok();
+    else
+      this.fail({
+        type: "throws",
+        message: (actual ?
+            "wrong error thrown: " + actual.message :
+            "did not throw an error as expected") + (message ? ": " + message : ""),
+      });
+  }
+
+  _guessPredicate(expected) {
+    let predicate;
+
+    if (expected === undefined) {
+      predicate = function () {
+        return true;
+      };
+    } else if (typeof expected === "string") {
+      predicate = function (actual) {
+        return typeof actual.message === "string" &&
+            actual.message.indexOf(expected) !== -1;
+      };
+    } else if (expected instanceof RegExp) {
+      predicate = function (actual) {
+        return expected.test(actual.message);
+      };
+    } else if (typeof expected === 'function') {
+      predicate = expected;
+    } else {
+      throw new Error('expected should be a string, regexp, or predicate function');
+    }
+
+    return predicate;
+  }
+
   // expected can be:
   //  undefined: accept any exception.
   //  string: pass if the string is a substring of the exception message.
@@ -204,26 +239,8 @@ export class TestCaseResults {
   // particular class, use a predicate function.
   //
   throws(f, expected, message) {
-    var actual, predicate;
-
-    if (expected === undefined) {
-      predicate = function (actual) {
-        return true;
-      };
-    } else if (typeof expected === "string") {
-      predicate = function (actual) {
-        return typeof actual.message === "string" &&
-               actual.message.indexOf(expected) !== -1;
-      };
-    } else if (expected instanceof RegExp) {
-      predicate = function (actual) {
-        return expected.test(actual.message);
-      };
-    } else if (typeof expected === 'function') {
-      predicate = expected;
-    } else {
-      throw new Error('expected should be a string, regexp, or predicate function');
-    }
+    let actual;
+    const predicate = this._guessPredicate(expected);
 
     try {
       f();
@@ -231,15 +248,27 @@ export class TestCaseResults {
       actual = exception;
     }
 
-    if (actual && predicate(actual))
-      this.ok();
-    else
-      this.fail({
-        type: "throws",
-        message: (actual ?
-          "wrong error thrown: " + actual.message :
-          "did not throw an error as expected") + (message ? ": " + message : ""),
-      });
+    this._assertActual(actual, predicate, message);
+  }
+
+  /**
+   * Same as throw, but accepts an async function as a parameter.
+   * @param f
+   * @param expected
+   * @param message
+   * @returns {Promise}
+   */
+  async throwsAsync(f, expected, message) {
+    let actual;
+    const predicate = this._guessPredicate(expected);
+
+    try {
+      await f();
+    } catch (exception) {
+      actual = exception;
+    }
+
+    this._assertActual(actual, predicate, message);
   }
 
   isTrue(v, msg) {
@@ -309,7 +338,7 @@ export class TestCaseResults {
         pass = true;
       }
     } else {
-      /* fail -- not something that contains other things */;
+      /* fail -- not something that contains other things */
     }
 
     if (pass === ! not) {
@@ -546,37 +575,37 @@ export class TestRun {
     }
 
     if (Meteor.isServer) {
-      // On the server, ensure that only one test runs at a time, even
-      // with multiple clients.
       this.manager.testQueue.queueTask(() => {
-        // The future resolves when the test completes or times out.
-        var future = new Future();
-        Meteor.setTimeout(
-          () => {
-            if (future.isResolved())
-              // If the future has resolved the test has completed.
-              return;
-            test.timedOut = true;
-            this._report(test, {
-              type: "exception",
-              details: {
-                message: "test timed out"
-              }
-            });
-            future['return']();
-          },
-          3 * 60 * 1000  // 3 minutes
-        );
-        this._runTest(test, () => {
-          // The test can complete after it has timed out (it might
-          // just be slow), so only resolve the future if the test
-          // hasn't timed out.
-          if (! future.isResolved())
-            future['return']();
-        }, stop_at_offset);
-        // Wait for the test to complete or time out.
-        future.wait();
-        onComplete && onComplete();
+        // On the server, ensure that only one test runs at a time, even
+        // with multiple clients.
+        let hasRan = false;
+        const timeoutPromise = new Promise((resolve) => {
+          Meteor.setTimeout(() => {
+            if (!hasRan) {
+              test.timedOut = true;
+              this._report(test, {
+                type: "exception",
+                details: {
+                  message: "test timed out"
+                }
+              });
+            }
+
+            resolve();
+          }, 3 * 60 * 1000);
+        });
+        const runnerPromise = new Promise((resolve) => {
+          this._runTest(test, () => {
+            if (!hasRan) {
+              hasRan = true;
+            }
+            resolve();
+          }, stop_at_offset);
+        });
+
+        Promise.race([runnerPromise, timeoutPromise]).finally(() => {
+          onComplete && onComplete();
+        });
       });
     } else {
       // client
diff --git a/packages/tinytest/tinytest_server.js b/packages/tinytest/tinytest_server.js
index c43fb12b34..331a7007e7 100644
--- a/packages/tinytest/tinytest_server.js
+++ b/packages/tinytest/tinytest_server.js
@@ -9,7 +9,7 @@ import {
 
 export { Tinytest };
 
-const Fiber = require('fibers');
+const Fiber = Meteor._isFibersEnabled && require('fibers');
 const handlesForRun = new Map;
 const reportsForRun = new Map;
 
@@ -58,7 +58,7 @@ Meteor.methods({
     }
 
     function onReport(report) {
-      if (! Fiber.current) {
+      if (Fiber && !Fiber.current) {
         Meteor._debug("Trying to report a test not in a fiber! "+
                       "You probably forgot to wrap a callback in bindEnvironment.");
         console.trace();
diff --git a/packages/twitter-oauth/package.js b/packages/twitter-oauth/package.js
index 3f5cde5730..34e0d8bff8 100644
--- a/packages/twitter-oauth/package.js
+++ b/packages/twitter-oauth/package.js
@@ -7,7 +7,6 @@ Package.onUse(function(api) {
   api.use('oauth1', ['client', 'server']);
   api.use('oauth', ['client', 'server']);
   api.use('random', 'client');
-  api.use('underscore', 'server');
   api.use('service-configuration', ['client', 'server']);
 
   api.addFiles('twitter_common.js', ['server', 'client']);
diff --git a/packages/twitter-oauth/twitter_server.js b/packages/twitter-oauth/twitter_server.js
index d597f0db1e..090d455172 100644
--- a/packages/twitter-oauth/twitter_server.js
+++ b/packages/twitter-oauth/twitter_server.js
@@ -15,9 +15,9 @@ var urls = {
 // https://dev.twitter.com/docs/api/1.1/get/account/verify_credentials
 Twitter.whitelistedFields = ['profile_image_url', 'profile_image_url_https', 'lang', 'email'];
 
-OAuth.registerService('twitter', 1, urls, function(oauthBinding) {
-  var identity = oauthBinding.get('https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true').data;
-
+OAuth.registerService('twitter', 1, urls, async function(oauthBinding) {
+  const response = await oauthBinding.getAsync('https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true');
+  const  { data: identity } = response;
   var serviceData = {
     id: identity.id_str,
     screenName: identity.screen_name,
@@ -26,8 +26,8 @@ OAuth.registerService('twitter', 1, urls, function(oauthBinding) {
   };
 
   // include helpful fields from twitter
-  var fields = _.pick(identity, Twitter.whitelistedFields);
-  _.extend(serviceData, fields);
+  const { identity: fields } = Twitter.whitelistedFields;
+  Object.assign(serviceData, fields);
 
   return {
     serviceData: serviceData,
diff --git a/packages/webapp-hashing/package.js b/packages/webapp-hashing/package.js
index af8f7e762d..4098a5b937 100644
--- a/packages/webapp-hashing/package.js
+++ b/packages/webapp-hashing/package.js
@@ -5,7 +5,6 @@ Package.describe({
 
 Package.onUse(function(api) {
   api.use('ecmascript');
-  api.use('underscore', 'server');
   api.addFiles('webapp-hashing.js', 'server');
   api.export('WebAppHashing');
 });
diff --git a/packages/webapp-hashing/webapp-hashing.js b/packages/webapp-hashing/webapp-hashing.js
index f2fc190449..0968b45323 100644
--- a/packages/webapp-hashing/webapp-hashing.js
+++ b/packages/webapp-hashing/webapp-hashing.js
@@ -1,4 +1,4 @@
-var crypto = Npm.require("crypto");
+import { createHash } from "crypto";
 
 WebAppHashing = {};
 
@@ -13,13 +13,11 @@ WebAppHashing = {};
 
 WebAppHashing.calculateClientHash =
   function (manifest, includeFilter, runtimeConfigOverride) {
-  var hash = crypto.createHash('sha1');
+  var hash = createHash('sha1');
 
   // Omit the old hashed client values in the new hash. These may be
   // modified in the new boilerplate.
-  var runtimeCfg = _.omit(__meteor_runtime_config__,
-    ['autoupdateVersion', 'autoupdateVersionRefreshable',
-     'autoupdateVersionCordova']);
+  var { autoupdateVersion, autoupdateVersionRefreshable, autoupdateVersionCordova, ...runtimeCfg } = __meteor_runtime_config__;
 
   if (runtimeConfigOverride) {
     runtimeCfg = runtimeConfigOverride;
@@ -27,7 +25,7 @@ WebAppHashing.calculateClientHash =
 
   hash.update(JSON.stringify(runtimeCfg, 'utf8'));
 
-  _.each(manifest, function (resource) {
+  manifest.forEach(function (resource) {
       if ((! includeFilter || includeFilter(resource.type, resource.replaceable)) &&
           (resource.where === 'client' || resource.where === 'internal')) {
       hash.update(resource.path);
@@ -39,7 +37,7 @@ WebAppHashing.calculateClientHash =
 
 WebAppHashing.calculateCordovaCompatibilityHash =
   function(platformVersion, pluginVersions) {
-  const hash = crypto.createHash('sha1');
+  const hash = createHash('sha1');
 
   hash.update(platformVersion);
 
diff --git a/packages/weibo-oauth/package.js b/packages/weibo-oauth/package.js
index 02cea0b9c6..9ce2620c89 100644
--- a/packages/weibo-oauth/package.js
+++ b/packages/weibo-oauth/package.js
@@ -7,7 +7,6 @@ Package.onUse(api => {
   api.use('oauth1', ['client', 'server']);
   api.use('oauth', ['client', 'server']);
   api.use('random', 'client');
-  api.use('http@1.4.4 || 2.0.0', 'server');
   api.use(['service-configuration', 'ecmascript'], ['client', 'server']);
 
   api.addFiles('weibo_client.js', 'client');
diff --git a/packages/weibo-oauth/weibo_server.js b/packages/weibo-oauth/weibo_server.js
index 539022aa8d..24d56438fd 100644
--- a/packages/weibo-oauth/weibo_server.js
+++ b/packages/weibo-oauth/weibo_server.js
@@ -1,8 +1,8 @@
 Weibo = {};
 
-OAuth.registerService('weibo', 2, null, query => {
+OAuth.registerService('weibo', 2, null, async query => {
 
-  const response = getTokenResponse(query);
+  const response = await getTokenResponse(query);
   const uid = parseInt(response.uid, 10);
 
   // different parts of weibo's api seem to expect numbers, or strings
@@ -11,7 +11,7 @@ OAuth.registerService('weibo', 2, null, query => {
     throw new Error(`Expected 'uid' to parse to an integer: ${JSON.stringify(response)}`);
   }
 
-  const identity = getIdentity(response.access_token, uid);
+  const identity = await getIdentity(response.access_token, uid);
 
   return {
     serviceData: {
@@ -31,46 +31,48 @@ OAuth.registerService('weibo', 2, null, query => {
 // - uid
 // - access_token
 // - expires_in: lifetime of this token in seconds (5 years(!) right now)
-const getTokenResponse = query => {
-  const config = ServiceConfiguration.configurations.findOne({service: 'weibo'});
-  if (!config)
-    throw new ServiceConfiguration.ConfigError();
+const getTokenResponse = async (query) => {
+  const config = ServiceConfiguration.configurations.findOne({
+    service: 'weibo',
+  });
+  if (!config) throw new ServiceConfiguration.ConfigError();
 
-  let response;
-  try {
-    response = HTTP.post(
-      "https://api.weibo.com/oauth2/access_token", {params: {
-        code: query.code,
-        client_id: config.clientId,
-        client_secret: OAuth.openSecret(config.secret),
-        redirect_uri: OAuth._redirectUri('weibo', config, null, {replaceLocalhost: true}),
-        grant_type: 'authorization_code'
-      }});
-  } catch (err) {
-    throw Object.assign(new Error(`Failed to complete OAuth handshake with Weibo. ${err.message}`),
-                   {response: err.response});
-  }
-
-  // result.headers["content-type"] is 'text/plain;charset=UTF-8', so
-  // the http package doesn't automatically populate result.data
-  response.data = JSON.parse(response.content);
-
-  if (response.data.error) { // if the http response was a json object with an error attribute
-    throw new Error(`Failed to complete OAuth handshake with Weibo. ${response.data.error}`);
-  } else {
-    return response.data;
-  }
+  return OAuth._fetch('https://api.weibo.com/oauth2/access_token', 'POST', {
+    queryParams: {
+      code: query.code,
+      client_id: config.clientId,
+      client_secret: OAuth.openSecret(config.secret),
+      redirect_uri: OAuth._redirectUri('weibo', config, null, {
+        replaceLocalhost: true,
+      }),
+      grant_type: 'authorization_code',
+    },
+  })
+    .then((res) => res.json())
+    .catch((err) => {
+      throw Object.assign(
+        new Error(
+          `Failed to complete OAuth handshake with Weibo. ${err.message}`
+        ),
+        { response: err.response }
+      );
+    });
 };
 
-const getIdentity = (accessToken, userId) => {
-  try {
-    return HTTP.get(
-      "https://api.weibo.com/2/users/show.json",
-      {params: {access_token: accessToken, uid: userId}}).data;
-  } catch (err) {
-    throw Object.assign(new Error("Failed to fetch identity from Weibo. " + err.message),
-                   {response: err.response});
-  }
+const getIdentity = async (accessToken, userId) => {
+  return OAuth._fetch('https://api.weibo.com/2/users/show.json', 'GET', {
+    queryParams: {
+      access_token: accessToken,
+      uid: userId,
+    },
+  })
+    .then((res) => res.json())
+    .catch((err) => {
+      throw Object.assign(
+        new Error('Failed to fetch identity from Weibo. ' + err.message),
+        { response: err.response }
+      );
+    });
 };
 
 Weibo.retrieveCredential = (credentialToken, credentialSecret) =>
diff --git a/tools/runners/run-all.js b/tools/runners/run-all.js
index 805b81365c..04fa462eff 100644
--- a/tools/runners/run-all.js
+++ b/tools/runners/run-all.js
@@ -14,7 +14,7 @@ const Selenium = require('./run-selenium.js').Selenium;
 const AppRunner = require('./run-app.js').AppRunner;
 const MongoRunner = require('./run-mongo.js').MongoRunner;
 const HMRServer = require('./run-hmr').HMRServer;
-const Updater = require('./run-updater.js').Updater;
+const Updater = require('./run-updater').Updater;
 
 class Runner {
   constructor({
@@ -123,7 +123,7 @@ class Runner {
         hmrPath: HMRPath,
         secret: hmrSecret,
         projectContext: self.projectContext,
-        cordovaServerPort 
+        cordovaServerPort
       });
     }
 
diff --git a/tools/runners/run-app.js b/tools/runners/run-app.js
index d02d044dc5..fbc3b03af5 100644
--- a/tools/runners/run-app.js
+++ b/tools/runners/run-app.js
@@ -947,7 +947,7 @@ Object.assign(AppRunner.prototype, {
       var runResult = self._runOnce({
         onListen: function () {
           if (! self.noRestartBanner && ! firstRun) {
-            runLog.logRestart();
+            runLog.logRestart(self);
             Console.enableProgressDisplay(false);
           }
         },
diff --git a/tools/runners/run-log.js b/tools/runners/run-log.js
index a661f69963..d7a2f812f6 100644
--- a/tools/runners/run-log.js
+++ b/tools/runners/run-log.js
@@ -145,7 +145,7 @@ Object.assign(RunLog.prototype, {
     self.temporaryMessageLength = msg.length;
   },
 
-  logRestart: function () {
+  logRestart: function (options) {
     var self = this;
 
     if (self.consecutiveRestartMessages) {
@@ -159,7 +159,7 @@ Object.assign(RunLog.prototype, {
       self.consecutiveRestartMessages = 1;
     }
 
-    var message = "=> Meteor server restarted";
+    var message = "=> Meteor server restarted at: " + options.rootUrl;
     if (self.consecutiveRestartMessages > 1) {
       message += " (x" + self.consecutiveRestartMessages + ")";
     }
diff --git a/tools/runners/run-updater.js b/tools/runners/run-updater.js
index dac475e9d0..c4a51be525 100644
--- a/tools/runners/run-updater.js
+++ b/tools/runners/run-updater.js
@@ -1,60 +1,52 @@
-var Console = require('../console/console.js').Console;
+import { Console } from '../console/console';
 
-var Updater = function () {
-  var self = this;
-  self.timer = null;
-};
+const CHECK_UPDATE_INTERVAL = 3 * 60 * 60 * 1000; // every 3 hours
 
 // XXX make it take a runLog?
 // XXX need to deal with updater writing messages (bypassing old
 // stdout interception.. maybe it should be global after all..)
-Object.assign(Updater.prototype, {
-  start: function () {
-    var self = this;
+export class Updater {
+  constructor() {
+    this.timer = null;
+  }
 
-    if (self.timer) {
-      throw new Error("already running?");
+  start() {
+    if (this.timer) {
+      throw new Error('already running?');
     }
 
+    const self = this;
     // Check every 3 hours. (Should not share buildmessage state with
     // the main fiber.)
     async function check() {
       self._check();
     }
 
-    self.timer = setInterval(check, 3 * 60 * 60 * 1000);
+    this.timer = setInterval(check, CHECK_UPDATE_INTERVAL);
 
     // Also start a check now, but don't block on it. (This should
     // not share buildmessage state with the main fiber.)
     check();
-  },
+  }
 
-  _check: function () {
-    var self = this;
-    var updater = require('../packaging/updater.js');
+  _check() {
+    const updater = require('../packaging/updater');
     try {
-      updater.tryToDownloadUpdate({showBanner: true});
+      updater.tryToDownloadUpdate({ showBanner: true });
     } catch (e) {
       // oh well, this was the background. Only show errors if we are in debug
       // mode.
-      Console.debug("Error inside updater.");
+      Console.debug('Error inside updater.');
       Console.debug(e.stack);
-      return;
     }
-  },
-
-  // Returns immediately. However if an update check is currently
-  // running it will complete in the background. Idempotent.
-  stop: function () {
-    var self = this;
-
-    if (self.timer) {
-      return;
-    }
-    clearInterval(self.timer);
-    self.timer = null;
   }
-});
 
+  // Returns immediately. However, if an update check is currently
+  // running it will complete in the background. Idempotent.
+  stop() {
+    if (!this.timer) return;
 
-exports.Updater = Updater;
+    clearInterval(this.timer);
+    this.timer = null;
+  }
+}
diff --git a/tools/tests/server-restart-port.js b/tools/tests/server-restart-port.js
new file mode 100644
index 0000000000..ef6a985541
--- /dev/null
+++ b/tools/tests/server-restart-port.js
@@ -0,0 +1,25 @@
+import * as selftest from '../tool-testing/selftest';
+
+selftest.define("server outputs port number on restarting", () => testHelper({
+    path: "server/main.js",
+    id: "server/main.js"
+}));
+
+function testHelper(server) {
+  const s = new selftest.Sandbox();
+  s.createApp("myapp", "client-refresh");
+  s.cd("myapp");
+
+  let run = s.run("--port", "21000");
+  run.match("Started proxy");
+  run.waitSecs(15);
+
+  run.match(server.id + " 0");
+
+  s.write(server.path, s.read(server.path).replace(
+    /module.id, (\d+)/,
+    (match, n) => `module.id, ${ ++n }`,
+  ));
+
+  run.match("Meteor server restarted at: http://localhost:21000/");
+}