Skip to content

Commit 0e97152

Browse files
committed
Implement emulators discovery.
1 parent 37cee9e commit 0e97152

File tree

3 files changed

+584
-1
lines changed

3 files changed

+584
-1
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* @license
3+
* Copyright 2021 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { URL } from 'url';
19+
import { EmulatorConfig, HostAndPort } from '../public_types';
20+
import nodeFetch from 'node-fetch';
21+
22+
/**
23+
* Use the Firebase Emulator hub to discover other running emulators.
24+
*
25+
* @param hub the host and port where the Emulator Hub is running
26+
* @private
27+
*/
28+
export async function discoverEmulators(
29+
hub: HostAndPort,
30+
fetch: typeof nodeFetch = nodeFetch
31+
): Promise<DiscoveredEmulators> {
32+
const res = await fetch(makeUrl(hub, '/emulators'));
33+
if (!res.ok) {
34+
throw new Error(
35+
`HTTP Error ${res.status} when attempting to reach Emulator Hub at ${res.url}, are you sure it is running?`
36+
);
37+
}
38+
39+
const emulators: DiscoveredEmulators = {};
40+
41+
const data = await res.json();
42+
43+
if (data.database) {
44+
emulators.database = {
45+
host: data.database.host,
46+
port: data.database.port
47+
};
48+
}
49+
50+
if (data.firestore) {
51+
emulators.firestore = {
52+
host: data.firestore.host,
53+
port: data.firestore.port
54+
};
55+
}
56+
57+
if (data.storage) {
58+
emulators.storage = {
59+
host: data.storage.host,
60+
port: data.storage.port
61+
};
62+
}
63+
64+
if (data.hub) {
65+
emulators.hub = {
66+
host: data.hub.host,
67+
port: data.hub.port
68+
};
69+
}
70+
return emulators;
71+
}
72+
73+
/**
74+
* @private
75+
*/
76+
export interface DiscoveredEmulators {
77+
database?: HostAndPort;
78+
firestore?: HostAndPort;
79+
storage?: HostAndPort;
80+
hub?: HostAndPort;
81+
}
82+
83+
function makeUrl(hostAndPort: HostAndPort | string, path: string): URL {
84+
if (typeof hostAndPort === 'object') {
85+
const { host, port } = hostAndPort;
86+
if (host.includes(':')) {
87+
hostAndPort = `[${host}]:${port}`;
88+
} else {
89+
hostAndPort = `${host}:${port}`;
90+
}
91+
}
92+
const url = new URL(`http://${hostAndPort}/`);
93+
url.pathname = path;
94+
return url;
95+
}
96+
97+
/**
98+
* @private
99+
*/
100+
export function getEmulatorHostAndPort(
101+
emulator: keyof DiscoveredEmulators,
102+
conf?: EmulatorConfig,
103+
discovered?: DiscoveredEmulators
104+
) {
105+
if (conf && ('host' in conf || 'port' in conf)) {
106+
const { host, port } = conf;
107+
if (host || port) {
108+
if (!host || !port) {
109+
throw new Error(
110+
`Invalid configuration ${emulator}.host=${host} and ${emulator}.port=${port}. ` +
111+
'If either parameter is supplied, both must be defined.'
112+
);
113+
}
114+
if (discovered && !discovered[emulator]) {
115+
console.warn(
116+
`Warning: config for the ${emulator} emulator is specified, but the Emulator hub ` +
117+
'reports it as not running. This may lead to errors such as connection refused.'
118+
);
119+
}
120+
return {
121+
host: fixHostname(conf.host, discovered?.hub?.host),
122+
port: conf.port
123+
};
124+
}
125+
}
126+
const envVar = EMULATOR_HOST_ENV_VARS[emulator];
127+
const fallback = discovered?.[emulator] || emulatorFromEnvVar(envVar);
128+
if (fallback) {
129+
if (discovered && !discovered[emulator]) {
130+
console.warn(
131+
`Warning: the environment variable ${envVar} is set, but the Emulator hub reports the ` +
132+
`${emulator} emulator as not running. This may lead to errors such as connection refused.`
133+
);
134+
}
135+
return {
136+
host: fixHostname(fallback.host, discovered?.hub?.host),
137+
port: fallback.port
138+
};
139+
}
140+
}
141+
142+
// Visible for testing.
143+
export const EMULATOR_HOST_ENV_VARS = {
144+
'database': 'FIREBASE_DATABASE_EMULATOR_HOST',
145+
'firestore': 'FIRESTORE_EMULATOR_HOST',
146+
'hub': 'FIREBASE_EMULATOR_HUB',
147+
'storage': 'FIREBASE_STORAGE_EMULATOR_HOST'
148+
};
149+
150+
function emulatorFromEnvVar(envVar: string): HostAndPort | undefined {
151+
const hostAndPort = process.env[envVar];
152+
if (!hostAndPort) {
153+
return undefined;
154+
}
155+
156+
let parsed: URL;
157+
try {
158+
parsed = new URL(`http://${hostAndPort}`);
159+
} catch {
160+
throw new Error(
161+
`Invalid format in environment variable ${envVar}=${hostAndPort} (expected host:port)`
162+
);
163+
}
164+
let host = parsed.hostname;
165+
const port = Number(parsed.port || '80');
166+
if (!Number.isInteger(port)) {
167+
throw new Error(
168+
`Invalid port in environment variable ${envVar}=${hostAndPort}`
169+
);
170+
}
171+
return { host, port };
172+
}
173+
174+
/**
175+
* Return a connectable hostname, replacing wildcard 0.0.0.0 or :: with loopback
176+
* addresses 127.0.0.1 / ::1 correspondingly. See below for why this is needed:
177+
* https://github.com/firebase/firebase-tools-ui/issues/286
178+
*
179+
* This assumes emulators are running on the same device as the Emulator UI
180+
* server, which should hold if both are started from the same CLI command.
181+
*/
182+
function fixHostname(host: string, fallbackHost?: string): string {
183+
host = host.replace('[', '').replace(']', ''); // Remove IPv6 brackets
184+
if (host === '0.0.0.0') {
185+
host = fallbackHost || '127.0.0.1';
186+
} else if (host === '::') {
187+
host = fallbackHost || '::1';
188+
}
189+
return host;
190+
}

packages/rules-unit-testing/src/initialize.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
*/
1717

1818
import { RulesTestEnvironment, TestEnvironmentConfig } from './public_types';
19+
import {
20+
DiscoveredEmulators,
21+
discoverEmulators,
22+
getEmulatorHostAndPort
23+
} from './impl/discovery';
1924

2025
/**
2126
* Initializes a test environment for rules unit testing. Call this function first for test setup.
@@ -38,6 +43,37 @@ import { RulesTestEnvironment, TestEnvironmentConfig } from './public_types';
3843
* });
3944
* ```
4045
*/
41-
export async function initializeTestEnvironment(): Promise<RulesTestEnvironment> {
46+
export async function initializeTestEnvironment(
47+
config: TestEnvironmentConfig
48+
): Promise<RulesTestEnvironment> {
49+
const projectId = config.projectId || process.env.GCLOUD_PROJECT;
50+
if (!projectId) {
51+
throw new Error(
52+
'Missing projectId option or env var GCLOUD_PROJECT! Please specify the projectId either ' +
53+
'way.\n(A demo-* projectId is strongly recommended for unit tests, such as "demo-test".)'
54+
);
55+
}
56+
const hub = getEmulatorHostAndPort('hub', config.hub);
57+
let discovered = hub ? { ...(await discoverEmulators(hub)), hub } : undefined;
58+
59+
const emulators: DiscoveredEmulators = {};
60+
if (hub) {
61+
emulators.hub = hub;
62+
}
63+
64+
for (const emulator of SUPPORTED_EMULATORS) {
65+
const hostAndPort = getEmulatorHostAndPort(
66+
emulator,
67+
config[emulator],
68+
discovered
69+
);
70+
if (hostAndPort) {
71+
emulators[emulator] = hostAndPort;
72+
}
73+
}
74+
75+
// TODO: Create test env and set security rules.
4276
throw new Error('unimplemented');
4377
}
78+
79+
const SUPPORTED_EMULATORS = ['database', 'firestore', 'storage'] as const;

0 commit comments

Comments
 (0)