firebase-tools 10.2.0 → 10.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/apiv2.js +3 -0
- package/lib/appdistribution/options-parser-util.js +1 -1
- package/lib/auth.js +3 -3
- package/lib/command.js +1 -1
- package/lib/commands/apps-android-sha-create.js +2 -2
- package/lib/commands/apps-sdkconfig.js +1 -1
- package/lib/commands/auth-import.js +1 -1
- package/lib/commands/database-rules-list.js +2 -2
- package/lib/commands/emulators-start.js +1 -1
- package/lib/commands/ext-configure.js +58 -4
- package/lib/commands/ext-dev-init.js +49 -49
- package/lib/commands/ext-export.js +7 -2
- package/lib/commands/ext-install.js +163 -104
- package/lib/commands/ext-uninstall.js +17 -8
- package/lib/commands/ext-update.js +64 -11
- package/lib/commands/functions-config-clone.js +1 -1
- package/lib/commands/functions-config-export.js +1 -1
- package/lib/commands/hosting-clone.js +3 -3
- package/lib/commands/remoteconfig-get.js +1 -1
- package/lib/config.js +6 -3
- package/lib/deploy/extensions/deploymentSummary.js +3 -3
- package/lib/deploy/extensions/planner.js +7 -6
- package/lib/deploy/extensions/tasks.js +1 -1
- package/lib/deploy/functions/backend.js +21 -5
- package/lib/deploy/functions/checkIam.js +5 -5
- package/lib/deploy/functions/containerCleaner.js +3 -3
- package/lib/deploy/functions/ensure.js +3 -3
- package/lib/deploy/functions/functionsDeployHelper.js +2 -2
- package/lib/deploy/functions/prepare.js +5 -3
- package/lib/deploy/functions/pricing.js +1 -1
- package/lib/deploy/functions/prompts.js +2 -2
- package/lib/deploy/functions/release/fabricator.js +7 -7
- package/lib/deploy/functions/release/index.js +1 -1
- package/lib/deploy/functions/release/planner.js +43 -26
- package/lib/deploy/functions/release/reporter.js +3 -0
- package/lib/deploy/functions/runtimes/discovery/index.js +6 -6
- package/lib/deploy/functions/runtimes/discovery/parsing.js +1 -1
- package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +22 -12
- package/lib/deploy/functions/runtimes/golang/index.js +2 -2
- package/lib/deploy/functions/runtimes/node/index.js +53 -0
- package/lib/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.js +2 -2
- package/lib/deploy/functions/runtimes/node/parseTriggers.js +52 -15
- package/lib/deploy/functions/runtimes/node/versioning.js +2 -2
- package/lib/deploy/functions/services/firebaseAlerts.js +30 -0
- package/lib/deploy/functions/services/index.js +9 -1
- package/lib/deploy/functions/services/storage.js +10 -4
- package/lib/deploy/functions/triggerRegionHelper.js +1 -1
- package/lib/deploy/functions/validate.js +3 -3
- package/lib/deploy/hosting/client.js +9 -0
- package/lib/deploy/hosting/convertConfig.js +6 -0
- package/lib/deploy/hosting/deploy.js +2 -2
- package/lib/deploy/hosting/hashcache.js +21 -19
- package/lib/deploy/hosting/index.js +5 -5
- package/lib/deploy/hosting/prepare.js +25 -25
- package/lib/deploy/hosting/release.js +21 -24
- package/lib/deploy/hosting/uploader.js +5 -5
- package/lib/deploy/remoteconfig/functions.js +2 -2
- package/lib/emulator/auth/cloudFunctions.js +1 -1
- package/lib/emulator/auth/operations.js +1 -1
- package/lib/emulator/commandUtils.js +5 -1
- package/lib/emulator/constants.js +4 -0
- package/lib/emulator/controller.js +54 -24
- package/lib/emulator/download.js +18 -1
- package/lib/emulator/downloadableEmulators.js +30 -13
- package/lib/emulator/emulatorLogger.js +12 -1
- package/lib/emulator/extensions/validation.js +70 -0
- package/lib/emulator/extensionsEmulator.js +175 -0
- package/lib/emulator/functionsEmulator.js +106 -44
- package/lib/emulator/functionsEmulatorRuntime.js +44 -36
- package/lib/emulator/functionsEmulatorShared.js +17 -10
- package/lib/emulator/functionsEmulatorShell.js +1 -1
- package/lib/emulator/functionsEmulatorUtils.js +4 -4
- package/lib/emulator/functionsRuntimeWorker.js +2 -2
- package/lib/emulator/hub.js +4 -3
- package/lib/emulator/loggingEmulator.js +1 -1
- package/lib/emulator/pubsubEmulator.js +1 -1
- package/lib/emulator/registry.js +10 -2
- package/lib/emulator/storage/apis/firebase.js +314 -332
- package/lib/emulator/storage/apis/gcloud.js +241 -121
- package/lib/emulator/storage/crc.js +5 -1
- package/lib/emulator/storage/errors.js +9 -0
- package/lib/emulator/storage/files.js +159 -300
- package/lib/emulator/storage/index.js +27 -73
- package/lib/emulator/storage/metadata.js +65 -51
- package/lib/emulator/storage/multipart.js +62 -0
- package/lib/emulator/storage/persistence.js +78 -0
- package/lib/emulator/storage/rules/config.js +33 -0
- package/lib/emulator/storage/rules/manager.js +81 -0
- package/lib/emulator/storage/rules/runtime.js +8 -7
- package/lib/emulator/storage/rules/utils.js +48 -0
- package/lib/emulator/storage/server.js +2 -2
- package/lib/emulator/storage/upload.js +106 -0
- package/lib/emulator/types.js +3 -0
- package/lib/ensureApiEnabled.js +5 -1
- package/lib/error.js +1 -1
- package/lib/extensions/askUserForParam.js +1 -1
- package/lib/extensions/changelog.js +3 -1
- package/lib/extensions/checkProjectBilling.js +1 -1
- package/lib/extensions/displayExtensionInfo.js +1 -1
- package/lib/extensions/emulator/optionsHelper.js +56 -8
- package/lib/extensions/emulator/specHelper.js +10 -23
- package/lib/extensions/export.js +1 -51
- package/lib/extensions/extensionsApi.js +1 -1
- package/lib/extensions/extensionsHelper.js +32 -19
- package/lib/extensions/listExtensions.js +2 -0
- package/lib/extensions/manifest.js +144 -0
- package/lib/extensions/metricsUtils.js +4 -4
- package/lib/extensions/paramHelper.js +9 -8
- package/lib/extensions/refs.js +1 -1
- package/lib/extensions/secretsUtils.js +3 -3
- package/lib/functional.js +1 -1
- package/lib/functions/env.js +6 -7
- package/lib/functions/events/v2.js +11 -0
- package/lib/gcp/cloudfunctions.js +42 -11
- package/lib/gcp/cloudfunctionsv2.js +48 -17
- package/lib/gcp/cloudtasks.js +1 -1
- package/lib/gcp/docker.js +2 -2
- package/lib/gcp/resourceManager.js +4 -4
- package/lib/gcp/run.js +2 -2
- package/lib/gcp/storage.js +1 -0
- package/lib/hosting/api.js +1 -1
- package/lib/hosting/functionsProxy.js +15 -5
- package/lib/hosting/proxy.js +2 -2
- package/lib/init/features/account.js +1 -1
- package/lib/management/database.js +1 -1
- package/lib/previews.js +1 -1
- package/lib/responseToError.js +16 -7
- package/lib/serve/functions.js +2 -1
- package/lib/serve/hosting.js +1 -1
- package/lib/utils.js +15 -2
- package/npm-shrinkwrap.json +904 -412
- package/package.json +3 -3
- package/schema/firebase-config.json +32 -0
- package/templates/init/functions/javascript/package.lint.json +3 -3
- package/templates/init/functions/javascript/package.nolint.json +2 -2
- package/templates/init/functions/typescript/package.lint.json +7 -7
- package/templates/init/functions/typescript/package.nolint.json +3 -3
- package/lib/deploy/extensions/params.js +0 -39
- package/lib/deploy/functions/eventTypes.js +0 -10
|
@@ -13,13 +13,13 @@ exports.firebaseRoles = {
|
|
|
13
13
|
hostingAdmin: "roles/firebasehosting.admin",
|
|
14
14
|
runViewer: "roles/run.viewer",
|
|
15
15
|
};
|
|
16
|
-
async function getIamPolicy(
|
|
17
|
-
const response = await apiClient.post(`/projects/${
|
|
16
|
+
async function getIamPolicy(projectIdOrNumber) {
|
|
17
|
+
const response = await apiClient.post(`/projects/${projectIdOrNumber}:getIamPolicy`);
|
|
18
18
|
return response.body;
|
|
19
19
|
}
|
|
20
20
|
exports.getIamPolicy = getIamPolicy;
|
|
21
|
-
async function setIamPolicy(
|
|
22
|
-
const response = await apiClient.post(`/projects/${
|
|
21
|
+
async function setIamPolicy(projectIdOrNumber, newPolicy, updateMask = "") {
|
|
22
|
+
const response = await apiClient.post(`/projects/${projectIdOrNumber}:setIamPolicy`, {
|
|
23
23
|
policy: newPolicy,
|
|
24
24
|
updateMask: updateMask,
|
|
25
25
|
});
|
package/lib/gcp/run.js
CHANGED
|
@@ -63,7 +63,7 @@ async function getIamPolicy(serviceName, httpClient = client) {
|
|
|
63
63
|
}
|
|
64
64
|
exports.getIamPolicy = getIamPolicy;
|
|
65
65
|
async function setInvokerCreate(projectId, serviceName, invoker, httpClient = client) {
|
|
66
|
-
if (invoker.length
|
|
66
|
+
if (invoker.length === 0) {
|
|
67
67
|
throw new error_1.FirebaseError("Invoker cannot be an empty array");
|
|
68
68
|
}
|
|
69
69
|
const invokerMembers = proto.getInvokerMembers(invoker, projectId);
|
|
@@ -79,7 +79,7 @@ async function setInvokerCreate(projectId, serviceName, invoker, httpClient = cl
|
|
|
79
79
|
exports.setInvokerCreate = setInvokerCreate;
|
|
80
80
|
async function setInvokerUpdate(projectId, serviceName, invoker, httpClient = client) {
|
|
81
81
|
var _a;
|
|
82
|
-
if (invoker.length
|
|
82
|
+
if (invoker.length === 0) {
|
|
83
83
|
throw new error_1.FirebaseError("Invoker cannot be an empty array");
|
|
84
84
|
}
|
|
85
85
|
const invokerMembers = proto.getInvokerMembers(invoker, projectId);
|
package/lib/gcp/storage.js
CHANGED
|
@@ -29,6 +29,7 @@ async function upload(source, uploadUrl, extraHeaders) {
|
|
|
29
29
|
method: "PUT",
|
|
30
30
|
path: url.pathname,
|
|
31
31
|
queryParams: url.searchParams,
|
|
32
|
+
responseType: "xml",
|
|
32
33
|
headers: Object.assign({ "content-type": "application/zip" }, extraHeaders),
|
|
33
34
|
body: source.stream,
|
|
34
35
|
skipLog: { resBody: true },
|
package/lib/hosting/api.js
CHANGED
|
@@ -192,7 +192,7 @@ async function removeAuthDomain(project, url) {
|
|
|
192
192
|
return domains;
|
|
193
193
|
}
|
|
194
194
|
const targetDomain = url.replace("https://", "");
|
|
195
|
-
const authDomains = domains.filter((domain) => domain
|
|
195
|
+
const authDomains = domains.filter((domain) => domain !== targetDomain);
|
|
196
196
|
return (0, auth_1.updateAuthDomains)(project, authDomains);
|
|
197
197
|
}
|
|
198
198
|
exports.removeAuthDomain = removeAuthDomain;
|
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.functionsProxy = void 0;
|
|
3
4
|
const lodash_1 = require("lodash");
|
|
4
5
|
const proxy_1 = require("./proxy");
|
|
5
6
|
const projectUtils_1 = require("../projectUtils");
|
|
6
7
|
const registry_1 = require("../emulator/registry");
|
|
7
8
|
const types_1 = require("../emulator/types");
|
|
8
9
|
const functionsEmulator_1 = require("../emulator/functionsEmulator");
|
|
9
|
-
|
|
10
|
+
const error_1 = require("../error");
|
|
11
|
+
function functionsProxy(options) {
|
|
10
12
|
return (rewrite) => {
|
|
11
13
|
return new Promise((resolve) => {
|
|
12
14
|
const projectId = (0, projectUtils_1.needProjectId)(options);
|
|
13
|
-
|
|
15
|
+
if (!("function" in rewrite)) {
|
|
16
|
+
throw new error_1.FirebaseError(`A non-function rewrite cannot be used in functionsProxy`, {
|
|
17
|
+
exit: 2,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (!rewrite.region) {
|
|
21
|
+
rewrite.region = "us-central1";
|
|
22
|
+
}
|
|
23
|
+
let url = `https://${rewrite.region}-${projectId}.cloudfunctions.net/${rewrite.function}`;
|
|
14
24
|
let destLabel = "live";
|
|
15
25
|
if ((0, lodash_1.includes)(options.targets, "functions")) {
|
|
16
26
|
destLabel = "local";
|
|
17
27
|
const functionsEmu = registry_1.EmulatorRegistry.get(types_1.Emulators.FUNCTIONS);
|
|
18
28
|
if (functionsEmu) {
|
|
19
|
-
url = functionsEmulator_1.FunctionsEmulator.getHttpFunctionUrl(functionsEmu.getInfo().host, functionsEmu.getInfo().port, projectId, rewrite.function,
|
|
29
|
+
url = functionsEmulator_1.FunctionsEmulator.getHttpFunctionUrl(functionsEmu.getInfo().host, functionsEmu.getInfo().port, projectId, rewrite.function, rewrite.region);
|
|
20
30
|
}
|
|
21
31
|
}
|
|
22
|
-
resolve((0, proxy_1.proxyRequestHandler)(url, `${destLabel} Function ${rewrite.function}`));
|
|
32
|
+
resolve((0, proxy_1.proxyRequestHandler)(url, `${destLabel} Function ${rewrite.region}/${rewrite.function}`));
|
|
23
33
|
});
|
|
24
34
|
};
|
|
25
35
|
}
|
|
26
|
-
exports.
|
|
36
|
+
exports.functionsProxy = functionsProxy;
|
package/lib/hosting/proxy.js
CHANGED
|
@@ -54,7 +54,7 @@ function proxyRequestHandler(url, rewriteIdentifier) {
|
|
|
54
54
|
continue;
|
|
55
55
|
}
|
|
56
56
|
const value = req.headers[key];
|
|
57
|
-
if (value
|
|
57
|
+
if (value === undefined) {
|
|
58
58
|
headers.delete(key);
|
|
59
59
|
}
|
|
60
60
|
else if (Array.isArray(value)) {
|
|
@@ -115,7 +115,7 @@ function proxyRequestHandler(url, rewriteIdentifier) {
|
|
|
115
115
|
if (location) {
|
|
116
116
|
try {
|
|
117
117
|
const locationURL = new url_1.URL(location);
|
|
118
|
-
if (locationURL.origin
|
|
118
|
+
if (locationURL.origin === u.origin) {
|
|
119
119
|
const unborkedLocation = location.replace(locationURL.origin, "");
|
|
120
120
|
proxyRes.response.headers.set("location", unborkedLocation);
|
|
121
121
|
}
|
|
@@ -27,7 +27,7 @@ async function promptForAccount() {
|
|
|
27
27
|
message: "Please select an option:",
|
|
28
28
|
choices,
|
|
29
29
|
});
|
|
30
|
-
if (emailChoice
|
|
30
|
+
if (emailChoice === "__add__") {
|
|
31
31
|
const newAccount = await (0, auth_1.loginAdditionalAccount)(true);
|
|
32
32
|
if (!newAccount) {
|
|
33
33
|
throw new error_1.FirebaseError("Failed to add new account", { exit: 1 });
|
|
@@ -168,7 +168,7 @@ function convertDatabaseInstance(serverInstance) {
|
|
|
168
168
|
throw new error_1.FirebaseError(`DatabaseInstance response is missing field "name"`);
|
|
169
169
|
}
|
|
170
170
|
const m = serverInstance.name.match(INSTANCE_RESOURCE_NAME_REGEX);
|
|
171
|
-
if (!m || m.length
|
|
171
|
+
if (!m || m.length !== 4) {
|
|
172
172
|
throw new error_1.FirebaseError(`Error parsing instance resource name: ${serverInstance.name}, matches: ${m}`);
|
|
173
173
|
}
|
|
174
174
|
return {
|
package/lib/previews.js
CHANGED
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.previews = void 0;
|
|
4
4
|
const lodash_1 = require("lodash");
|
|
5
5
|
const configstore_1 = require("./configstore");
|
|
6
|
-
exports.previews = Object.assign({ rtdbrules: false, ext: false, extdev: false, rtdbmanagement: false, functionsv2: false, golang: false, deletegcfartifacts: false, artifactregistry: false }, configstore_1.configstore.get("previews"));
|
|
6
|
+
exports.previews = Object.assign({ rtdbrules: false, ext: false, extdev: false, extensionsemulator: false, rtdbmanagement: false, functionsv2: false, golang: false, deletegcfartifacts: false, artifactregistry: false, emulatoruisnapshot: false }, configstore_1.configstore.get("previews"));
|
|
7
7
|
if (process.env.FIREBASE_CLI_PREVIEWS) {
|
|
8
8
|
process.env.FIREBASE_CLI_PREVIEWS.split(",").forEach((feature) => {
|
|
9
9
|
if ((0, lodash_1.has)(exports.previews, feature)) {
|
package/lib/responseToError.js
CHANGED
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
const _ = require("lodash");
|
|
3
3
|
const { FirebaseError } = require("./error");
|
|
4
4
|
module.exports = function (response, body) {
|
|
5
|
-
if (typeof body === "string" && response.statusCode === 404) {
|
|
6
|
-
body = {
|
|
7
|
-
error: {
|
|
8
|
-
message: "Not Found",
|
|
9
|
-
},
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
5
|
if (response.statusCode < 400) {
|
|
13
6
|
return null;
|
|
14
7
|
}
|
|
8
|
+
if (typeof body === "string") {
|
|
9
|
+
if (response.statusCode === 404) {
|
|
10
|
+
body = {
|
|
11
|
+
error: {
|
|
12
|
+
message: "Not Found",
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
body = {
|
|
18
|
+
error: {
|
|
19
|
+
message: body,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
15
24
|
if (typeof body !== "object") {
|
|
16
25
|
try {
|
|
17
26
|
body = JSON.parse(body);
|
package/lib/serve/functions.js
CHANGED
|
@@ -29,8 +29,9 @@ class FunctionsServer {
|
|
|
29
29
|
functionsDir,
|
|
30
30
|
nodeMajorVersion,
|
|
31
31
|
env: {},
|
|
32
|
+
secretEnv: [],
|
|
32
33
|
};
|
|
33
|
-
const args = Object.assign({ projectId, projectDir: options.config.projectDir, emulatableBackends: [this.backend], account }, partialArgs);
|
|
34
|
+
const args = Object.assign({ projectId, projectDir: options.config.projectDir, emulatableBackends: [this.backend], projectAlias: options.projectAlias, account }, partialArgs);
|
|
34
35
|
if (options.host) {
|
|
35
36
|
utils.assertIsStringOrUndefined(options.host);
|
|
36
37
|
args.host = options.host;
|
package/lib/serve/hosting.js
CHANGED
|
@@ -46,7 +46,7 @@ function startServer(options, config, port, init) {
|
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
48
|
rewriters: {
|
|
49
|
-
function: (0, functionsProxy_1.
|
|
49
|
+
function: (0, functionsProxy_1.functionsProxy)(options),
|
|
50
50
|
run: (0, cloudRunProxy_1.default)(options),
|
|
51
51
|
},
|
|
52
52
|
}).listen(() => {
|
package/lib/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.assertIsStringOrUndefined = exports.assertIsNumber = exports.assertIsString = exports.assertDefined = exports.thirtyDaysFromNow = exports.isRunningInWSL = exports.isCloudEnvironment = exports.datetimeString = exports.createDestroyer = exports.promiseWithSpinner = exports.setupLoggers = exports.tryParse = exports.tryStringify = exports.promiseProps = exports.promiseWhile = exports.promiseAllSettled = exports.getFunctionsEventProvider = exports.endpoint = exports.makeActiveProject = exports.streamToString = exports.stringToStream = exports.explainStdin = exports.allSettled = exports.reject = exports.logLabeledError = exports.logLabeledWarning = exports.logWarning = exports.logLabeledBullet = exports.logBullet = exports.logLabeledSuccess = exports.logSuccess = exports.addSubdomain = exports.addDatabaseNamespace = exports.getDatabaseViewDataUrl = exports.getDatabaseUrl = exports.envOverride = exports.getInheritedOption = exports.consoleUrl = exports.envOverrides = void 0;
|
|
3
|
+
exports.groupBy = exports.assertIsStringOrUndefined = exports.assertIsNumber = exports.assertIsString = exports.assertDefined = exports.thirtyDaysFromNow = exports.isRunningInWSL = exports.isCloudEnvironment = exports.datetimeString = exports.createDestroyer = exports.promiseWithSpinner = exports.setupLoggers = exports.tryParse = exports.tryStringify = exports.promiseProps = exports.promiseWhile = exports.promiseAllSettled = exports.getFunctionsEventProvider = exports.endpoint = exports.makeActiveProject = exports.streamToString = exports.stringToStream = exports.explainStdin = exports.allSettled = exports.reject = exports.logLabeledError = exports.logLabeledWarning = exports.logWarning = exports.logLabeledBullet = exports.logBullet = exports.logLabeledSuccess = exports.logSuccess = exports.addSubdomain = exports.addDatabaseNamespace = exports.getDatabaseViewDataUrl = exports.getDatabaseUrl = exports.envOverride = exports.getInheritedOption = exports.consoleUrl = exports.envOverrides = void 0;
|
|
4
4
|
const _ = require("lodash");
|
|
5
5
|
const url = require("url");
|
|
6
6
|
const clc = require("cli-color");
|
|
@@ -295,7 +295,7 @@ function setupLoggers() {
|
|
|
295
295
|
logger_1.logger.add(new winston.transports.Console({
|
|
296
296
|
level: "info",
|
|
297
297
|
format: winston.format.printf((info) => [info.message, ...(info[triple_beam_1.SPLAT] || [])]
|
|
298
|
-
.filter((chunk) => typeof chunk
|
|
298
|
+
.filter((chunk) => typeof chunk === "string")
|
|
299
299
|
.join(" ")),
|
|
300
300
|
}));
|
|
301
301
|
}
|
|
@@ -393,3 +393,16 @@ function assertIsStringOrUndefined(val, message) {
|
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
exports.assertIsStringOrUndefined = assertIsStringOrUndefined;
|
|
396
|
+
function groupBy(arr, f) {
|
|
397
|
+
return arr.reduce((result, item) => {
|
|
398
|
+
const key = f(item);
|
|
399
|
+
if (result[key]) {
|
|
400
|
+
result[key].push(item);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
result[key] = [item];
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}, {});
|
|
407
|
+
}
|
|
408
|
+
exports.groupBy = groupBy;
|