mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 01:01:40 +03:00
feat(infra): introduce op pattern (#8734)
This commit is contained in:
parent
571e25a7a1
commit
d6618b6891
@ -16,6 +16,7 @@
|
|||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@blocksuite/affine": "0.17.28",
|
"@blocksuite/affine": "0.17.28",
|
||||||
"@datastructures-js/binary-search-tree": "^5.3.2",
|
"@datastructures-js/binary-search-tree": "^5.3.2",
|
||||||
|
"eventemitter2": "^6.4.9",
|
||||||
"foxact": "^0.2.33",
|
"foxact": "^0.2.33",
|
||||||
"fractional-indexing": "^3.2.0",
|
"fractional-indexing": "^3.2.0",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
159
packages/common/infra/src/op/README.md
Normal file
159
packages/common/infra/src/op/README.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
Operation Pattern is a tiny `RPC` framework available both in frontend and backend.
|
||||||
|
|
||||||
|
It introduces super simple call and listen signatures to make Worker, cross tabs SharedWorker or BroadcastChannel easier to use and reduce boilerplate.
|
||||||
|
|
||||||
|
# usage
|
||||||
|
|
||||||
|
## Register Op Handlers
|
||||||
|
|
||||||
|
### Function call handler
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Ops extends OpSchema {
|
||||||
|
add: [{ a: number; b: number }, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
// register
|
||||||
|
const consumer: OpConsumer<Ops>;
|
||||||
|
consumer.register('add', ({ a, b }) => a + b);
|
||||||
|
|
||||||
|
// call
|
||||||
|
const client: OpClient<Ops>;
|
||||||
|
const ret = client.call('add', { a: 1, b: 2 })); // Promise<3>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream call handler
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Ops extends OpSchema {
|
||||||
|
subscribeStatus: [number, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// register
|
||||||
|
const consumer: OpConsumer<Ops>;
|
||||||
|
consumer.register('subscribeStatus', (id: number) => {
|
||||||
|
return interval(3000).pipe(map(() => 'connected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// subscribe
|
||||||
|
const client: OpClient<Ops>;
|
||||||
|
client.subscribe('subscribeStatus', 123, {
|
||||||
|
next: status => {
|
||||||
|
ui.setServerStatus(status);
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
ui.setServerError(error);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
//
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transfer variables
|
||||||
|
|
||||||
|
> [Transferable Objects](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects)
|
||||||
|
|
||||||
|
#### Client transferables
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Ops extends OpSchema {
|
||||||
|
heavyWork: [{ name: string; data: Uint8Array; data2: Uint8Array }, void];
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: OpClient<Ops>;
|
||||||
|
const data = new Uint8Array([1, 2, 3]);
|
||||||
|
const nonTransferredData = new Uint8Array([1, 2, 3]);
|
||||||
|
client.call(
|
||||||
|
'heavyWork',
|
||||||
|
transfer(
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
data: data,
|
||||||
|
data2: nonTransferredData,
|
||||||
|
},
|
||||||
|
[data.buffer]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// after transferring, you can not use the transferred variables anymore!!!
|
||||||
|
// moved
|
||||||
|
assertEq(data.byteLength, 0);
|
||||||
|
// copied
|
||||||
|
assertEq(nonTransferredData.byteLength, 3);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Consumer transferables
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Ops extends OpSchema {
|
||||||
|
job: [{ id: string }, Uint8Array];
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumer: OpConsumer<Ops>;
|
||||||
|
consumer.register('ops', ({ id }) => {
|
||||||
|
return interval(3000).pipe(
|
||||||
|
map(() => {
|
||||||
|
const data = new Uint8Array([1, 2, 3]);
|
||||||
|
transfer(data, [data.buffer]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
### BroadcastChannel
|
||||||
|
|
||||||
|
:::CAUTION
|
||||||
|
|
||||||
|
BroadcastChannel doesn't support transfer transferable objects. All data passed through it's `postMessage` api would be structured cloned
|
||||||
|
|
||||||
|
see [Structured_clone_algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const channel = new BroadcastChannel('domain');
|
||||||
|
const consumer = new OpConsumer(channel);
|
||||||
|
consumer.listen();
|
||||||
|
|
||||||
|
const client = new OpClient(channel);
|
||||||
|
client.listen();
|
||||||
|
```
|
||||||
|
|
||||||
|
### MessagePort
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { port1, port2 } = new MessagePort();
|
||||||
|
|
||||||
|
const client = new OpClient(port1);
|
||||||
|
const consumer = new OpConsumer(port2);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const worker = new Worker('./xxx-worker');
|
||||||
|
const client = new OpClient(worker);
|
||||||
|
|
||||||
|
// in worker
|
||||||
|
const consumer = new OpConsumer(globalThis);
|
||||||
|
consumer.listen();
|
||||||
|
```
|
||||||
|
|
||||||
|
### SharedWorker
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const worker = new SharedWorker('./xxx-worker');
|
||||||
|
const client = new OpClient(worker.port);
|
||||||
|
|
||||||
|
// in worker
|
||||||
|
globalThis.addEventListener('connect', event => {
|
||||||
|
const port = event.ports[0];
|
||||||
|
const consumer = new OpConsumer(port);
|
||||||
|
consumer.listen();
|
||||||
|
});
|
||||||
|
```
|
215
packages/common/infra/src/op/__tests__/client.spec.ts
Normal file
215
packages/common/infra/src/op/__tests__/client.spec.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { afterEach } from 'node:test';
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OpClient } from '../client';
|
||||||
|
import { type MessageHandlers, transfer } from '../message';
|
||||||
|
import type { OpSchema } from '../types';
|
||||||
|
|
||||||
|
interface TestOps extends OpSchema {
|
||||||
|
add: [{ a: number; b: number }, number];
|
||||||
|
bin: [Uint8Array, Uint8Array];
|
||||||
|
sub: [Uint8Array, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vitest' {
|
||||||
|
interface TestContext {
|
||||||
|
producer: OpClient<TestOps>;
|
||||||
|
handlers: MessageHandlers;
|
||||||
|
postMessage: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('op client', () => {
|
||||||
|
beforeEach(ctx => {
|
||||||
|
const { port1 } = new MessageChannel();
|
||||||
|
// @ts-expect-error patch postMessage
|
||||||
|
port1.postMessage = vi.fn(port1.postMessage);
|
||||||
|
// @ts-expect-error patch postMessage
|
||||||
|
ctx.postMessage = port1.postMessage;
|
||||||
|
ctx.producer = new OpClient(port1);
|
||||||
|
// @ts-expect-error internal api
|
||||||
|
ctx.handlers = ctx.producer.handlers;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send call op', async ctx => {
|
||||||
|
// @ts-expect-error internal api
|
||||||
|
const pendingCalls = ctx.producer.pendingCalls;
|
||||||
|
const result = ctx.producer.call('add', { a: 1, b: 2 });
|
||||||
|
|
||||||
|
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"id": "add:1",
|
||||||
|
"name": "add",
|
||||||
|
"payload": {
|
||||||
|
"a": 1,
|
||||||
|
"b": 2,
|
||||||
|
},
|
||||||
|
"type": "call",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(pendingCalls.has('add:1')).toBe(true);
|
||||||
|
|
||||||
|
// fake consumer return
|
||||||
|
ctx.handlers.return({ type: 'return', id: 'add:1', data: 3 });
|
||||||
|
|
||||||
|
await expect(result).resolves.toBe(3);
|
||||||
|
|
||||||
|
expect(pendingCalls.has('add:1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transfer transferables with call op', async ctx => {
|
||||||
|
const data = new Uint8Array([1, 2, 3]);
|
||||||
|
const result = ctx.producer.call('bin', transfer(data, [data.buffer]));
|
||||||
|
|
||||||
|
expect(ctx.postMessage.mock.calls[0][1].transfer[0]).toBeInstanceOf(
|
||||||
|
ArrayBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
// fake consumer return
|
||||||
|
ctx.handlers.return({
|
||||||
|
type: 'return',
|
||||||
|
id: 'bin:1',
|
||||||
|
data: new Uint8Array([3, 2, 1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).resolves.toEqual(new Uint8Array([3, 2, 1]));
|
||||||
|
expect(data.byteLength).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel call', async ctx => {
|
||||||
|
const promise = ctx.producer.call('add', { a: 1, b: 2 });
|
||||||
|
|
||||||
|
promise.cancel();
|
||||||
|
|
||||||
|
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "add:1",
|
||||||
|
"type": "cancel",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('canceled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout call', async ctx => {
|
||||||
|
const promise = ctx.producer.call('add', { a: 1, b: 2 });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send subscribe op', async ctx => {
|
||||||
|
let ob = {
|
||||||
|
next: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
complete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error internal api
|
||||||
|
const subscriptions = ctx.producer.obs;
|
||||||
|
ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob);
|
||||||
|
|
||||||
|
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"id": "sub:1",
|
||||||
|
"name": "sub",
|
||||||
|
"payload": Uint8Array [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
],
|
||||||
|
"type": "subscribe",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(subscriptions.has('sub:1')).toBe(true);
|
||||||
|
|
||||||
|
// fake consumer return
|
||||||
|
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 1 });
|
||||||
|
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 2 });
|
||||||
|
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 3 });
|
||||||
|
|
||||||
|
expect(subscriptions.has('sub:1')).toBe(true);
|
||||||
|
|
||||||
|
ctx.handlers.complete({ type: 'complete', id: 'sub:1' });
|
||||||
|
|
||||||
|
expect(ob.next).toHaveBeenCalledTimes(3);
|
||||||
|
expect(ob.complete).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(subscriptions.has('sub:1')).toBe(false);
|
||||||
|
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sub:1",
|
||||||
|
"type": "unsubscribe",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
|
||||||
|
// smoking
|
||||||
|
ob = {
|
||||||
|
next: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
complete: vi.fn(),
|
||||||
|
};
|
||||||
|
ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob);
|
||||||
|
|
||||||
|
expect(subscriptions.has('sub:2')).toBe(true);
|
||||||
|
|
||||||
|
ctx.handlers.next({ type: 'next', id: 'sub:2', data: 1 });
|
||||||
|
ctx.handlers.error({
|
||||||
|
type: 'error',
|
||||||
|
id: 'sub:2',
|
||||||
|
error: new Error('test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ob.next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ob.error).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(subscriptions.has('sub')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transfer transferables with subscribe op', async ctx => {
|
||||||
|
const data = new Uint8Array([1, 2, 3]);
|
||||||
|
const unsubscribe = ctx.producer.subscribe(
|
||||||
|
'bin',
|
||||||
|
transfer(data, [data.buffer]),
|
||||||
|
{
|
||||||
|
next: vi.fn(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(data.byteLength).toBe(0);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unsubscribe subscription op', ctx => {
|
||||||
|
const unsubscribe = ctx.producer.subscribe(
|
||||||
|
'sub',
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
{
|
||||||
|
next: vi.fn(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sub:1",
|
||||||
|
"type": "unsubscribe",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
197
packages/common/infra/src/op/__tests__/consumer.spec.ts
Normal file
197
packages/common/infra/src/op/__tests__/consumer.spec.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { afterEach } from 'node:test';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OpConsumer } from '../consumer';
|
||||||
|
import { type MessageHandlers, transfer } from '../message';
|
||||||
|
import type { OpSchema } from '../types';
|
||||||
|
|
||||||
|
interface TestOps extends OpSchema {
|
||||||
|
add: [{ a: number; b: number }, number];
|
||||||
|
any: [any, any];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vitest' {
|
||||||
|
interface TestContext {
|
||||||
|
consumer: OpConsumer<TestOps>;
|
||||||
|
handlers: MessageHandlers;
|
||||||
|
postMessage: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('op consumer', () => {
|
||||||
|
beforeEach(ctx => {
|
||||||
|
const { port2 } = new MessageChannel();
|
||||||
|
// @ts-expect-error patch postMessage
|
||||||
|
port2.postMessage = vi.fn(port2.postMessage);
|
||||||
|
// @ts-expect-error patch postMessage
|
||||||
|
ctx.postMessage = port2.postMessage;
|
||||||
|
ctx.consumer = new OpConsumer(port2);
|
||||||
|
// @ts-expect-error internal api
|
||||||
|
ctx.handlers = ctx.consumer.handlers;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if no handler registered', async ctx => {
|
||||||
|
ctx.handlers.call({ type: 'call', id: 'add:1', name: 'add', payload: {} });
|
||||||
|
await vi.advanceTimersToNextTimerAsync();
|
||||||
|
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"error": [Error: Handler for operation [add] is not registered.],
|
||||||
|
"id": "add:1",
|
||||||
|
"type": "return",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle call message', async ctx => {
|
||||||
|
ctx.consumer.register('add', ({ a, b }) => a + b);
|
||||||
|
|
||||||
|
ctx.handlers.call({
|
||||||
|
type: 'call',
|
||||||
|
id: 'add:1',
|
||||||
|
name: 'add',
|
||||||
|
payload: { a: 1, b: 2 },
|
||||||
|
});
|
||||||
|
await vi.advanceTimersToNextTimerAsync();
|
||||||
|
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"data": 3,
|
||||||
|
"id": "add:1",
|
||||||
|
"type": "return",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cancel message', async ctx => {
|
||||||
|
ctx.consumer.register('add', ({ a, b }, { signal }) => {
|
||||||
|
const { reject, resolve, promise } = Promise.withResolvers<number>();
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', () => {
|
||||||
|
reject(new Error('canceled'));
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(a + b);
|
||||||
|
}, Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.handlers.call({
|
||||||
|
type: 'call',
|
||||||
|
id: 'add:1',
|
||||||
|
name: 'add',
|
||||||
|
payload: { a: 1, b: 2 },
|
||||||
|
});
|
||||||
|
ctx.handlers.cancel({ type: 'cancel', id: 'add:1' });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
expect(ctx.postMessage).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transfer transferables in return', async ctx => {
|
||||||
|
const data = new Uint8Array([1, 2, 3]);
|
||||||
|
const nonTransferred = new Uint8Array([4, 5, 6]);
|
||||||
|
|
||||||
|
ctx.consumer.register('any', () => {
|
||||||
|
return transfer({ data: { data, nonTransferred } }, [data.buffer]);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
|
||||||
|
await vi.advanceTimersToNextTimerAsync();
|
||||||
|
expect(ctx.postMessage).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
expect(data.byteLength).toBe(0);
|
||||||
|
expect(nonTransferred.byteLength).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle subscribe message', async ctx => {
|
||||||
|
ctx.consumer.register('any', data => {
|
||||||
|
return new Observable(observer => {
|
||||||
|
data.forEach((v: number) => observer.next(v));
|
||||||
|
observer.complete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.handlers.subscribe({
|
||||||
|
type: 'subscribe',
|
||||||
|
id: 'any:1',
|
||||||
|
name: 'any',
|
||||||
|
payload: transfer(new Uint8Array([1, 2, 3]), [
|
||||||
|
new Uint8Array([1, 2, 3]).buffer,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
await vi.advanceTimersToNextTimerAsync();
|
||||||
|
expect(ctx.postMessage.mock.calls.map(call => call[0]))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"data": 1,
|
||||||
|
"id": "any:1",
|
||||||
|
"type": "next",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": 2,
|
||||||
|
"id": "any:1",
|
||||||
|
"type": "next",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": 3,
|
||||||
|
"id": "any:1",
|
||||||
|
"type": "next",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "any:1",
|
||||||
|
"type": "complete",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unsubscribe message', async ctx => {
|
||||||
|
ctx.consumer.register('any', data => {
|
||||||
|
return new Observable(observer => {
|
||||||
|
data.forEach((v: number) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.next(v);
|
||||||
|
}, 1);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.complete();
|
||||||
|
}, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.handlers.subscribe({
|
||||||
|
type: 'subscribe',
|
||||||
|
id: 'any:1',
|
||||||
|
name: 'any',
|
||||||
|
payload: transfer(new Uint8Array([1, 2, 3]), [
|
||||||
|
new Uint8Array([1, 2, 3]).buffer,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.handlers.unsubscribe({ type: 'unsubscribe', id: 'any:1' });
|
||||||
|
|
||||||
|
await vi.advanceTimersToNextTimerAsync();
|
||||||
|
expect(ctx.postMessage.mock.calls).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "any:1",
|
||||||
|
"type": "complete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
76
packages/common/infra/src/op/__tests__/message.spec.ts
Normal file
76
packages/common/infra/src/op/__tests__/message.spec.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AutoMessageHandler,
|
||||||
|
ignoreUnknownEvent,
|
||||||
|
KNOWN_MESSAGE_TYPES,
|
||||||
|
type MessageCommunicapable,
|
||||||
|
type MessageHandlers,
|
||||||
|
} from '../message';
|
||||||
|
|
||||||
|
class CustomMessageHandler extends AutoMessageHandler {
|
||||||
|
public handlers: Partial<MessageHandlers> = {
|
||||||
|
call: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
return: vi.fn(),
|
||||||
|
next: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
complete: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vitest' {
|
||||||
|
interface TestContext {
|
||||||
|
sendPort: MessageCommunicapable;
|
||||||
|
receivePort: MessageCommunicapable;
|
||||||
|
handler: CustomMessageHandler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('message', () => {
|
||||||
|
beforeEach(ctx => {
|
||||||
|
const listeners: ((event: MessageEvent) => void)[] = [];
|
||||||
|
ctx.sendPort = {
|
||||||
|
postMessage: (msg: any) => {
|
||||||
|
listeners.forEach(listener => {
|
||||||
|
listener(new MessageEvent('message', { data: msg }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.receivePort = {
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
addEventListener: vi.fn((_event, handler) => {
|
||||||
|
listeners.push(handler);
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
ctx.handler = new CustomMessageHandler(ctx.receivePort);
|
||||||
|
ctx.handler.listen();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore unknown message type', ctx => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
// @ts-expect-error internal api
|
||||||
|
ctx.handler.handleMessage = ignoreUnknownEvent(handler);
|
||||||
|
|
||||||
|
ctx.sendPort.postMessage('connected');
|
||||||
|
ctx.sendPort.postMessage({ type: 'call1' });
|
||||||
|
ctx.sendPort.postMessage(new Uint8Array());
|
||||||
|
ctx.sendPort.postMessage(null);
|
||||||
|
ctx.sendPort.postMessage(undefined);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle known message type', async ctx => {
|
||||||
|
for (const type of KNOWN_MESSAGE_TYPES) {
|
||||||
|
ctx.sendPort.postMessage({ type });
|
||||||
|
expect(ctx.handler.handlers[type]).toBeCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
206
packages/common/infra/src/op/client.ts
Normal file
206
packages/common/infra/src/op/client.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { merge } from 'lodash-es';
|
||||||
|
import { Observable, type Observer } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AutoMessageHandler,
|
||||||
|
type CallMessage,
|
||||||
|
type CancelMessage,
|
||||||
|
fetchTransferables,
|
||||||
|
type MessageCommunicapable,
|
||||||
|
type MessageHandlers,
|
||||||
|
type SubscribeMessage,
|
||||||
|
type UnsubscribeMessage,
|
||||||
|
} from './message';
|
||||||
|
import type { OpInput, OpNames, OpOutput, OpSchema } from './types';
|
||||||
|
|
||||||
|
export interface CancelablePromise<T> extends Promise<T> {
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingCall extends PromiseWithResolvers<any> {
|
||||||
|
id: string;
|
||||||
|
timeout: number | NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpClientOptions {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||||
|
private readonly callIds = new Map<OpNames<Ops>, number>();
|
||||||
|
private readonly pendingCalls = new Map<string, PendingCall>();
|
||||||
|
private readonly obs = new Map<string, Observer<any>>();
|
||||||
|
private readonly options: OpClientOptions = {
|
||||||
|
timeout: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(port: MessageCommunicapable, options: OpClientOptions = {}) {
|
||||||
|
super(port);
|
||||||
|
merge(this.options, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override get handlers() {
|
||||||
|
return {
|
||||||
|
return: this.handleReturnMessage,
|
||||||
|
next: this.handleSubscriptionNextMessage,
|
||||||
|
error: this.handleSubscriptionErrorMessage,
|
||||||
|
complete: this.handleSubscriptionCompleteMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleReturnMessage: MessageHandlers['return'] = msg => {
|
||||||
|
const pending = this.pendingCalls.get(msg.id);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('error' in msg) {
|
||||||
|
pending.reject(msg.error);
|
||||||
|
} else {
|
||||||
|
pending.resolve(msg.data);
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingCalls.delete(msg.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleSubscriptionNextMessage: MessageHandlers['next'] =
|
||||||
|
msg => {
|
||||||
|
const ob = this.obs.get(msg.id);
|
||||||
|
if (!ob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob.next(msg.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleSubscriptionErrorMessage: MessageHandlers['error'] =
|
||||||
|
msg => {
|
||||||
|
const ob = this.obs.get(msg.id);
|
||||||
|
if (!ob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob.error(msg.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =
|
||||||
|
msg => {
|
||||||
|
const ob = this.obs.get(msg.id);
|
||||||
|
if (!ob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob.complete();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected nextCallId(op: OpNames<Ops>) {
|
||||||
|
let id = this.callIds.get(op) ?? 0;
|
||||||
|
id++;
|
||||||
|
this.callIds.set(op, id);
|
||||||
|
|
||||||
|
return `${op}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected currentCallId(op: OpNames<Ops>) {
|
||||||
|
return this.callIds.get(op) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
call<Op extends OpNames<Ops>>(
|
||||||
|
op: Op,
|
||||||
|
...args: OpInput<Ops, Op>
|
||||||
|
): CancelablePromise<OpOutput<Ops, Op>> {
|
||||||
|
const promiseWithResolvers = Promise.withResolvers<any>();
|
||||||
|
const payload = args[0];
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
type: 'call',
|
||||||
|
id: this.nextCallId(op),
|
||||||
|
name: op as string,
|
||||||
|
payload,
|
||||||
|
} satisfies CallMessage;
|
||||||
|
|
||||||
|
const promise = promiseWithResolvers.promise as CancelablePromise<any>;
|
||||||
|
|
||||||
|
const raise = (reason: string) => {
|
||||||
|
const pending = this.pendingCalls.get(msg.id);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'cancel',
|
||||||
|
id: msg.id,
|
||||||
|
} satisfies CancelMessage);
|
||||||
|
promiseWithResolvers.reject(new Error(reason));
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingCalls.delete(msg.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
promise.cancel = () => {
|
||||||
|
raise('canceled');
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
raise('timeout');
|
||||||
|
}, this.options.timeout);
|
||||||
|
|
||||||
|
const transferables = fetchTransferables(payload);
|
||||||
|
|
||||||
|
this.port.postMessage(msg, { transfer: transferables });
|
||||||
|
this.pendingCalls.set(msg.id, {
|
||||||
|
...promiseWithResolvers,
|
||||||
|
timeout,
|
||||||
|
id: msg.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<Op extends OpNames<Ops>, Out extends OpOutput<Ops, Op>>(
|
||||||
|
op: Op,
|
||||||
|
...args: [
|
||||||
|
...OpInput<Ops, Op>,
|
||||||
|
Partial<Observer<Out>> | ((value: Out) => void),
|
||||||
|
]
|
||||||
|
): () => void {
|
||||||
|
const payload = args[0];
|
||||||
|
const observer = args[1] as Partial<Observer<Out>> | ((value: Out) => void);
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
type: 'subscribe',
|
||||||
|
id: this.nextCallId(op),
|
||||||
|
name: op as string,
|
||||||
|
payload,
|
||||||
|
} satisfies SubscribeMessage;
|
||||||
|
|
||||||
|
const sub = new Observable<Out>(ob => {
|
||||||
|
this.obs.set(msg.id, ob);
|
||||||
|
}).subscribe(observer);
|
||||||
|
|
||||||
|
sub.add(() => {
|
||||||
|
this.obs.delete(msg.id);
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'unsubscribe',
|
||||||
|
id: msg.id,
|
||||||
|
} satisfies UnsubscribeMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferables = fetchTransferables(payload);
|
||||||
|
this.port.postMessage(msg, { transfer: transferables });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
super.close();
|
||||||
|
this.pendingCalls.forEach(call => {
|
||||||
|
call.reject(new Error('client destroyed'));
|
||||||
|
});
|
||||||
|
this.pendingCalls.clear();
|
||||||
|
this.obs.forEach(ob => {
|
||||||
|
ob.complete();
|
||||||
|
});
|
||||||
|
this.obs.clear();
|
||||||
|
}
|
||||||
|
}
|
188
packages/common/infra/src/op/consumer.ts
Normal file
188
packages/common/infra/src/op/consumer.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import EventEmitter2 from 'eventemitter2';
|
||||||
|
import {
|
||||||
|
defer,
|
||||||
|
from,
|
||||||
|
fromEvent,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
share,
|
||||||
|
take,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AutoMessageHandler,
|
||||||
|
type CallMessage,
|
||||||
|
fetchTransferables,
|
||||||
|
type MessageHandlers,
|
||||||
|
type ReturnMessage,
|
||||||
|
type SubscribeMessage,
|
||||||
|
type SubscriptionCompleteMessage,
|
||||||
|
type SubscriptionErrorMessage,
|
||||||
|
type SubscriptionNextMessage,
|
||||||
|
} from './message';
|
||||||
|
import type { OpInput, OpNames, OpOutput, OpSchema } from './types';
|
||||||
|
|
||||||
|
interface OpCallContext {
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpHandler<Ops extends OpSchema, Op extends OpNames<Ops>> = (
|
||||||
|
payload: OpInput<Ops, Op>[0],
|
||||||
|
ctx: OpCallContext
|
||||||
|
) =>
|
||||||
|
| OpOutput<Ops, Op>
|
||||||
|
| Promise<OpOutput<Ops, Op>>
|
||||||
|
| Observable<OpOutput<Ops, Op>>;
|
||||||
|
|
||||||
|
export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
|
||||||
|
private readonly eventBus = new EventEmitter2();
|
||||||
|
|
||||||
|
private readonly registeredOpHandlers = new Map<
|
||||||
|
OpNames<Ops>,
|
||||||
|
OpHandler<Ops, any>
|
||||||
|
>();
|
||||||
|
|
||||||
|
private readonly processing = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
override get handlers() {
|
||||||
|
return {
|
||||||
|
call: this.handleCallMessage,
|
||||||
|
cancel: this.handleCancelMessage,
|
||||||
|
subscribe: this.handleSubscribeMessage,
|
||||||
|
unsubscribe: this.handleCancelMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleCallMessage: MessageHandlers['call'] = async msg => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.processing.set(msg.id, abortController);
|
||||||
|
|
||||||
|
this.eventBus.emit(`before:${msg.name}`, msg.payload);
|
||||||
|
this.ob$(msg, abortController.signal)
|
||||||
|
.pipe(take(1))
|
||||||
|
.subscribe({
|
||||||
|
next: data => {
|
||||||
|
this.eventBus.emit(`after:${msg.name}`, msg.payload, data);
|
||||||
|
const transferables = fetchTransferables(data);
|
||||||
|
this.port.postMessage(
|
||||||
|
{
|
||||||
|
type: 'return',
|
||||||
|
id: msg.id,
|
||||||
|
data,
|
||||||
|
} satisfies ReturnMessage,
|
||||||
|
{ transfer: transferables }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'return',
|
||||||
|
id: msg.id,
|
||||||
|
error: error as Error,
|
||||||
|
} satisfies ReturnMessage);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.processing.delete(msg.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleSubscribeMessage: MessageHandlers['subscribe'] =
|
||||||
|
msg => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.processing.set(msg.id, abortController);
|
||||||
|
|
||||||
|
this.ob$(msg, abortController.signal).subscribe({
|
||||||
|
next: data => {
|
||||||
|
const transferables = fetchTransferables(data);
|
||||||
|
this.port.postMessage(
|
||||||
|
{
|
||||||
|
type: 'next',
|
||||||
|
id: msg.id,
|
||||||
|
data,
|
||||||
|
} satisfies SubscriptionNextMessage,
|
||||||
|
{ transfer: transferables }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'error',
|
||||||
|
id: msg.id,
|
||||||
|
error: error as Error,
|
||||||
|
} satisfies SubscriptionErrorMessage);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'complete',
|
||||||
|
id: msg.id,
|
||||||
|
} satisfies SubscriptionCompleteMessage);
|
||||||
|
this.processing.delete(msg.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleCancelMessage: MessageHandlers['cancel'] &
|
||||||
|
MessageHandlers['unsubscribe'] = msg => {
|
||||||
|
const abortController = this.processing.get(msg.id);
|
||||||
|
if (!abortController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
register<Op extends OpNames<Ops>>(op: Op, handler: OpHandler<Ops, Op>) {
|
||||||
|
this.registeredOpHandlers.set(op, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
before<Op extends OpNames<Ops>>(
|
||||||
|
op: Op,
|
||||||
|
handler: (...input: OpInput<Ops, Op>) => void
|
||||||
|
) {
|
||||||
|
this.eventBus.on(`before:${op}`, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
after<Op extends OpNames<Ops>>(
|
||||||
|
op: Op,
|
||||||
|
handler: (...args: [...OpInput<Ops, Op>, OpOutput<Ops, Op>]) => void
|
||||||
|
) {
|
||||||
|
this.eventBus.on(`after:${op}`, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
ob$(op: CallMessage | SubscribeMessage, signal: AbortSignal) {
|
||||||
|
return defer(() => {
|
||||||
|
const handler = this.registeredOpHandlers.get(op.name as any);
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(
|
||||||
|
`Handler for operation [${op.name}] is not registered.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret$ = handler(op.payload, { signal });
|
||||||
|
|
||||||
|
let ob$: Observable<any>;
|
||||||
|
if (ret$ instanceof Promise) {
|
||||||
|
ob$ = from(ret$);
|
||||||
|
} else if (ret$ instanceof Observable) {
|
||||||
|
ob$ = ret$;
|
||||||
|
} else {
|
||||||
|
ob$ = of(ret$);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ob$.pipe(share(), takeUntil(fromEvent(signal, 'abort')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
super.close();
|
||||||
|
this.registeredOpHandlers.clear();
|
||||||
|
this.processing.forEach(controller => {
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
this.processing.clear();
|
||||||
|
this.eventBus.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
4
packages/common/infra/src/op/index.ts
Normal file
4
packages/common/infra/src/op/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './client';
|
||||||
|
export * from './consumer';
|
||||||
|
export { type MessageCommunicapable, transfer } from './message';
|
||||||
|
export type { OpSchema } from './types';
|
155
packages/common/infra/src/op/message.ts
Normal file
155
packages/common/infra/src/op/message.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
const PRODUCER_MESSAGE_TYPES = [
|
||||||
|
'call',
|
||||||
|
'cancel',
|
||||||
|
'subscribe',
|
||||||
|
'unsubscribe',
|
||||||
|
] as const;
|
||||||
|
const CONSUMER_MESSAGE_TYPES = ['return', 'next', 'error', 'complete'] as const;
|
||||||
|
export const KNOWN_MESSAGE_TYPES = new Set([
|
||||||
|
...PRODUCER_MESSAGE_TYPES,
|
||||||
|
...CONSUMER_MESSAGE_TYPES,
|
||||||
|
]);
|
||||||
|
|
||||||
|
type MessageType =
|
||||||
|
| (typeof PRODUCER_MESSAGE_TYPES)[number]
|
||||||
|
| (typeof CONSUMER_MESSAGE_TYPES)[number];
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
type: MessageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// in
|
||||||
|
export interface CallMessage extends Message {
|
||||||
|
type: 'call';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
payload: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelMessage extends Message {
|
||||||
|
type: 'cancel';
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscribeMessage extends Message {
|
||||||
|
type: 'subscribe';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
payload: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnsubscribeMessage extends Message {
|
||||||
|
type: 'unsubscribe';
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// out
|
||||||
|
export type ReturnMessage = {
|
||||||
|
type: 'return';
|
||||||
|
id: string;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface SubscriptionNextMessage extends Message {
|
||||||
|
type: 'next';
|
||||||
|
id: string;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionErrorMessage extends Message {
|
||||||
|
type: 'error';
|
||||||
|
id: string;
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionCompleteMessage = {
|
||||||
|
type: 'complete';
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Messages =
|
||||||
|
| CallMessage
|
||||||
|
| CancelMessage
|
||||||
|
| SubscribeMessage
|
||||||
|
| UnsubscribeMessage
|
||||||
|
| ReturnMessage
|
||||||
|
| SubscriptionNextMessage
|
||||||
|
| SubscriptionErrorMessage
|
||||||
|
| SubscriptionCompleteMessage;
|
||||||
|
|
||||||
|
export type MessageHandlers = {
|
||||||
|
[Type in Messages['type']]: (
|
||||||
|
message: Extract<Messages, { type: Type }>
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageCommunicapable = Pick<
|
||||||
|
MessagePort,
|
||||||
|
'postMessage' | 'addEventListener' | 'removeEventListener'
|
||||||
|
> & {
|
||||||
|
start?(): void;
|
||||||
|
close?(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ignoreUnknownEvent(handler: (data: Messages) => void) {
|
||||||
|
return (event: MessageEvent<Message>) => {
|
||||||
|
const data = event.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
typeof data.type !== 'string' ||
|
||||||
|
!KNOWN_MESSAGE_TYPES.has(data.type)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(data as any);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSFERABLES_CACHE = new Map<any, Transferable[]>();
|
||||||
|
export function transfer<T>(data: T, transferables: Transferable[]): T {
|
||||||
|
TRANSFERABLES_CACHE.set(data, transferables);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTransferables(data: any): Transferable[] | undefined {
|
||||||
|
const transferables = TRANSFERABLES_CACHE.get(data);
|
||||||
|
if (transferables) {
|
||||||
|
TRANSFERABLES_CACHE.delete(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transferables;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class AutoMessageHandler {
|
||||||
|
protected abstract handlers: Partial<MessageHandlers>;
|
||||||
|
|
||||||
|
constructor(protected readonly port: MessageCommunicapable) {}
|
||||||
|
|
||||||
|
protected handleMessage = ignoreUnknownEvent((msg: Messages) => {
|
||||||
|
const handler = this.handlers[msg.type];
|
||||||
|
if (!handler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(msg as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
listen() {
|
||||||
|
this.port.addEventListener('message', this.handleMessage);
|
||||||
|
this.port.start?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.port.close?.();
|
||||||
|
this.port.removeEventListener('message', this.handleMessage);
|
||||||
|
}
|
||||||
|
}
|
36
packages/common/infra/src/op/types.ts
Normal file
36
packages/common/infra/src/op/types.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
type KeyToKey<T extends OpSchema> = {
|
||||||
|
[K in keyof T]: string extends K ? never : K;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type ValuesOf<T> = T extends {
|
||||||
|
[K in keyof T]: infer _U;
|
||||||
|
}
|
||||||
|
? _U
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export interface OpSchema {
|
||||||
|
[key: string]: [any, any?];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequiredInput<In> = In extends void ? [] : In extends never ? [] : [In];
|
||||||
|
|
||||||
|
export type OpNames<T extends OpSchema> = ValuesOf<KeyToKey<T>>;
|
||||||
|
export type OpInput<
|
||||||
|
Ops extends OpSchema,
|
||||||
|
Type extends OpNames<Ops>,
|
||||||
|
> = Type extends keyof Ops
|
||||||
|
? Ops[Type] extends [infer In]
|
||||||
|
? RequiredInput<In>
|
||||||
|
: Ops[Type] extends [infer In, infer _Out]
|
||||||
|
? RequiredInput<In>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type OpOutput<
|
||||||
|
Ops extends OpSchema,
|
||||||
|
Type extends OpNames<Ops>,
|
||||||
|
> = Type extends keyof Ops
|
||||||
|
? Ops[Type] extends [infer _In, infer Out]
|
||||||
|
? Out
|
||||||
|
: never
|
||||||
|
: never;
|
@ -13075,6 +13075,7 @@ __metadata:
|
|||||||
"@blocksuite/affine": "npm:0.17.28"
|
"@blocksuite/affine": "npm:0.17.28"
|
||||||
"@datastructures-js/binary-search-tree": "npm:^5.3.2"
|
"@datastructures-js/binary-search-tree": "npm:^5.3.2"
|
||||||
"@testing-library/react": "npm:^16.0.0"
|
"@testing-library/react": "npm:^16.0.0"
|
||||||
|
eventemitter2: "npm:^6.4.9"
|
||||||
fake-indexeddb: "npm:^6.0.0"
|
fake-indexeddb: "npm:^6.0.0"
|
||||||
foxact: "npm:^0.2.33"
|
foxact: "npm:^0.2.33"
|
||||||
fractional-indexing: "npm:^3.2.0"
|
fractional-indexing: "npm:^3.2.0"
|
||||||
@ -19984,7 +19985,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"eventemitter2@npm:6.4.9":
|
"eventemitter2@npm:6.4.9, eventemitter2@npm:^6.4.9":
|
||||||
version: 6.4.9
|
version: 6.4.9
|
||||||
resolution: "eventemitter2@npm:6.4.9"
|
resolution: "eventemitter2@npm:6.4.9"
|
||||||
checksum: 10/b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08
|
checksum: 10/b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08
|
||||||
|
Loading…
Reference in New Issue
Block a user