diff --git a/packages/platform-browser/src/browser/transfer_state.ts b/packages/platform-browser/src/browser/transfer_state.ts new file mode 100644 index 0000000000..5748b6b5ff --- /dev/null +++ b/packages/platform-browser/src/browser/transfer_state.ts @@ -0,0 +1,163 @@ +/** + * @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 + */ + +import {APP_ID, Injectable, NgModule} from '@angular/core'; +import {DOCUMENT} from '../dom/dom_tokens'; + +export function escapeHtml(text: string): string { + const escapedText: {[k: string]: string} = { + '&': '&a;', + '"': '&q;', + '\'': '&s;', + '<': '&l;', + '>': '&g;', + }; + return text.replace(/[&"'<>]/g, s => escapedText[s]); +} + +export function unescapeHtml(text: string): string { + const unescapedText: {[k: string]: string} = { + '&a;': '&', + '&q;': '"', + '&s;': '\'', + '&l;': '<', + '&g;': '>', + }; + return text.replace(/&[^;]+;/g, s => unescapedText[s]); +} + +/** + * A type-safe key to use with `TransferState`. + * + * Example: + * + * ``` + * const COUNTER_KEY = makeStateKey('counter'); + * let value = 10; + * + * transferState.set(COUNTER_KEY, value); + * ``` + * + * @experimental + */ +export type StateKey = string & {__not_a_string: never}; + +/** + * Create a `StateKey` that can be used to store value of type T with `TransferState`. + * + * Example: + * + * ``` + * const COUNTER_KEY = makeStateKey('counter'); + * let value = 10; + * + * transferState.set(COUNTER_KEY, value); + * ``` + * + * @experimental + */ +export function makeStateKey(key: string): StateKey { + return key as StateKey; +} + +/** + * A key value store that is transferred from the application on the server side to the application + * on the client side. + * + * `TransferState` will be available as an injectable token. To use it import + * `ServerTransferStateModule` on the server and `BrowserTransferStateModule` on the client. + * + * The values in the store are serialized/deserialized using JSON.stringify/JSON.parse. So only + * boolean, number, string, null and non-class objects will be serialized and deserialzied in a + * non-lossy manner. + * + * @experimental + */ +@Injectable() +export class TransferState { + private store: {[k: string]: {} | undefined} = {}; + private onSerializeCallbacks: {[k: string]: () => {} | undefined} = {}; + + /** @internal */ + static init(initState: {}) { + const transferState = new TransferState(); + transferState.store = initState; + return transferState; + } + + /** + * Get the value corresponding to a key. Return `defaultValue` if key is not found. + */ + get(key: StateKey, defaultValue: T): T { return this.store[key] as T || defaultValue; } + + /** + * Set the value corresponding to a key. + */ + set(key: StateKey, value: T): void { this.store[key] = value; } + + /** + * Remove a key from the store. + */ + remove(key: StateKey): void { delete this.store[key]; } + + /** + * Test whether a key exists in the store. + */ + hasKey(key: StateKey) { return this.store.hasOwnProperty(key); } + + /** + * Register a callback to provide the value for a key when `toJson` is called. + */ + onSerialize(key: StateKey, callback: () => T): void { + this.onSerializeCallbacks[key] = callback; + } + + /** + * Serialize the current state of the store to JSON. + */ + toJson(): string { + // Call the onSerialize callbacks and put those values into the store. + for (const key in this.onSerializeCallbacks) { + if (this.onSerializeCallbacks.hasOwnProperty(key)) { + try { + this.store[key] = this.onSerializeCallbacks[key](); + } catch (e) { + console.warn('Exception in onSerialize callback: ', e); + } + } + } + return JSON.stringify(this.store); + } +} + +export function initTransferState(doc: Document, appId: string) { + // Locate the script tag with the JSON data transferred from the server. + // The id of the script tag is set to the Angular appId + 'state'. + const script = doc.getElementById(appId + '-state'); + let initialState = {}; + if (script && script.textContent) { + try { + initialState = JSON.parse(unescapeHtml(script.textContent)); + } catch (e) { + console.warn('Exception while restoring TransferState for app ' + appId, e); + } + } + return TransferState.init(initialState); +} + +/** + * NgModule to install on the client side while using the `TransferState` to transfer state from + * server to client. + * + * @experimental + */ +@NgModule({ + providers: [{provide: TransferState, useFactory: initTransferState, deps: [DOCUMENT, APP_ID]}], +}) +export class BrowserTransferStateModule { +} diff --git a/packages/platform-browser/src/platform-browser.ts b/packages/platform-browser/src/platform-browser.ts index 1a8c9171c0..6587f011d8 100644 --- a/packages/platform-browser/src/platform-browser.ts +++ b/packages/platform-browser/src/platform-browser.ts @@ -10,6 +10,7 @@ export {BrowserModule, platformBrowser} from './browser'; export {Meta, MetaDefinition} from './browser/meta'; export {Title} from './browser/title'; export {disableDebugTools, enableDebugTools} from './browser/tools/tools'; +export {BrowserTransferStateModule, StateKey, TransferState, makeStateKey} from './browser/transfer_state'; export {By} from './dom/debug/by'; export {DOCUMENT} from './dom/dom_tokens'; export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager'; diff --git a/packages/platform-browser/src/private_export.ts b/packages/platform-browser/src/private_export.ts index 0ae42de328..0355d5e248 100644 --- a/packages/platform-browser/src/private_export.ts +++ b/packages/platform-browser/src/private_export.ts @@ -11,6 +11,7 @@ export {BrowserDomAdapter as ɵBrowserDomAdapter} from './browser/browser_adapte export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './browser/location/browser_platform_location'; export {TRANSITION_ID as ɵTRANSITION_ID} from './browser/server-transition'; export {BrowserGetTestability as ɵBrowserGetTestability} from './browser/testability'; +export {escapeHtml as ɵescapeHtml} from './browser/transfer_state'; export {ELEMENT_PROBE_PROVIDERS as ɵELEMENT_PROBE_PROVIDERS} from './dom/debug/ng_probe'; export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom/dom_adapter'; export {DomRendererFactory2 as ɵDomRendererFactory2, NAMESPACE_URIS as ɵNAMESPACE_URIS, flattenStyles as ɵflattenStyles, shimContentAttribute as ɵshimContentAttribute, shimHostAttribute as ɵshimHostAttribute} from './dom/dom_renderer'; diff --git a/packages/platform-browser/test/browser/transfer_state_spec.ts b/packages/platform-browser/test/browser/transfer_state_spec.ts new file mode 100644 index 0000000000..78e4d668c2 --- /dev/null +++ b/packages/platform-browser/test/browser/transfer_state_spec.ts @@ -0,0 +1,112 @@ +/** + * @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 + */ + + +import {TestBed} from '@angular/core/testing'; +import {BrowserModule, BrowserTransferStateModule, TransferState} from '@angular/platform-browser'; +import {StateKey, escapeHtml, makeStateKey, unescapeHtml} from '@angular/platform-browser/src/browser/transfer_state'; +import {DOCUMENT} from '@angular/platform-browser/src/dom/dom_tokens'; + +export function main() { + function removeScriptTag(doc: Document, id: string) { + const existing = doc.getElementById(id); + if (existing) { + doc.body.removeChild(existing); + } + } + + function addScriptTag(doc: Document, appId: string, data: {}) { + const script = doc.createElement('script'); + const id = appId + '-state'; + script.id = id; + script.setAttribute('type', 'application/json'); + script.textContent = escapeHtml(JSON.stringify(data)); + + // Remove any stale script tags. + removeScriptTag(doc, id); + + doc.body.appendChild(script); + } + + describe('TransferState', () => { + const APP_ID = 'test-app'; + let doc: Document; + + const TEST_KEY = makeStateKey('test'); + const DELAYED_KEY = makeStateKey('delayed'); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule.withServerTransition({appId: APP_ID}), + BrowserTransferStateModule, + ] + }); + doc = TestBed.get(DOCUMENT); + }); + + afterEach(() => { removeScriptTag(doc, APP_ID + '-state'); }); + + it('is initialized from script tag', () => { + addScriptTag(doc, APP_ID, {test: 10}); + const transferState: TransferState = TestBed.get(TransferState); + expect(transferState.get(TEST_KEY, 0)).toBe(10); + }); + + it('is initialized to empty state if script tag not found', () => { + const transferState: TransferState = TestBed.get(TransferState); + expect(transferState.get(TEST_KEY, 0)).toBe(0); + }); + + it('supports adding new keys using set', () => { + const transferState: TransferState = TestBed.get(TransferState); + transferState.set(TEST_KEY, 20); + expect(transferState.get(TEST_KEY, 0)).toBe(20); + expect(transferState.hasKey(TEST_KEY)).toBe(true); + }); + + it('supports removing keys', () => { + const transferState: TransferState = TestBed.get(TransferState); + transferState.set(TEST_KEY, 20); + transferState.remove(TEST_KEY); + expect(transferState.get(TEST_KEY, 0)).toBe(0); + expect(transferState.hasKey(TEST_KEY)).toBe(false); + }); + + it('supports serialization using toJson()', () => { + const transferState: TransferState = TestBed.get(TransferState); + transferState.set(TEST_KEY, 20); + expect(transferState.toJson()).toBe('{"test":20}'); + }); + + it('calls onSerialize callbacks when calling toJson()', () => { + const transferState: TransferState = TestBed.get(TransferState); + transferState.set(TEST_KEY, 20); + + let value = 'initial'; + transferState.onSerialize(DELAYED_KEY, () => value); + value = 'changed'; + + expect(transferState.toJson()).toBe('{"test":20,"delayed":"changed"}'); + }); + }); + + describe('escape/unescape', () => { + it('works with all escaped characters', () => { + const testString = ' + + + + + diff --git a/packages/platform-server/integrationtest/src/transferstate/transfer-state.component.ts b/packages/platform-server/integrationtest/src/transferstate/transfer-state.component.ts new file mode 100644 index 0000000000..1bf40a67ff --- /dev/null +++ b/packages/platform-server/integrationtest/src/transferstate/transfer-state.component.ts @@ -0,0 +1,36 @@ +/** + * @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 + */ + +import {isPlatformServer} from '@angular/common'; +import {Component, Inject, PLATFORM_ID} from '@angular/core'; +import {StateKey, TransferState, makeStateKey} from '@angular/platform-browser'; + +const COUNTER_KEY = makeStateKey('counter'); + +@Component({ + selector: 'transfer-state-app', + template: ` +
{{counter}}
+ `, +}) +export class TransferStateComponent { + counter = 0; + + constructor(@Inject(PLATFORM_ID) private platformId: {}, private transferState: TransferState) {} + + ngOnInit() { + if (isPlatformServer(this.platformId)) { + // Set it to 5 in the server. + this.counter = 5; + this.transferState.set(COUNTER_KEY, 50); + } else { + // Get the transferred counter state in the client(should be 50 and not 0). + this.counter = this.transferState.get(COUNTER_KEY, 0); + } + } +} diff --git a/packages/platform-server/integrationtest/webpack.client.config.js b/packages/platform-server/integrationtest/webpack.client.config.js index d8eda93db9..05684eccb7 100644 --- a/packages/platform-server/integrationtest/webpack.client.config.js +++ b/packages/platform-server/integrationtest/webpack.client.config.js @@ -11,6 +11,7 @@ const path = require('path'); module.exports = { entry: { helloworld: './built/src/helloworld/client.js', + transferstate: './built/src/transferstate/client.js', }, output: {path: path.join(__dirname, 'built'), filename: '[name]-bundle.js'}, module: {loaders: [{test: /\.js$/, loader: 'babel-loader?presets[]=es2015'}]}, diff --git a/packages/platform-server/src/platform-server.ts b/packages/platform-server/src/platform-server.ts index b67f16a5a2..762cefdd88 100644 --- a/packages/platform-server/src/platform-server.ts +++ b/packages/platform-server/src/platform-server.ts @@ -9,6 +9,7 @@ export {PlatformState} from './platform_state'; export {ServerModule, platformDynamicServer, platformServer} from './server'; export {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens'; +export {ServerTransferStateModule} from './transfer_state'; export {renderModule, renderModuleFactory} from './utils'; export * from './private_export'; diff --git a/packages/platform-server/src/transfer_state.ts b/packages/platform-server/src/transfer_state.ts new file mode 100644 index 0000000000..fa34af3891 --- /dev/null +++ b/packages/platform-server/src/transfer_state.ts @@ -0,0 +1,42 @@ +/** + * @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 + */ + +import {APP_ID, NgModule} from '@angular/core'; +import {DOCUMENT, TransferState, ɵescapeHtml as escapeHtml} from '@angular/platform-browser'; + +import {BEFORE_APP_SERIALIZED} from './tokens'; + +export function serializeTransferStateFactory( + doc: Document, appId: string, transferStore: TransferState) { + return () => { + const script = doc.createElement('script'); + script.id = appId + '-state'; + script.setAttribute('type', 'application/json'); + script.textContent = escapeHtml(transferStore.toJson()); + doc.body.appendChild(script); + }; +} + +/** + * NgModule to install on the server side while using the `TransferState` to transfer state from + * server to client. + * + * @experimental + */ +@NgModule({ + providers: [ + TransferState, { + provide: BEFORE_APP_SERIALIZED, + useFactory: serializeTransferStateFactory, + deps: [DOCUMENT, APP_ID, TransferState], + multi: true, + } + ] +}) +export class ServerTransferStateModule { +} diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 132df6f585..cfa7fd72c4 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -14,9 +14,9 @@ import {ApplicationRef, CompilerFactory, Component, HostListener, Input, NgModul import {TestBed, async, inject} from '@angular/core/testing'; import {Http, HttpModule, Response, ResponseOptions, XHRBackend} from '@angular/http'; import {MockBackend, MockConnection} from '@angular/http/testing'; -import {BrowserModule, DOCUMENT, Title} from '@angular/platform-browser'; +import {BrowserModule, DOCUMENT, StateKey, Title, TransferState, makeStateKey} from '@angular/platform-browser'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; -import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformState, ServerModule, platformDynamicServer, renderModule, renderModuleFactory} from '@angular/platform-server'; +import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformState, ServerModule, ServerTransferStateModule, platformDynamicServer, renderModule, renderModuleFactory} from '@angular/platform-server'; import {Subscription} from 'rxjs/Subscription'; import {filter} from 'rxjs/operator/filter'; import {first} from 'rxjs/operator/first'; @@ -257,6 +257,47 @@ class MyInputComponent { class NameModule { } +const TEST_KEY = makeStateKey('test'); +const STRING_KEY = makeStateKey('testString'); + +@Component({selector: 'app', template: 'Works!'}) +class TransferComponent { + constructor(private transferStore: TransferState) {} + ngOnInit() { this.transferStore.set(TEST_KEY, 10); } +} + +@Component({selector: 'esc-app', template: 'Works!'}) +class EscapedComponent { + constructor(private transferStore: TransferState) {} + ngOnInit() { + this.transferStore.set(STRING_KEY, ''; + + beforeEach(() => { called = false; }); + afterEach(() => { expect(called).toBe(true); }); + + it('adds transfer script tag when using renderModule', async(() => { + renderModule(TransferStoreModule, {document: ''}).then(output => { + expect(output).toBe(defaultExpectedOutput); + called = true; + }); + })); + + it('adds transfer script tag when using renderModuleFactory', + async(inject([PlatformRef], (defaultPlatform: PlatformRef) => { + const compilerFactory: CompilerFactory = + defaultPlatform.injector.get(CompilerFactory, null); + const moduleFactory = + compilerFactory.createCompiler().compileModuleSync(TransferStoreModule); + renderModuleFactory(moduleFactory, {document: ''}).then(output => { + expect(output).toBe(defaultExpectedOutput); + called = true; + }); + }))); + + it('cannot break out of '); + called = true; + }); + })); + }); }); } diff --git a/tools/cjs-jasmine/index.ts b/tools/cjs-jasmine/index.ts index 12621bf006..3bc4ea2c5e 100644 --- a/tools/cjs-jasmine/index.ts +++ b/tools/cjs-jasmine/index.ts @@ -73,6 +73,7 @@ var specFiles: any = .concat(glob.sync('@angular/platform-browser/test/security/**/*_spec.js', {cwd: distAll})) .concat(['/@angular/platform-browser/test/browser/meta_spec.js']) .concat(['/@angular/platform-browser/test/browser/title_spec.js']) + .concat(['/@angular/platform-browser/test/browser/transfer_state_spec.js']) .reduce((specFiles: string[], paths: string[]) => specFiles.concat(paths), []); jasmine.DEFAULT_TIMEOUT_INTERVAL = 100; diff --git a/tools/public_api_guard/platform-browser/platform-browser.d.ts b/tools/public_api_guard/platform-browser/platform-browser.d.ts index dcd7a2b1eb..5c55225bd9 100644 --- a/tools/public_api_guard/platform-browser/platform-browser.d.ts +++ b/tools/public_api_guard/platform-browser/platform-browser.d.ts @@ -6,6 +6,10 @@ export declare class BrowserModule { }): ModuleWithProviders; } +/** @experimental */ +export declare class BrowserTransferStateModule { +} + /** @experimental */ export declare class By { static all(): Predicate; @@ -55,6 +59,9 @@ export declare class HammerGestureConfig { buildHammer(element: HTMLElement): HammerInstance; } +/** @experimental */ +export declare function makeStateKey = string & { + __not_a_string: never; +}; + /** @experimental */ export declare class Title { constructor(_doc: any); @@ -116,5 +128,15 @@ export declare class Title { setTitle(newTitle: string): void; } +/** @experimental */ +export declare class TransferState { + get(key: StateKey, defaultValue: T): T; + hasKey(key: StateKey): boolean; + onSerialize(key: StateKey, callback: () => T): void; + remove(key: StateKey): void; + set(key: StateKey, value: T): void; + toJson(): string; +} + /** @stable */ export declare const VERSION: Version; diff --git a/tools/public_api_guard/platform-server/platform-server.d.ts b/tools/public_api_guard/platform-server/platform-server.d.ts index 242d062904..bc25795a0f 100644 --- a/tools/public_api_guard/platform-server/platform-server.d.ts +++ b/tools/public_api_guard/platform-server/platform-server.d.ts @@ -41,5 +41,9 @@ export declare function renderModuleFactory(moduleFactory: NgModuleFactory export declare class ServerModule { } +/** @experimental */ +export declare class ServerTransferStateModule { +} + /** @stable */ export declare const VERSION: Version;