Skip to content

Commit

Permalink
Allow configuration of web frameworks backend (#5504)
Browse files Browse the repository at this point in the history
* Allow a subset of HttpsOptions to be specified in firebase.json (hosting.frameworksBackend)
* Don't build the functions.yaml in firebase-tools, instead lean on firebase-functions to encode
* Prompt for SSR region during frameworks "hosting init", defaulting to us-central1

---------

Co-authored-by: James Daniels <[email protected]>
  • Loading branch information
chalosalvador and jamesdaniels authored Feb 14, 2023
1 parent d6d396a commit d6826ee
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 27 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Allow configuration of the Cloud Function generated for full-stack web frameworks (#5504)
129 changes: 129 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string>",
"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<string,string>": {
"additionalProperties": false,
"type": "object"
}
},
"properties": {
Expand Down Expand Up @@ -473,6 +593,9 @@
"cleanUrls": {
"type": "boolean"
},
"frameworksBackend": {
"$ref": "#/definitions/FrameworksBackendOptions"
},
"headers": {
"items": {
"anyOf": [
Expand Down Expand Up @@ -1064,6 +1187,9 @@
"cleanUrls": {
"type": "boolean"
},
"frameworksBackend": {
"$ref": "#/definitions/FrameworksBackendOptions"
},
"headers": {
"items": {
"anyOf": [
Expand Down Expand Up @@ -1655,6 +1781,9 @@
"cleanUrls": {
"type": "boolean"
},
"frameworksBackend": {
"$ref": "#/definitions/FrameworksBackendOptions"
},
"headers": {
"items": {
"anyOf": [
Expand Down
2 changes: 2 additions & 0 deletions src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions src/firebaseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -80,6 +104,7 @@ export type HostingBase = {
i18n?: {
root: string;
};
frameworksBackend?: FrameworksBackendOptions;
};

export type HostingSingle = HostingBase & {
Expand Down
50 changes: 24 additions & 26 deletions src/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -432,7 +447,7 @@ export async function prepareFrameworks(
const rewrite: HostingRewrites = {
source: "**",
function: {
functionId: functionName,
functionId,
},
};
if (experiments.isEnabled("pintags")) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion src/init/features/hosting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -116,10 +116,24 @@ export async function doSetup(setup: any, config: any): Promise<void> {
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();
Expand Down

0 comments on commit d6826ee

Please sign in to comment.