feat(rpc): implement RPC framwork and make Univer running in web worker (#438)

* docs: init English architecture doc

* docs: update

* feat: implement basic rpc

* test: add basic test

* feat: implement RPC over web worker
This commit is contained in:
Wenzhao Hu
2023-11-13 23:06:35 +08:00
committed by GitHub
parent 76ec4ab782
commit e16034dcc4
44 changed files with 2582 additions and 55 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ yarn-debug.log*
yarn-error.log*
# Dependency directories
**/node_modules/
**/node_modules/**
# TypeScript cache
*.tsbuildinfo
+2 -1
View File
@@ -8,7 +8,6 @@
"commentTranslate.hover.enabled": true,
"commentTranslate.source": "Google",
"cSpell.words": [
"undos",
"Bandings",
"Catmull",
"clazz",
@@ -35,6 +34,7 @@
"Heiti",
"Hiragino",
"ilvl",
"IRPC",
"Kaiti",
"KANBAN",
"kcode",
@@ -57,6 +57,7 @@
"Tahoma",
"tinycolor",
"undoredo",
"undos",
"unhine",
"univer",
"univerdoc",
+316
View File
@@ -0,0 +1,316 @@
# Architecture Nodes
## Univer Architecture Introduction
Univer is a web-based office collaboration and data processing SDK, mainly in the form of Office products (sheet / doc / slide). Univer organizes code in a plugin-based manner, allowing users to choose plugins according to their actual needs to form a Univer application. For example, users can add capabilities such as collaborative editing, macro recording, and AI-generated scripting languages to traditional spreadsheets in a plugin-based manner. Users can also embed Univer applications into their own applications, and integrate the capabilities of Univer and their own applications through plugins. And, with the help of Univer's official database connectors, user can load and process data in Univer, leveraging Univer's plugin ecosystem.
## Core Requirements for Univer Architecture Design
Some of these design requirements come from the product planning of Univer, and some come from the learning of team members participating in other project research and development.
1. **100% embrace the web technology stack**. Univer needs to run in a considerable number of environments, meet the needs of rapid iteration, and allow customers, ISVs, and communities to have the ability to develop secondary development. The only technology stack that can meet these needs is the technology stack with web technology as its core.
2. **Pluginization and high scalability**. Univer's modules should be as plug-in as possible, and the coupling relationship between plug-ins should be decoupled as much as possible, so as to reduce the cost of adapting to different user needs and different operating environments, and reduce the threshold for secondary development.
3. **Hierarchical structure and one-way dependency**. Univer's modules are not allowed to have circular dependencies, which allows us to load the required levels and modules according to the needs of different environments.
4. **Design for multiple platforms**. Decouple the coupling relationship between code and specific operating environment to facilitate migration to different operating environments.
5. **Design for high testability**. The modules are based on interfaces as much as possible to establish dependency relationships, which is convenient for independent testing.
## Plugins and Dependency Injection
### Plugin
Univer's modules should be considered from the perspective of **business type (Sheet / Doc / Slide), concern (configuration management / UI / shortcut keys / canvas rendering), function (Sheet basic operation / Sheet conditional format / Sheet filter, operating environment (desktop / mobile / Node.js, etc.)** and other factors, divided into various plugins (plugin), combined into a Univer application.
For example, you could create a standard Spreadsheet application like this:
```ts
import { DocPlugin } from '@univerjs/base-docs';
import { RenderEngine } from '@univerjs/base-render';
import { SheetPlugin } from '@univerjs/base-sheets';
import { UIPlugin } from '@univerjs/base-ui';
import { LocaleType, Univer } from '@univerjs/core';
import { greenTheme } from '@univerjs/design';
import { FormulaPlugin } from '@univerjs/sheets-plugin-formula';
import { SheetUIPlugin } from '@univerjs/ui-plugin-sheets';
const univer = new Univer({
theme: greenTheme,
locale: LocaleType.EN_US,
});
univer.registerPlugin(DocPlugin, {
hasScroll: false,
});
univer.registerPlugin(RenderEngine);
univer.registerPlugin(UIPlugin, {
container: 'univer-container', // where to mount the UI
header: true,
toolbar: true,
footer: true,
});
univer.registerPlugin(SheetPlugin);
univer.registerPlugin(SheetUIPlugin);
univer.registerPlugin(FormulaPlugin);
// call univer.createUniverSheet() to create a spreadsheet
```
The design based on plugins enables Univer to meet various operating environments (PC browser / Node / mobile terminal), different functional requirements, different configuration requirements, secondary development, third-party plugins and other needs.
You can refer to the document [Plugin Extension Capability](./plugin-extension-capability.md) for more information about the plugin extension capability.
### Dependency injection
<img width="962" alt="" src="https://github.com/dream-num/univer/assets/12122021/2102f948-4ede-49b2-ac7d-45adf943a5b5">
The plugin can divide the code into the various layers of modules introduced in the "Hierarchical Structure" section below according to actual needs. The service and controller in these modules need to be added to the dependency injection system of Univer, so that Univer can automatically resolve the dependency relationship between these modules and instantiate these modules. The documentation of the dependency injection system refers to [redi - redi](https://redi.wendell.fun/zh-CN).
### Public and private modules of plugins and extension points
You can export the identifier of these modules in the index.ts file of each plugin. If the identifier of a module is exported, then other plugins can import the identifier of these modules to establish a dependency relationship with these modules, and these modules become public modules of the previous plugin, otherwise it is a private module. If you are familiar with Angular, you will easily find that this is very similar to the concept of NgModule, except that we do not need to declare the exports field, but use the export of es module to distinguish public modules.
### Plugin lifecycle
Plugins have the following four lifecycles
```ts
export const enum LifecycleStages {
Starting,
Ready,
Rendered,
Steady,
}
```
* `Starting` The first lifecycle of the plugin mounted on the Univer instance, at this time the Univer business instance has not been created. The plugin should add its own modules to the dependency injection system during this lifecycle. It is not recommended to initialize the internal modules of the plugin outside this lifecycle.
* `Ready` The first business instance of Univer has been created, and the plugin can do most of the initialization work during this lifecycle.
* `Rendered` The first rendering has been completed, and the plugin can do initialization work that requires DOM dependency during this lifecycle.
* `Steady` Triggered after a period of time after `Rendered`, the plugin can do non-first screen must work during this lifecycle to improve loading performance.
Correspondingly, there are four lifecycle hooks on the Plugin type
```ts
/**
* Plug-in base class, all plug-ins must inherit from this base class. Provide basic methods.
*/
export abstract class Plugin {
onStarting(_injector: Injector): void {}
onReady(): void {}
onRendered(): void {}
onSteady(): void {}
}
```
In addition to these four lifecycle hooks, modules within the plugin can use the OnLifecycle decorator to declare that they need to be initialized at a specific lifecycle stage, for example:
```ts
@OnLifecycle(LifecycleStages.Rendered, IMEInputController)
export class IMEInputController extends Disposable {}
```
You can also listen to lifecycle events by injecting `LifecycleService`.
```ts
export class YourService {
constructor(
@Inject(LifecycleService) private _lifecycleService: LifecycleService,
) {
super();
this._lifecycleService.lifecycle$.subscribe((stage) => this._initModulesOnStage(stage));
}
}
```
### When should you write a plugin?
The division of plugins is primarily based on whether certain modules need to be loaded in specific scenarios. For example, when running on the Node.js platform, UI-related modules may not be required, so these modules can be placed in a separate plugin and not loaded on the Node.js platform. Another example is when you want to allow users to choose whether to load a particular feature, you can place that feature in a separate plugin.
## Layers
![image](../img/layers.png)
The modules within a plugin should generally belong to the following layers:
* View: Handles rendering and interaction, including canvas rendering and React components.
* Controller: Encapsulates business logic, especially functional logic, and dispatches commands.
* Command: Executes logic using the command pattern, modifying the state or data of lower layers such as Service/Model.
* Service: Encapsulates functionality based on concerns for use by higher-level modules, stores internal application state, and manipulates underlying data, etc.
* Model: Stores business data.
There should be a unidirectional dependency relationship between the layers. Except for some Controllers that act as view-models in MVVM and may hold references to UI layer objects, other layers are prohibited from referencing code from higher-level modules.
Note: The code within a plugin is not limited to belonging to only one layer. For example, a plugin may provide both View and Controller simultaneously.
## Command System
Changes to the application state and data are executed through the command system. The Univer core provides a command service, with the dependency injection token ICommandService. Higher-level modules can encapsulate business logic within commands and execute the business logic by accessing other services through the command system. With the command system, Univer can easily implement collaborative editing, macro recording, undo/redo, and follow browsing capabilities.
Plugins can register commands using the registerCommand interface provided by ICommandService and execute commands using the executeCommand interface.
```ts
export interface ICommand<P extends object = object, R = boolean> {
/**
* ${businessName}.${type}.${name}
*/
readonly id: string;
readonly type: CommandType;
handler(accessor: IAccessor, params?: P): Promise<R>;
/** When this command is unregistered, this function would be called. */
onDispose?: () => void;
}
export interface ICommandService {
registerCommand(command: ICommand): IDisposable;
executeCommand<P extends object = object, R = boolean>(
id: string,
params?: P,
options?: IExecutionOptions
): Promise<R> | R;
}
```
There are three types of commands in total:
```ts
export const enum CommandType {
/** Command could generate some operations or mutations. */
COMMAND = 0,
/** An operation that do not require conflict resolve. */
OPERATION = 1,
/** An operation that need to be resolved before applied on peer client. */
MUTATION = 2,
}
```
* `COMMAND` is responsible for creating, orchestrating, and executing `MUTATION` or `OPERATION` based on specific business logic. For example, a **Delete Row `COMMAND`** would generate a **Delete Row `MUTATION`**, an **Insert Row `MUTATION`** for undo, and a **Set Cell Content `MUTATION`**.
* `COMMAND` is the main carrier of business logic. If a _user action_ requires different _underlying behaviors_ based on the application state—for example, when a user clicks the bold text button and the effective range of the bold operation needs to be determined based on the current selection—the corresponding logic should be handled by the `COMMAND`.
* It can dispatch other `COMMAND`, `OPERATION`, or `MUTATION`.
* Asynchronous execution is allowed.
* `MUTATION` represents the changes made to the persisted data and involves conflict resolution in collaborative editing. Examples include inserting rows or columns, modifying cell content, changing filter ranges, and other operations.
* It cannot dispatch any other commands.
* **It must be executed synchronously**.
* `OPERATION` represents the changes made to non-persisted data (or application state) and does not involve conflict resolution. Examples include modifying scroll position, changing sidebar states, and other operations.
* It cannot dispatch any other commands.
* **It must be executed synchronously**.
### Collaborative Editing
`ICommandService` provides event listening interfaces that allow plugins to listen to which commands have been executed and what parameters were used for execution. In practice, an event is dispatched after a command is executed. The event looks like:
```ts
/**
* The command info, only a command id and responsible params
*/
export interface ICommandInfo<T extends object = object> {
id: string;
type: CommandType;
/**
* Args should be serializable.
*/
params?: T;
}
```
For collaborative editing, the collaboration plugin can listen to all `MUTATION` type commands and, through collaborative editing algorithms, send these `MUTATION` to other collaborative clients. The plugin can then use `ICommandService` to reapply these `MUTATION`.
### Operation Recording and Playback
By listening to the execution of `OPERATION` and `MUTATION`, plugins can record user actions and implement features such as:
* Collaborative cursors
* Magic Share, similar to Lark video
* Macro recording
* AppScript
and more.
## User Interface
Univer provides mechanisms to simplify UI development and reduce the workload of menus, shortcuts, and interaction components across different devices. Functional plugins do not need to concern themselves with UI details; they can focus solely on business logic.
### ShortcutService
By injecting an `IShortcutItem` into the `IShortcutService`, you can register a shortcut key and configure its key combination, priority, trigger conditions, and the associated command to be executed.
```ts
export interface IShortcutItem<P extends object = object> {
/** This should reuse the corresponding command's id. */
id: string;
description?: string;
priority?: number;
/** A callback that will be triggered to examine if the shortcut should be invoked. */
preconditions?: (contextService: IContextService) => boolean;
/** A command can be bound to several bindings, with different static parameters perhaps. */
binding: number;
mac?: number;
win?: number;
linux?: number;
/** Static parameters of this shortcut. Would be send to `CommandService.executeCommand`. */
staticParameters?: P;
}
export interface IShortcutService {
registerShortcut(shortcut: IShortcutItem): IDisposable;
getCommandShortcut(id: string): string | null;
}
```
### MenuService
By registering an IMenuItem with the IMenuService, you can configure a menu item.
```ts
interface IMenuItemBase<V> {
/** ID of the menu item. Normally it should be the same as the ID of the command that it would invoke. */
id: string;
title: string;
description?: string;
icon?: string;
tooltip?: string;
/** In what menu should the item display. */
positions: OneOrMany<MenuPosition | string>;
/** @deprecated this type seems unnecessary */
type: MenuItemType;
/**
* Custom label component id.
* */
label?:
| string
| {
name: string;
props?: Record<string, string | number>;
};
hidden$?: Observable<boolean>;
disabled$?: Observable<boolean>;
/** On observable value that should emit the value of the corresponding selection component. */
value$?: Observable<V>;
}
export interface IMenuService {
menuChanged$: Observable<void>;
addMenuItem(item: IMenuItem): IDisposable;
/** Get menu items for display at a given position or a submenu. */
getMenuItems(position: MenuPosition | string): Array<IDisplayMenuItem<IMenuItem>>;
getMenuItem(id: string): IMenuItem | null;
}
```
+53
View File
@@ -0,0 +1,53 @@
# Plugin Extension Capability
Univer follows a "small core" + "multiple plugins" architecture pattern. The core code (mainly the core package) and plugins can provide extension points to enrich the functionality of Univer.
As mentioned in the Architecture Overview section, plugin extension points should be added to the dependency injection system as public modules.
The following section provides a brief introduction to the core package of Univer and some of the main extension points exposed by plugins. Detailed API documentation will be generated using a documentation generation tool later.
## Core Package
The core package, as the lowest-level package, provides the Univer container type and core extension points, including:
* Command System (ICommandService)
* Registering commands/mutations/operations
* Listening to command execution
* Context (IContextService)
* Recording application runtime state information
* Configuration Management (IConfigService)
* Lifecycle (LifecycleService)
* Logging (ILogService)
* Printing different types of logs
* Controlling log storage and reporting methods
* Internationalization (ILocaleService)
* Permissions (IPermissionService)
* Controlling execution permissions for commands
* Controlling permissions for Univer documents
* Undo/Redo (IUndoRedoService)
## base-ui
Provides basic operations and UI capabilities:
* Provides basic React components
* Global interactions
* Popup dialogs or notifications (INotificationService / IMessageService)
* Toolbar and menu (IMenuService)
* Registering menu items in different locations (toolbar, context menu, etc.)
* Shortcuts (IShortcutService)
* Registering keyboard shortcuts
* Getting the shortcut keys associated with a command
* Component mounting point (IDesktopUIController)
* Rendering custom content at specified locations
* Copy and paste (IClipboardService)
* Supplementing clipboard content when copying to the clipboard, supplementing or modifying mutations when pasting from the clipboard
* Focus management (IFocusService)
## base-render
Provides basic rendering capabilities:
* Custom rendering
* Handling mouse interactions
* Rendering rich text content
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,5 +1,9 @@
# Architecture Notes 架构概要
## Univer 架构简介
Univer 是一个基于 web 技术的办公协同和数据处理 SDK,其主要形态为为 Office 类产品( sheet / doc / slide)。Univer 通过插件化的方式组织代码,这使得用户可以按照其实际需要选择插件组合成为一个 Univer 应用,例如用户可以通过插件化的方式在传统的电子表格的基础上增加协同编辑、宏录制、AI 生成脚本语言等能力。用户也可以将 Univer 应用嵌入到自己的应用当中,并通过开发插件将 Univer 的能力和用户自己的应用的能力融汇贯通。
## Univer 架构设计的核心需求
这些设计的需求部分来自于 Univer 的产品规划,部分来自于团队成员参与别的项目研发所得到的 learning
View File
View File
+1
View File
@@ -0,0 +1 @@
# Univer Sheet Demo
+1 -1
View File
@@ -38,7 +38,7 @@ const ctx = await esbuild[args.watch ? 'context' : 'build']({
},
}),
],
entryPoints: ['./src/main.tsx'],
entryPoints: ['./src/main.tsx', './src/worker.ts'],
outdir: './local',
});
+2
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@univerjs/base-docs": "workspace:*",
"@univerjs/base-formula-engine": "workspace:*",
"@univerjs/base-render": "workspace:*",
"@univerjs/base-sheets": "workspace:*",
"@univerjs/base-ui": "workspace:*",
@@ -23,6 +24,7 @@
"@univerjs/common-plugin-image": "workspace:*",
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/rpc": "workspace:*",
"@univerjs/sheets-plugin-debugger": "workspace:*",
"@univerjs/sheets-plugin-find": "workspace:*",
"@univerjs/sheets-plugin-formula": "workspace:*",
+4 -1
View File
@@ -5,6 +5,7 @@ import { UIPlugin } from '@univerjs/base-ui';
import { DEFAULT_WORKBOOK_DATA_DEMO } from '@univerjs/common-plugin-data';
import { LocaleType, Univer } from '@univerjs/core';
import { greenTheme } from '@univerjs/design';
import { IUniverRPCMainThreadPluginConfig, UniverRPCMainThreadPlugin } from '@univerjs/rpc';
import { DebuggerPlugin } from '@univerjs/sheets-plugin-debugger';
// import { FindPlugin } from '@univerjs/sheets-plugin-find';
import { FormulaPlugin } from '@univerjs/sheets-plugin-formula';
@@ -42,13 +43,15 @@ univer.registerPlugin(DebuggerPlugin);
// univer.registerPlugin(NumfmtPlugin);
// univer.registerPlugin(FindPlugin);
univer.registerPlugin(FormulaPlugin);
univer.registerPlugin(UniverRPCMainThreadPlugin, {
workerURL: './worker.js',
} as IUniverRPCMainThreadPluginConfig);
// univer.registerPlugin(ImportXlsxPlugin);
// univer.registerPlugin(ImagePlugin);
// create univer sheet instance
univer.createUniverSheet(DEFAULT_WORKBOOK_DATA_DEMO);
// use for console test
declare global {
interface Window {
univer?: Univer;
+13
View File
@@ -0,0 +1,13 @@
// The entry of web worker scripts
import { SheetPlugin } from '@univerjs/base-sheets';
import { LocaleType, Univer } from '@univerjs/core';
import { UniverRPCWorkerThreadPlugin } from '@univerjs/rpc';
// Univer web worker is also a univer application.
const univer = new Univer({
locale: LocaleType.EN_US,
});
univer.registerPlugin(SheetPlugin);
univer.registerPlugin(UniverRPCWorkerThreadPlugin);
+1 -1
View File
@@ -47,7 +47,7 @@
"@univerjs/base-ui": "workspace:*",
"@univerjs/common-plugin-data": "workspace:*",
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"rxjs": "^7.8.1"
},
"devDependencies": {
+1 -1
View File
@@ -41,7 +41,7 @@
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.10"
"@wendellhu/redi": "^0.12.11"
},
"devDependencies": {
"@types/node": "^20.9.0",
+1 -1
View File
@@ -45,7 +45,7 @@
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"rxjs": "^7.8.1"
},
"devDependencies": {
+1 -1
View File
@@ -48,7 +48,7 @@
"@ctrl/tinycolor": "^4.0.2",
"@univerjs/base-render": "workspace:*",
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"rxjs": "^7.8.1"
},
"devDependencies": {
+1 -1
View File
@@ -40,7 +40,7 @@
"@univerjs/base-render": "workspace:*",
"@univerjs/base-ui": "workspace:*",
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.10"
"@wendellhu/redi": "^0.12.11"
},
"devDependencies": {
"@types/node": "^20.9.0",
+1 -1
View File
@@ -51,7 +51,7 @@
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/icons": "^0.1.7",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"clsx": "^2.0.0",
"localforage": "^1.10.0",
"rc-notification": "^5.3.0",
+1 -1
View File
@@ -11,7 +11,7 @@
"@univerjs/base-sheets": "workspace:*",
"@univerjs/base-ui": "workspace:*",
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"rxjs": "^7.8.1"
},
"devDependencies": {
+1 -1
View File
@@ -56,7 +56,7 @@
"url": "https://github.com/dream-num/univer.git"
},
"dependencies": {
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"dayjs": "^1.11.10",
"nanoid": "5.0.2",
"numeral": "^2.0.6",
+1 -1
View File
@@ -25,7 +25,7 @@ export abstract class Plugin {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onStarting(_injector: Injector): void {}
onStarting(injector: Injector): void {}
onReady(): void {}
+6
View File
@@ -86,6 +86,7 @@ export class DisposableCollection implements IDisposable {
}
export class Disposable implements IDisposable {
protected _disposed = false;
private readonly _collection = new DisposableCollection();
protected disposeWithMe(disposable: IDisposable): IDisposable {
@@ -93,6 +94,11 @@ export class Disposable implements IDisposable {
}
dispose(): void {
if (this._disposed) {
return;
}
this._disposed = true;
this._collection.dispose();
}
}
+17
View File
@@ -0,0 +1,17 @@
# @univerjs/rpc
## Introduction
Univer RPC provides RPC framework for Univer.
## Usage
### Installation
```shell
npm i @univerjs/rpc
```
### API
Check [Univer](https://github.com/dream-num/univer/)
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@univerjs/rpc",
"private": true,
"author": "DreamNum Inc. <developer@univer.ai>",
"main": "./src/index.ts",
"module": "./src/index.ts",
"dependencies": {
"@univerjs/core": "workspace:*",
"@wendellhu/redi": "^0.12.11",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/numeral": "^2.0.5",
"@vitest/coverage-istanbul": "^0.34.6",
"esbuild": "^0.19.5",
"less": "^4.2.0",
"ts-node": "^10.9.1",
"vitest": "^0.34.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export {
type IUniverRPCMainThreadPluginConfig,
type IUniverRPCWorkerThreadPluginConfig,
UniverRPCMainThreadPlugin,
UniverRPCWorkerThreadPlugin,
} from './plugin';
+73
View File
@@ -0,0 +1,73 @@
import { Plugin, PluginType } from '@univerjs/core';
import { createIdentifier, Inject, Injector } from '@wendellhu/redi';
import {
createWebWorkerMessagePortOnMain,
createWebWorkerMessagePortOnWorker,
} from './services/implementations/web-worker-message-port';
import { ChannelClient, ChannelServer, fromModule, toModule } from './services/rpc/rpc.service';
export interface IUniverRPCMainThreadPluginConfig {
workerURL: string | URL;
}
interface ITestService {
getName(): Promise<string>;
}
const ITestService = createIdentifier<ITestService>('ITestService');
/**
* This plugin is used to register the RPC services on the main thread. It
* is also responsible for booting up the Web Worker instance of Univer.
*/
export class UniverRPCMainThreadPlugin extends Plugin {
static override type = PluginType.Univer;
constructor(
private readonly _config: IUniverRPCMainThreadPluginConfig,
@Inject(Injector) protected readonly _injector: Injector
) {
super('UNIVER_RPC_MAIN_THREAD_PLUGIN');
}
override async onStarting(injector: Injector): Promise<void> {
const worker = new Worker(this._config.workerURL);
const messageProtocol = createWebWorkerMessagePortOnMain(worker);
const client = new ChannelClient(messageProtocol);
// Register services / controllers here over RPC.
injector.add([ITestService, { useValue: toModule<ITestService>(client.getChannel('test')) }]);
const name = await this._injector.get<ITestService>(ITestService).getName();
console.log('debug', name);
}
}
export interface IUniverRPCWorkerThreadPluginConfig {}
/**
* This plugin is used to register the RPC services on the worker thread.
*/
export class UniverRPCWorkerThreadPlugin extends Plugin {
static override type = PluginType.Univer;
constructor(
private readonly _config: UniverRPCWorkerThreadPlugin,
@Inject(Injector) protected readonly _injector: Injector
) {
super('UNIVER_RPC_WORKER_THREAD_PLUGIN');
}
override onStarting(): void {
const messageProtocol = createWebWorkerMessagePortOnWorker();
const server = new ChannelServer(messageProtocol);
server.registerChannel(
'test',
fromModule({
getName(): Promise<string> {
return Promise.resolve('Univer');
},
})
);
}
}
@@ -0,0 +1,6 @@
import { Disposable } from '@univerjs/core';
/**
* This service is responsible for syncing
*/
export class DataSyncPrimaryService extends Disposable {}
@@ -0,0 +1,3 @@
import { Disposable } from '@univerjs/core';
export class DataSyncReplicaService extends Disposable {}
@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Observable, shareReplay } from 'rxjs';
import { IMessageProtocol } from '../rpc/rpc.service';
/**
* Generate an `IMessageProtocol` on the web worker.
*/
export function createWebWorkerMessagePortOnWorker(): IMessageProtocol {
return {
send(message: unknown): void {
postMessage(message);
},
onMessage: new Observable<any>((subscriber) => {
const handler = (event: MessageEvent) => {
subscriber.next(event.data);
};
addEventListener('message', handler);
return () => removeEventListener('message', handler);
}).pipe(shareReplay(1)),
};
}
/**
* Generate an `IMessageProtocol` on the main thread side.
* @param worker The Web Worker object
* @returns
*/
export function createWebWorkerMessagePortOnMain(worker: Worker): IMessageProtocol {
return {
send(message) {
worker.postMessage(message);
},
onMessage: new Observable<any>((subscriber) => {
const handler = (event: MessageEvent) => {
subscriber.next(event.data);
};
worker.addEventListener('message', handler);
return () => worker.removeEventListener('message', handler);
}).pipe(shareReplay(1)),
};
}
@@ -0,0 +1,91 @@
/* eslint-disable no-magic-numbers */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Observable, of, Subject } from 'rxjs';
import { beforeEach, describe, expect, it } from 'vitest';
import { ChannelClient, ChannelServer, fromModule, IMessageProtocol, toModule } from '../rpc.service';
describe('Test ChannelClient & ChannelServer', () => {
let clientProtocol: TestMessageProtocolForClient;
let serverProtocol: TestMessageProtocolForServer;
let client: ChannelClient;
let server: ChannelServer;
class TestMessageProtocolForClient implements IMessageProtocol {
private readonly _message$ = new Subject<any>();
onMessage = this._message$.asObservable();
send(message: any): void {
serverProtocol.mockSendMessage(message);
}
mockSendMessage(message: any): void {
this._message$.next(message);
}
}
class TestMessageProtocolForServer implements IMessageProtocol {
private readonly _message$ = new Subject<any>();
onMessage = this._message$.asObservable();
send(message: any): void {
clientProtocol.mockSendMessage(message);
}
mockSendMessage(message: any): void {
this._message$.next(message);
}
}
beforeEach(() => {
clientProtocol = new TestMessageProtocolForClient();
serverProtocol = new TestMessageProtocolForServer();
client = new ChannelClient(clientProtocol);
server = new ChannelServer(serverProtocol);
});
describe('Test fromService and toService', async () => {
it('Should remote call work', async () => {
interface INameService {
getName(): Promise<string>;
getCount$(): Observable<number>;
}
const clientService = toModule<INameService>(client.getChannel('test'));
server.registerChannel(
'test',
fromModule({
async getName(): Promise<string> {
return 'service';
},
getCount$(): Observable<number> {
return of(1, 2, 3);
},
} as INameService)
);
const name = await clientService.getName();
expect(name).toBe('service');
// Should not emit any value when the returned observable is not subscribe.
const values: number[] = [];
const observable = clientService.getCount$();
await timer(300);
expect(values).toEqual([]);
// Should send request only after the observable is subscribed.
const subscription = observable.subscribe((value) => values.push(value));
await timer(300);
expect(values).toEqual([1, 2, 3]);
expect(subscription.closed).toBe(true);
});
});
});
function timer(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
@@ -0,0 +1,403 @@
/* eslint-disable no-magic-numbers */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { RxDisposable } from '@univerjs/core';
import { BehaviorSubject, firstValueFrom, isObservable, Observable, of, Subscriber, Subscription } from 'rxjs';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { filter, take, takeUntil } from 'rxjs/operators';
/** This protocol is for transferring data from the two peer univer instance running in different locations. */
export interface IMessageProtocol {
send(message: any): void;
onMessage: Observable<any>;
}
/**
* Channel is a combination of methods and event sources. These methods and
* event sources are usually provided by the same service or controller.
*/
export interface IChannel {
call<T>(method: string, args?: any): Promise<T>;
subscribe<T>(event: string, args?: any): Observable<T>;
}
/**
* Wrapper a service or a controller into a channel so it could be invoked by
* a remote client. When the protocol is called, it would forward to the
* underlying service or controller.
*
* @param module the wrapper service or controller
* @returns the channel instance
*/
export function fromModule(module: unknown): IChannel {
const handler = module as { [key: string]: unknown };
// Iterate over the module and find all the methods and event sources.
// const observables = new Map<string, Observable<any>>();
return new (class implements IChannel {
call<T>(method: string, args?: any): Promise<T> {
const target = handler[method];
if (typeof target === 'function') {
let res = target.apply(handler, args);
if (!(res instanceof Promise)) {
res = Promise.resolve(res);
}
return res;
}
throw new Error(`[RPC]: method not found for ${method}!`);
}
subscribe<T>(eventMethod: string, args: any): Observable<T> {
const target = handler[eventMethod];
if (typeof target === 'function') {
const res = target.apply(handler, args);
if (!isObservable(res)) {
return of(res);
}
return res as Observable<T>;
}
throw new Error(`[RPC]: observable method not found for ${eventMethod}!`);
}
})();
}
/**
* Wrap a channel into a service or a controller so it could be invoked by
* the upper layer modules. When the service or controller is called, it would
* request the remote server by calling the channel.
*
* @param channel
* @returns
*/
export function toModule<T extends object>(channel: IChannel): T {
return new Proxy({} as T, {
get(_: T, propKey: string) {
// eslint-disable-next-line func-names
return function (...args: any[]) {
const isObservable = propertyIsEventSource(propKey);
if (isObservable) {
const observable = channel.subscribe(propKey, args[0]);
return observable;
}
return channel.call(propKey, args[0]);
};
},
});
}
function propertyIsEventSource(name: string): boolean {
return name.endsWith('$');
}
export interface IChannelClient {
getChannel<T extends IChannel>(channelName: string): T;
}
/**
*
*/
export interface IChannelServer {
registerChannel<T extends IChannel>(channelName: string, channel: T): void;
}
const enum RequestType {
/** A simple remote calling wrapper in a Promise. */
CALL = 100,
/** Subscribe to a remote Observable. */
SUBSCRIBE = 101,
/** Cancel subscription to that remove Observable. */
UNSUBSCRIBE = 102,
}
interface IRPCRequest {
seq: number;
type: RequestType;
channelName: string;
method: string;
args?: any;
}
const enum ResponseType {
/**
* When underlying protocol is established. The server should send the
* client an `INITIALIZE` response to indicate that the server is up
* and ready to provide services.
*/
INITIALIZE = 0,
CALL_SUCCESS = 100,
CALL_FAILURE = 101,
SUBSCRIBE_NEXT = 200,
SUBSCRIBE_ERROR = 201,
SUBSCRIBE_COMPLETE = 202,
}
// TODO@wzhudev:
interface IRPCResponse {
/** It should be the same as its corresponding requests' `seq`. */
seq: number;
type: ResponseType;
data?: any;
}
interface IResponseHandler {
handle(response: IRPCResponse): void;
}
/**
* This method provides implementation for `IChannel` and is responsible for
* transforming a local calling to a RPC calling.
*/
export class ChannelClient extends RxDisposable implements IChannelClient {
private _initialized = new BehaviorSubject<boolean>(false);
private _lastRequestCounter = 0;
private _pendingRequests = new Map<number, IResponseHandler>();
private _pendingSubscriptions = new Map<number, Subscriber<any>>();
constructor(private readonly _protocol: IMessageProtocol) {
super();
// TODO: subscribe to the state of the protocol and see if it is connected \
// and initialized.
this._protocol.onMessage.pipe(takeUntil(this.dispose$)).subscribe((message) => this._onResponse(message));
}
getChannel<T extends IChannel>(channelName: string): T {
const self = this;
return {
call(method: string, args?: any) {
if (self._disposed) {
return Promise.reject();
}
return self._remoteCall(channelName, method, args);
},
subscribe(eventMethod: string, args?: any) {
if (self._disposed) {
throw new Error('[ChannelClient]: client is disposed!');
}
return self._remoteSubscribe(channelName, eventMethod, args);
},
} as T;
}
private _whenReady(): Promise<boolean> {
return firstValueFrom(
this._initialized.pipe(
filter((v) => v),
take(1)
)
);
}
private async _remoteCall(channelName: string, method: string, args?: any): Promise<any> {
await this._whenReady();
const sequence = ++this._lastRequestCounter;
const type = RequestType.CALL;
const request: IRPCRequest = { seq: sequence, type, channelName, method, args };
const client = this;
return new Promise((resolve, reject) => {
// We trigger remote calling in this Promise's callback and deal
// with response here as well.
const responseHandler: IResponseHandler = {
handle(response: IRPCResponse) {
switch (response.type) {
case ResponseType.CALL_SUCCESS:
client._pendingRequests.delete(sequence);
resolve(response.data);
break;
case ResponseType.CALL_FAILURE:
client._pendingRequests.delete(sequence);
reject(response.data);
break;
default:
throw new Error('[ChannelClient]: unknown response type!');
}
},
};
this._pendingRequests.set(sequence, responseHandler);
this._sendRequest(request);
});
}
private _remoteSubscribe(channelName: string, method: string, args?: any): Observable<any> {
return new Observable((subscriber) => {
let sequence: number = -1;
this._whenReady().then(() => {
sequence = ++this._lastRequestCounter;
const type = RequestType.SUBSCRIBE;
const request: IRPCRequest = { seq: sequence, type, channelName, method, args };
const responseHandler: IResponseHandler = {
handle(response: IRPCResponse) {
switch (response.type) {
case ResponseType.SUBSCRIBE_NEXT:
subscriber.next(response.data);
break;
case ResponseType.SUBSCRIBE_ERROR:
subscriber.error(response.data);
break;
case ResponseType.SUBSCRIBE_COMPLETE:
subscriber.complete();
break;
default:
throw new Error('[ChannelClient]: unknown response type!');
}
},
};
this._pendingRequests.set(sequence, responseHandler);
this._sendRequest(request);
});
return () => {
if (sequence === -1) {
return;
}
const cancelSubscriptionRequest: IRPCRequest = {
type: RequestType.UNSUBSCRIBE,
seq: sequence,
channelName,
method,
};
this._sendRequest(cancelSubscriptionRequest);
};
});
}
private _sendRequest(request: IRPCRequest): void {
this._protocol.send(request);
}
private _onResponse(response: IRPCResponse): void {
switch (response.type) {
case ResponseType.INITIALIZE:
this._initialized.next(true);
break;
case ResponseType.CALL_SUCCESS:
case ResponseType.CALL_FAILURE:
case ResponseType.SUBSCRIBE_NEXT:
case ResponseType.SUBSCRIBE_COMPLETE:
case ResponseType.SUBSCRIBE_ERROR:
this._pendingRequests.get(response.seq)?.handle(response);
break;
}
}
}
export class ChannelServer extends RxDisposable implements IChannelServer {
private _channels = new Map<string, IChannel>();
private _subscriptions = new Map<number, Subscription>();
constructor(private readonly _protocol: IMessageProtocol) {
super();
this._protocol.onMessage.pipe(takeUntil(this.dispose$)).subscribe((message) => this._onRequest(message));
this._sendResponse({ seq: -1, type: ResponseType.INITIALIZE });
}
registerChannel(channelName: string, channel: IChannel): void {
this._channels.set(channelName, channel);
}
private _onRequest(request: IRPCRequest): void {
switch (request.type) {
case RequestType.CALL:
this._onMethodCall(request);
break;
case RequestType.SUBSCRIBE:
this._onSubscribe(request);
break;
case RequestType.UNSUBSCRIBE:
this._onUnsubscribe(request);
break;
default:
break;
}
}
private _onMethodCall(request: IRPCRequest): void {
const channel = this._channels.get(request.channelName);
let promise: Promise<any>;
try {
if (!channel) {
throw new Error('[ChannelServer]: Channel not found!');
}
promise = channel.call(request.method, request.args);
} catch (err: unknown) {
promise = Promise.reject(err);
}
promise
.then((data) => {
this._sendResponse({ seq: request.seq, type: ResponseType.CALL_SUCCESS, data });
})
.catch((err) => {
if (err instanceof Error) {
this._sendResponse({ seq: request.seq, type: ResponseType.CALL_FAILURE, data: err.message });
} else {
this._sendResponse({ seq: request.seq, type: ResponseType.CALL_FAILURE, data: String(err) });
}
});
}
private _onSubscribe(request: IRPCRequest): void {
const channel = this._channels.get(request.channelName);
try {
if (!channel) {
throw new Error('[ChannelServer]: Channel not found!');
}
const observable = channel.subscribe(request.method, request.args);
const subscription = observable.subscribe({
next: (data) => {
this._sendResponse({ seq: request.seq, type: ResponseType.SUBSCRIBE_NEXT, data });
},
error: (err) => {
this._sendResponse({ seq: request.seq, type: ResponseType.SUBSCRIBE_ERROR, data: err.message });
this._sendResponse({ seq: request.seq, type: ResponseType.SUBSCRIBE_COMPLETE });
},
complete: () => {
this._sendResponse({ seq: request.seq, type: ResponseType.SUBSCRIBE_COMPLETE });
},
});
this._subscriptions.set(request.seq, subscription);
} catch (err: unknown) {
if (err instanceof Error) {
this._sendResponse({ seq: request.seq, type: ResponseType.SUBSCRIBE_ERROR, data: err.message });
} else {
this._sendResponse({ seq: request.seq, type: ResponseType.SUBSCRIBE_ERROR, data: String(err) });
}
}
}
private _onUnsubscribe(request: IRPCRequest): void {
const subscription = this._subscriptions.get(request.seq);
if (subscription) {
subscription.unsubscribe();
this._subscriptions.delete(request.seq);
}
}
private _sendResponse(response: IRPCResponse): void {
this._protocol.send(response);
}
}
+1 -1
View File
@@ -24,6 +24,6 @@
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/base-sheets": "workspace:*",
"@wendellhu/redi": "^0.12.10"
"@wendellhu/redi": "^0.12.11"
}
}
+1 -1
View File
@@ -49,7 +49,7 @@
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/ui-plugin-sheets": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
+1 -1
View File
@@ -29,7 +29,7 @@
"@univerjs/design": "workspace:*",
"@univerjs/icons": "^0.1.7",
"@univerjs/ui-plugin-sheets": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rxjs": "^7.8.1"
@@ -46,7 +46,7 @@
"@univerjs/common-plugin-data": "workspace:*",
"@univerjs/core": "workspace:*",
"@univerjs/ui-plugin-sheets": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"luckyexcel": "^1.0.1"
},
"devDependencies": {
+1 -1
View File
@@ -48,7 +48,7 @@
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/ui-plugin-sheets": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
+1 -1
View File
@@ -45,7 +45,7 @@
"@univerjs/base-ui": "workspace:*",
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
+1 -1
View File
@@ -54,7 +54,7 @@
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/icons": "^0.1.7",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rxjs": "^7.8.1"
+1 -1
View File
@@ -42,7 +42,7 @@
"@univerjs/base-ui": "workspace:*",
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@wendellhu/redi": "^0.12.10",
"@wendellhu/redi": "^0.12.11",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
+74 -34
View File
@@ -210,6 +210,9 @@ importers:
'@univerjs/base-docs':
specifier: workspace:*
version: link:../../packages/base-docs
'@univerjs/base-formula-engine':
specifier: workspace:*
version: link:../../packages/base-formula-engine
'@univerjs/base-render':
specifier: workspace:*
version: link:../../packages/base-render
@@ -231,6 +234,9 @@ importers:
'@univerjs/design':
specifier: workspace:*
version: link:../../packages/design
'@univerjs/rpc':
specifier: workspace:*
version: link:../../packages/rpc
'@univerjs/sheets-plugin-debugger':
specifier: workspace:*
version: link:../../packages/sheets-plugin-debugger
@@ -369,8 +375,8 @@ importers:
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
rxjs:
specifier: ^7.8.1
version: 7.8.1
@@ -400,8 +406,8 @@ importers:
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
devDependencies:
'@types/node':
specifier: ^20.9.0
@@ -453,8 +459,8 @@ importers:
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
rxjs:
specifier: ^7.8.1
version: 7.8.1
@@ -490,8 +496,8 @@ importers:
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
rxjs:
specifier: ^7.8.1
version: 7.8.1
@@ -530,8 +536,8 @@ importers:
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
devDependencies:
'@types/node':
specifier: ^20.9.0
@@ -567,8 +573,8 @@ importers:
specifier: ^0.1.7
version: 0.1.7(react-dom@18.2.0)(react@18.2.0)
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
clsx:
specifier: ^2.0.0
version: 2.0.0
@@ -692,8 +698,8 @@ importers:
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
rxjs:
specifier: ^7.8.1
version: 7.8.1
@@ -720,8 +726,8 @@ importers:
packages/core:
dependencies:
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
dayjs:
specifier: ^1.11.10
version: 1.11.10
@@ -839,6 +845,40 @@ importers:
specifier: ^0.34.6
version: 0.34.6(happy-dom@12.10.3)(less@4.2.0)
packages/rpc:
dependencies:
'@univerjs/core':
specifier: workspace:*
version: link:../core
'@wendellhu/redi':
specifier: ^0.12.11
version: 0.12.11
rxjs:
specifier: ^7.8.1
version: 7.8.1
devDependencies:
'@types/node':
specifier: ^20.9.0
version: 20.9.0
'@types/numeral':
specifier: ^2.0.5
version: 2.0.5
'@vitest/coverage-istanbul':
specifier: ^0.34.6
version: 0.34.6(vitest@0.34.6)
esbuild:
specifier: ^0.19.5
version: 0.19.5
less:
specifier: ^4.2.0
version: 4.2.0
ts-node:
specifier: ^10.9.1
version: 10.9.1(@types/node@20.9.0)(typescript@5.2.2)
vitest:
specifier: ^0.34.6
version: 0.34.6(happy-dom@12.10.3)(less@4.2.0)
packages/sheets-plugin-debugger:
dependencies:
'@univerjs/base-sheets':
@@ -854,8 +894,8 @@ importers:
specifier: workspace:*
version: link:../design
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
packages/sheets-plugin-find:
dependencies:
@@ -881,8 +921,8 @@ importers:
specifier: workspace:*
version: link:../ui-plugin-sheets
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
react:
specifier: ^18.2.0
version: 18.2.0
@@ -945,8 +985,8 @@ importers:
specifier: workspace:*
version: link:../ui-plugin-sheets
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
react:
specifier: ^18.2.0
version: 18.2.0
@@ -1006,8 +1046,8 @@ importers:
specifier: workspace:*
version: link:../ui-plugin-sheets
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
luckyexcel:
specifier: ^1.0.1
version: 1.0.1
@@ -1058,8 +1098,8 @@ importers:
specifier: workspace:*
version: link:../ui-plugin-sheets
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
react:
specifier: ^18.2.0
version: 18.2.0
@@ -1110,8 +1150,8 @@ importers:
specifier: workspace:*
version: link:../design
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
react:
specifier: ^18.2.0
version: 18.2.0
@@ -1171,8 +1211,8 @@ importers:
specifier: ^0.1.7
version: 0.1.7(react-dom@18.2.0)(react@18.2.0)
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
react:
specifier: ^18.2.0
version: 18.2.0
@@ -1229,8 +1269,8 @@ importers:
specifier: workspace:*
version: link:../design
'@wendellhu/redi':
specifier: ^0.12.10
version: 0.12.10
specifier: ^0.12.11
version: 0.12.11
react:
specifier: ^18.2.0
version: 18.2.0
@@ -5856,8 +5896,8 @@ packages:
pretty-format: 29.7.0
dev: true
/@wendellhu/redi@0.12.10:
resolution: {integrity: sha512-24XDJV301XAqYOfG5gMScFignkuprC04MgmTrNNjmRJLP+eV3GbNkm9QvuV6QP6XQFhJDoQG4V4krL2iEhZLjg==}
/@wendellhu/redi@0.12.11:
resolution: {integrity: sha512-alOLRtosmlklEMp0/s6L0aTENKEShwQAfUk0Qu4DDCfZvAILf4nV0v4glIpj835MmPblX9VfDFsYT/7MiX+FFg==}
dev: false
/@yarnpkg/esbuild-plugin-pnp@3.0.0-rc.15(esbuild@0.18.20):