diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..89c36052247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Allow configuration of the Cloud Function generated for full-stack web frameworks (#5504) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 839508468e6..e2536dd4f15 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -5,6 +5,126 @@ "ExtensionsConfig": { "additionalProperties": false, "type": "object" + }, + "FrameworksBackendOptions": { + "additionalProperties": false, + "properties": { + "concurrency": { + "description": "Number of requests a function can serve at once.", + "type": "number" + }, + "cors": { + "description": "If true, allows CORS on requests to this function.\nIf this is a `string` or `RegExp`, allows requests from domains that match the provided value.\nIf this is an `Array`, allows requests from domains matching at least one entry of the array.\nDefaults to true for {@link https.CallableFunction} and false otherwise.", + "type": [ + "string", + "boolean" + ] + }, + "cpu": { + "anyOf": [ + { + "enum": [ + "gcf_gen1" + ], + "type": "string" + }, + { + "type": "number" + } + ], + "description": "Fractional number of CPUs to allocate to a function." + }, + "enforceAppCheck": { + "description": "Determines whether Firebase AppCheck is enforced. Defaults to false.", + "type": "boolean" + }, + "ingressSettings": { + "description": "Ingress settings which control where this function can be called from.", + "enum": [ + "ALLOW_ALL", + "ALLOW_INTERNAL_AND_GCLB", + "ALLOW_INTERNAL_ONLY" + ], + "type": "string" + }, + "invoker": { + "description": "Invoker to set access control on https functions.", + "enum": [ + "public" + ], + "type": "string" + }, + "labels": { + "$ref": "#/definitions/Record", + "description": "User labels to set on the function." + }, + "maxInstances": { + "description": "Max number of instances to be running in parallel.", + "type": "number" + }, + "memory": { + "description": "Amount of memory to allocate to a function.", + "enum": [ + "128MiB", + "16GiB", + "1GiB", + "256MiB", + "2GiB", + "32GiB", + "4GiB", + "512MiB", + "8GiB" + ], + "type": "string" + }, + "minInstances": { + "description": "Min number of actual instances to be running at a given time.", + "type": "number" + }, + "omit": { + "description": "If true, do not deploy or emulate this function.", + "type": "boolean" + }, + "preserveExternalChanges": { + "description": "Controls whether function configuration modified outside of function source is preserved. Defaults to false.", + "type": "boolean" + }, + "region": { + "description": "HTTP functions can override global options and can specify multiple regions to deploy to.", + "type": "string" + }, + "secrets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "serviceAccount": { + "description": "Specific service account for the function to run as.", + "type": "string" + }, + "timeoutSeconds": { + "description": "Timeout for the function in sections, possible values are 0 to 540.\nHTTPS functions can specify a higher timeout.", + "type": "number" + }, + "vpcConnector": { + "description": "Connect cloud function to specified VPC connector.", + "type": "string" + }, + "vpcConnectorEgressSettings": { + "description": "Egress settings for VPC connector.", + "enum": [ + "ALL_TRAFFIC", + "PRIVATE_RANGES_ONLY" + ], + "type": "string" + } + }, + "type": "object" + }, + "Record": { + "additionalProperties": false, + "type": "object" } }, "properties": { @@ -473,6 +593,9 @@ "cleanUrls": { "type": "boolean" }, + "frameworksBackend": { + "$ref": "#/definitions/FrameworksBackendOptions" + }, "headers": { "items": { "anyOf": [ @@ -1064,6 +1187,9 @@ "cleanUrls": { "type": "boolean" }, + "frameworksBackend": { + "$ref": "#/definitions/FrameworksBackendOptions" + }, "headers": { "items": { "anyOf": [ @@ -1655,6 +1781,9 @@ "cleanUrls": { "type": "boolean" }, + "frameworksBackend": { + "$ref": "#/definitions/FrameworksBackendOptions" + }, "headers": { "items": { "anyOf": [ diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 3eb9ef5602d..92ac00b6d99 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -166,6 +166,8 @@ export class Delegate { HOME: process.env.HOME, PATH: process.env.PATH, NODE_ENV: process.env.NODE_ENV, + // Web Frameworks fails without this environment variable + __FIREBASE_FRAMEWORKS_ENTRY__: process.env.__FIREBASE_FRAMEWORKS_ENTRY__, }; if (Object.keys(config || {}).length) { env.CLOUD_RUNTIME_CONFIG = JSON.stringify(config); diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index ad1351aa8f9..32583495261 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -6,6 +6,8 @@ // import { RequireAtLeastOne } from "./metaprogramming"; +import type { HttpsOptions } from "firebase-functions/v2/https"; +import { IngressSetting, MemoryOption, VpcEgressSetting } from "firebase-functions/v2/options"; // should be sourced from - https://github.com/firebase/firebase-tools/blob/master/src/deploy/functions/runtimes/index.ts#L15 type CloudFunctionRuntimes = "nodejs10" | "nodejs12" | "nodejs14" | "nodejs16" | "nodejs18"; @@ -67,6 +69,28 @@ export type HostingHeaders = HostingSource & { }[]; }; +// Allow only serializable options, since this is in firebase.json +// TODO(jamesdaniels) look into allowing serialized CEL expressions, params, and regexp +// and if we can build this interface automatically via Typescript silliness +interface FrameworksBackendOptions extends HttpsOptions { + omit?: boolean; + cors?: string | boolean; + memory?: MemoryOption; + timeoutSeconds?: number; + minInstances?: number; + maxInstances?: number; + concurrency?: number; + vpcConnector?: string; + vpcConnectorEgressSettings?: VpcEgressSetting; + serviceAccount?: string; + ingressSettings?: IngressSetting; + secrets?: string[]; + // Only allow a single region to be specified + region?: string; + // Invoker can only be public + invoker?: "public"; +} + export type HostingBase = { public?: string; source?: string; @@ -80,6 +104,7 @@ export type HostingBase = { i18n?: { root: string; }; + frameworksBackend?: FrameworksBackendOptions; }; export type HostingSingle = HostingBase & { diff --git a/src/frameworks/index.ts b/src/frameworks/index.ts index a132536f229..f123a8943a4 100644 --- a/src/frameworks/index.ts +++ b/src/frameworks/index.ts @@ -109,8 +109,15 @@ const SupportLevelWarnings = { export const FIREBASE_FRAMEWORKS_VERSION = "^0.6.0"; export const FIREBASE_FUNCTIONS_VERSION = "^3.23.0"; export const FIREBASE_ADMIN_VERSION = "^11.0.1"; -export const DEFAULT_REGION = "us-central1"; export const NODE_VERSION = parseInt(process.versions.node, 10).toString(); +export const DEFAULT_REGION = "us-central1"; +export const ALLOWED_SSR_REGIONS = [ + { name: "us-central1 (Iowa)", value: "us-central1" }, + { name: "us-west1 (Oregon)", value: "us-west1" }, + { name: "us-east1 (South Carolina)", value: "us-east1" }, + { name: "europe-west1 (Belgium)", value: "europe-west1" }, + { name: "asia-east1 (Taiwan)", value: "asia-east1" }, +]; const DEFAULT_FIND_DEP_OPTIONS: FindDepOptions = { cwd: process.cwd(), @@ -294,8 +301,9 @@ export async function prepareFrameworks( if (configs.length === 0) { return; } + const allowedRegionsValues = ALLOWED_SSR_REGIONS.map((r) => r.value); for (const config of configs) { - const { source, site, public: publicDir } = config; + const { source, site, public: publicDir, frameworksBackend } = config; if (!source) { continue; } @@ -309,8 +317,15 @@ export async function prepareFrameworks( if (publicDir) { throw new Error(`hosting.public and hosting.source cannot both be set in firebase.json`); } + const ssrRegion = frameworksBackend?.region ?? DEFAULT_REGION; + if (!allowedRegionsValues.includes(ssrRegion)) { + const validRegions = allowedRegionsValues.join(", "); + throw new FirebaseError( + `Hosting config for site ${site} places server-side content in region ${ssrRegion} which is not known. Valid regions are ${validRegions}` + ); + } const getProjectPath = (...args: string[]) => join(projectRoot, source, ...args); - const functionName = `ssr${site.toLowerCase().replace(/-/g, "")}`; + const functionId = `ssr${site.toLowerCase().replace(/-/g, "")}`; const usesFirebaseAdminSdk = !!findDependency("firebase-admin", { cwd: getProjectPath() }); const usesFirebaseJsSdk = !!findDependency("@firebase/app", { cwd: getProjectPath() }); if (usesFirebaseAdminSdk) { @@ -432,7 +447,7 @@ export async function prepareFrameworks( const rewrite: HostingRewrites = { source: "**", function: { - functionId: functionName, + functionId, }, }; if (experiments.isEnabled("pintags")) { @@ -482,27 +497,8 @@ export async function prepareFrameworks( frameworksEntry = framework, } = await codegenFunctionsDirectory(getProjectPath(), functionsDist); - await writeFile( - join(functionsDist, "functions.yaml"), - JSON.stringify( - { - endpoints: { - [functionName]: { - platform: "gcfv2", - // TODO allow this to be configurable - region: [DEFAULT_REGION], - labels: {}, - httpsTrigger: {}, - entryPoint: "ssr", - }, - }, - specVersion: "v1alpha1", - requiredAPIs: [], - }, - null, - 2 - ) - ); + // Set the framework entry in the env variables to handle generation of the functions.yaml + process.env.__FIREBASE_FRAMEWORKS_ENTRY__ = frameworksEntry; packageJson.main = "server.js"; delete packageJson.devDependencies; @@ -580,7 +576,9 @@ ${firebaseDefaults ? `__FIREBASE_DEFAULTS__=${JSON.stringify(firebaseDefaults)}\ join(functionsDist, "server.js"), `const { onRequest } = require('firebase-functions/v2/https'); const server = import('firebase-frameworks'); -exports.ssr = onRequest((req, res) => server.then(it => it.handle(req, res))); +exports.${functionId} = onRequest(${JSON.stringify( + frameworksBackend || {} + )}, (req, res) => server.then(it => it.handle(req, res))); ` ); } else { diff --git a/src/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index 02641a47f8f..8c58d9acc66 100644 --- a/src/init/features/hosting/index.ts +++ b/src/init/features/hosting/index.ts @@ -6,7 +6,7 @@ import { Client } from "../../../apiv2"; import { initGitHub } from "./github"; import { prompt, promptOnce } from "../../../prompt"; import { logger } from "../../../logger"; -import { discover, WebFrameworks } from "../../../frameworks"; +import { ALLOWED_SSR_REGIONS, DEFAULT_REGION, discover, WebFrameworks } from "../../../frameworks"; import * as experiments from "../../../experiments"; import { join } from "path"; @@ -116,10 +116,24 @@ export async function doSetup(setup: any, config: any): Promise { await WebFrameworks[setup.hosting.whichFramework].init!(setup, config); } + await promptOnce( + { + name: "region", + type: "list", + message: "In which region would you like to host server-side content, if applicable?", + default: DEFAULT_REGION, + choices: ALLOWED_SSR_REGIONS, + }, + setup.hosting + ); + setup.config.hosting = { source: setup.hosting.source, // TODO swap out for framework ignores ignore: DEFAULT_IGNORES, + frameworksBackend: { + region: setup.hosting.region, + }, }; } else { logger.info();