Skip to content

Commit

Permalink
feat(schematics): firebase function deployment for angular universal (#…
Browse files Browse the repository at this point in the history
…2305)

1. Refactoring of the `ng-add` schematics. It decomposes the function to two separate ones responsible for static file deployments and SSR. Unfortunately, I wasn't able to get rid of the extra schematic from `collection.json` since currently our APIs do not allow manually persisting the `Tree` on the disk.
2. Minor refactoring of the `deploy` builder to incorporate the functionality for server-side rendering enabled deployments.
3. Refactoring of the tests to reflect the updated structure of `ng-add` and the deploy action.
4. Implementation of deployment to Firebase functions.

This implementation supports Angular Universal version 9 and above. Originally I was thinking of checking the dependency versions manually with `semver` during `ng deploy`/`ng add`, but then decided that the peer dependency check that `@angular/fire` does might be sufficient.

Co-authored-by: NothingEverHappens <kirjs@google.com>
  • Loading branch information
mgechev and NothingEverHappens authored Mar 27, 2020
1 parent fb4159d commit 882b254
Show file tree
Hide file tree
Showing 21 changed files with 1,624 additions and 480 deletions.
82 changes: 43 additions & 39 deletions angular.json
Original file line number Diff line number Diff line change
@@ -1,42 +1,46 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": ".",
"projects": {
"angularfire": {
"projectType": "library",
"root": "src",
"sourceRoot": "src",
"prefix": "angularfire",
"architect": {
"build": {
"builder": "@angular-devkit/build-ng-packagr:build",
"options": {
"tsConfig": "tsconfig.json",
"project": "src/package.json"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.json",
"tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": ".",
"projects": {
"angularfire": {
"projectType": "library",
"root": "src",
"sourceRoot": "src",
"prefix": "angularfire",
"architect": {
"build": {
"builder": "@angular-devkit/build-ng-packagr:build",
"options": {
"tsConfig": "tsconfig.json",
"project": "src/package.json"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.json",
"tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}},
"defaultProject": "angularfire"
}
}
}
},
"defaultProject": "angularfire",
"cli": {
"analytics": "86795b8f-9036-4a53-929c-a7303453d677"
}
}
36 changes: 30 additions & 6 deletions docs/deploy/getting-started.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Deploy your application on Firebase Hosting
# Deploy your application on Firebase Hosting & Functions

In this guide, we'll look at how to use `@angular/fire` to automatically deploy an Angular application to Firebase hosting by using the Angular CLI.
In this guide, we'll look at how to use `@angular/fire` to automatically deploy an Angular application to Firebase hosting or functions by using the Angular CLI.

`@angular/fire` uses Firebase functions to deploy your Angular universal projects, with server-side rendering enabled.

**Angular Universal deployments work with `@nguniversal/*` version 9.0.0 and above**.

## Step 1: add `@angular/fire` to your project

Expand All @@ -12,7 +16,9 @@ ng add @angular/fire

*Note that the command above assumes you have global Angular CLI installed. To install Angular CLI globally run `npm i -g @angular/cli`.*

The command above will trigger the `@angular/fire` `ng-add` schematics. The schematics will open a web browser and guide you through the Firebase authentication flow (if you're not signed in already). After you authenticate, you'll see a prompt to select a Firebase hosting project.
First, the command above will check if you have an Angular universal project. It'll do so by looking at your `angular.json` project, looking for a `server` target for the specified project. If it finds one, it'll ask you if you want to deploy the project in a firebase function.

After that it will trigger the `@angular/fire` `ng-add` schematics. The schematics will open a web browser and guide you through the Firebase authentication flow (if you're not signed in already). After you authenticate, you'll see a prompt to select a Firebase hosting project.

The schematics will do the following:

Expand All @@ -22,7 +28,7 @@ The schematics will do the following:

In the end, your `angular.json` project will look like below:

```json
```js
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
Expand All @@ -32,7 +38,9 @@ In the end, your `angular.json` project will look like below:
// ...
"deploy": {
"builder": "@angular/fire:deploy",
"options": {}
"options": {} // Here you may find an "ssr": true option if you've
// selected that you want to deploy your Angular universal project
// as a firebase function.
}
}
}
Expand All @@ -53,14 +61,30 @@ ng add @angular/fire --project=[PROJECT_NAME]
As the second step, to deploy your project run:

```
ng run [ANGULAR_PROJECT_NAME]:deploy
ng deploy --project=[PROJECT_NAME]
```

*The `--project` option is optional. Learn more [here](https://angular.io/cli/deploy).*

The command above will trigger:

1. Production build of your application
2. Deployment of the produced assets to the firebase hosting project you selected during `ng add`

If you've specified that you want a server-side rendering enabled deployment in a firebase function, the command will also:

1. Create a firebase function in `dist`, which directly consumes `main.js` from your server output directory.
2. Create `package.json` for the firebase function with the required dependencies.
3. Deploy the static assets to firebase hosting and your universal server as a Firebase function.

If you want to preview your Angular Universal project before we deploy it as a Firebase Function you can run:

```
ng deploy --preview
```

We'll create the function and a `package.json` in your project output directory. This way, you can later run `firebase serve` in your project root so you can test everything before deploying.

## 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).
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,16 @@
"@angular/platform-browser-dynamic": "^9.0.0-0 || ^9.0.0 || ^10.0.0-0",
"@angular/router": "^9.0.0-0 || ^9.0.0 || ^10.0.0-0",
"firebase": "^7.8.0",
"firebase-admin": "^8.9.2",
"firebase-functions": "^3.3.0",
"firebase-tools": "^7.12.1",
"fs-extra": "^8.0.1",
"fuzzy": "^0.1.3",
"inquirer": "^6.2.2",
"inquirer-autocomplete-prompt": "^1.0.1",
"rxfire": "^3.9.7",
"rxjs": "^6.5.3",
"semver": "^7.1.3",
"tslib": "^1.10.0",
"ws": "^7.2.1",
"xhr2": "^0.1.4",
Expand All @@ -72,10 +76,11 @@
"@types/jasmine": "^3.3.13",
"@types/node": "^12.6.2",
"@types/request": "0.0.30",
"@types/semver": "^7.1.0",
"codelyzer": "^5.0.0",
"concurrently": "^2.2.0",
"conventional-changelog-cli": "^1.2.0",
"fs-extra": "^8.0.1",
"firebase-functions-test": "^0.1.7",
"gzip-size": "^5.1.1",
"jasmine": "^3.4.0",
"jasmine-core": "^3.4.0",
Expand Down
4 changes: 2 additions & 2 deletions src/core/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"description": "Add firebase deploy schematic",
"factory": "./schematics/public_api#ngAdd"
},
"ng-add-setup-firebase-deploy": {
"ng-add-setup-project": {
"description": "Setup ng deploy",
"factory": "./schematics/public_api#setupNgDeploy"
"factory": "./schematics/public_api#ngAddSetupProject"
}
}
}
104 changes: 88 additions & 16 deletions src/schematics/deploy/actions.jasmine.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,148 @@
import { JsonObject, logging } from '@angular-devkit/core';
import {experimental, JsonObject, logging} from '@angular-devkit/core';
import { BuilderContext, BuilderRun, ScheduleOptions, Target, } from '@angular-devkit/architect';
import { FirebaseTools, FirebaseDeployConfig } from '../interfaces';
import deploy from './actions';
import {FirebaseTools, FirebaseDeployConfig, BuildTarget, FSHost} from '../interfaces';
import deploy, {deployToFunction} from './actions';


let context: BuilderContext;
let firebaseMock: FirebaseTools;
let fsHost: FSHost;

const FIREBASE_PROJECT = 'ikachu-aa3ef';
const PROJECT = 'pirojok-project';
const BUILD_TARGET = `${PROJECT}:build:production`;
const BUILD_TARGET: BuildTarget = {
name: `${PROJECT}:build:production`
};

const projectTargets: experimental.workspace.WorkspaceTool = {
build: {
options: {
outputPath: 'dist/browser'
}
},
server: {
options: {
outputPath: 'dist/server'
}
}
};

describe('Deploy Angular apps', () => {
beforeEach(() => initMocks());

it('should call login', async () => {
const spy = spyOn(firebaseMock, 'login');
await deploy(firebaseMock, context, 'host', BUILD_TARGET, FIREBASE_PROJECT);
await deploy(firebaseMock, context, projectTargets, [BUILD_TARGET], FIREBASE_PROJECT, false, false);
expect(spy).toHaveBeenCalled();
});

it('should invoke the builder', async () => {
const spy = spyOn(context, 'scheduleTarget').and.callThrough();
await deploy(firebaseMock, context, 'host', BUILD_TARGET, FIREBASE_PROJECT);
await deploy(firebaseMock, context, projectTargets, [BUILD_TARGET], FIREBASE_PROJECT, false, false);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith({
target: 'build',
configuration: 'production',
project: PROJECT
});
}, undefined);
});

it('should allow the buildTarget to be specified', async () => {
const buildTarget = `${PROJECT}:prerender`;
const buildTarget = {
name: `${PROJECT}:prerender`,
options: {}
};
const spy = spyOn(context, 'scheduleTarget').and.callThrough();
await deploy(firebaseMock, context, 'host', buildTarget, FIREBASE_PROJECT);
await deploy(firebaseMock, context, projectTargets, [buildTarget], FIREBASE_PROJECT, false, false);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith({ target: 'prerender', project: PROJECT });
expect(spy).toHaveBeenCalledWith({ target: 'prerender', project: PROJECT }, {});
});

it('should invoke firebase.deploy', async () => {
const spy = spyOn(firebaseMock, 'deploy').and.callThrough();
await deploy(firebaseMock, context, 'host', BUILD_TARGET, FIREBASE_PROJECT);
await deploy(firebaseMock, context, projectTargets, [BUILD_TARGET], FIREBASE_PROJECT, false, false);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith({
cwd: 'host', only: 'hosting:' + PROJECT
cwd: 'cwd',
only: 'hosting:' + PROJECT
});
});

describe('error handling', () => {
it('throws if there is no firebase project', async () => {
try {
await deploy(firebaseMock, context, 'host', BUILD_TARGET)
fail();
await deploy(firebaseMock, context, projectTargets, [BUILD_TARGET], undefined, false, false);
} catch (e) {
console.log(e);
expect(e.message).toMatch(/Cannot find firebase project/);
}
});

it('throws if there is no target project', async () => {
context.target = undefined;
try {
await deploy(firebaseMock, context, 'host', BUILD_TARGET, FIREBASE_PROJECT)
fail();
await deploy(firebaseMock, context, projectTargets, [BUILD_TARGET], FIREBASE_PROJECT, false, false)
} catch (e) {
expect(e.message).toMatch(/Cannot execute the build target/);
}
});
});
});

describe('universal deployment', () => {
beforeEach(() => initMocks());

it('should create a firebase function', async () => {
const spy = spyOn(fsHost, 'writeFileSync');
await deployToFunction(firebaseMock, context, '/home/user', projectTargets, false, fsHost);

expect(spy).toHaveBeenCalledTimes(2);

const packageArgs = spy.calls.argsFor(0);
const functionArgs = spy.calls.argsFor(1);

expect(packageArgs[0]).toBe('dist/package.json');
expect(functionArgs[0]).toBe('dist/index.js');
});

it('should rename the index.html file in the nested dist', async () => {
const spy = spyOn(fsHost, 'renameSync');
await deployToFunction(firebaseMock, context, '/home/user', projectTargets, false, fsHost);

expect(spy).toHaveBeenCalledTimes(1);

const packageArgs = spy.calls.argsFor(0);

expect(packageArgs).toEqual([
'dist/dist/browser/index.html',
'dist/dist/browser/index.original.html'
]);
});

it('should invoke firebase.deploy', async () => {
const spy = spyOn(firebaseMock, 'deploy');
await deployToFunction(firebaseMock, context, '/home/user', projectTargets, false, fsHost);

expect(spy).toHaveBeenCalledTimes(1);
});

it('should not deploy if the command is invoked with --preview', async () => {
const spy = spyOn(firebaseMock, 'deploy');
await deployToFunction(firebaseMock, context, '/home/user', projectTargets, true, fsHost);
expect(spy).not.toHaveBeenCalled();
});
});

const initMocks = () => {
fsHost = {
moveSync(_: string, __: string) {
},
renameSync(_: string, __: string) {
},
writeFileSync(_: string, __: string) {
}
};

firebaseMock = {
login: () => Promise.resolve(),
projects: {
Expand Down
Loading

0 comments on commit 882b254

Please sign in to comment.