Skip to content

Commit

Permalink
Major overhaul of ApplicationBuilder API to separate out Application …
Browse files Browse the repository at this point in the history
…from ApplicationBuilder

Previously, ApplicationBuilder was called builder but had no build()
method. This didn't make a lot of sense, so now all ApplicationBuilder
implementations have a build() function that returns an Application,
which itself can be used to discover routes, pre-render, and demand
render URLs. The Application object returned from build() must be
disposed when you are finished with it using application.dispose().
  • Loading branch information
clbond committed Mar 29, 2017
1 parent 3986b62 commit d990742
Show file tree
Hide file tree
Showing 38 changed files with 698 additions and 621 deletions.
107 changes: 61 additions & 46 deletions README.md
Expand Up @@ -88,41 +88,28 @@ When you build your application, you are outputting two targets: your actual Ang
Your actual HTTP server code will look something like the following:

```typescript
import {ApplicationFromModule, DocumentStore} from 'angular-ssr';
import {ApplicationFromModule} from 'angular-ssr';

import {join} from 'path';

import {AppModule} from '../src/app/app.module';

const dist = join(process.cwd(), 'dist');

const application = new ApplicationFromModule(AppModule, join(dist, 'index.html'));
const builder = new ApplicationBuilderFromModule(AppModule, join(dist, 'index.html'));

// Pre-render all routes that do not take parameters (angular-ssr will discover automatically).
// This is completely optional and may not make sense for your application if even parameterless
// routes contain dynamic content. If you don't want prerendering, skip to the next block of code
const prerender = async () => {
const snapshots = await application.prerender();
const application = builder.build();

snapshots.subscribe(
snapshot => {
app.get(snapshot.uri, (req, res) => res.send(snapshot.renderedDocument));
});
};

prerender();

// Demand render all other routes (eg /blog/post/12)
app.get('*', (req, res) => {
application.renderUri(absoluteUri(req))
.then(snapshot => {
app.get('*',
async (req, res) => {
try {
const snapshot = await application.renderUri(absoluteUri(req));
res.send(snapshot.renderedDocument);
})
.catch(exception => {
console.error(`Failed to render ${req.url}`, exception);
res.send(application.templateDocument()); // fall back on client-side rendering
});
});
}
catch (exception) { // log this failure in a real application
res.send(builder.templateDocument()); // fall back on client-side rendering
}
});

export const absoluteUri = (request: express.Request): string => {
return url.format({
Expand All @@ -133,6 +120,22 @@ export const absoluteUri = (request: express.Request): string => {
};
```

If you wish to do _prerendering_ (rendering of all routes that do not take parameters), which is appropriate for applications that are mostly static controls and so forth, then you can write a bit more code like this:

```typescript
// Pre-render all routes that do not take parameters (angular-ssr will discover automatically).
// This is completely optional and may not make sense for your application if even parameterless
// routes contain dynamic content. If you don't want prerendering, skip to the next block of code
const prerender = async () => {
const snapshots = await application.prerender();

snapshots.subscribe(
snapshot => app.get(snapshot.uri, (req, res) => res.send(snapshot.renderedDocument)));
};
```

This bit is completely optional.

### Caching

The caching implementations in `angular-ssr` are completely optional and are not integral to the product in any way. The library provides two caching implementations: one that is variant-aware (`DocumentVariantStore`) and one that is not (`DocumentStore`). They are both fixed-size LRU caches that default to 65k items but can accept different sizes in their constructors. But they are very simple abstractions that just sit atop `application.renderUri()` and there is absolutely no requirement that you use them. They all share the same basic implementation:
Expand Down Expand Up @@ -162,7 +165,7 @@ In this case, your code will look something like this:

```typescript
import {
ApplicationFromModule,
ApplicationBuilderFromModule,
ApplicationRenderer,
HtmlOutput,
} from 'angular-ssr';
Expand All @@ -173,7 +176,9 @@ import {AppModule} from '../src/app.module';

const dist = join(process.cwd(), 'dist');

const application = new ApplicationFromModule(ServerModule, join(dist, 'index.html'));
const builder = new ApplicationBuilderFromModule(ServerModule, join(dist, 'index.html'));

const application = builder.build();

const html = new HtmlOutput(dist);

Expand Down Expand Up @@ -308,9 +313,9 @@ export class LocaleTransition implements StateTransition<string> {
}
}

const application = new ApplicationFromModule(AppModule, join(process.cwd(), 'dist', 'index.html'));
const builder = new ApplicationBuilderFromModule(AppModule, join(process.cwd(), 'dist', 'index.html'));

application.variants({
builder.variants({
locale: {
values: new Set<string>([
'en-CA',
Expand All @@ -321,11 +326,16 @@ application.variants({
}
});

const application = builder.build();

type ApplicationVariants = {locale: string};

// DocumentVariantStore is a variant-aware special-case of DocumentStore. When you
// query it, you must provide values for each variant key that we described in the
// call to variants() above. But in this application, there is only one key: locale.
// And again, if you do not want caching in your application or you want finer-grained
// control over it, just skip DocumentVariantStore and call application.renderUri,
// which will render a new document each time you call it, with no caching.
const documentStore = new DocumentVariantStore<ApplicationVariants>(application);

app.get('*', async (req, res) => {
Expand All @@ -338,7 +348,7 @@ app.get('*', async (req, res) => {
res.send(snapshot.renderedDocument);
}
catch (exception) {
res.send(application.templateDocument()); // fall back on client-side rendering
res.send(builder.templateDocument()); // fall back on client-side rendering
}
});
```
Expand All @@ -352,9 +362,8 @@ The example in `examples/demand-express` has working code that implements what w
Many applications may wish to transfer some state from the server to the client as part of application bootstrap. `angular-ssr` makes this easy. Simply tell your `ApplicationBuilder` object about your state reader class or function, and any state returned from it will be made available in a global variable called `bootstrapApplicationState`:

```typescript
const application = new ApplicationFromModule(AppModule);

application.stateReader(ServerStateReader);
const builder = new ApplicationBuilderFromModule(AppModule, htmlTemplate);
builder.stateReader(ServerStateReader);
```

And your `ServerStateReader` class implementation might look like this:
Expand All @@ -367,7 +376,7 @@ import {Store} from '@ngrx/store';
import {StateReader} from 'angular-ssr';

@Injectable()
export class ServerStateReader implements StateReader {
export class ServerStateReader implements StateReader<MyState> {
constructor(private store: Store<AppState>) {}

getState(): Promise<MyState> {
Expand All @@ -379,12 +388,7 @@ export class ServerStateReader implements StateReader {
Note that you can inject any service you wish into your state reader. `angular-ssr` will query the constructor arguments using the ng dependency injector the same way it works in application code. Alternatively, you can supply a function which just accepts a bare `Injector` and you can query the DI yourself:

```typescript
import {Store} from '@ngrx/store';

application.stateReader(
(injector: Injector) => {
return injector.get(Store).select(s => s.fooBar).take(1).toPromise();
});
builder.stateReader((injector: Injector) => injector.get(Store).select(s => s.fooBar).toPromise());
```

Both solutions are functionally equivalent.
Expand All @@ -393,18 +397,29 @@ Both solutions are functionally equivalent.

# More details on server-side rendering code

The main contract that you use to define your application in a server context is called [`ApplicationBuilder`](https://github.com/clbond/angular-ssr/blob/master/source/application/builder/builder.ts). It has thorough comments and explains all the ways that you can configure your application when doing server-side rendering.
The main contract that you use to define your application in a server context is called [`ApplicationBuilder`](https://github.com/clbond/angular-ssr/blob/master/source/application/builder/builder.ts). It has thorough comments and explains all the ways that you can configure your application when doing server-side rendering. Once you have configured your `ApplicationBuilder`, you call `build()` to get an instance of `Application`. You can use the `Application` instance to do prerendering and demand rendering of routes / URLs.

But `ApplicationBuilder` is an interface. It has three concrete implementations:
`ApplicationBuilder` is an interface. It has three concrete implementations that you can instantiate, depending on which suits your needs:

* `ApplicationFromModule<V, M>`
* `ApplicationBuilderFromModule<V, M>`
* If your code has access to the root `@NgModule` of your application, then this is probably the `ApplicationBuilder` that you want to use. It takes a module type and a template HTML document (`dist/index.html`) as its constructor arguments.
* `ApplicationFromModuleFactory<V>`
* `ApplicationBuilderFromModuleFactory<V>`
* If your application code has already been run through `ngc` and produced `.ngfactory.js` files, then you can pass your root `@NgModule`'s NgFactory -- not the module definition itself, but its compilation output -- to `ApplicationFromModuleFactory<V>` and you can skip the template compilation process.
* `ApplicationFromSource<V>`
* `ApplicationBuilderFromSource<V>`
* You can use this for projects that use `@angular/cli` if you wish to use inplace compilation to generate an `NgModuleFactory` from raw source code. It's fairly unlikely that you will ever use this class: its main purpose is for the implementation of the `ng-render` command.

Other classes of interest are [`DocumentStore`](https://github.com/clbond/angular-ssr/blob/master/source/store/document-store.ts) and [`DocumentVariantStore`](https://github.com/clbond/angular-ssr/blob/master/source/store/document-variant-store.ts). You can use these in conjunction with `ApplicationBuilder` to maintain and query a cache of rendered pages.
The typical usage of `ApplicationBuilder` looks something like:

```typescript
const builder = new ApplicationBuilderFromModule(MyModule);
builder.templateDocument(indexHtmlFile);

const application = builder.build();

const renderedDocument = application.renderUri('http://localhost/');
```

Other classes of interest are [`DocumentStore`](https://github.com/clbond/angular-ssr/blob/master/source/store/document-store.ts) and [`DocumentVariantStore`](https://github.com/clbond/angular-ssr/blob/master/source/store/document-variant-store.ts). You can use these in conjunction with `ApplicationBuilder` to maintain and query a cache of rendered pages. Alternatively, you can provide your own caching mechanism and just call `application.renderUri()` when there is a miss.

## `Snapshot<V>`

Expand Down
16 changes: 11 additions & 5 deletions examples/demand-express/server/index.ts
Expand Up @@ -4,7 +4,7 @@ import express = require('express');

import {enableProdMode} from '@angular/core';

import {ApplicationFromModule, DocumentVariantStore} from 'angular-ssr';
import {ApplicationBuilderFromModule, DocumentVariantStore} from 'angular-ssr';

import {AppModule} from '../app/app.module';

Expand All @@ -18,19 +18,25 @@ configure(http);

enableProdMode();

const application = new ApplicationFromModule<Variants, AppModule>(AppModule, index);
const builder = new ApplicationBuilderFromModule<Variants, AppModule>(AppModule, index);
builder.variants(variants);

application.variants(variants);
const application = builder.build();

const documentStore = new DocumentVariantStore(application); // has default lru cache size
// NOTE(cbond): It is important to note that this caching implementation is limited and
// probably not suitable for your application. It is a fixed-size LRU cache that only
// makes sense for applications whose content does not change over time. If the content
// of your application routes does change over time, consider writing your own cache
// on top of application.renderUri or just avoid caching altogether.
const documentStore = new DocumentVariantStore(application);

http.get('*', (request, response) => {
documentStore.load(absoluteUri(request), {locale: request.cookies['locale'] || 'en-US'})
.then(snapshot => {
response.send(snapshot.renderedDocument);
})
.catch(exception => {
response.send(application.templateDocument()); // fall back on client document
response.send(builder.templateDocument()); // fall back on client document
});
});

Expand Down
8 changes: 4 additions & 4 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "angular-ssr",
"version": "0.0.97",
"version": "0.0.98",
"description": "Angular server-side rendering implementation",
"main": "build/index.js",
"typings": "build/index.d.ts",
Expand Down Expand Up @@ -56,8 +56,7 @@
"typescript": ">=2.1.0"
},
"optionalPeerDependencies": {
"@ngrx/store": "^2.2.1",
"preboot": "4.5.2"
"@ngrx/store": "^2.2.1"
},
"dependencies": {
"@types/babel-core": "6.7.14",
Expand All @@ -78,6 +77,7 @@
"mock-local-storage": "^1.0.2",
"node-stylus-require": "^1.1.0",
"npm-run-all": "^4.0.2",
"preboot": "^4.5.2",
"require-sass": "^1.0.4",
"rimraf": "2.5.4",
"rxjs": "5.1.0",
Expand Down Expand Up @@ -106,7 +106,7 @@
},
"jest": {
"automock": false,
"bail": true,
"bail": false,
"browser": false,
"modulePaths": [
"<rootDir>/source",
Expand Down
105 changes: 105 additions & 0 deletions source/application/builder/application.ts
@@ -0,0 +1,105 @@
import {NgModuleFactory} from '@angular/core';

import {Observable, Subject} from 'rxjs';

import {Disposable} from './../../disposable';
import {PlatformImpl, bootstrapWithExecute, forkZone} from '../../platform';
import {RenderOperation, RenderVariantOperation} from '../operation';
import {Snapshot, snapshot} from '../../snapshot';
import {Route, applicationRoutes, renderableRoutes} from '../../route';
import {baseUri} from '../../static';
import {composeTransitions} from '../../variants';
import {fork} from './fork';

import uri = require('url');

export abstract class Application<V, M> implements Disposable {
constructor(
private platformImpl: PlatformImpl,
private render: RenderOperation,
private moduleFactory: () => Promise<NgModuleFactory<M>>
) {}

abstract dispose(): void;

async prerender(): Promise<Observable<Snapshot<V>>> {
if (this.render.routes == null || this.render.routes.length === 0) {
this.render.routes = renderableRoutes(await this.discoverRoutes());

if (this.render.routes.length === 0) {
return Observable.of();
}
}

return this.renderToStream(this.render);
}

renderUri(uri: string, variant?: V): Promise<Snapshot<V>> {
uri = resolveToAbsoluteUri(uri);

const transition = composeTransitions(this.render.variants, variant);

const vop: RenderVariantOperation<V> = {scope: this.render, uri, variant, transition};

return this.renderVariant(vop);
}

async discoverRoutes(): Promise<Array<Route>> {
const moduleFactory = await this.moduleFactory();

return await applicationRoutes(this.platformImpl, moduleFactory, this.render.templateDocument);
}

private renderToStream(operation: RenderOperation): Observable<Snapshot<V>> {
const subject = new Subject<Snapshot<V>>();

const bind = async (suboperation: RenderVariantOperation<V>) => {
try {
subject.next(await this.renderVariant(suboperation));
}
catch (exception) {
subject.error(exception);
}
};

const promises = fork<V>(operation).map(suboperation => bind(suboperation));

Promise.all(promises).then(() => subject.complete());

return subject.asObservable();
}

private async renderVariant(operation: RenderVariantOperation<V>): Promise<Snapshot<V>> {
const {uri, scope: {templateDocument}} = operation;

const moduleFactory = await this.moduleFactory();

const instantiate = async () =>
await bootstrapWithExecute<M, Snapshot<V>>(this.platformImpl, moduleFactory, ref => snapshot(ref, operation));

return await forkZone(templateDocument, uri, instantiate);
}
}

let relativeUriWarning = false;

const resolveToAbsoluteUri = (relativeUri: string): string => {
if (relativeUri == null ||
relativeUri.length === 0 ||
relativeUri === '/') {
return baseUri;
}

const resolved = uri.resolve(baseUri, relativeUri);

if (resolved !== relativeUri) {
if (relativeUriWarning === false) {
console.warn(`It is best to avoid using relative URIs like ${relativeUri} when requesting render results`);
console.warn('The reason is that your application may key its service URIs from "window.location" in some manner');
console.warn(`I have resolved this relative URI to ${resolved} and this may impact your application`);
relativeUriWarning = true;
}
}

return resolved;
};

0 comments on commit d990742

Please sign in to comment.