From 9559d3e949ef551dca9cc63b1459898f5a9e4af4 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Fri, 10 Feb 2017 17:00:27 -0800 Subject: [PATCH] feat(platform-server): support @angular/http from @angular/platform-server This change installs HttpModule with ServerModule, and overrides bindings to service Http requests made from the server with the 'xhr2' NPM package. Outgoing requests are wrapped in a Zone macro-task, so they will be tracked within the Angular zone and cause the isStable API to show 'false' until they return. This is essential for Universal support of server-side HTTP. --- build.sh | 2 +- .../@angular/compiler-cli/tsconfig-build.json | 1 + .../language-service/tsconfig-build.json | 1 + modules/@angular/platform-server/package.json | 3 +- modules/@angular/platform-server/src/http.ts | 127 ++++++++++++++++++ .../@angular/platform-server/src/server.ts | 10 +- .../platform-server/test/integration_spec.ts | 120 ++++++++++++++++- .../platform-server/tsconfig-build.json | 1 + npm-shrinkwrap.clean.json | 4 + npm-shrinkwrap.json | 6 + package.json | 1 + scripts/ci-lite/offline_compiler_test.sh | 2 +- test-main.js | 1 + 13 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 modules/@angular/platform-server/src/http.ts diff --git a/build.sh b/build.sh index 5d2d9943af..1b3f7ebfdb 100755 --- a/build.sh +++ b/build.sh @@ -10,11 +10,11 @@ PACKAGES=(core forms platform-browser platform-browser-dynamic + http platform-server platform-webworker platform-webworker-dynamic animation - http upgrade router compiler-cli diff --git a/modules/@angular/compiler-cli/tsconfig-build.json b/modules/@angular/compiler-cli/tsconfig-build.json index 7bed131916..feeb281c8e 100644 --- a/modules/@angular/compiler-cli/tsconfig-build.json +++ b/modules/@angular/compiler-cli/tsconfig-build.json @@ -10,6 +10,7 @@ "@angular/core": ["../../../dist/packages-dist/core"], "@angular/common": ["../../../dist/packages-dist/common"], "@angular/compiler": ["../../../dist/packages-dist/compiler"], + "@angular/http": ["../../../dist/packages-dist/http"], "@angular/platform-server": ["../../../dist/packages-dist/platform-server"], "@angular/platform-browser": ["../../../dist/packages-dist/platform-browser"], "@angular/tsc-wrapped": ["../../../dist/tools/@angular/tsc-wrapped"] diff --git a/modules/@angular/language-service/tsconfig-build.json b/modules/@angular/language-service/tsconfig-build.json index 85882c8938..d180fe6ac4 100644 --- a/modules/@angular/language-service/tsconfig-build.json +++ b/modules/@angular/language-service/tsconfig-build.json @@ -17,6 +17,7 @@ "@angular/compiler": ["../../../dist/packages-dist/compiler"], "@angular/compiler/*": ["../../../dist/packages-dist/compiler/*"], "@angular/compiler-cli": ["../../../dist/packages-dist/compiler-cli"], + "@angular/http": ["../../../dist/packages-dist/http"], "@angular/platform-server": ["../../../dist/packages-dist/platform-server"], "@angular/platform-browser": ["../../../dist/packages-dist/platform-browser"], "@angular/tsc-wrapped": ["../../../dist/tools/@angular/tsc-wrapped"], diff --git a/modules/@angular/platform-server/package.json b/modules/@angular/platform-server/package.json index 9746b80167..0ff8d27b70 100644 --- a/modules/@angular/platform-server/package.json +++ b/modules/@angular/platform-server/package.json @@ -14,7 +14,8 @@ "@angular/platform-browser": "0.0.0-PLACEHOLDER" }, "dependencies": { - "parse5": "^2.2.1" + "parse5": "^2.2.1", + "xhr2": "^0.1.4" }, "repository": { "type": "git", diff --git a/modules/@angular/platform-server/src/http.ts b/modules/@angular/platform-server/src/http.ts new file mode 100644 index 0000000000..1cd67c7d45 --- /dev/null +++ b/modules/@angular/platform-server/src/http.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +const xhr2: any = require('xhr2'); + +import {Injectable, Provider} from '@angular/core'; +import {BrowserXhr, Connection, ConnectionBackend, Http, ReadyState, Request, RequestOptions, Response, XHRBackend, XSRFStrategy} from '@angular/http'; + +import {Observable} from 'rxjs/Observable'; +import {Observer} from 'rxjs/Observer'; +import {Subscription} from 'rxjs/Subscription'; + +@Injectable() +export class ServerXhr implements BrowserXhr { + build(): XMLHttpRequest { return new xhr2.XMLHttpRequest(); } +} + +@Injectable() +export class ServerXsrfStrategy implements XSRFStrategy { + configureRequest(req: Request): void {} +} + +export class ZoneMacroTaskConnection implements Connection { + response: Observable; + lastConnection: Connection; + + constructor(public request: Request, backend: XHRBackend) { + this.response = new Observable((observer: Observer) => { + let task: Task = null; + let scheduled: boolean = false; + let sub: Subscription = null; + let savedResult: any = null; + let savedError: any = null; + + const scheduleTask = (_task: Task) => { + task = _task; + scheduled = true; + + this.lastConnection = backend.createConnection(request); + sub = (this.lastConnection.response as Observable) + .subscribe( + res => savedResult = res, + err => { + if (!scheduled) { + throw new Error('invoke twice'); + } + savedError = err; + scheduled = false; + task.invoke(); + }, + () => { + if (!scheduled) { + throw new Error('invoke twice'); + } + scheduled = false; + task.invoke(); + }); + }; + + const cancelTask = (_task: Task) => { + if (!scheduled) { + return; + } + scheduled = false; + if (sub) { + sub.unsubscribe(); + sub = null; + } + }; + + const onComplete = () => { + if (savedError !== null) { + observer.error(savedError); + } else { + observer.next(savedResult); + observer.complete(); + } + }; + + // MockBackend is currently synchronous, which means that if scheduleTask is by + // scheduleMacroTask, the request will hit MockBackend and the response will be + // sent, causing task.invoke() to be called. + const _task = Zone.current.scheduleMacroTask( + 'ZoneMacroTaskConnection.subscribe', onComplete, {}, () => null, cancelTask); + scheduleTask(_task); + + return () => { + if (scheduled && task) { + task.zone.cancelTask(task); + scheduled = false; + } + if (sub) { + sub.unsubscribe(); + sub = null; + } + }; + }); + } + + get readyState(): ReadyState { + return !!this.lastConnection ? this.lastConnection.readyState : ReadyState.Unsent; + } +} + +export class ZoneMacroTaskBackend implements ConnectionBackend { + constructor(private backend: XHRBackend) {} + + createConnection(request: any): ZoneMacroTaskConnection { + return new ZoneMacroTaskConnection(request, this.backend); + } +} + +export function httpFactory(xhrBackend: XHRBackend, options: RequestOptions) { + const macroBackend = new ZoneMacroTaskBackend(xhrBackend); + return new Http(macroBackend, options); +} + +export const SERVER_HTTP_PROVIDERS: Provider[] = [ + {provide: Http, useFactory: httpFactory, deps: [XHRBackend, RequestOptions]}, + {provide: BrowserXhr, useClass: ServerXhr}, + {provide: XSRFStrategy, useClass: ServerXsrfStrategy}, +]; diff --git a/modules/@angular/platform-server/src/server.ts b/modules/@angular/platform-server/src/server.ts index 780fc30a94..2424f0e298 100644 --- a/modules/@angular/platform-server/src/server.ts +++ b/modules/@angular/platform-server/src/server.ts @@ -8,8 +8,11 @@ import {PlatformLocation} from '@angular/common'; import {platformCoreDynamic} from '@angular/compiler'; -import {APP_BOOTSTRAP_LISTENER, InjectionToken, Injector, NgModule, PLATFORM_INITIALIZER, PlatformRef, Provider, RENDERER_V2_DIRECT, RendererV2, RootRenderer, createPlatformFactory, isDevMode, platformCore} from '@angular/core'; +import {APP_BOOTSTRAP_LISTENER, Injectable, InjectionToken, Injector, NgModule, PLATFORM_INITIALIZER, PlatformRef, Provider, RENDERER_V2_DIRECT, RendererV2, RootRenderer, createPlatformFactory, isDevMode, platformCore} from '@angular/core'; +import {HttpModule} from '@angular/http'; import {BrowserModule, DOCUMENT} from '@angular/platform-browser'; + +import {SERVER_HTTP_PROVIDERS} from './http'; import {ServerPlatformLocation} from './location'; import {Parse5DomAdapter, parseDocument} from './parse5_adapter'; import {PlatformState} from './platform_state'; @@ -86,9 +89,8 @@ export const INITIAL_CONFIG = new InjectionToken('Server.INITIAL */ @NgModule({ exports: [BrowserModule], - providers: [ - SERVER_RENDER_PROVIDERS, - ] + imports: [HttpModule], + providers: [SERVER_RENDER_PROVIDERS, SERVER_HTTP_PROVIDERS], }) export class ServerModule { } diff --git a/modules/@angular/platform-server/test/integration_spec.ts b/modules/@angular/platform-server/test/integration_spec.ts index 0cf4070c7a..fb9dabae81 100644 --- a/modules/@angular/platform-server/test/integration_spec.ts +++ b/modules/@angular/platform-server/test/integration_spec.ts @@ -7,11 +7,14 @@ */ import {PlatformLocation} from '@angular/common'; -import {ApplicationRef, CompilerFactory, Component, NgModule, NgModuleRef, PlatformRef, destroyPlatform, getPlatform} from '@angular/core'; +import {ApplicationRef, CompilerFactory, Component, NgModule, NgModuleRef, NgZone, PlatformRef, destroyPlatform, getPlatform} from '@angular/core'; import {async, inject} from '@angular/core/testing'; +import {Http, HttpModule, Response, ResponseOptions, XHRBackend} from '@angular/http'; +import {MockBackend, MockConnection} from '@angular/http/testing'; import {DOCUMENT} from '@angular/platform-browser'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {INITIAL_CONFIG, PlatformState, ServerModule, platformDynamicServer, renderModule, renderModuleFactory} from '@angular/platform-server'; + import {Subscription} from 'rxjs/Subscription'; import {filter} from 'rxjs/operator/filter'; import {first} from 'rxjs/operator/first'; @@ -21,7 +24,15 @@ import {toPromise} from 'rxjs/operator/toPromise'; class MyServerApp { } -@NgModule({declarations: [MyServerApp], imports: [ServerModule], bootstrap: [MyServerApp]}) +@NgModule({ + bootstrap: [MyServerApp], + declarations: [MyServerApp], + imports: [ServerModule], + providers: [ + MockBackend, + {provide: XHRBackend, useExisting: MockBackend}, + ] +}) class ExampleModule { } @@ -55,6 +66,30 @@ class MyStylesApp { class ExampleStylesModule { } +@NgModule({ + bootstrap: [MyServerApp], + declarations: [MyServerApp], + imports: [HttpModule, ServerModule], + providers: [ + MockBackend, + {provide: XHRBackend, useExisting: MockBackend}, + ] +}) +export class HttpBeforeExampleModule { +} + +@NgModule({ + bootstrap: [MyServerApp], + declarations: [MyServerApp], + imports: [ServerModule, HttpModule], + providers: [ + MockBackend, + {provide: XHRBackend, useExisting: MockBackend}, + ] +}) +export class HttpAfterExampleModule { +} + export function main() { if (getDOM().supportsDOMEvents()) return; // NODE only @@ -196,5 +231,86 @@ export function main() { }); }))); }); + + describe('http', () => { + it('can inject Http', async(() => { + const platform = platformDynamicServer( + [{provide: INITIAL_CONFIG, useValue: {document: ''}}]); + platform.bootstrapModule(ExampleModule).then(ref => { + expect(ref.injector.get(Http) instanceof Http).toBeTruthy(); + }); + })); + it('can make Http requests', async(() => { + const platform = platformDynamicServer( + [{provide: INITIAL_CONFIG, useValue: {document: ''}}]); + platform.bootstrapModule(ExampleModule).then(ref => { + const mock = ref.injector.get(MockBackend); + const http = ref.injector.get(Http); + ref.injector.get(NgZone).run(() => { + NgZone.assertInAngularZone(); + mock.connections.subscribe((mc: MockConnection) => { + NgZone.assertInAngularZone(); + expect(mc.request.url).toBe('/testing'); + mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200}))); + }); + http.get('/testing').subscribe(resp => { + NgZone.assertInAngularZone(); + expect(resp.text()).toBe('success!'); + }); + }); + }); + })); + it('requests are macrotasks', async(() => { + const platform = platformDynamicServer( + [{provide: INITIAL_CONFIG, useValue: {document: ''}}]); + platform.bootstrapModule(ExampleModule).then(ref => { + const mock = ref.injector.get(MockBackend); + const http = ref.injector.get(Http); + expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeFalsy(); + ref.injector.get(NgZone).run(() => { + NgZone.assertInAngularZone(); + mock.connections.subscribe((mc: MockConnection) => { + expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeTruthy(); + mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200}))); + }); + http.get('/testing').subscribe(resp => { expect(resp.text()).toBe('success!'); }); + }); + }); + })); + it('works when HttpModule is included before ServerModule', async(() => { + const platform = platformDynamicServer( + [{provide: INITIAL_CONFIG, useValue: {document: ''}}]); + platform.bootstrapModule(HttpBeforeExampleModule).then(ref => { + const mock = ref.injector.get(MockBackend); + const http = ref.injector.get(Http); + expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeFalsy(); + ref.injector.get(NgZone).run(() => { + NgZone.assertInAngularZone(); + mock.connections.subscribe((mc: MockConnection) => { + expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeTruthy(); + mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200}))); + }); + http.get('/testing').subscribe(resp => { expect(resp.text()).toBe('success!'); }); + }); + }); + })); + it('works when HttpModule is included after ServerModule', async(() => { + const platform = platformDynamicServer( + [{provide: INITIAL_CONFIG, useValue: {document: ''}}]); + platform.bootstrapModule(HttpAfterExampleModule).then(ref => { + const mock = ref.injector.get(MockBackend); + const http = ref.injector.get(Http); + expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeFalsy(); + ref.injector.get(NgZone).run(() => { + NgZone.assertInAngularZone(); + mock.connections.subscribe((mc: MockConnection) => { + expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeTruthy(); + mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200}))); + }); + http.get('/testing').subscribe(resp => { expect(resp.text()).toBe('success!'); }); + }); + }); + })); + }); }); } diff --git a/modules/@angular/platform-server/tsconfig-build.json b/modules/@angular/platform-server/tsconfig-build.json index 184e13986d..954ffaec03 100644 --- a/modules/@angular/platform-server/tsconfig-build.json +++ b/modules/@angular/platform-server/tsconfig-build.json @@ -11,6 +11,7 @@ "@angular/core": ["../../../dist/packages-dist/core"], "@angular/common": ["../../../dist/packages-dist/common"], "@angular/compiler": ["../../../dist/packages-dist/compiler"], + "@angular/http": ["../../../dist/packages-dist/http"], "@angular/platform-browser": ["../../../dist/packages-dist/platform-browser"], "@angular/platform-browser-dynamic": ["../../../dist/packages-dist/platform-browser-dynamic"] }, diff --git a/npm-shrinkwrap.clean.json b/npm-shrinkwrap.clean.json index 6936590276..16e309e9a8 100644 --- a/npm-shrinkwrap.clean.json +++ b/npm-shrinkwrap.clean.json @@ -6713,6 +6713,10 @@ "version": "2.0.0", "dev": true }, + "xhr2": { + "version": "0.1.4", + "dev": true + }, "xml2js": { "version": "0.4.15", "dev": true diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index b89ed6c8b9..4a2a00f148 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -9817,6 +9817,12 @@ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-2.0.0.tgz", "dev": true }, + "xhr2": { + "version": "0.1.4", + "from": "xhr2@>=0.1.4 <0.2.0", + "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.1.4.tgz", + "dev": true + }, "xml2js": { "version": "0.4.15", "from": "xml2js@>=0.4.4 <0.5.0", diff --git a/package.json b/package.json index 7afe52f53d..5d9714660b 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "universal-analytics": "^0.3.9", "vrsource-tslint-rules": "^4.0.0", "webpack": "^1.12.6", + "xhr2": "^0.1.4", "yargs": "^3.31.0", "yarn": "^0.19.1" } diff --git a/scripts/ci-lite/offline_compiler_test.sh b/scripts/ci-lite/offline_compiler_test.sh index 4fffbe1953..1076926ad7 100755 --- a/scripts/ci-lite/offline_compiler_test.sh +++ b/scripts/ci-lite/offline_compiler_test.sh @@ -3,7 +3,7 @@ set -ex -o pipefail # These ones can be `npm link`ed for fast development LINKABLE_PKGS=( - $(pwd)/dist/packages-dist/{common,forms,core,compiler,compiler-cli,platform-{browser,server},platform-browser-dynamic,router} + $(pwd)/dist/packages-dist/{common,forms,core,compiler,compiler-cli,platform-{browser,server},platform-browser-dynamic,router,http} $(pwd)/dist/tools/@angular/tsc-wrapped ) diff --git a/test-main.js b/test-main.js index a9e67cb726..1c51cb2af2 100644 --- a/test-main.js +++ b/test-main.js @@ -24,6 +24,7 @@ System.config({ 'rxjs': 'node_modules/rxjs', 'parse5': 'dist/all/empty.js', 'url': 'dist/all/empty.js', + 'xhr2': 'dist/all/empty.js', '@angular/platform-server/src/parse5_adapter': 'dist/all/empty.js', 'angular2/*': 'dist/all/angular2/*.js', 'angular2/src/alt_router/router_testing_providers':