reactant-share
Version:
A framework for building shared web applications with Reactant
373 lines (362 loc) • 11.2 kB
text/typescript
/* 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;
},
};
};