Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hosting SSR region #5504

Merged
merged 15 commits into from
Feb 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = [
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, is there a motive behind limiting the regions? For example, we currently use europe-west2 (London) but it seems this configuration would be disallowed?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's because cloud Functions V2 are only available in those regions.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tamerxkilinc This page https://cloud.google.com/functions/docs/locations?hl=en#tier_2_pricing shows that Cloud Functions v2 are currently supported in many more regions. Does that mean we can already use them or are there other limiting factors?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be possible to use the new regions now

{ 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",
jamesdaniels marked this conversation as resolved.
Show resolved Hide resolved
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