/
Flow.ts
434 lines (379 loc) · 14.2 KB
/
Flow.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
import { ConnectionIndicator } from './ConnectionIndicator';
import { ConnectionState, ConnectionStateChangeListener, ConnectionStateStore } from './ConnectionState';
export interface FlowConfig {
imports?: () => void;
}
interface AppConfig {
productionMode: boolean;
appId: string;
uidl: object;
clientRouting: boolean;
}
interface AppInitResponse {
appConfig: AppConfig;
pushScript?: string;
}
interface Router {
render: (ctx: NavigationParameters, shouldUpdateHistory: boolean) => Promise<void>;
}
interface HTMLRouterContainer extends HTMLElement {
onBeforeEnter?: (ctx: NavigationParameters, cmd: PreventAndRedirectCommands, router: Router) => void | Promise<any>;
onBeforeLeave?: (ctx: NavigationParameters, cmd: PreventCommands, router: Router) => void | Promise<any>;
serverConnected?: (cancel: boolean, url?: NavigationParameters) => void;
}
interface FlowRoute {
action: (params: NavigationParameters) => Promise<HTMLRouterContainer>;
path: string;
}
interface FlowRoot {
$: any;
$server: any;
}
export interface NavigationParameters {
pathname: string;
search: string;
}
export interface PreventCommands {
prevent: () => any;
}
export interface PreventAndRedirectCommands extends PreventCommands {
redirect: (route: string) => any;
}
// flow uses body for keeping references
const flowRoot: FlowRoot = window.document.body as any;
const $wnd = (window as any) as {
Vaadin: {
Flow: any;
TypeScript: any;
connectionState: ConnectionStateStore;
};
} & EventTarget;
/**
* Client API for flow UI operations.
*/
export class Flow {
config: FlowConfig;
response?: AppInitResponse = undefined;
pathname = '';
// @ts-ignore
container: HTMLRouterContainer;
// flag used to inform Testbench whether a server route is in progress
private isActive = false;
private baseRegex = /^\//;
private appShellTitle: string;
constructor(config?: FlowConfig) {
flowRoot.$ = flowRoot.$ || [];
this.config = config || {};
// TB checks for the existence of window.Vaadin.Flow in order
// to consider that TB needs to wait for `initFlow()`.
$wnd.Vaadin = $wnd.Vaadin || {};
$wnd.Vaadin.Flow = $wnd.Vaadin.Flow || {};
$wnd.Vaadin.Flow.clients = {
TypeScript: {
isActive: () => this.isActive
}
};
// Regular expression used to remove the app-context
const elm = document.head.querySelector('base');
this.baseRegex = new RegExp(
'^' +
// IE11 does not support document.baseURI
(document.baseURI || (elm && elm.href) || '/').replace(/^https?:\/\/[^/]+/i, '')
);
this.appShellTitle = document.title;
// Put a vaadin-connection-indicator in the dom
this.addConnectionIndicator();
}
/**
* Return a `route` object for vaadin-router in an one-element array.
*
* The `FlowRoute` object `path` property handles any route,
* and the `action` returns the flow container without updating the content,
* delaying the actual Flow server call to the `onBeforeEnter` phase.
*
* This is a specific API for its use with `vaadin-router`.
*/
get serverSideRoutes(): [FlowRoute] {
return [
{
path: '(.*)',
action: this.action
}
];
}
loadingStarted() {
// Make Testbench know that server request is in progress
this.isActive = true;
$wnd.Vaadin.connectionState.loadingStarted();
}
loadingFinished() {
// Make Testbench know that server request has finished
this.isActive = false;
$wnd.Vaadin.connectionState.loadingFinished();
}
private get action(): (params: NavigationParameters) => Promise<HTMLRouterContainer> {
// Return a function which is bound to the flow instance, thus we can use
// the syntax `...serverSideRoutes` in vaadin-router.
// @ts-ignore
return async (params: NavigationParameters) => {
// Store last action pathname so as we can check it in events
this.pathname = params.pathname;
if ($wnd.Vaadin.connectionState.online) {
// @ts-ignore
try {
await this.flowInit();
} catch (error) {
if (error instanceof FlowUiInitializationError) {
// error initializing Flow: assume connection lost
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTION_LOST;
return this.offlineStubAction();
} else {
throw error;
}
}
} else {
// insert an offline stub
return this.offlineStubAction();
}
// When an action happens, navigation will be resolved `onBeforeEnter`
this.container.onBeforeEnter = (ctx, cmd) => this.flowNavigate(ctx, cmd);
// For covering the 'server -> client' use case
this.container.onBeforeLeave = (ctx, cmd) => this.flowLeave(ctx, cmd);
return this.container;
};
}
// Send a remote call to `JavaScriptBootstrapUI` to check
// whether navigation has to be cancelled.
private async flowLeave(
// @ts-ignore
ctx: NavigationParameters,
cmd?: PreventCommands
): Promise<any> {
// server -> server, viewing offline stub, or browser is offline
const connectionState = $wnd.Vaadin.connectionState;
if (this.pathname === ctx.pathname || !this.isFlowClientLoaded() || connectionState.offline) {
return Promise.resolve({});
}
// 'server -> client'
return new Promise((resolve) => {
this.loadingStarted();
// The callback to run from server side to cancel navigation
this.container.serverConnected = (cancel) => {
resolve(cmd && cancel ? cmd.prevent() : {});
this.loadingFinished();
};
// Call server side to check whether we can leave the view
flowRoot.$server.leaveNavigation(this.getFlowRoute(ctx));
});
}
// Send the remote call to `JavaScriptBootstrapUI` to render the flow
// route specified by the context
private async flowNavigate(ctx: NavigationParameters, cmd?: PreventAndRedirectCommands): Promise<HTMLElement> {
if (this.response) {
return new Promise((resolve) => {
this.loadingStarted();
// The callback to run from server side once the view is ready
this.container.serverConnected = (cancel, redirectContext?: NavigationParameters) => {
if (cmd && cancel) {
resolve(cmd.prevent());
} else if (cmd && cmd.redirect && redirectContext) {
resolve(cmd.redirect(redirectContext.pathname));
} else {
this.container.style.display = '';
resolve(this.container);
}
this.loadingFinished();
};
// Call server side to navigate to the given route
flowRoot.$server.connectClient(
this.container.localName,
this.container.id,
this.getFlowRoute(ctx),
this.appShellTitle
);
});
} else {
// No server response => offline or erroneous connection
return Promise.resolve(this.container);
}
}
private getFlowRoute(context: NavigationParameters | Location): string {
return (context.pathname + (context.search || '')).replace(this.baseRegex, '');
}
// import flow client modules and initialize UI in server side.
private async flowInit(serverSideRouting = false): Promise<AppInitResponse> {
// Do not start flow twice
if (!this.isFlowClientLoaded()) {
// show flow progress indicator
this.loadingStarted();
// Initialize server side UI
this.response = await this.flowInitUi(serverSideRouting);
// Enable or disable server side routing
this.response.appConfig.clientRouting = !serverSideRouting;
const { pushScript, appConfig } = this.response;
if (typeof pushScript === 'string') {
await this.loadScript(pushScript);
}
const { appId } = appConfig;
// Load bootstrap script with server side parameters
const bootstrapMod = await import('./FlowBootstrap');
await bootstrapMod.init(this.response);
// Load custom modules defined by user
if (typeof this.config.imports === 'function') {
this.injectAppIdScript(appId);
await this.config.imports();
}
// Load flow-client module
const clientMod = await import('./FlowClient');
await this.flowInitClient(clientMod);
if (!serverSideRouting) {
// we use a custom tag for the flow app container
const tag = `flow-container-${appId.toLowerCase()}`;
this.container = flowRoot.$[appId] = document.createElement(tag);
this.container.id = appId;
// It might be that components created from server expect that their content has been rendered.
// Appending eagerly the container we avoid these kind of errors.
// Note that the client router will move this container to the outlet if the navigation succeed
this.container.style.display = 'none';
document.body.appendChild(this.container);
}
// hide flow progress indicator
this.loadingFinished();
}
return this.response!;
}
private async loadScript(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = () => resolve();
script.onerror = reject;
script.src = url;
document.body.appendChild(script);
});
}
private injectAppIdScript(appId: string) {
const appIdWithoutHashCode = appId.substring(0, appId.lastIndexOf('-'));
const scriptAppId = document.createElement('script');
scriptAppId.type = 'module';
scriptAppId.setAttribute('data-app-id', appIdWithoutHashCode);
document.body.append(scriptAppId);
}
// After the flow-client javascript module has been loaded, this initializes flow UI
// in the browser.
private async flowInitClient(clientMod: any): Promise<void> {
clientMod.init();
// client init is async, we need to loop until initialized
return new Promise((resolve) => {
const intervalId = setInterval(() => {
// client `isActive() == true` while initializing or processing
const initializing = Object.keys($wnd.Vaadin.Flow.clients)
.filter((key) => key !== 'TypeScript')
.reduce((prev, id) => prev || $wnd.Vaadin.Flow.clients[id].isActive(), false);
if (!initializing) {
clearInterval(intervalId);
resolve();
}
}, 5);
});
}
// Returns the `appConfig` object
private async flowInitUi(serverSideRouting: boolean): Promise<AppInitResponse> {
// appConfig was sent in the index.html request
const initial = $wnd.Vaadin && $wnd.Vaadin.TypeScript && $wnd.Vaadin.TypeScript.initial;
if (initial) {
$wnd.Vaadin.TypeScript.initial = undefined;
return Promise.resolve(initial);
}
// send a request to the `JavaScriptBootstrapHandler`
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const httpRequest = xhr as any;
const currentPath = location.pathname || '/';
const requestPath =
`${currentPath}?v-r=init` + (serverSideRouting ? `&location=${encodeURI(this.getFlowRoute(location))}` : '');
httpRequest.open('GET', requestPath);
httpRequest.onerror = () =>
reject(
new FlowUiInitializationError(
`Invalid server response when initializing Flow UI.
${httpRequest.status}
${httpRequest.responseText}`
)
);
httpRequest.onload = () => {
const contentType = httpRequest.getResponseHeader('content-type');
if (contentType && contentType.indexOf('application/json') !== -1) {
resolve(JSON.parse(httpRequest.responseText));
} else {
httpRequest.onerror();
}
};
httpRequest.send();
});
}
// Create shared connection state store and connection indicator
private addConnectionIndicator() {
// add connection indicator to DOM
ConnectionIndicator.create();
// Listen to browser online/offline events and update the loading indicator accordingly.
// Note: if flow-client is loaded, it instead handles the state transitions.
$wnd.addEventListener('online', () => {
if (!this.isFlowClientLoaded()) {
// Send an HTTP HEAD request for sw.js to verify server reachability.
// We do not expect sw.js to be cached, so the request goes to the
// server rather than being served from local cache.
// Require network-level failure to revert the state to CONNECTION_LOST
// (HTTP error code is ok since it still verifies server's presence).
$wnd.Vaadin.connectionState.state = ConnectionState.RECONNECTING;
const http = new XMLHttpRequest();
const serverRoot = location.pathname || '/';
http.open('HEAD', serverRoot + (serverRoot.endsWith('/') ? '' : ' /') + 'sw.js');
http.onload = () => {
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTED;
};
http.onerror = () => {
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTION_LOST;
};
http.send();
}
});
$wnd.addEventListener('offline', () => {
if (!this.isFlowClientLoaded()) {
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTION_LOST;
}
});
}
private async offlineStubAction() {
await import('./OfflineStub');
const offlineStub = document.createElement('vaadin-offline-stub') as HTMLRouterContainer;
this.response = undefined;
let onlineListener: ConnectionStateChangeListener | undefined;
const removeOfflineStubAndOnlineListener = () => {
if (onlineListener !== undefined) {
$wnd.Vaadin.connectionState.removeStateChangeListener(onlineListener);
onlineListener = undefined;
}
};
offlineStub.onBeforeEnter = (ctx, _cmds, router) => {
onlineListener = () => {
if ($wnd.Vaadin.connectionState.online) {
removeOfflineStubAndOnlineListener();
router.render(ctx, false);
}
};
$wnd.Vaadin.connectionState.addStateChangeListener(onlineListener);
};
offlineStub.onBeforeLeave = (_ctx, _cmds, _router) => {
removeOfflineStubAndOnlineListener();
};
return offlineStub;
}
private isFlowClientLoaded(): boolean {
return this.response !== undefined;
}
}
class FlowUiInitializationError extends Error {
constructor(message: any) {
super(message);
}
}