FE-601: temp app initializer (#3)

This commit is contained in:
Alexandra Usacheva 2018-04-26 18:25:05 +03:00 committed by GitHub
parent c228bb131e
commit c62825ee8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 633 additions and 14 deletions

37
package-lock.json generated
View File

@ -10,12 +10,39 @@
"integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==",
"dev": true
},
"@types/ismobilejs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/ismobilejs/-/ismobilejs-0.4.1.tgz",
"integrity": "sha512-h7d/EWxHjRNY/VuIWBsciHahuIRmSVvtK+34cal0m/UBN75wcuEXxzfn91PRr7iyyJqMccW+oiwp6T+T9khamQ==",
"dev": true
},
"@types/lodash": {
"version": "4.14.107",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.107.tgz",
"integrity": "sha512-afvjfP2rl3yvtv2qrCRN23zIQcDinF+munMJCoHEw2BXF22QJogTlVfNPTACQ6ieDyA6VnyKT4WLuN/wK368ng==",
"dev": true
},
"@types/lodash-es": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.0.tgz",
"integrity": "sha512-h8lkWQSgT4qjs9PcIhcL2nWubZeXRVzjZxYlRFmcX9BW1PIk5qRc0djtRWZqtM+GDDFhwBt0ztRu72D/YxIcEw==",
"dev": true,
"requires": {
"@types/lodash": "4.14.107"
}
},
"@types/node": {
"version": "9.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-9.6.5.tgz",
"integrity": "sha512-NOLEgsT6UiDTjnWG5Hd2Mg25LRyz/oe8ql3wbjzgSFeRzRROhPmtlsvIrei4B46UjERF0td9SZ1ZXPLOdcrBHg==",
"dev": true
},
"@types/ramda": {
"version": "0.25.24",
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.25.24.tgz",
"integrity": "sha512-c0TmWA7d4y9KLJJwL/cLPEfSReSgFQK9BtemcCATT48lMeyD7HG8IfGY8bamSuz/Byx1l+13hZV0PCvHsgMB3w==",
"dev": true
},
"@types/react": {
"version": "16.3.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.3.11.tgz",
@ -6152,6 +6179,11 @@
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
"dev": true
},
"ismobilejs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-0.4.1.tgz",
"integrity": "sha1-Gl8SbHD+05yT2jgPpiy65XI+fcI="
},
"isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
@ -8985,6 +9017,11 @@
"integrity": "sha1-DPf4T5Rj/wrlHExLFC2VvjdyTZw=",
"dev": true
},
"ramda": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz",
"integrity": "sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ=="
},
"randomatic": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz",

View File

@ -19,6 +19,9 @@
},
"homepage": "https://github.com/rbkmoney/wallet-utils#readme",
"devDependencies": {
"@types/ismobilejs": "~0.4.1",
"@types/lodash-es": "~4.17.0",
"@types/ramda": "~0.25.24",
"@types/react": "~16.3.11",
"@types/react-dom": "~16.0.5",
"cache-loader": "~1.2.2",
@ -47,6 +50,9 @@
"write-file-webpack-plugin": "~4.2.0"
},
"dependencies": {
"ismobilejs": "~0.4.1",
"lodash-es": "~4.17.8",
"ramda": "~0.25.0",
"react": "~16.3.2",
"react-dom": "~16.3.2",
"redux-form": "~7.3.0",

View File

@ -1,13 +1,17 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Child } from '../communication';
import './styles/main.scss';
const app = document.getElementById('app');
/* tslint:disable:no-expression-statement */
ReactDOM.render(
<div>start</div>,
app
);
/* tslint:enable:no-expression-statement */
Child.resolve()
.then((transport) => {
/* tslint:disable:no-expression-statement */
ReactDOM.render(
<div>start</div>,
app
);
/* tslint:enable:no-expression-statement */
});

View File

@ -0,0 +1,29 @@
import { ContextResolver, RealTransport, StubTransport, Transport, TransportInfo } from '.';
import { isInFrame } from '../is-in-iframe';
export class Child {
static resolve(): Promise<Transport> {
return new Promise((resolve) => {
if (ContextResolver.isAvailable() && window.opener) {
const target = window.opener;
const context = ContextResolver.get();
return resolve(new RealTransport(target, context.parentOrigin, window));
} else if (isInFrame() && !window.opener) {
return resolve(new StubTransport());
} else {
const shake = (e: MessageEvent) => {
if (e && e.data === TransportInfo.parentHandshakeMessageName) {
const target = e.source;
target.postMessage(TransportInfo.childHandshakeMessageName, e.origin);
ContextResolver.set({
parentOrigin: e.origin
});
return resolve(new RealTransport(target, e.origin, window));
}
};
window.addEventListener('message', shake, false);
}
});
}
}

View File

@ -0,0 +1,22 @@
export class ContextResolver {
static set(context: any): void {
try {
sessionStorage.setItem(this.key, JSON.stringify(context));
} catch (e) {}
}
static get(): any {
try {
return JSON.parse(sessionStorage.getItem(this.key));
} catch (e) {}
}
static isAvailable(): boolean {
try {
return !!JSON.parse(sessionStorage.getItem(this.key));
} catch (e) {}
}
private static key = 'wallet-context';
}

View File

@ -0,0 +1,9 @@
export * from './child';
export * from './context-resolver';
export * from './transport';
export * from './real-transport';
export * from './stub-transport';
export * from './model/transport-info';
export * from './model/possible-events';
export * from './model/transport-message';
export * from './parent';

View File

@ -0,0 +1,4 @@
export enum ActionType {
userIdentity = 'userIdentity',
createOutput = 'createOutput'
}

View File

@ -0,0 +1,6 @@
import { InitializerData } from '.';
import { CreateOutputParams } from '../../initializer/model';
export interface CreateOutputInitializerData extends InitializerData {
params: CreateOutputParams;
}

View File

@ -0,0 +1,7 @@
export * from './possible-events';
export * from './transport-info';
export * from './transport-message';
export * from './action-type';
export * from './user-identity-initializer-data';
export * from './initializer-data';
export * from './create-output-initializer-data';

View File

@ -0,0 +1,6 @@
import { ActionType } from '.';
export interface InitializerData {
type: ActionType;
token: string;
}

View File

@ -0,0 +1,9 @@
export enum PossibleEvents {
init = 'init',
onCancel = 'onCancel',
onCompleteIdentityChallenge = 'onCompleteIdentityChallenge',
onFailIdentityChallenge = 'onFailIdentityChallenge',
onCancelIdentityChallenge = 'onCancelIdentityChallenge',
onCreateOutput = 'onCreateOutput',
abort = 'abort'
}

View File

@ -0,0 +1,5 @@
export enum TransportInfo {
transportName = 'rbkmoney-wallet',
parentHandshakeMessageName = 'rbkmoney-wallet-parent-handshake',
childHandshakeMessageName = 'rbkmoney-wallet-child-handshake'
}

View File

@ -0,0 +1,7 @@
import { TransportInfo } from '../index';
export class TransportMessage {
data: any;
name: string;
transport: TransportInfo;
}

View File

@ -0,0 +1,6 @@
import { StartIdentityChallengeParams } from '../../initializer/model';
import { InitializerData } from '.';
export interface UserIdentityInitializerData extends InitializerData {
params: StartIdentityChallengeParams;
}

View File

@ -0,0 +1,40 @@
import { RealTransport } from './real-transport';
import { Transport } from './transport';
import { TransportInfo } from './model/transport-info';
export class Parent {
private target: Window;
private origin: string;
private parent: Window;
constructor(target: Window, origin: string) {
this.target = target;
this.origin = origin;
this.parent = window;
}
sendHandshake(): Promise<Transport> {
let interval: any;
return new Promise((resolve, reject) => {
const reply = (e: any) => {
if (e.data === TransportInfo.childHandshakeMessageName) {
clearInterval(interval);
return resolve(new RealTransport(this.target, this.origin, this.parent));
}
};
this.parent.addEventListener('message', reply, false);
let attempt = 0;
const maxHandshakeRequests = 20;
const doSend = () => {
attempt++;
this.target.postMessage(TransportInfo.parentHandshakeMessageName, this.origin);
if (attempt === maxHandshakeRequests) {
clearInterval(interval);
return reject('failed handshake');
}
};
interval = setInterval(doSend, 500);
});
}
}

View File

@ -0,0 +1,44 @@
import { PossibleEvents, Transport, TransportInfo, TransportMessage } from '.';
export class RealTransport implements Transport {
private target: Window;
private origin: string;
private events: any = {};
constructor(target: Window, origin: string, source: Window) {
this.target = target;
this.origin = origin;
source.addEventListener('message', this.listener.bind(this), false);
}
emit(name: PossibleEvents, data?: any): void {
const serialized = JSON.stringify({
data,
name,
transport: TransportInfo.transportName
} as TransportMessage);
this.target.postMessage(serialized, this.origin);
}
on(eventName: PossibleEvents, callback: (data: any) => any): void {
this.events[eventName] = callback;
}
destroy(): void {
window.removeEventListener('message', this.listener.bind(this), false);
}
private listener(e: MessageEvent): void {
let parsed: TransportMessage;
try {
parsed = JSON.parse(e.data);
/* tslint:disable:no-empty */
} catch (e) {}
if (parsed && (parsed.name in this.events)) {
if (parsed.transport === TransportInfo.transportName) {
this.events[parsed.name].call(this, parsed.data);
}
}
}
}

View File

@ -0,0 +1,17 @@
import { PossibleEvents, Transport } from '.';
export class StubTransport implements Transport {
emit(name: PossibleEvents, data: any): void {
console.info('transport stub emit: ', name, data);
}
on(eventName: PossibleEvents, callback: (data: any) => any): void {
callback({});
console.info('transport stub on: ', eventName, callback);
}
destroy(): void {
console.info('transport stub destroy');
}
}

View File

@ -0,0 +1,9 @@
import { PossibleEvents } from './model/index';
export interface Transport {
emit(name: PossibleEvents, data?: any): void;
on(eventName: PossibleEvents, callback: (data: any) => any): void;
destroy(): void;
}

9
src/get-origin.ts Normal file
View File

@ -0,0 +1,9 @@
export const ieCurrentScriptStub = {
src: 'https://wallets.rbk.money/wallet-utils.js' // TODO: поправить домен!!!
};
const getCurrentScript = (): HTMLScriptElement =>
(document.currentScript || ieCurrentScriptStub) as HTMLScriptElement;
export const getOrigin = (): string =>
new URL(getCurrentScript().src).origin;

View File

@ -0,0 +1,68 @@
import assign from 'lodash-es/assign';
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
const generateId = () => `${s4()}${s4()}${s4()}${s4()}`;
const styles = {
overflowX: 'hidden',
overflowY: 'auto',
visibility: 'visible',
border: '0 none transparent',
display: 'none',
margin: '0px',
padding: '0px',
position: 'fixed',
left: '0',
top: '0',
width: '100%',
height: '100%',
zIndex: 2147483647
};
const create = (origin: string): HTMLIFrameElement => {
const iframe = document.createElement('iframe');
iframe.setAttribute('src', `${origin}/v1/app.html`);
iframe.setAttribute('name', `rbkmoney-wallet-${generateId()}`);
iframe.setAttribute('class', 'rbkmoney-wallet');
iframe.setAttribute('allowtransparency', 'true');
iframe.setAttribute('frameborder', '0');
assign(iframe.style, styles);
return iframe;
};
export class IframeContainer {
private container: HTMLIFrameElement;
constructor(origin: string) {
this.container = create(origin);
this.render();
}
reinitialize(): void {
this.hide();
this.destroy();
this.render();
}
render(): void {
document.body.appendChild(this.container);
}
destroy(): void {
document.body.removeChild(this.container);
}
show(): void {
this.container.style.display = 'block';
}
hide(): void {
this.container.style.display = 'none';
}
getName(): string {
return this.container.getAttribute('name');
}
}

View File

@ -0,0 +1,31 @@
import { IframeContainer } from './iframe-container';
import { Parent, PossibleEvents, Transport } from '../communication';
import { Initializer } from './initializer';
import { InitializerData } from '../communication/model';
export class IframeInitializer extends Initializer {
private container: IframeContainer;
constructor(protected accessToken: string, protected origin: string) {
super(accessToken, origin);
this.container = new IframeContainer(origin);
}
open(data: InitializerData): Promise<Transport> {
const target = (window.frames as any)[this.container.getName()];
this.container.show();
const parent = new Parent(target, this.origin);
return new Promise((resolve) => {
return parent.sendHandshake().then((transport) => {
transport.emit(PossibleEvents.init, {data});
resolve(transport);
});
});
}
close(): void {
this.container.reinitialize();
}
}

View File

@ -0,0 +1,123 @@
import * as isMobile from 'ismobilejs';
import isString from 'lodash-es/isString';
import isFunction from 'lodash-es/isFunction';
import { Initializer } from './initializer';
import { PopupInitializer } from './popup-initializer';
import { IframeInitializer } from './iframe-initializer';
import {
StartIdentityChallengeParams,
CancelEvent,
IdentityChallengeEvent,
WalletUtilsEvent,
CreateOutputEvent,
CreateOutputParams
} from './model';
import { getOrigin } from '../get-origin';
import { PossibleEvents, Transport } from '../communication';
import { ActionType, UserIdentityInitializerData, CreateOutputInitializerData } from '../communication/model';
const logPrefix = '[RBKmoney wallet utils]';
const origin = getOrigin();
const getInitializer = (accessToken: string): Initializer =>
isMobile.any
? new PopupInitializer(accessToken, origin)
: new IframeInitializer(accessToken, origin);
const toIdentityInitializerData = (token: string, params: StartIdentityChallengeParams): UserIdentityInitializerData => {
if (!params) {
throw new Error(`${logPrefix}: Missing StartIdentityChallengeParams`);
}
if (!isString(params.identityID)) {
throw new Error(`${logPrefix}: Wrong identityID`);
}
return {
token,
params,
type: ActionType.userIdentity
};
};
const toOutputInitializerData = (token: string, params: CreateOutputParams): CreateOutputInitializerData => {
if (!params) {
throw new Error(`${logPrefix}: Missing CreateOutputParams`);
}
if (!isString(params.identityID)) {
throw new Error(`${logPrefix}: Wrong identityID`);
}
if (!isString(params.name)) {
throw new Error(`${logPrefix}: Wrong name`);
}
return {
token,
params,
type: ActionType.createOutput
};
};
export class RbkmoneyWalletUtils {
onCompleteIdentityChallenge: (event: IdentityChallengeEvent) => void;
onFailIdentityChallenge: (event: IdentityChallengeEvent) => void;
onCancelIdentityChallenge: (event: IdentityChallengeEvent) => void;
onCreateOutput: (event: CreateOutputEvent) => void;
onCancel: (event: CancelEvent) => void;
private initializer: Initializer;
constructor(private readonly token: string) {
if (!isString(token)) {
throw new Error(`${logPrefix}: Wrong param token`);
}
this.token = token;
this.initializer = getInitializer(token);
}
startIdentityChallenge(params: StartIdentityChallengeParams): void {
const data = toIdentityInitializerData(this.token, params);
this.initializer.open(data)
.then((transport: Transport) => {
transport.on(PossibleEvents.onCompleteIdentityChallenge, (e) =>
this.provideCallback(this.onCompleteIdentityChallenge, {
data: e.data
}));
transport.on(PossibleEvents.onFailIdentityChallenge, (e) =>
this.provideCallback(this.onFailIdentityChallenge, {
data: e.data
}));
transport.on(PossibleEvents.onCancelIdentityChallenge, (e) =>
this.provideCallback(this.onCancelIdentityChallenge, {
data: e.data
}));
transport.on(PossibleEvents.onCancel, () => {
this.provideCallback(this.onCancel, {});
});
})
.catch((e) => this.provideCallback(this.onCancel, {error: e}));
}
createOutput(params: CreateOutputParams): void {
const data = toOutputInitializerData(this.token, params);
this.initializer.open(data)
.then((transport: Transport) => {
transport.on(PossibleEvents.onCreateOutput, (e) =>
this.provideCallback(this.onCreateOutput, {
data: e.data
}));
})
.catch((e) => this.provideCallback(this.onCancel, {error: e}));
}
private provideCallback(callback: (data: WalletUtilsEvent) => void, data: any): void {
if (isFunction(callback)) {
callback({
target: this,
...data
});
}
}
}
/* tslint:disable */
(window as any).RbkmoneyWalletUtils = RbkmoneyWalletUtils;
/* tslint:enable */

View File

@ -0,0 +1,11 @@
import { Transport } from '../communication';
import { InitializerData } from '../communication/model';
export abstract class Initializer {
protected constructor(protected readonly token: string, protected readonly origin: string) {}
abstract open(data: InitializerData): Promise<Transport>;
abstract close(): void;
}

View File

@ -0,0 +1,4 @@
export enum CancelEventType {
uiDismissing = 'uiDismissing',
error = 'error'
}

View File

@ -0,0 +1,9 @@
import { WalletUtilsEvent } from './wallet-utils-event';
export interface CancelEvent extends WalletUtilsEvent {
type: CancelEventType;
}
export enum CancelEventType {
cancel = 'cancel'
}

View File

@ -0,0 +1,5 @@
import { Output, WalletUtilsEvent } from '.';
export interface CreateOutputEvent extends WalletUtilsEvent {
output: Output;
}

View File

@ -0,0 +1,4 @@
export interface CreateOutputParams {
identityID: string;
name: string;
}

View File

@ -0,0 +1,5 @@
import { LogicError, CancelEvent } from '.';
export interface ErrorCancelEvent extends CancelEvent {
error: LogicError;
}

View File

@ -0,0 +1,6 @@
import { WalletUtilsEvent } from './wallet-utils-event';
import { IdentityChallenge } from './identity-challenge';
export interface IdentityChallengeEvent extends WalletUtilsEvent {
identityChallenge: IdentityChallenge;
}

View File

@ -0,0 +1 @@
export type IdentityChallenge = any;

View File

@ -0,0 +1,10 @@
export * from './cancel-event';
export * from './create-output-event';
export * from './create-output-params';
export * from './identity-challenge';
export * from './identity-challenge-event';
export * from './start-identity-challenge-params';
export * from './wallet-utils-event';
export * from './output';
export * from './error-cancel-event';
export * from './logic-error';

View File

@ -0,0 +1,4 @@
export interface LogicError {
code: string;
message: string;
}

View File

@ -0,0 +1 @@
export type Output = any;

View File

@ -0,0 +1,8 @@
declare enum IdentityLevel {
partial = 'partial'
}
export interface StartIdentityChallengeParams {
readonly identityID: string;
readonly level?: IdentityLevel;
}

View File

@ -0,0 +1,5 @@
import { RbkmoneyWalletUtils } from '../index';
export interface WalletUtilsEvent {
target: RbkmoneyWalletUtils;
}

View File

@ -0,0 +1,40 @@
import { Parent, Transport } from '../communication';
import { Initializer } from './initializer';
import { InitializerData } from '../communication/model';
const serialize = (params: any): string => {
let urlParams = '';
for (const prop in params) {
if (params.hasOwnProperty(prop)) {
const value = params[prop];
if ((typeof value === 'function') || (value === undefined) || (value === null)) {
continue;
}
if (urlParams !== '') {
urlParams += '&';
}
urlParams += `${prop}=${encodeURIComponent(value)}`;
}
}
return urlParams;
};
export class PopupInitializer extends Initializer {
constructor(protected accessToken: string, protected origin: string) {
super(accessToken, origin);
}
open(data: InitializerData): Promise<Transport> {
const url = `${this.origin}/v1/app.html?${serialize(data)}`;
const target = window.open(url);
const parent = new Parent(target, this.origin);
return new Promise((resolve) => {
return parent.sendHandshake().then((transport) => {
resolve(transport);
});
});
}
close(): void {}
}

7
src/is-in-iframe.ts Normal file
View File

@ -0,0 +1,7 @@
export const isInFrame = (): boolean => {
try {
return window.self === window.top;
} catch (e) {
return false;
}
};

View File

@ -10,22 +10,23 @@
"object-literal-sort-keys": false,
"interface-name": false,
"no-namespace": false,
"no-empty": false,
"no-var-keyword": true,
"no-parameter-reassignment": true,
"typedef": [true, "call-signature"],
"readonly-keyword": true,
"readonly-keyword": false,
"readonly-array": true,
"no-let": true,
"no-object-mutation": true,
"no-let": false,
"no-object-mutation": false,
"no-delete": true,
"no-method-signature": true,
"no-method-signature": false,
"no-this": true,
"no-class": true,
"no-this": false,
"no-class": false,
"no-mixed-interface": true,
"no-expression-statement": true,
"no-if-statement": true
"no-expression-statement": false,
"no-if-statement": false
}
}