diff --git a/docs/deploy/getting-started.md b/docs/deploy/getting-started.md index b9fb66276..c2f4a7e59 100644 --- a/docs/deploy/getting-started.md +++ b/docs/deploy/getting-started.md @@ -88,3 +88,56 @@ We'll create the function and a `package.json` in your project output directory. ## Step 3: customization To customize the deployment flow, you can use the configuration files you're already familiar with from `firebase-tools`. You can find more in the [firebase documentation](https://firebase.google.com/docs/hosting/full-config). + +### Configuring Cloud Functions + +Setting `functionsNodeVersion` and `functionsRuntimeOptions` in your `angular.json` allow you to custimze the version of Node.js Cloud Functions is running and run-time settings like timeout, VPC connectors, and memory. + +```json +"deploy": { + "builder": "@angular/fire:deploy", + "options": { + "functionsNodeVersion": 12, + "functionsRuntimeOptions": { + "memory": "2GB", + "timeoutSeconds": 10, + "vpcConnector": "my-vpc-connector", + "vpcConnectorEgressSettings": "PRIVATE_RANGES_ONLY" + } + } +} +``` + +### Working with multiple Firebase Projects + +If you have multiple build targets and deploy targets, it is possible to specify them in your `angular.json` or `workspace.json`. + +It is possible to use either your project name or project alias in `firebaseProject`. The setting provided here is equivalent to passing a project name or alias to `firebase deploy --project projectNameOrAlias`. + +The `buildTarget` simply points to an existing build configuration for your project. Most projects have a default configuration and a production configuration (commonly activated by using the `--prod` flag) but it is possible to specify as many build configurations as needed. + +You may specify a `buildTarget` and `firebaseProject` in your `options` as follows: + +```json +"deploy": { + "builder": "@angular/fire:deploy", + "options": { + "buildTarget": "projectName:build", + "firebaseProject": "developmentProject" + }, + "configurations": { + "production": { + "buildTarget": "projectName:build:production", + "firebaseProject": "productionProject" + } + } +} +``` + +The above configuration specifies the following: + +1. `ng deploy` will deploy the default project with default configuration. +2. `ng deploy projectName` will deploy the specified project with default configuration. +3. `ng deploy projectName --prod` or `ng deploy projectName --configuration='production'` will deploy `projectName` with production build settings to your production environment. + +All of the options are optional. If you do not specify a `buildTarget`, it defaults to a production build (`projectName:build:production`). If you do not specify a `firebaseProject`, it defaults to the first matching deploy target found in your `.firebaserc` (where your projectName is the same as your Firebase deploy target name). The `configurations` section is also optional. \ No newline at end of file diff --git a/sample/angular.json b/sample/angular.json index 169743b1d..efc85ec64 100644 --- a/sample/angular.json +++ b/sample/angular.json @@ -194,7 +194,11 @@ "deploy": { "builder": "@angular/fire:deploy", "options": { - "ssr": true + "ssr": true, + "functionsNodeVersion": 12, + "functionsRuntimeOptions": { + "memory": "1GB" + } } } } diff --git a/src/schematics/deploy/actions.jasmine.ts b/src/schematics/deploy/actions.jasmine.ts index 9e5bdc059..1e914f411 100644 --- a/src/schematics/deploy/actions.jasmine.ts +++ b/src/schematics/deploy/actions.jasmine.ts @@ -82,19 +82,19 @@ describe('Deploy Angular apps', () => { it('should call login', async () => { const spy = spyOn(firebaseMock, 'login'); - await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, false); + await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }); expect(spy).toHaveBeenCalled(); }); it('should not call login', async () => { const spy = spyOn(firebaseMock, 'login'); - await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, false, FIREBASE_TOKEN); + await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }, FIREBASE_TOKEN); expect(spy).not.toHaveBeenCalled(); }); it('should invoke the builder', async () => { const spy = spyOn(context, 'scheduleTarget').and.callThrough(); - await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, false); + await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith({ target: 'build', @@ -109,14 +109,14 @@ describe('Deploy Angular apps', () => { options: {} }; const spy = spyOn(context, 'scheduleTarget').and.callThrough(); - await deploy(firebaseMock, context, buildTarget, undefined, FIREBASE_PROJECT, false); + await deploy(firebaseMock, context, buildTarget, undefined, FIREBASE_PROJECT, { preview: false }); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith({ target: 'prerender', project: PROJECT }, {}); }); it('should invoke firebase.deploy', async () => { const spy = spyOn(firebaseMock, 'deploy').and.callThrough(); - await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, false, FIREBASE_TOKEN); + await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }, FIREBASE_TOKEN); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith({ cwd: 'cwd', @@ -128,7 +128,7 @@ describe('Deploy Angular apps', () => { describe('error handling', () => { it('throws if there is no firebase project', async () => { try { - await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, false); + await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, { preview: false }); } catch (e) { console.log(e); expect(e.message).toMatch(/Cannot find firebase project/); @@ -138,7 +138,7 @@ describe('Deploy Angular apps', () => { it('throws if there is no target project', async () => { context.target = undefined; try { - await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, false); + await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }); } catch (e) { expect(e.message).toMatch(/Cannot execute the build target/); } @@ -151,7 +151,16 @@ describe('universal deployment', () => { it('should create a firebase function', async () => { const spy = spyOn(fsHost, 'writeFileSync'); - await deployToFunction(firebaseMock, context, '/home/user', STATIC_BUILD_TARGET, SERVER_BUILD_TARGET, false, undefined, fsHost); + await deployToFunction( + firebaseMock, + context, + '/home/user', + STATIC_BUILD_TARGET, + SERVER_BUILD_TARGET, + { preview: false }, + undefined, + fsHost + ); expect(spy).toHaveBeenCalledTimes(2); @@ -164,7 +173,16 @@ describe('universal deployment', () => { it('should rename the index.html file in the nested dist', async () => { const spy = spyOn(fsHost, 'renameSync'); - await deployToFunction(firebaseMock, context, '/home/user', STATIC_BUILD_TARGET, SERVER_BUILD_TARGET, false, undefined, fsHost); + await deployToFunction( + firebaseMock, + context, + '/home/user', + STATIC_BUILD_TARGET, + SERVER_BUILD_TARGET, + { preview: false }, + undefined, + fsHost + ); expect(spy).toHaveBeenCalledTimes(1); @@ -178,7 +196,16 @@ describe('universal deployment', () => { it('should invoke firebase.deploy', async () => { const spy = spyOn(firebaseMock, 'deploy'); - await deployToFunction(firebaseMock, context, '/home/user', STATIC_BUILD_TARGET, SERVER_BUILD_TARGET, false, undefined, fsHost); + await deployToFunction( + firebaseMock, + context, + '/home/user', + STATIC_BUILD_TARGET, + SERVER_BUILD_TARGET, + { preview: false }, + undefined, + fsHost + ); expect(spy).toHaveBeenCalledTimes(1); }); diff --git a/src/schematics/deploy/actions.ts b/src/schematics/deploy/actions.ts index 612fb78dc..aeabc0642 100644 --- a/src/schematics/deploy/actions.ts +++ b/src/schematics/deploy/actions.ts @@ -1,13 +1,15 @@ import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; -import { BuildTarget, FirebaseTools, FSHost } from '../interfaces'; +import { BuildTarget, DeployBuilderSchema, FirebaseTools, FSHost } from '../interfaces'; import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs'; import { copySync, removeSync } from 'fs-extra'; import { dirname, join } from 'path'; import { execSync } from 'child_process'; -import { defaultFunction, defaultPackage, NODE_VERSION } from './functions-templates'; +import { defaultFunction, defaultPackage } from './functions-templates'; import { satisfies } from 'semver'; import * as open from 'open'; +export type DeployBuilderOptions = DeployBuilderSchema | Record; + const escapeRegExp = (str: string) => str.replace(/[\-\[\]\/{}()*+?.\\^$|]/g, '\\$&'); const moveSync = (src: string, dest: string) => { @@ -19,11 +21,11 @@ const deployToHosting = ( firebaseTools: FirebaseTools, context: BuilderContext, workspaceRoot: string, - preview: boolean, + options: DeployBuilderOptions, firebaseToken?: string, ) => { - if (preview) { + if (options.preview) { const port = 5000; // TODO make this configurable setTimeout(() => { @@ -71,10 +73,10 @@ const getVersionRange = (v: number) => `^${v}.0.0`; const findPackageVersion = (name: string) => { const match = execSync(`npm list ${name}`).toString().match(` ${escapeRegExp(name)}@.+\\w`); - return match ? match[0].split(`${name}@`)[1] : null; + return match ? match[0].split(`${name}@`)[1].split(/\s/)[0] : null; }; -const getPackageJson = (context: BuilderContext, workspaceRoot: string) => { +const getPackageJson = (context: BuilderContext, workspaceRoot: string, options: DeployBuilderOptions) => { const dependencies = { 'firebase-admin': 'latest', 'firebase-functions': 'latest' @@ -111,7 +113,7 @@ const getPackageJson = (context: BuilderContext, workspaceRoot: string) => { }); } } // TODO should we throw? - return defaultPackage(dependencies, devDependencies); + return defaultPackage(dependencies, devDependencies, options); }; export const deployToFunction = async ( @@ -120,15 +122,10 @@ export const deployToFunction = async ( workspaceRoot: string, staticBuildTarget: BuildTarget, serverBuildTarget: BuildTarget, - preview: boolean, + options: DeployBuilderOptions, firebaseToken?: string, - fsHost: FSHost = defaultFsHost, + fsHost: FSHost = defaultFsHost ) => { - if (!satisfies(process.versions.node, getVersionRange(NODE_VERSION))) { - context.logger.warn( - `⚠️ Your Node.js version (${process.versions.node}) does not match the Firebase Functions runtime (${NODE_VERSION}).` - ); - } const staticBuildOptions = await context.getTargetOptions(targetFromTargetString(staticBuildTarget.name)); if (!staticBuildOptions.outputPath || typeof staticBuildOptions.outputPath !== 'string') { @@ -156,14 +153,23 @@ export const deployToFunction = async ( fsHost.moveSync(staticOut, newClientPath); fsHost.moveSync(serverOut, newServerPath); + const packageJson = getPackageJson(context, workspaceRoot, options); + const nodeVersion = JSON.parse(packageJson).engines.node; + + if (!satisfies(process.versions.node, getVersionRange(nodeVersion))) { + context.logger.warn( + `⚠️ Your Node.js version (${process.versions.node}) does not match the Firebase Functions runtime (${nodeVersion}).` + ); + } + fsHost.writeFileSync( join(dirname(serverOut), 'package.json'), - getPackageJson(context, workspaceRoot) + packageJson ); fsHost.writeFileSync( join(dirname(serverOut), 'index.js'), - defaultFunction(serverOut) + defaultFunction(serverOut, options) ); fsHost.renameSync( @@ -171,7 +177,7 @@ export const deployToFunction = async ( join(newClientPath, 'index.original.html') ); - if (preview) { + if (options.preview) { const port = 5000; // TODO make this configurable setTimeout(() => { @@ -211,7 +217,7 @@ export default async function deploy( staticBuildTarget: BuildTarget, serverBuildTarget: BuildTarget | undefined, firebaseProject: string, - preview: boolean, + options: DeployBuilderOptions, firebaseToken?: string, ) { if (!firebaseToken) { @@ -266,7 +272,7 @@ export default async function deploy( context.workspaceRoot, staticBuildTarget, serverBuildTarget, - preview, + options, firebaseToken, ); } else { @@ -274,7 +280,7 @@ export default async function deploy( firebaseTools, context, context.workspaceRoot, - preview, + options, firebaseToken, ); } diff --git a/src/schematics/deploy/builder.ts b/src/schematics/deploy/builder.ts index 463a1f14d..7e4d50b96 100644 --- a/src/schematics/deploy/builder.ts +++ b/src/schematics/deploy/builder.ts @@ -1,9 +1,8 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; -import deploy from './actions'; -import { BuildTarget, DeployBuilderSchema } from '../interfaces'; +import deploy, { DeployBuilderOptions } from './actions'; +import { BuildTarget } from '../interfaces'; import { getFirebaseProjectName } from '../utils'; -type DeployBuilderOptions = DeployBuilderSchema & Record; // Call the createBuilder() function to create a builder. This mirrors // createJobHandler() but add typings specific to Architect Builders. @@ -13,7 +12,7 @@ export default createBuilder( throw new Error('Cannot deploy the application without a target'); } - const firebaseProject = getFirebaseProjectName( + const firebaseProject = options.firebaseProject || getFirebaseProjectName( context.workspaceRoot, context.target.project ); @@ -27,10 +26,7 @@ export default createBuilder( let serverBuildTarget: BuildTarget | undefined; if (options.ssr) { serverBuildTarget = { - name: options.universalBuildTarget || `${context.target.project}:server:production`, - options: { - bundleDependencies: 'all' - } + name: options.universalBuildTarget || `${context.target.project}:server:production` }; } @@ -41,7 +37,7 @@ export default createBuilder( staticBuildTarget, serverBuildTarget, firebaseProject, - !!options.preview, + options, process.env.FIREBASE_TOKEN, ); } catch (e) { diff --git a/src/schematics/deploy/functions-templates.ts b/src/schematics/deploy/functions-templates.ts index ad67a3b8e..d28342232 100644 --- a/src/schematics/deploy/functions-templates.ts +++ b/src/schematics/deploy/functions-templates.ts @@ -1,15 +1,18 @@ +import { DeployBuilderOptions } from './actions'; + // TODO allow these to be configured -export const NODE_VERSION = 10; -const FUNCTION_NAME = 'ssr'; -const FUNCTION_REGION = 'us-central1'; -const RUNTIME_OPTIONS = { +const DEFAULT_NODE_VERSION = 10; +const DEFAULT_FUNCTION_NAME = 'ssr'; +const DEFAULT_FUNCTION_REGION = 'us-central1'; +const DEFAULT_RUNTIME_OPTIONS = { timeoutSeconds: 60, memory: '1GB' }; export const defaultPackage = ( dependencies: {[key: string]: string}, - devDependencies: {[key: string]: string} + devDependencies: {[key: string]: string}, + options: DeployBuilderOptions, ) => `{ "name": "functions", "description": "Angular Universal Application", @@ -22,7 +25,7 @@ export const defaultPackage = ( "logs": "firebase functions:log" }, "engines": { - "node": "${NODE_VERSION}" + "node": "${options.functionsNodeVersion || DEFAULT_NODE_VERSION}" }, "dependencies": ${JSON.stringify(dependencies, null, 4)}, "devDependencies": ${JSON.stringify(devDependencies, null, 4)}, @@ -31,7 +34,8 @@ export const defaultPackage = ( `; export const defaultFunction = ( - path: string + path: string, + options: DeployBuilderOptions, ) => `const functions = require('firebase-functions'); // Increase readability in Cloud Logging @@ -39,9 +43,9 @@ require("firebase-functions/lib/logger/compat"); const expressApp = require('./${path}/main').app(); -exports.${FUNCTION_NAME} = functions - .region('${FUNCTION_REGION}') - .runWith(${JSON.stringify(RUNTIME_OPTIONS)}) +exports.${DEFAULT_FUNCTION_NAME} = functions + .region('${DEFAULT_FUNCTION_REGION}') + .runWith(${JSON.stringify(options.functionsRuntimeOptions || DEFAULT_RUNTIME_OPTIONS)}) .https .onRequest(expressApp); `; diff --git a/src/schematics/deploy/schema.json b/src/schematics/deploy/schema.json index a48deed07..df687f668 100644 --- a/src/schematics/deploy/schema.json +++ b/src/schematics/deploy/schema.json @@ -9,6 +9,18 @@ "description": "Target to build.", "pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$" }, + "firebaseProject": { + "type": "string", + "description": "The Firebase project name or project alias to use when deploying" + }, + "functionsNodeVersion": { + "type": "number", + "description": "Version of Node.js to run Cloud Functions on" + }, + "functionsRuntimeOptions": { + "type": "object", + "description": "Runtime options for Cloud Functions" + }, "preview": { "type": "boolean", "description": "Do not deploy the application, just set up the Firebase Function in the project output directory. Can be used for testing the Firebase Function with `firebase serve`." diff --git a/src/schematics/interfaces.ts b/src/schematics/interfaces.ts index d976400c2..d1bf163a3 100644 --- a/src/schematics/interfaces.ts +++ b/src/schematics/interfaces.ts @@ -1,3 +1,5 @@ +import { RuntimeOptions } from 'firebase-functions'; + export interface Project { projectId: string; projectNumber: string; @@ -60,9 +62,12 @@ export interface FirebaseRc { export interface DeployBuilderSchema { buildTarget?: string; + firebaseProject?: string; preview?: boolean; universalBuildTarget?: string; ssr?: boolean; + functionsNodeVersion?: number; + functionsRuntimeOptions?: RuntimeOptions; } export interface BuildTarget { diff --git a/test/ng-build/ng9/src/app/app.component.spec.ts b/test/ng-build/ng9/src/app/app.component.spec.ts index dd8cd7669..15ecd03d5 100644 --- a/test/ng-build/ng9/src/app/app.component.spec.ts +++ b/test/ng-build/ng9/src/app/app.component.spec.ts @@ -1,8 +1,8 @@ -import { TestBed, async } from '@angular/core/testing'; +import { TestBed, waitForAsync } from '@angular/core/testing'; import { AppComponent } from './app.component'; describe('AppComponent', () => { - beforeEach(async(() => { + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ AppComponent