diff --git a/.changeset/tame-parrots-tie.md b/.changeset/tame-parrots-tie.md new file mode 100644 index 00000000000..a6b60f83952 --- /dev/null +++ b/.changeset/tame-parrots-tie.md @@ -0,0 +1,6 @@ +--- +'@firebase/util': minor +'firebase': minor +--- + +Add support for the `FIREBASE_WEBAPP_CONFIG` environment variable at install time. diff --git a/packages/util/package.json b/packages/util/package.json index c7b182a84ec..c2852958d80 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -22,7 +22,8 @@ "./package.json": "./package.json" }, "files": [ - "dist" + "dist", + "postinstall.js" ], "scripts": { "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", @@ -38,13 +39,15 @@ "test:node": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha test/**/*.test.* --config ../../config/mocharc.node.js", "trusted-type-check": "tsec -p tsconfig.json --noEmit", "api-report": "api-extractor run --local --verbose", - "typings:public": "node ../../scripts/build/use_typings.js ./dist/util-public.d.ts" + "typings:public": "node ../../scripts/build/use_typings.js ./dist/util-public.d.ts", + "postinstall": "node ./postinstall.js" }, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" }, "devDependencies": { + "@rollup/plugin-replace": "6.0.2", "rollup": "2.79.2", "rollup-plugin-typescript2": "0.36.0", "typescript": "5.5.4" diff --git a/packages/util/postinstall.js b/packages/util/postinstall.js new file mode 100644 index 00000000000..6987d1316da --- /dev/null +++ b/packages/util/postinstall.js @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { writeFile, readFile } = require('node:fs/promises'); +const { pathToFileURL } = require('node:url'); +const { isAbsolute, join } = require('node:path'); + +const ENV_VARIABLE = 'FIREBASE_WEBAPP_CONFIG'; + +async function getPartialConfig() { + const envVariable = process.env[ENV_VARIABLE]?.trim(); + + if (!envVariable) { + return undefined; + } + + // Like FIREBASE_CONFIG (admin autoinit) FIREBASE_WEBAPP_CONFIG can be + // either a JSON representation of FirebaseOptions or the path to a filename + if (envVariable.startsWith('{"')) { + try { + return JSON.parse(envVariable); + } catch (e) { + console.warn( + `JSON payload in \$${ENV_VARIABLE} could not be parsed, ignoring.\n`, + e + ); + return undefined; + } + } + + const fileURL = pathToFileURL( + isAbsolute(envVariable) ? envVariable : join(process.cwd(), envVariable) + ); + + try { + const fileContents = await readFile(fileURL, 'utf-8'); + return JSON.parse(fileContents); + } catch (e) { + console.warn( + `Contents of "${envVariable}" could not be parsed, ignoring \$${ENV_VARIABLE}.\n`, + e + ); + return undefined; + } +} + +async function getFinalConfig(partialConfig) { + if (!partialConfig) { + return undefined; + } + // In Firebase App Hosting the config provided to the environment variable is up-to-date and + // "complete" we should not reach out to the webConfig endpoint to freshen it + if (process.env.X_GOOGLE_TARGET_PLATFORM === 'fah') { + return partialConfig; + } + const projectId = partialConfig.projectId || '-'; + // If the projectId starts with demo- this is an demo project from the firebase emulators + // treat the config as whole + if (projectId.startsWith('demo-')) { + return partialConfig; + } + const appId = partialConfig.appId; + const apiKey = partialConfig.apiKey; + if (!appId || !apiKey) { + console.warn( + `Unable to fetch Firebase config, appId and apiKey are required, ignoring \$${ENV_VARIABLE}.` + ); + return undefined; + } + + const url = `https://firebase.googleapis.com/v1alpha/projects/${projectId}/apps/${appId}/webConfig`; + + try { + const response = await fetch(url, { + headers: { 'x-goog-api-key': apiKey } + }); + if (!response.ok) { + console.warn( + `Unable to fetch Firebase config, ignoring \$${ENV_VARIABLE}.` + ); + console.warn( + `${url} returned ${response.statusText} (${response.status})` + ); + try { + console.warn((await response.json()).error.message); + } catch (e) {} + return undefined; + } + const json = await response.json(); + return { ...json, apiKey }; + } catch (e) { + console.warn( + `Unable to fetch Firebase config, ignoring \$${ENV_VARIABLE}.\n`, + e + ); + return undefined; + } +} + +function handleUnexpectedError(e) { + console.warn( + `Unexpected error encountered in @firebase/util postinstall script, ignoring \$${ENV_VARIABLE}.` + ); + console.warn(e); + process.exit(0); +} + +getPartialConfig() + .catch(handleUnexpectedError) + .then(getFinalConfig) + .catch(handleUnexpectedError) + .then(async finalConfig => { + const defaults = finalConfig && { + config: finalConfig, + emulatorHosts: { + firestore: process.env.FIRESTORE_EMULATOR_HOST, + database: process.env.FIREBASE_DATABASE_EMULATOR_HOST, + storage: process.env.FIREBASE_STORAGE_EMULATOR_HOST, + auth: process.env.FIREBASE_AUTH_EMULATOR_HOST + } + }; + + await Promise.all([ + writeFile( + join(__dirname, 'dist', 'postinstall.js'), + `'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.getDefaultsFromPostinstall = () => (${JSON.stringify(defaults)});` + ), + writeFile( + join(__dirname, 'dist', 'postinstall.mjs'), + `const getDefaultsFromPostinstall = () => (${JSON.stringify(defaults)}); +export { getDefaultsFromPostinstall };` + ) + ]); + + process.exit(0); + }) + .catch(handleUnexpectedError); diff --git a/packages/util/rollup.config.js b/packages/util/rollup.config.js index d428092d8ee..57750f3d518 100644 --- a/packages/util/rollup.config.js +++ b/packages/util/rollup.config.js @@ -16,16 +16,26 @@ */ import typescriptPlugin from 'rollup-plugin-typescript2'; +import replacePlugin from '@rollup/plugin-replace'; import typescript from 'typescript'; import pkg from './package.json'; import { emitModulePackageFile } from '../../scripts/build/rollup_emit_module_package_file'; -const deps = Object.keys( - Object.assign({}, pkg.peerDependencies, pkg.dependencies) -); +const deps = [ + ...Object.keys(Object.assign({}, pkg.peerDependencies, pkg.dependencies)), + './postinstall' +]; const buildPlugins = [typescriptPlugin({ typescript })]; +function replaceSrcPostinstallWith(path) { + return replacePlugin({ + './src/postinstall': `'${path}'`, + delimiters: ["'", "'"], + preventAssignment: true + }); +} + const browserBuilds = [ { input: 'index.ts', @@ -34,7 +44,7 @@ const browserBuilds = [ format: 'es', sourcemap: true }, - plugins: buildPlugins, + plugins: [...buildPlugins, replaceSrcPostinstallWith('./postinstall.mjs')], external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, { @@ -44,7 +54,7 @@ const browserBuilds = [ format: 'cjs', sourcemap: true }, - plugins: buildPlugins, + plugins: [...buildPlugins, replaceSrcPostinstallWith('./postinstall.js')], external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) } ]; @@ -57,7 +67,7 @@ const nodeBuilds = [ format: 'cjs', sourcemap: true }, - plugins: buildPlugins, + plugins: [...buildPlugins, replaceSrcPostinstallWith('./postinstall.js')], external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, { @@ -67,9 +77,32 @@ const nodeBuilds = [ format: 'es', sourcemap: true }, - plugins: [...buildPlugins, emitModulePackageFile()], + plugins: [ + ...buildPlugins, + emitModulePackageFile(), + replaceSrcPostinstallWith('../postinstall.mjs') + ], external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) } ]; -export default [...browserBuilds, ...nodeBuilds]; +const autoinitBuild = [ + { + input: './src/postinstall.ts', + output: { + file: './dist/postinstall.js', + format: 'cjs' + }, + plugins: buildPlugins + }, + { + input: './src/postinstall.ts', + output: { + file: './dist/postinstall.mjs', + format: 'es' + }, + plugins: buildPlugins + } +]; + +export default [...browserBuilds, ...nodeBuilds, ...autoinitBuild]; diff --git a/packages/util/src/defaults.ts b/packages/util/src/defaults.ts index 2c972604663..d7882e152dd 100644 --- a/packages/util/src/defaults.ts +++ b/packages/util/src/defaults.ts @@ -17,6 +17,7 @@ import { base64Decode } from './crypt'; import { getGlobal } from './global'; +import { getDefaultsFromPostinstall } from './postinstall'; /** * Keys for experimental properties on the `FirebaseDefaults` object. @@ -100,6 +101,7 @@ const getDefaultsFromCookie = (): FirebaseDefaults | undefined => { export const getDefaults = (): FirebaseDefaults | undefined => { try { return ( + getDefaultsFromPostinstall() || getDefaultsFromGlobal() || getDefaultsFromEnvVariable() || getDefaultsFromCookie() diff --git a/packages/util/src/postinstall.ts b/packages/util/src/postinstall.ts new file mode 100644 index 00000000000..f16f60d5b49 --- /dev/null +++ b/packages/util/src/postinstall.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FirebaseDefaults } from './defaults'; + +// This value is retrieved and hardcoded by the NPM postinstall script +export const getDefaultsFromPostinstall: () => + | FirebaseDefaults + | undefined = () => undefined; diff --git a/yarn.lock b/yarn.lock index 28171de3f6d..bffdd9c7a25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2633,6 +2633,14 @@ is-module "^1.0.0" resolve "^1.22.1" +"@rollup/plugin-replace@6.0.2": + version "6.0.2" + resolved "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz#2f565d312d681e4570ff376c55c5c08eb6f1908d" + integrity sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ== + dependencies: + "@rollup/pluginutils" "^5.0.1" + magic-string "^0.30.3" + "@rollup/plugin-strip@2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@rollup/plugin-strip/-/plugin-strip-2.1.0.tgz#04c2d2ccfb2c6b192bb70447fbf26e336379a333" @@ -11233,7 +11241,7 @@ magic-string@^0.25.2, magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.2, magic-string@~0.30.0: +magic-string@^0.30.2, magic-string@^0.30.3, magic-string@~0.30.0: version "0.30.17" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==