UNPKG

reactant-share

Version:

A framework for building shared web applications with Reactant

373 lines (362 loc) 11.2 kB
/* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable no-param-reassign */ /* eslint-disable no-shadow */ /* eslint-disable no-async-promise-executor */ import { App, createApp as createReactantApp, Renderer, ModuleRef, } from 'reactant'; import { createTransport } from 'data-transport'; import { LastAction, LastActionOptions, ILastActionOptions, } from 'reactant-last-action'; import { LOCATION_CHANGE } from 'reactant-router'; import { Config, ISharedAppOptions, Port, Transports } from './interfaces'; import { handleServer } from './server'; import { handleClient } from './client'; import { createBroadcastTransport } from './createTransport'; import { isClientName, SharedAppOptions } from './constants'; import { PortDetector } from './modules/portDetector'; import { useLock } from './lock'; import { IdentifierChecker } from './modules/identifierChecker'; import { PatchesChecker } from './modules/patchesChecker'; const createBaseApp = <T, S extends any[], R extends Renderer<S>>({ share, ...options }: Config<T, S, R>): App<T, S, R> => { options.modules ??= []; options.devOptions ??= {}; options.devOptions.enablePatches = true; options.modules.push( LastAction, { provide: LastActionOptions, useFactory: (moduleRef: ModuleRef) => { let portDetector: PortDetector; return { stateKey: `lastAction-${share.name}`, // ignore router state and isolated state sync for last action ignoreAction: (action) => { if (!portDetector) { portDetector = moduleRef.get(PortDetector); } const firstPath = action._patches?.[0]?.path[0]; return ( action.type === LOCATION_CHANGE || (firstPath && portDetector.hasIsolatedState(`${firstPath}`)) ); }, } as ILastActionOptions; }, deps: [ModuleRef], }, { provide: SharedAppOptions, useValue: share as ISharedAppOptions, }, PortDetector ); if (__DEV__) { options.modules.push(IdentifierChecker); } if (share.enablePatchesChecker) { options.modules.push(PatchesChecker); } let app: App<T, S, R>; let disposeServer: (() => void) | undefined; let disposeClient: (() => void) | undefined; const serverTransport = share.transports?.server; const clientTransport = share.transports?.client; const isServer = share.port === 'server'; const { transform } = share; share.transform = (changedPort: Port) => { const serverTransport = share.transports?.server; const clientTransport = share.transports?.client; if (changedPort === 'server') { if (!serverTransport) { throw new Error(`'transports.server' does not exist.`); } disposeServer = handleServer({ app, transport: serverTransport, disposeServer, disposeClient, enablePatchesChecker: share.enablePatchesChecker, }); } else { if (!clientTransport) { throw new Error(`'transports.client' does not exist.`); } disposeClient = handleClient({ app, transport: clientTransport, disposeServer, disposeClient, enablePatchesFilter: share.enablePatchesFilter, }); } transform?.(changedPort); }; app = createReactantApp(options); if (share.port) { if (isServer) { if (!serverTransport) { throw new Error(`'transports.server' does not exist.`); } disposeServer = handleServer({ app, transport: serverTransport, enablePatchesChecker: share.enablePatchesChecker, }); } else { if (!clientTransport) { throw new Error(`'transports.client' does not exist.`); } disposeClient = handleClient({ app, transport: clientTransport, enablePatchesFilter: share.enablePatchesFilter, }); } } return app; }; const createSharedTabApp = async <T, S extends any[], R extends Renderer<S>>( options: Config<T, S, R> ) => { if (__DEV__ && options.share.forcedSyncClient === false) { console.warn(`'forcedSyncClient' must be enabled in 'SharedTab' mode`); } options.share.forcedSyncClient = true; /** * Performance issue with broadcast-channel repo in Safari. */ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); if (isSafari && !options.share.forcedShare) { options.share.transports = {}; options.share.port = undefined; options.share.type = 'Base'; const app = createBaseApp(options); return app; } options.share.transports ??= {}; options.share.transports.client ??= createBroadcastTransport( options.share.name, options.share.enableTransportDebugger, options.share.transportLogger ); options.share.transports.server ??= createBroadcastTransport( options.share.name, options.share.enableTransportDebugger, options.share.transportLogger ); if (options.share.port) { const app = createBaseApp(options); return app; } let app: App<T, S, R>; let isServer = false; app = await Promise.race([ new Promise<App<T, S, R>>((resolve) => { // TODO: clear locks for testing in SharedTab mode useLock(`reactant-share-app-lock:${options.share.name}`, async () => { if (!app) { options.share.port = 'server'; app = createBaseApp(options); } else { options.share.transform?.('server'); } isServer = true; resolve(app); return new Promise(() => { // }); }); }), new Promise<App<T, S, R>>(async (resolve) => { // `isServer` is a variable that is not updated synchronously. // and low version safari will trigger event itself. const isClient = await options.share.transports?.client?.emit( isClientName ); if (isClient && !isServer) { options.share.port = 'client'; const app = createBaseApp(options); resolve(app); } }), ]); return app; }; /** * ## Description * * You can create an shared app with `createSharedApp()` passing app configuration, * which will asynchronously return an object including `instance`, `store`, * and `bootstrap()` method(You can run `bootstrap` to start the app inject into the browser or mobile). * * ## Example * * ```ts * import { createSharedApp, injectable, state, action, delegate, mockPairTransports } from 'reactant-share'; * * @injectable({ * name: 'counter', * }) * class Counter { * @state * count = 0; * * @action * increase() { * this.count += 1; * } * } * * export default async () => { * const transports = mockPairTransports(); * * const server = await createSharedApp({ * modules: [], * main: Counter, * render: () => {}, * share: { * name: 'counter', * type: 'Base', * port: 'server', * transports: { * server: transports[0], * }, * }, * }); * * const client = await createSharedApp({ * modules: [], * main: Counter, * render: () => {}, * share: { * name: 'counter', * type: 'Base', * port: 'client', * transports: { * client: transports[1], * }, * }, * }); * * await delegate(client.instance, 'increase', []); * * expect(client.instance.count).toBe(1); * expect(server.instance.count).toBe(1); * }; * ``` */ export const createSharedApp = async < T, S extends any[], R extends Renderer<S> >( options: Config<T, S, R> ): Promise<App<T, S, R>> => { let app: App<T, S, R>; let transports: Transports; if (typeof options.share === 'undefined') { throw new Error(`'createSharedApp(options)' should be set 'share' option.`); } // Check to minimized patch. options.share.enablePatchesChecker ??= __DEV__; // force Sync for all client options.share.forcedSyncClient ??= true; switch (options.share.type) { case 'SharedWorker': try { transports = { server: options.share.transports?.server, client: options.share.transports?.client, }; if (options.share.port === 'client' && options.share.worker) { if (__DEV__ && !transports.client) { if (!(options.share.worker instanceof SharedWorker)) { throw new Error( `'options.share.worker' is not a SharedWorker instance.` ); } } transports.client ??= createTransport('SharedWorkerClient', { worker: options.share.worker as SharedWorker, prefix: `reactant-share:${options.share.name}`, verbose: options.share.enableTransportDebugger, logger: options.share.transportLogger, }); } if (options.share.port === 'server') { transports.server ??= createTransport('SharedWorkerInternal', { prefix: `reactant-share:${options.share.name}`, verbose: options.share.enableTransportDebugger, logger: options.share.transportLogger, }); } else if (options.share.port === 'client' && !transports.client) { if (typeof options.share.workerURL !== 'string') { throw new Error( `The value of 'options.share.workerURL' should be a string.` ); } transports.client = createTransport('SharedWorkerClient', { worker: new SharedWorker(options.share.workerURL), prefix: `reactant-share:${options.share.name}`, verbose: options.share.enableTransportDebugger, logger: options.share.transportLogger, }); } options.share.transports = transports; app = createBaseApp(options); } catch (e) { console.warn(e); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { port, workerURL, name, ...shareOptions } = options.share; app = await createSharedTabApp({ ...options, share: { ...shareOptions, type: 'SharedTab', name, forcedSyncClient: true, }, }); } break; case 'SharedTab': app = await createSharedTabApp(options); break; case 'Base': app = createBaseApp(options); break; default: throw new Error( `The value of 'options.share.type' be 'SharedTab', 'SharedWorker' or 'Base'.` ); } const { bootstrap } = app; return { ...app, destroy: () => { app.destroy(); options.share.transports?.client?.dispose(); options.share.transports?.server?.dispose(); }, bootstrap: async (...args: S) => { const result = bootstrap(...args); const portDetector = app.container.get(PortDetector); if (portDetector.isClient) { await portDetector.syncFullStatePromise; } return result; }, }; };