import React from "react";
import {createRoot} from "react-dom/client";
import {Provider} from "react-redux";
import {Router} from "react-router-dom";
import axios from "axios";
import {NavigationGuard} from "./NavigationGuard";
import {app} from "../app";
import {executeAction, type ErrorListener} from "../module";
import {type Action, idleTimeoutAction} from "../reducer";
import {APIException} from "../Exception";
import {call, delay, type SagaGenerator} from "../typed-saga";
import {ErrorBoundary} from "../util/ErrorBoundary";
import {ajax} from "../util/network";
import {isBrowserSupported, isIOS} from "../util/navigator-util";
import {captureError, errorToException} from "../util/error-util";
import {DEFAULT_IDLE_TIMEOUT, IdleDetector} from "../util/IdleDetector";
import type {Location} from "history";
import type {LoggerConfig} from "../Logger";
/**
* Configuration for frontend version check.
* If the `versionCheckURL` API response changes, `onRemind` will be executed.
*/
interface VersionCheckConfig {
onRemind: () => SagaGenerator;
versionCheckURL: string; // Must be GET Method, returning whatever JSON
frequencyInSecond?: number; // Default: 600 (10 min)
}
/**
* Configuration for browser related features.
* - onOldBrowserDetected: Alert to user or redirect when using unsupported legacy browser.
* - onLocationChange: A global event handler for any location change events.
* - navigationPreventionMessage: Only useful if you are leaving some page, whose "setNavigationPrevented" is toggled as true.
*/
interface BrowserConfig {
onOldBrowserDetected?: () => void;
onLocationChange?: (location: Location) => void;
navigationPreventionMessage?: string;
}
interface BootstrapOption {
componentType: React.ComponentType;
errorListener: ErrorListener;
rootContainer?: HTMLElement;
browserConfig?: BrowserConfig;
loggerConfig?: LoggerConfig;
versionConfig?: VersionCheckConfig;
idleTimeoutInSecond?: number; // Default: 5 min, never Idle if non-positive value given
}
export const LOGGER_ACTION = "@@framework/logger";
export const VERSION_CHECK_ACTION = "@@framework/version-check";
export const GLOBAL_ERROR_ACTION = "@@framework/global";
export const GLOBAL_PROMISE_REJECTION_ACTION = "@@framework/promise-rejection";
export function bootstrap(option: BootstrapOption): void {
detectOldBrowser(option.browserConfig?.onOldBrowserDetected);
setupGlobalErrorHandler(option.errorListener);
setupAppExitListener(option.loggerConfig?.serverURL);
setupLocationChangeListener(option.browserConfig?.onLocationChange);
setupIdleTimeout(option.idleTimeoutInSecond ?? DEFAULT_IDLE_TIMEOUT);
runBackgroundLoop(option.loggerConfig, option.versionConfig);
renderRoot(option.componentType, option.rootContainer || injectRootContainer(), option.browserConfig?.navigationPreventionMessage || "Are you sure to leave current page?");
}
function detectOldBrowser(onOldBrowserDetected?: () => void) {
if (!isBrowserSupported()) {
if (onOldBrowserDetected) {
onOldBrowserDetected();
} else {
let alertMessage: string;
const navigatorLanguage = navigator.languages ? navigator.languages[0] : navigator.language;
if (navigatorLanguage.startsWith("zh")) {
alertMessage = "对不起,您的浏览器版本太老不支持\n请使用最新版的浏览器再访问";
} else {
alertMessage = "Your browser is not supported.\nPlease upgrade your browser to latest and visit again.\nSorry for the inconvenience.";
}
alert(alertMessage);
}
// After that, the following code may still run
}
}
function setupGlobalErrorHandler(errorListener: ErrorListener) {
app.errorHandler = errorListener.onError.bind(errorListener);
window.addEventListener(
"error",
event => {
try {
const analyzeByTarget = (): string => {
if (event.target && event.target !== window) {
const element = event.target as HTMLElement;
return `DOM source error: ${element.outerHTML}`;
}
return `Unrecognized error, serialized as ${JSON.stringify(event)}`;
};
captureError(event.error || event.message || analyzeByTarget(), GLOBAL_ERROR_ACTION);
} catch (e) {
/**
* This should not happen normally.
* However, global error handler might catch external webpage errors, and fail to parse error due to cross-origin limitations.
* A typical example is: Permission denied to access property `foo`
*/
app.logger.warn({
action: GLOBAL_ERROR_ACTION,
errorCode: "ERROR_HANDLER_FAILURE",
errorMessage: errorToException(e).message,
elapsedTime: 0,
info: {},
});
}
},
true
);
window.addEventListener(
"unhandledrejection",
event => {
try {
captureError(event.reason, GLOBAL_PROMISE_REJECTION_ACTION);
} catch (e) {
app.logger.warn({
action: GLOBAL_PROMISE_REJECTION_ACTION,
errorCode: "ERROR_HANDLER_FAILURE",
errorMessage: errorToException(e).message,
elapsedTime: 0,
info: {},
});
}
},
true
);
}
function renderRoot(EntryComponent: React.ComponentType, rootContainer: HTMLElement, navigationPreventionMessage: string) {
const root = createRoot(rootContainer);
root.render(
);
}
function injectRootContainer(): HTMLElement {
const rootContainer = document.createElement("div");
rootContainer.id = "framework-app-root";
document.body.appendChild(rootContainer);
return rootContainer;
}
function setupAppExitListener(eventServerURL?: string) {
if (eventServerURL) {
// ref: https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW5
window.addEventListener(
isIOS() ? "pagehide" : "unload",
() => {
try {
app.logger.info({action: "@@EXIT"});
const logs = app.logger.collect();
/**
* navigator.sendBeacon() uses HTTP POST, but does not support CORS.
* We have to use text/plain as content type, instead of JSON.
*/
const textData = JSON.stringify({events: logs});
navigator.sendBeacon(eventServerURL, textData);
} catch (e) {
// Silent if sending error
}
},
false
);
}
}
function setupLocationChangeListener(listener?: (location: Location) => void) {
if (listener) {
app.history.listen(listener);
}
}
function setupIdleTimeout(timeout: number) {
app.store.dispatch>(idleTimeoutAction(timeout));
}
function runBackgroundLoop(loggerConfig?: LoggerConfig, versionCheckConfig?: VersionCheckConfig) {
app.logger.info({action: "@@ENTER"});
app.loggerConfig = loggerConfig || null;
if (loggerConfig) {
app.sagaMiddleware.run(function* () {
while (true) {
yield delay((loggerConfig.frequencyInSecond || 20) * 1000);
yield* call(sendEventLogs);
}
});
}
if (versionCheckConfig) {
app.sagaMiddleware.run(function* () {
let lastChecksum: string | null = null;
while (true) {
const newChecksum = yield* call(fetchVersionChecksum, versionCheckConfig.versionCheckURL);
if (newChecksum) {
if (lastChecksum !== null && newChecksum !== lastChecksum) {
yield* executeAction(VERSION_CHECK_ACTION, versionCheckConfig.onRemind);
}
lastChecksum = newChecksum;
}
yield delay((versionCheckConfig.frequencyInSecond || 600) * 1000);
}
});
}
}
export async function sendEventLogs(): Promise {
if (app.loggerConfig) {
const logs = app.logger.collect(200);
const logLength = logs.length;
if (logLength > 0) {
try {
/**
* Event server URL may be different from current domain (supposing abc.com)
*
* In order to support this, we must ensure:
* - Event server allows cross-origin request from current domain
* - Root-domain cookies, whose domain is set by current domain as ".abc.com", can be sent (withCredentials = true)
*/
await ajax("POST", app.loggerConfig.serverURL, {}, {events: logs}, {withCredentials: true});
app.logger.emptyLastCollection();
} catch (e) {
if (e instanceof APIException) {
// For APIException, retry always leads to same error, so have to give up
// Do not log network exceptions
app.logger.emptyLastCollection();
app.logger.exception(e, {
action: LOGGER_ACTION,
info: {dropped_logs: logLength.toString()},
});
}
}
}
}
}
/**
* Only call this function if necessary, i.e: initial checksum, or after long-staying check
* Return latest checksum, or null for failure.
*/
async function fetchVersionChecksum(url: string): Promise {
const startTime = Date.now();
try {
const response = await axios.get(url);
const checksum = JSON.stringify(response.data);
app.logger.info({
action: VERSION_CHECK_ACTION,
elapsedTime: Date.now() - startTime,
info: {checksum},
});
return checksum;
} catch (e) {
app.logger.warn({
action: VERSION_CHECK_ACTION,
elapsedTime: Date.now() - startTime,
errorCode: "VERSION_CHECK_FAILURE",
errorMessage: errorToException(e).message,
});
return null;
}
}