diff --git a/configs/client-default.js b/configs/client-default.js
index 550221b9..9f42185b 100644
--- a/configs/client-default.js
+++ b/configs/client-default.js
@@ -702,6 +702,7 @@ module.exports = function(options) {
premium: options.project.premium,
}
},
+ "plugins/c9.ide.abtesting/abtesting",
{
packagePath: "plugins/c9.ide.welcome/welcome",
staticPrefix: staticPrefix + "/plugins/c9.ide.welcome",
diff --git a/configs/standalone.js b/configs/standalone.js
index 8b7e468c..290e0892 100644
--- a/configs/standalone.js
+++ b/configs/standalone.js
@@ -158,7 +158,6 @@ module.exports = function(config, optimist) {
"c9.vfs.client": true,
"c9.cli.bridge": true,
"c9.nodeapi": true,
- "c9.ide.experiment": true,
"saucelabs.preview": true,
"salesforce.sync": true,
"salesforce.language": true
@@ -194,7 +193,6 @@ module.exports = function(config, optimist) {
"./c9.vfs.server/statics",
"./c9.analytics/mock_analytics",
"./c9.metrics/mock_metrics",
- "./c9.ide.experiment/mock_experiment",
{
packagePath: "./c9.vfs.server/vfs.connect.standalone",
workspaceDir: baseProc,
diff --git a/node_modules/c9/ratelimit.js b/node_modules/c9/ratelimit.js
index 404c6dfa..2b4c4ef7 100644
--- a/node_modules/c9/ratelimit.js
+++ b/node_modules/c9/ratelimit.js
@@ -1,32 +1,46 @@
var error = require("http-error");
+var MAX_EXPIRE_INTERVAL = 5000;
+
/**
* In memory rate limiter as connect middleware
*/
module.exports = ratelimit;
function ratelimit(key, duration, max) {
- var limit = {};
+ var requests = Object.create(null); // in case there handles like 'constructor'
+ setInterval(function() {
+ Object.keys(requests).forEach(expireRequests);
+ }, Math.min(duration * 0.75, MAX_EXPIRE_INTERVAL));
+
+ function expireRequests(handle) {
+ var requestsForHandle = requests[handle];
+ var totalToSplice = 0;
+ var expireTime = Date.now() - duration;
+ /* Requests are already sorted by date as they are appended, so we just loop
+ until we find one that shouldn't have expired and splice them from the list */
+ for (totalToSplice = 0; totalToSplice < requestsForHandle.length; totalToSplice++) {
+ if (requestsForHandle[totalToSplice] >= expireTime) break;
+ }
+ requests[handle].splice(0, totalToSplice);
+ if (requests[handle].length == 0) {
+ delete requests[handle];
+ }
+
+ return true;
+ }
+
return function(req, res, next) {
var handle = req.params[key];
- var lim = limit[handle] || (limit[handle] = []);
- var now = Date.now();
- for (var i = 0; i < lim.length; i++) {
- if (now - lim[i] > duration) {
- lim.splice(i, 1);
- i--;
- }
- else break;
- }
- if (lim.length > max) {
+ requests[handle] = requests[handle] || [];
+ if (requests[handle].length >= max) {
var err = new error.TooManyRequests("Rate limit exceeded");
- err.retryIn = duration - (Date.now() - lim[0]);
- next(err);
- return;
+ err.retryIn = Math.min(duration, 5000);
+ return next(err);
}
- lim.push(Date.now());
+ requests[handle].push(Date.now());
return next();
};
}
diff --git a/node_modules/c9/ratelimit_test.js b/node_modules/c9/ratelimit_test.js
new file mode 100644
index 00000000..fd674350
--- /dev/null
+++ b/node_modules/c9/ratelimit_test.js
@@ -0,0 +1,104 @@
+"use server";
+
+require("c9/inline-mocha")(module);
+
+var ratelimit = require("./ratelimit");
+var assert = require("assert");
+var async = require("async");
+
+describe("ratelimit", function() {
+
+ it("Should limit based on key", function (done) {
+ var limiter = ratelimit("username", 10, 1);
+ limiter({params: {username: "super"}}, null, function (err) {
+ assert(!err, err);
+ limiter({params: {username: "super"}}, null, function (err) {
+ assert(err);
+ assert.equal(err.code, 429);
+ done();
+ });
+ });
+ });
+
+ it("Should work with different keys", function (done) {
+ var limiter = ratelimit("username", 10, 1);
+ limiter({params: {username: "super"}}, null, function (err) {
+ assert(!err, err);
+ limiter({params: {username: "aloha"}}, null, function (err) {
+ assert(!err, err);
+ done();
+ });
+ });
+ });
+
+
+
+ it("Should work again after a delay", function (done) {
+ var limiter = ratelimit("username", 10, 1);
+ limiter({params: {username: "super"}}, null, function (err) {
+ assert(!err, err);
+ setTimeout(function() {
+ limiter({params: {username: "super"}}, null, function (err) {
+ assert(!err, err);
+ done();
+ });
+ }, 25);
+ });
+ });
+
+ it("Should work with many requests", function (done) {
+ var MAX_REQUESTS = 5;
+ var limiter = ratelimit("username", 10, MAX_REQUESTS);
+ var successfulRequests = 0;
+ async.times(10, function(n, next) {
+ limiter({params: {username: "super"}}, null, function (err) {
+ if (err) return next(err);
+ successfulRequests++;
+ next();
+ });
+ }, function (err) {
+ assert.equal(successfulRequests, MAX_REQUESTS);
+ setTimeout(function() {
+ limiter({params: {username: "super"}}, null, function (err) {
+ assert(!err, err);
+ done();
+ });
+ }, 25);
+ });
+ });
+
+ it("Should expire keys at the correct times", function (done) {
+ var limiter = ratelimit("username", 50, 2);
+ async.series([
+ function(next) {
+ limiter({params: {username: "mario"}}, null, function(err) {
+ assert(!err, err);
+ setTimeout(next, 25);
+ });
+ },
+ function (next) {
+ limiter({params: {username: "mario"}}, null, function(err) {
+ assert(!err, err);
+ setTimeout(next, 40);
+ });
+ },
+ function (next) {
+ limiter({params: {username: "mario"}}, null, function(err) {
+ assert(!err, err);
+ limiter({params: {username: "mario"}}, null, function(err) {
+ assert(err);
+ assert.equal(err.code, 429);
+ setTimeout(next, 20);
+ });
+ });
+ },
+ function (next) {
+ limiter({params: {username: "mario"}}, null, function(err) {
+ assert(!err, err);
+ next();
+ });
+ }
+ ], done);
+ });
+
+});
\ No newline at end of file
diff --git a/package.json b/package.json
index c6584b2e..417363a6 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "c9",
"description": "New Cloud9 Client",
- "version": "3.1.2449",
+ "version": "3.1.2532",
"author": "Ajax.org B.V. ",
"private": true,
"main": "bin/c9",
@@ -71,7 +71,7 @@
"c9.ide.language.javascript.infer": "#18acb93a3a",
"c9.ide.language.jsonalyzer": "#d8183d84b4",
"c9.ide.language.codeintel": "#fc867feec4",
- "c9.ide.collab": "#85ce41adac",
+ "c9.ide.collab": "#eda7de90a9",
"c9.ide.local": "#10eb45842a",
"c9.ide.find": "#e33fbaed2f",
"c9.ide.find.infiles": "#c0a13737ef",
@@ -97,7 +97,7 @@
"c9.ide.installer": "#b2e4ba0a92",
"c9.ide.language.python": "#aff0772c78",
"c9.ide.language.go": "#6ce1c7a7ef",
- "c9.ide.mount": "#4c39359b87",
+ "c9.ide.mount": "#6ddfd05db3",
"c9.ide.navigate": "#5d5707058c",
"c9.ide.newresource": "#981a408a7b",
"c9.ide.openfiles": "#2ae85a9e33",
diff --git a/plugins/c9.abtesting/abtesting.js b/plugins/c9.abtesting/abtesting.js
new file mode 100644
index 00000000..44fc2b00
--- /dev/null
+++ b/plugins/c9.abtesting/abtesting.js
@@ -0,0 +1,86 @@
+/**
+ * Server-side support for A/B testing experiments.
+ *
+ * Note that this plugin demands users and user IDs for most of its API,
+ * where this is optional in the client-side implementation.
+ */
+main.consumes = ["analytics"];
+main.provides = ["abtesting"];
+module.exports = main;
+
+function main(options, imports, register) {
+ var analytics = imports["analytics"];
+ var outplan = require("outplan");
+
+ var MS_PER_DAY = 1000 * 60 * 60 * 24;
+
+ outplan.configure({
+ logFunction: function(e) {
+ var label = e.name + " - " + e.event;
+ analytics.track(label, { variation: e.params.name });
+ }
+ });
+
+ function create(name, choices, options) {
+ return outplan.create(name, choices, options);
+ }
+
+ function expose(experimentName, userId, options) {
+ return outplan.expose(experimentName, userId, options);
+ }
+
+ function isUserCreatedAfter(experimentDate, user) {
+ if (!user || !user.date_add)
+ throw new Error("Expected: user");
+ var diffDays = (experimentDate - Date.now()) / MS_PER_DAY;
+ if (diffDays > 20) {
+ // Sanity check: new Date() takes zero-based month argument, one-based day argument
+ throw new Error("Passed a date far in the future to isUserCreatedAfter()");
+ }
+ return user.date_add > experimentDate;
+ }
+
+ register(null, {
+ "abtesting": {
+ /**
+ * Create a new experiment. Alias for require("outplan").create()
+ *
+ * @param {String} name
+ * The name of the experiment.
+ * @param {String[]|Object[]} choices
+ * A list of variations, e.g. ["A", "B"],
+ * or variation objects, e.g. [{ name: "A", color: "#AAA" }, { name: "B", color: "#BBB" }]
+ * @param {Object} [option]
+ * Options for the experiment. This may also include
+ * arguments for the distribution operator, e.g. weight.
+ * @param {Function} [options.operator]
+ * The distribution operator, e.g. outplan.WeightedChoice.
+ */
+ create: create,
+
+ /**
+ * Get the selected variation of an experiment, and call the log function with
+ * an "expose" event to track its exposure.
+ *
+ * @param {String} name The experiment name.
+ * @param {Number} userId A unique identifier for the current user.
+ * @param {Object} [options] Options
+ * @param {Boolean} [options.log=true] Whether to log an "exposure event"
+ */
+ expose: expose,
+
+ /**
+ * Helper to determine if the current user was created after the start of an experiment.
+ *
+ * @throws {Error} when a date in the future (~20 days from now) is passed.
+ * This error is thrown as a sanity check to make sure `new Date()`
+ * is called with a zero-based month argument (and a one-based day).
+ *
+ * @param {Date} experimentDate
+ * @param {Object} user A user object
+ * @param {Number} user.date_add
+ */
+ isUserCreatedAfter: isUserCreatedAfter,
+ }
+ });
+}
diff --git a/plugins/c9.error/views/error-401.html.ejs b/plugins/c9.error/views/error-401.html.ejs
index 41c00594..c2df5328 100644
--- a/plugins/c9.error/views/error-401.html.ejs
+++ b/plugins/c9.error/views/error-401.html.ejs
@@ -13,7 +13,7 @@
Status Page |
- Support |
+ Support |
Dashboard |
Home
diff --git a/plugins/c9.error/views/error-404.html.ejs b/plugins/c9.error/views/error-404.html.ejs
index 71d0152d..50572e49 100644
--- a/plugins/c9.error/views/error-404.html.ejs
+++ b/plugins/c9.error/views/error-404.html.ejs
@@ -13,7 +13,7 @@
Status Page |
- Support |
+ Support |
Dashboard |
Home
diff --git a/plugins/c9.error/views/error-500.html.ejs b/plugins/c9.error/views/error-500.html.ejs
index f833a5e0..f54b5848 100644
--- a/plugins/c9.error/views/error-500.html.ejs
+++ b/plugins/c9.error/views/error-500.html.ejs
@@ -30,7 +30,7 @@
<% } %>
Status Page |
- Support |
+ Support |
Dashboard |
Home
diff --git a/plugins/c9.error/views/error-503.html.ejs b/plugins/c9.error/views/error-503.html.ejs
index eb7394ba..6323c2e2 100644
--- a/plugins/c9.error/views/error-503.html.ejs
+++ b/plugins/c9.error/views/error-503.html.ejs
@@ -29,7 +29,7 @@
<% } %>
Status Page |
- Support |
+ Support |
Dashboard |
Home
diff --git a/plugins/c9.error/views/error.html.ejs b/plugins/c9.error/views/error.html.ejs
index 54bd1ec9..b686bbec 100644
--- a/plugins/c9.error/views/error.html.ejs
+++ b/plugins/c9.error/views/error.html.ejs
@@ -27,7 +27,7 @@
<% } %>
Status Page |
- Support |
+ Support |
Dashboard |
Home
diff --git a/plugins/c9.ide.abtesting/abtesting.js b/plugins/c9.ide.abtesting/abtesting.js
new file mode 100644
index 00000000..1a7160db
--- /dev/null
+++ b/plugins/c9.ide.abtesting/abtesting.js
@@ -0,0 +1,105 @@
+/**
+ * Client-side support for A/B testing experiments.
+ */
+define(function(require, exports, module) {
+ "use strict";
+
+ main.consumes = ["Plugin", "info", "c9.analytics"];
+ main.provides = ["abtesting"];
+ return main;
+
+ function main(options, imports, register) {
+ var Plugin = imports.Plugin;
+ var plugin = new Plugin("Ajax.org", main.consumes);
+ var info = imports.info;
+ var analytics = imports["c9.analytics"];
+ var outplan = require("outplan");
+
+ var MS_PER_DAY = 1000 * 60 * 60 * 24;
+
+ var userId;
+
+ plugin.on("load", function() {
+ userId = info.getUser().id;
+ outplan.configure({
+ logFunction: function(e) {
+ var label = e.name + " - " + e.event;
+ analytics.track(label, { variation: e.params.name });
+ }
+ });
+ });
+
+ plugin.on("unload", function() {
+ userId = undefined;
+ });
+
+ function create(name, choices, options) {
+ var experiment = outplan.create(name, choices, options);
+ experiment.expose = expose.bind(null, name);
+ return experiment;
+ }
+
+ function expose(experimentName, overrideUserId, options) {
+ if (overrideUserId && typeof overrideUserId === "object")
+ return expose(experimentName, null, options);
+
+ return outplan.expose(experimentName, overrideUserId == null ? userId : overrideUserId, options);
+ }
+
+ function isUserCreatedAfter(experimentDate) {
+ var diffDays = (experimentDate - Date.now()) / MS_PER_DAY;
+ if (diffDays > 20) {
+ // Sanity check: new Date() takes zero-based month argument, one-based day argument
+ throw new Error("Passed a date far in the future to isUserCreatedAfter()");
+ }
+ return info.getUser().date_add > experimentDate;
+ }
+
+ /**
+ * Support for A/B testing experiments.
+ */
+ plugin.freezePublicAPI({
+ /**
+ * Create a new experiment. Alias for require("outplan").create()
+ *
+ * @param {String} name
+ * The name of the experiment.
+ * @param {String[]|Object[]} choices
+ * A list of variations, e.g. ["A", "B"],
+ * or variation objects, e.g. [{ name: "A", color: "#AAA" }, { name: "B", color: "#BBB" }]
+ * @param {Object} [option]
+ * Options for the experiment. This may also include
+ * arguments for the distribution operator, e.g. weight.
+ * @param {Function} [options.operator]
+ * The distribution operator, e.g. outplan.WeightedChoice.
+ */
+ create: create,
+
+ /**
+ * Get the selected variation of an experiment, and call the log function with
+ * an "expose" event to track its exposure.
+ *
+ * @param {String} name The experiment name.
+ * @param {Number} [userId] A unique identifier for the current user.
+ * @param {Object} [options] Options
+ * @param {Boolean} [options.log=true] Whether to log an "exposure event"
+ */
+ expose: expose,
+
+ /**
+ * Helper to determine if the current user was created after the start of an experiment.
+ *
+ * @throws {Error} when a date in the future (~20 days from now) is passed.
+ * This error is thrown as a sanity check to make sure `new Date()`
+ * is called with a zero-based month argument (and a one-based day).
+ *
+ * @param {Date} experimentDate
+ */
+ isUserCreatedAfter: isUserCreatedAfter,
+ });
+
+ register(null, {
+ "abtesting": plugin
+ });
+ }
+});
diff --git a/plugins/c9.ide.experiment/mock_experiment.js b/plugins/c9.ide.experiment/mock_experiment.js
deleted file mode 100644
index e3b80734..00000000
--- a/plugins/c9.ide.experiment/mock_experiment.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Dummy implementation of experiments.
- */
-"use strict";
-
-plugin.consumes = [];
-plugin.provides = ["experiment"];
-
-module.exports = plugin;
-
-function plugin(options, imports, register) {
-
- register(null, {
- "experiment": {
- configure: function() {},
- onStart: function() {
- var chain = {
- variation: function() {
- return chain;
- }
- };
- return chain;
- }
- }
- });
-}
\ No newline at end of file
diff --git a/plugins/c9.ide.server/plugins.js b/plugins/c9.ide.server/plugins.js
index 4ba4113c..e92571bd 100644
--- a/plugins/c9.ide.server/plugins.js
+++ b/plugins/c9.ide.server/plugins.js
@@ -41,6 +41,7 @@ define(function(require, exports, module) {
ui: "lib/ui",
c9: "lib/c9",
frontdoor: "lib/frontdoor",
+ outplan: "lib/outplan/dist/outplan",
};
if (whitelist === "*") {
@@ -70,6 +71,7 @@ define(function(require, exports, module) {
"ui",
"emmet",
"frontdoor",
+ "outplan",
"mocha", // TESTING
"chai", // TESTING
].forEach(function(name) {
diff --git a/plugins/c9.preview/views/progress.html.ejs b/plugins/c9.preview/views/progress.html.ejs
index 0e0396e9..e6eff21a 100644
--- a/plugins/c9.preview/views/progress.html.ejs
+++ b/plugins/c9.preview/views/progress.html.ejs
@@ -12,7 +12,7 @@
Status Page |
- Support |
+ Support |
Dashboard |
Home