UNPKG

reactant-share

Version:

A framework for building shared web applications with Reactant

454 lines (407 loc) 12 kB
import { injectable, inject, actionIdentifier, storeKey, Service, identifierKey, } from 'reactant'; import { LastAction } from 'reactant-last-action'; import { loadFullStateActionName, SharedAppOptions, syncToClientsName, syncClientIdsFromClientsName, syncClientIdToServerName, removeClientIdToServerName, } from '../constants'; import type { CallbackWithHook, ClientEvents, Port, PortApp, Transports, Transport, ISharedAppOptions, ProxyExecParams, } from '../interfaces'; import { createId } from '../utils'; type OnClientDestroy = (clientId: string) => unknown; /** * Port Detector * * It provides port detection and client/server port switching functions. */ @injectable() export class PortDetector { protected portApp?: PortApp; protected lastHooks?: Set<ReturnType<CallbackWithHook>>; protected serverCallbacks = new Set< CallbackWithHook<Required<Transports>['server']> >(); protected clientCallbacks = new Set< CallbackWithHook<Required<Transports>['server']> >(); protected clientDestroyCallbacks = new Set<OnClientDestroy>(); syncFullStatePromise?: ReturnType< ClientEvents[typeof loadFullStateActionName] >; /** * previous port */ previousPort?: Port; /** * client id, it will be generated when the port is client, it is null in server port. */ clientId: string | null = null; /** * allow Disable Sync */ allowDisableSync = () => true; /** * client ids, it will collect all the client ids when the port is server, it is an empty array in client port. */ clientIds: string[] = []; /** * server hooks for delegate(this, key, args, { _extra: { serverHook: '$hookName' } }) method */ serverHooks: Record<string, (options: ProxyExecParams) => any> = {}; constructor( @inject(SharedAppOptions) public sharedAppOptions: ISharedAppOptions, public lastAction: LastAction ) { this.onClient((transport) => { this.clientId = createId(); this.clientIds = []; this.syncFullState({ forceSync: false }); const disposeSyncToClients = transport.listen( syncToClientsName, async (fullState) => { if (!fullState) return; const store = (this as Service)[storeKey]; store!.dispatch({ type: `${actionIdentifier}_${loadFullStateActionName}`, state: this.getNextState(fullState), _reactant: actionIdentifier, }); this.lastAction.sequence = fullState[this.lastAction.stateKey]._sequence; } ); transport.emit( { name: syncClientIdToServerName, respond: false }, this.clientId ); const disposeSyncClientIds = transport.listen( syncClientIdsFromClientsName, async () => { if (this.clientId) { // for all clients send current client id to server transport.emit( { name: syncClientIdToServerName, respond: false }, this.clientId ); } } ); const removeClientIdToServer = () => { transport.emit( { name: removeClientIdToServerName, respond: false }, this.clientId! ); }; // do not use `unload` event // https://developer.chrome.com/docs/web-platform/deprecating-unload // the pagehide event is just only triggered in shared worker mode window.addEventListener('pagehide', removeClientIdToServer); return () => { this.previousPort = 'client'; disposeSyncToClients?.(); disposeSyncClientIds?.(); window.removeEventListener('pagehide', removeClientIdToServer); }; }); this.onServer((transport) => { this.clientId = null; transport.emit({ name: syncClientIdsFromClientsName, respond: false }); const disposeSyncClientId = transport.listen( syncClientIdToServerName, (clientId) => { if (!this.clientIds.includes(clientId)) { this.clientIds.push(clientId); } } ); const disposeRemoveClientId = transport.listen( removeClientIdToServerName, (clientId) => { const index = this.clientIds.findIndex((id) => id === clientId); if (index !== -1) { this.clientIds.splice(index, 1); const callbacks = this.clientDestroyCallbacks; for (const callback of callbacks) { try { callback(clientId); } catch (e) { console.error(e); } } } } ); return () => { this.previousPort = 'server'; disposeSyncClientId?.(); disposeRemoveClientId?.(); }; }); } isolatedModules: Service[] = []; /** * all isolated instances state will not be sync to other clients or server. */ disableShare(instance: object) { if (__DEV__) { if (!this.shared) { console.warn(`The app is not shared, so it cannot be isolated.`); } if (this.isolatedModules.includes(instance)) { console.warn( `This module "${instance.constructor.name}" has been disabled for state sharing.` ); } } this.isolatedModules = this.isolatedModules.concat(instance); } protected lastIsolatedInstances?: Service[]; protected lastIsolatedInstanceKeys?: (string | undefined)[]; get isolatedInstanceKeys() { if (this.lastIsolatedInstances !== this.isolatedModules) { this.lastIsolatedInstanceKeys = this.isolatedModules.map( (instance) => instance[identifierKey] ); } return this.lastIsolatedInstanceKeys ?? []; } hasIsolatedState(key: string) { return this.isolatedInstanceKeys.includes(key); } get id() { return this.clientId ?? '__SERVER__'; } get shared() { return !!(this.sharedAppOptions.port && this.sharedAppOptions.type); } get name() { return this.sharedAppOptions.portName ?? 'default'; } get disableSyncClient() { return ( document.visibilityState === 'hidden' && !this.sharedAppOptions.forcedSyncClient && this.allowDisableSync() ); } protected detectPort(port: Port) { return this.portApp?.[port]; } /** * onServer * * When the port is server, this hook will execute. * And allow to return a function that will be executed when the current port is switched to client. */ onServer = (callback: CallbackWithHook<Required<Transports>['server']>) => { if (typeof callback !== 'function') { throw new Error(`'onServer' argument should be a function.`); } this.serverCallbacks.add(callback); if ( this.lastHooks && this.lastHooks.size > 0 && this.isServer && this.transport ) { try { const hook = callback(this.transport); this.lastHooks.add(hook); } catch (e) { console.error(e); } } return () => { this.serverCallbacks.delete(callback); }; }; /** * onClient * * When the port is client, this hook will execute. * And allow to return a function that will be executed when the current port is switched to server. */ onClient = (callback: CallbackWithHook<Required<Transports>['client']>) => { if (typeof callback !== 'function') { throw new Error(`'onClient' argument should be a function.`); } this.clientCallbacks.add(callback); if ( this.lastHooks && this.lastHooks.size > 0 && this.isClient && this.transport ) { try { const hook = callback(this.transport); this.lastHooks.add(hook); } catch (e) { console.error(e); } } return () => { this.clientCallbacks.delete(callback); }; }; /** * emit client destroy event with clientId */ onClientDestroy = (callback: OnClientDestroy) => { if (typeof callback !== 'function') { throw new Error(`'onClientDestroy' argument should be a function.`); } this.clientDestroyCallbacks.add(callback); return () => { this.clientDestroyCallbacks.delete(callback); }; }; get isWorkerMode() { return this.sharedAppOptions.type === 'SharedWorker'; } get isServerWorker() { return this.isWorkerMode && this.isServer; } get isServer() { return !!this.detectPort('server'); } get isClient() { return !!this.detectPort('client'); } get transports() { return this.sharedAppOptions.transports ?? {}; } transport?: Transport; setPort( currentPortApp: PortApp, transport: Required<Transports>[keyof Transports] ) { this.transport = transport; if (this.lastHooks) { for (const hook of this.lastHooks) { try { hook?.(); } catch (e) { console.error(e); } } } this.lastHooks = new Set(); this.portApp = currentPortApp; const callbacks = this.isClient ? this.clientCallbacks : this.serverCallbacks; for (const callback of callbacks) { try { const hook = callback(transport); this.lastHooks.add(hook); } catch (e) { console.error(e); } } } syncToClients() { const store = (this as Service)[storeKey]; if (this.transports.server) { this.transports.server?.emit( { name: syncToClientsName, respond: false }, store!.getState() ); } else { throw new Error( `Failed to 'syncToClients()', 'transports.server' does not exist.` ); } } async syncFullState({ forceSync = true } = {}) { if (forceSync) { this.syncFullStatePromise = undefined; } if (this.syncFullStatePromise) { await this.syncFullStatePromise; return; } if (typeof this.transports.client === 'undefined') { throw new Error(`The current client transport does not exist.`); } this.syncFullStatePromise = this.transports.client.emit( loadFullStateActionName, !forceSync ? this.lastAction.sequence : -1 ); const fullState = await this.syncFullStatePromise; this.syncFullStatePromise = undefined; if (typeof fullState === 'undefined') { throw new Error(`Failed to sync full state from server port.`); } if ( fullState === null || (!forceSync && this.lastAction.sequence > fullState[this.lastAction.stateKey]._sequence) ) return; const store = (this as Service)[storeKey]; if (__DEV__) { console.log( '[syncFullState]', 'old sequence:', this.lastAction.sequence, 'new sequence:', fullState[this.lastAction.stateKey]._sequence ); } store!.dispatch({ type: `${actionIdentifier}_${loadFullStateActionName}`, state: this.getNextState(fullState), _reactant: actionIdentifier, }); this.lastAction.sequence = fullState[this.lastAction.stateKey]._sequence; } /** * ignore router state and isolated state sync for last action */ protected getNextState(fullState: Record<string, any>) { const store = (this as Service)[storeKey]; const currentFullState = store!.getState(); const nextState: Record<string, any> = { ...fullState, router: currentFullState.router, }; if (this.isolatedInstanceKeys.length) { this.isolatedInstanceKeys.forEach((key) => { if (key) { nextState[key] = currentFullState[key]; } }); } return nextState; } /** * transform port with new transport */ transform(port: Port, transport?: Transport) { if (port !== 'server' && port !== 'client') { throw new Error(`The port '${port}' is not supported.`); } this.sharedAppOptions.transports![port] = transport ?? this.sharedAppOptions.transports![port]; this.sharedAppOptions.transform!(port); } }