feat(platform-server): add an API to transfer state from server (#19134)
TransferState provides a shared store that is transferred from the server to client. To use it import BrowserTransferStateModule from the client app module and ServerTransferStateModule from the server app module and TransferState will be available as an Injectable object. PR Close #19134
This commit is contained in:
parent
f96142cd7c
commit
cfd9ca0d6f
|
@ -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<number>('counter');
|
||||||
|
* let value = 10;
|
||||||
|
*
|
||||||
|
* transferState.set(COUNTER_KEY, value);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export type StateKey<T> = string & {__not_a_string: never};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a `StateKey<T>` that can be used to store value of type T with `TransferState`.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* const COUNTER_KEY = makeStateKey<number>('counter');
|
||||||
|
* let value = 10;
|
||||||
|
*
|
||||||
|
* transferState.set(COUNTER_KEY, value);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export function makeStateKey<T = void>(key: string): StateKey<T> {
|
||||||
|
return key as StateKey<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(key: StateKey<T>, defaultValue: T): T { return this.store[key] as T || defaultValue; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the value corresponding to a key.
|
||||||
|
*/
|
||||||
|
set<T>(key: StateKey<T>, value: T): void { this.store[key] = value; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a key from the store.
|
||||||
|
*/
|
||||||
|
remove<T>(key: StateKey<T>): void { delete this.store[key]; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a key exists in the store.
|
||||||
|
*/
|
||||||
|
hasKey<T>(key: StateKey<T>) { return this.store.hasOwnProperty(key); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback to provide the value for a key when `toJson` is called.
|
||||||
|
*/
|
||||||
|
onSerialize<T>(key: StateKey<T>, 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 {
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ export {BrowserModule, platformBrowser} from './browser';
|
||||||
export {Meta, MetaDefinition} from './browser/meta';
|
export {Meta, MetaDefinition} from './browser/meta';
|
||||||
export {Title} from './browser/title';
|
export {Title} from './browser/title';
|
||||||
export {disableDebugTools, enableDebugTools} from './browser/tools/tools';
|
export {disableDebugTools, enableDebugTools} from './browser/tools/tools';
|
||||||
|
export {BrowserTransferStateModule, StateKey, TransferState, makeStateKey} from './browser/transfer_state';
|
||||||
export {By} from './dom/debug/by';
|
export {By} from './dom/debug/by';
|
||||||
export {DOCUMENT} from './dom/dom_tokens';
|
export {DOCUMENT} from './dom/dom_tokens';
|
||||||
export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager';
|
export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager';
|
||||||
|
|
|
@ -11,6 +11,7 @@ export {BrowserDomAdapter as ɵBrowserDomAdapter} from './browser/browser_adapte
|
||||||
export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './browser/location/browser_platform_location';
|
export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './browser/location/browser_platform_location';
|
||||||
export {TRANSITION_ID as ɵTRANSITION_ID} from './browser/server-transition';
|
export {TRANSITION_ID as ɵTRANSITION_ID} from './browser/server-transition';
|
||||||
export {BrowserGetTestability as ɵBrowserGetTestability} from './browser/testability';
|
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 {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 {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';
|
export {DomRendererFactory2 as ɵDomRendererFactory2, NAMESPACE_URIS as ɵNAMESPACE_URIS, flattenStyles as ɵflattenStyles, shimContentAttribute as ɵshimContentAttribute, shimHostAttribute as ɵshimHostAttribute} from './dom/dom_renderer';
|
||||||
|
|
|
@ -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<number>('test');
|
||||||
|
const DELAYED_KEY = makeStateKey<string>('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 = '</script><script>alert(\'Hello&\' + "World");';
|
||||||
|
const testObj = {testString};
|
||||||
|
const escaped = escapeHtml(JSON.stringify(testObj));
|
||||||
|
expect(escaped).toBe(
|
||||||
|
'{&q;testString&q;:&q;&l;/script&g;&l;script&g;' +
|
||||||
|
'alert(&s;Hello&a;&s; + \\&q;World\\&q;);&q;}');
|
||||||
|
|
||||||
|
const unescapedObj = JSON.parse(unescapeHtml(escaped));
|
||||||
|
expect(unescapedObj['testString']).toBe(testString);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* @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 {browser, by, element} from 'protractor';
|
||||||
|
|
||||||
|
import {verifyNoBrowserErrors} from './util';
|
||||||
|
|
||||||
|
describe('TransferState', function() {
|
||||||
|
it('should transfer component state', function() {
|
||||||
|
// Load the page without waiting for Angular since it is not boostrapped automatically.
|
||||||
|
browser.driver.get(browser.baseUrl + 'transferstate');
|
||||||
|
|
||||||
|
// Test the contents from the server.
|
||||||
|
const serverDiv = browser.driver.findElement(by.css('div'));
|
||||||
|
expect(serverDiv.getText()).toEqual('5');
|
||||||
|
|
||||||
|
// Bootstrap the client side app and retest the contents
|
||||||
|
browser.executeScript('doBootstrap()');
|
||||||
|
expect(element(by.css('div')).getText()).toEqual('50');
|
||||||
|
|
||||||
|
// Make sure there were no client side errors.
|
||||||
|
verifyNoBrowserErrors();
|
||||||
|
});
|
||||||
|
});
|
|
@ -15,6 +15,9 @@ import * as express from 'express';
|
||||||
import {HelloWorldServerModuleNgFactory} from './helloworld/app.server.ngfactory';
|
import {HelloWorldServerModuleNgFactory} from './helloworld/app.server.ngfactory';
|
||||||
const helloworld = require('raw-loader!./helloworld/index.html');
|
const helloworld = require('raw-loader!./helloworld/index.html');
|
||||||
|
|
||||||
|
import {TransferStateServerModuleNgFactory} from './transferstate/app.server.ngfactory';
|
||||||
|
const transferstate = require('raw-loader!./transferstate/index.html');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
function render<T>(moduleFactory: NgModuleFactory<T>, html: string) {
|
function render<T>(moduleFactory: NgModuleFactory<T>, html: string) {
|
||||||
|
@ -36,5 +39,6 @@ app.get('/favicon.ico', (req, res) => { res.send(''); });
|
||||||
|
|
||||||
//-----------ADD YOUR SERVER SIDE RENDERED APP HERE ----------------------
|
//-----------ADD YOUR SERVER SIDE RENDERED APP HERE ----------------------
|
||||||
app.get('/helloworld', render(HelloWorldServerModuleNgFactory, helloworld));
|
app.get('/helloworld', render(HelloWorldServerModuleNgFactory, helloworld));
|
||||||
|
app.get('/transferstate', render(TransferStateServerModuleNgFactory, transferstate));
|
||||||
|
|
||||||
app.listen(9876, function() { console.log('Server listening on port 9876!'); });
|
app.listen(9876, function() { console.log('Server listening on port 9876!'); });
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* @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 {NgModule} from '@angular/core';
|
||||||
|
import {ServerModule, ServerTransferStateModule} from '@angular/platform-server';
|
||||||
|
|
||||||
|
import {TransferStateModule} from './app';
|
||||||
|
import {TransferStateComponent} from './transfer-state.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
bootstrap: [TransferStateComponent],
|
||||||
|
imports: [TransferStateModule, ServerModule, ServerTransferStateModule],
|
||||||
|
})
|
||||||
|
export class TransferStateServerModule {
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* @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 {NgModule} from '@angular/core';
|
||||||
|
import {BrowserModule, BrowserTransferStateModule} from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import {TransferStateComponent} from './transfer-state.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [TransferStateComponent],
|
||||||
|
bootstrap: [TransferStateComponent],
|
||||||
|
imports: [
|
||||||
|
BrowserModule.withServerTransition({appId: 'ts'}),
|
||||||
|
BrowserTransferStateModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class TransferStateModule {
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* @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 'zone.js/dist/zone.js';
|
||||||
|
|
||||||
|
import {enableProdMode} from '@angular/core';
|
||||||
|
import {platformBrowser} from '@angular/platform-browser';
|
||||||
|
import {TransferStateModuleNgFactory} from './app.ngfactory';
|
||||||
|
|
||||||
|
window['doBootstrap'] = function() {
|
||||||
|
platformBrowser().bootstrapModuleFactory(TransferStateModuleNgFactory);
|
||||||
|
};
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Hello World</title>
|
||||||
|
<script src="built/transferstate-bundle.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<transfer-state-app></transfer-state-app>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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<number>('counter');
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'transfer-state-app',
|
||||||
|
template: `
|
||||||
|
<div>{{counter}}</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ const path = require('path');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
helloworld: './built/src/helloworld/client.js',
|
helloworld: './built/src/helloworld/client.js',
|
||||||
|
transferstate: './built/src/transferstate/client.js',
|
||||||
},
|
},
|
||||||
output: {path: path.join(__dirname, 'built'), filename: '[name]-bundle.js'},
|
output: {path: path.join(__dirname, 'built'), filename: '[name]-bundle.js'},
|
||||||
module: {loaders: [{test: /\.js$/, loader: 'babel-loader?presets[]=es2015'}]},
|
module: {loaders: [{test: /\.js$/, loader: 'babel-loader?presets[]=es2015'}]},
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
export {PlatformState} from './platform_state';
|
export {PlatformState} from './platform_state';
|
||||||
export {ServerModule, platformDynamicServer, platformServer} from './server';
|
export {ServerModule, platformDynamicServer, platformServer} from './server';
|
||||||
export {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
|
export {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
|
||||||
|
export {ServerTransferStateModule} from './transfer_state';
|
||||||
export {renderModule, renderModuleFactory} from './utils';
|
export {renderModule, renderModuleFactory} from './utils';
|
||||||
|
|
||||||
export * from './private_export';
|
export * from './private_export';
|
||||||
|
|
|
@ -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 {
|
||||||
|
}
|
|
@ -14,9 +14,9 @@ import {ApplicationRef, CompilerFactory, Component, HostListener, Input, NgModul
|
||||||
import {TestBed, async, inject} from '@angular/core/testing';
|
import {TestBed, async, inject} from '@angular/core/testing';
|
||||||
import {Http, HttpModule, Response, ResponseOptions, XHRBackend} from '@angular/http';
|
import {Http, HttpModule, Response, ResponseOptions, XHRBackend} from '@angular/http';
|
||||||
import {MockBackend, MockConnection} from '@angular/http/testing';
|
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 {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 {Subscription} from 'rxjs/Subscription';
|
||||||
import {filter} from 'rxjs/operator/filter';
|
import {filter} from 'rxjs/operator/filter';
|
||||||
import {first} from 'rxjs/operator/first';
|
import {first} from 'rxjs/operator/first';
|
||||||
|
@ -257,6 +257,47 @@ class MyInputComponent {
|
||||||
class NameModule {
|
class NameModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEST_KEY = makeStateKey<number>('test');
|
||||||
|
const STRING_KEY = makeStateKey<string>('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, '</script><script>alert(\'Hello&\' + "World");');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
bootstrap: [TransferComponent],
|
||||||
|
declarations: [TransferComponent],
|
||||||
|
imports: [
|
||||||
|
BrowserModule.withServerTransition({appId: 'transfer'}),
|
||||||
|
ServerModule,
|
||||||
|
ServerTransferStateModule,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
class TransferStoreModule {
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
bootstrap: [EscapedComponent],
|
||||||
|
declarations: [EscapedComponent],
|
||||||
|
imports: [
|
||||||
|
BrowserModule.withServerTransition({appId: 'transfer'}),
|
||||||
|
ServerModule,
|
||||||
|
ServerTransferStateModule,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
class EscapedTransferStoreModule {
|
||||||
|
}
|
||||||
|
|
||||||
export function main() {
|
export function main() {
|
||||||
if (getDOM().supportsDOMEvents()) return; // NODE only
|
if (getDOM().supportsDOMEvents()) return; // NODE only
|
||||||
|
|
||||||
|
@ -673,5 +714,46 @@ export function main() {
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ServerTransferStoreModule', () => {
|
||||||
|
let called = false;
|
||||||
|
const defaultExpectedOutput =
|
||||||
|
'<html><head></head><body><app ng-version="0.0.0-PLACEHOLDER">Works!</app><script id="transfer-state" type="application/json">{&q;test&q;:10}</script></body></html>';
|
||||||
|
|
||||||
|
beforeEach(() => { called = false; });
|
||||||
|
afterEach(() => { expect(called).toBe(true); });
|
||||||
|
|
||||||
|
it('adds transfer script tag when using renderModule', async(() => {
|
||||||
|
renderModule(TransferStoreModule, {document: '<app></app>'}).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: '<app></app>'}).then(output => {
|
||||||
|
expect(output).toBe(defaultExpectedOutput);
|
||||||
|
called = true;
|
||||||
|
});
|
||||||
|
})));
|
||||||
|
|
||||||
|
it('cannot break out of <script> tag in serialized output', async(() => {
|
||||||
|
renderModule(EscapedTransferStoreModule, {
|
||||||
|
document: '<esc-app></esc-app>'
|
||||||
|
}).then(output => {
|
||||||
|
expect(output).toBe(
|
||||||
|
'<html><head></head><body><esc-app ng-version="0.0.0-PLACEHOLDER">Works!</esc-app>' +
|
||||||
|
'<script id="transfer-state" type="application/json">' +
|
||||||
|
'{&q;testString&q;:&q;&l;/script&g;&l;script&g;' +
|
||||||
|
'alert(&s;Hello&a;&s; + \\&q;World\\&q;);&q;}</script></body></html>');
|
||||||
|
called = true;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,6 +73,7 @@ var specFiles: any =
|
||||||
.concat(glob.sync('@angular/platform-browser/test/security/**/*_spec.js', {cwd: distAll}))
|
.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/meta_spec.js'])
|
||||||
.concat(['/@angular/platform-browser/test/browser/title_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), <string[]>[]);
|
.reduce((specFiles: string[], paths: string[]) => specFiles.concat(paths), <string[]>[]);
|
||||||
|
|
||||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 100;
|
jasmine.DEFAULT_TIMEOUT_INTERVAL = 100;
|
||||||
|
|
|
@ -6,6 +6,10 @@ export declare class BrowserModule {
|
||||||
}): ModuleWithProviders;
|
}): ModuleWithProviders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @experimental */
|
||||||
|
export declare class BrowserTransferStateModule {
|
||||||
|
}
|
||||||
|
|
||||||
/** @experimental */
|
/** @experimental */
|
||||||
export declare class By {
|
export declare class By {
|
||||||
static all(): Predicate<DebugElement>;
|
static all(): Predicate<DebugElement>;
|
||||||
|
@ -55,6 +59,9 @@ export declare class HammerGestureConfig {
|
||||||
buildHammer(element: HTMLElement): HammerInstance;
|
buildHammer(element: HTMLElement): HammerInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @experimental */
|
||||||
|
export declare function makeStateKey<T =
|
||||||
|
|
||||||
/** @experimental */
|
/** @experimental */
|
||||||
export declare class Meta {
|
export declare class Meta {
|
||||||
constructor(_doc: any);
|
constructor(_doc: any);
|
||||||
|
@ -109,6 +116,11 @@ export interface SafeUrl extends SafeValue {
|
||||||
export interface SafeValue {
|
export interface SafeValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @experimental */
|
||||||
|
export declare type StateKey<T> = string & {
|
||||||
|
__not_a_string: never;
|
||||||
|
};
|
||||||
|
|
||||||
/** @experimental */
|
/** @experimental */
|
||||||
export declare class Title {
|
export declare class Title {
|
||||||
constructor(_doc: any);
|
constructor(_doc: any);
|
||||||
|
@ -116,5 +128,15 @@ export declare class Title {
|
||||||
setTitle(newTitle: string): void;
|
setTitle(newTitle: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @experimental */
|
||||||
|
export declare class TransferState {
|
||||||
|
get<T>(key: StateKey<T>, defaultValue: T): T;
|
||||||
|
hasKey<T>(key: StateKey<T>): boolean;
|
||||||
|
onSerialize<T>(key: StateKey<T>, callback: () => T): void;
|
||||||
|
remove<T>(key: StateKey<T>): void;
|
||||||
|
set<T>(key: StateKey<T>, value: T): void;
|
||||||
|
toJson(): string;
|
||||||
|
}
|
||||||
|
|
||||||
/** @stable */
|
/** @stable */
|
||||||
export declare const VERSION: Version;
|
export declare const VERSION: Version;
|
||||||
|
|
|
@ -41,5 +41,9 @@ export declare function renderModuleFactory<T>(moduleFactory: NgModuleFactory<T>
|
||||||
export declare class ServerModule {
|
export declare class ServerModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @experimental */
|
||||||
|
export declare class ServerTransferStateModule {
|
||||||
|
}
|
||||||
|
|
||||||
/** @stable */
|
/** @stable */
|
||||||
export declare const VERSION: Version;
|
export declare const VERSION: Version;
|
||||||
|
|
Loading…
Reference in New Issue