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; } }