mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-11 14:08:55 +08:00
feat(server): realtime notification & task status (#14934)
#### PR Dependency Tree * **PR #14934** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Full realtime platform added: live notifications, comments, embedding progress, and transcription task updates via realtime subscriptions. * **Chores** * Frontend switched from polling/GraphQL queries to realtime channels; legacy query fields marked deprecated and client libs updated to use realtime APIs. [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14934) <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #14934** 👈 * **PR #14936** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
type RealtimeEvent,
|
||||
type RealtimeRequestEnvelope,
|
||||
type RealtimeSubscribeEnvelope,
|
||||
type RealtimeUnsubscribeEnvelope,
|
||||
} from '@affine/realtime';
|
||||
import {
|
||||
Manager as SocketIOManager,
|
||||
type Socket as SocketIO,
|
||||
@@ -52,6 +58,8 @@ interface ServerEvents {
|
||||
docId: string;
|
||||
awarenessUpdate: string;
|
||||
};
|
||||
|
||||
'realtime:event': RealtimeEvent;
|
||||
}
|
||||
|
||||
interface ClientEvents {
|
||||
@@ -116,6 +124,10 @@ interface ClientEvents {
|
||||
'space:delete-doc': { spaceType: string; spaceId: string; docId: string };
|
||||
|
||||
'telemetry:batch': [TelemetryBatch, TelemetryAck];
|
||||
|
||||
'realtime:request': [RealtimeRequestEnvelope, unknown];
|
||||
'realtime:subscribe': [RealtimeSubscribeEnvelope, { subscriptionId: string }];
|
||||
'realtime:unsubscribe': [RealtimeUnsubscribeEnvelope, { ok: true }];
|
||||
}
|
||||
|
||||
export type ServerEventsMap = {
|
||||
@@ -227,10 +239,11 @@ class SocketManager {
|
||||
|
||||
const SOCKET_MANAGER_CACHE = new Map<string, SocketManager>();
|
||||
function getSocketManager(endpoint: string, isSelfHosted: boolean) {
|
||||
let manager = SOCKET_MANAGER_CACHE.get(endpoint);
|
||||
const key = `${endpoint}:${isSelfHosted ? 'selfhosted' : 'cloud'}`;
|
||||
let manager = SOCKET_MANAGER_CACHE.get(key);
|
||||
if (!manager) {
|
||||
manager = new SocketManager(endpoint, isSelfHosted);
|
||||
SOCKET_MANAGER_CACHE.set(endpoint, manager);
|
||||
SOCKET_MANAGER_CACHE.set(key, manager);
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { RealtimeEvent } from '@affine/realtime';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { RealtimeManager, stableStringify } from '../manager';
|
||||
|
||||
type Handler = (payload?: unknown) => void;
|
||||
|
||||
class FakeSocket {
|
||||
readonly handlers = new Map<string, Handler>();
|
||||
readonly emitted: Array<{ event: string; payload: unknown }> = [];
|
||||
connected = true;
|
||||
disconnected = false;
|
||||
nextRequestAck: unknown = { data: { count: 1 } };
|
||||
subscribeAcks: unknown[] = [];
|
||||
nextSubscriptionId = 0;
|
||||
|
||||
on(event: string, handler: Handler) {
|
||||
this.handlers.set(event, handler);
|
||||
}
|
||||
|
||||
off(event: string) {
|
||||
this.handlers.delete(event);
|
||||
}
|
||||
|
||||
async emitWithAck(event: string, payload: unknown) {
|
||||
this.emitted.push({ event, payload });
|
||||
if (event === 'realtime:subscribe') {
|
||||
const ack = this.subscribeAcks.shift();
|
||||
if (ack) return ack;
|
||||
this.nextSubscriptionId += 1;
|
||||
return { data: { subscriptionId: `sub-${this.nextSubscriptionId}` } };
|
||||
}
|
||||
if (event === 'realtime:request') {
|
||||
return this.nextRequestAck;
|
||||
}
|
||||
return { data: {} };
|
||||
}
|
||||
|
||||
emit(event: string, payload?: unknown) {
|
||||
this.handlers.get(event)?.(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const socket = new FakeSocket();
|
||||
|
||||
vi.mock('../../impls/cloud/socket', () => ({
|
||||
SocketConnection: class {
|
||||
readonly inner = { socket };
|
||||
status = 'connected';
|
||||
readonly maybeConnection = { socket };
|
||||
|
||||
connect() {}
|
||||
|
||||
async waitForConnected() {}
|
||||
|
||||
disconnect() {
|
||||
socket.disconnected = true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('BUILD_CONFIG', { appVersion: 'test' });
|
||||
socket.handlers.clear();
|
||||
socket.emitted.length = 0;
|
||||
socket.nextRequestAck = { data: { count: 1 } };
|
||||
socket.subscribeAcks = [];
|
||||
socket.nextSubscriptionId = 0;
|
||||
socket.connected = true;
|
||||
socket.disconnected = false;
|
||||
});
|
||||
|
||||
test('stableStringify is deterministic for realtime subscription inputs', () => {
|
||||
expect(stableStringify({ workspaceId: 'space', docId: 'doc' })).toBe(
|
||||
stableStringify({ docId: 'doc', workspaceId: 'space' })
|
||||
);
|
||||
});
|
||||
|
||||
test('stableStringify follows JSON semantics for edge values', () => {
|
||||
expect(stableStringify({ a: undefined })).toBe(stableStringify({}));
|
||||
expect(stableStringify([undefined])).toBe('[null]');
|
||||
expect(stableStringify(new Date('2026-01-02T03:04:05.000Z'))).toBe(
|
||||
'"2026-01-02T03:04:05.000Z"'
|
||||
);
|
||||
});
|
||||
|
||||
test('request sends generic realtime request with client version', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
|
||||
await expect(manager.request('notification.count.get', {})).resolves.toEqual({
|
||||
count: 1,
|
||||
});
|
||||
|
||||
expect(socket.emitted).toEqual([
|
||||
{
|
||||
event: 'realtime:request',
|
||||
payload: {
|
||||
op: 'notification.count.get',
|
||||
input: {},
|
||||
clientVersion: 'test',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('request rejects server ack error', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
socket.nextRequestAck = {
|
||||
error: { name: 'Forbidden', message: 'No access' },
|
||||
};
|
||||
|
||||
await expect(manager.request('notification.count.get', {})).rejects.toThrow(
|
||||
'No access'
|
||||
);
|
||||
});
|
||||
|
||||
test('request rejects when aborted', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
socket.nextRequestAck = new Promise(() => {});
|
||||
const request = manager.request(
|
||||
'notification.count.get',
|
||||
{},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(request).rejects.toThrow('Realtime request aborted');
|
||||
});
|
||||
|
||||
test('subscribe routes events by topic and stable input key', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const received: unknown[] = [];
|
||||
const subscription = manager
|
||||
.subscribe('comment.changed', { workspaceId: 'space', docId: 'doc' })
|
||||
.subscribe(event => received.push(event));
|
||||
await vi.waitFor(() => expect(received).toEqual([{ type: 'ready' }]));
|
||||
|
||||
socket.emit('realtime:event', {
|
||||
topic: 'comment.changed',
|
||||
inputKey: stableStringify({ workspaceId: 'space', docId: 'other' }),
|
||||
sentAt: 1,
|
||||
event: { changed: true },
|
||||
} satisfies RealtimeEvent);
|
||||
socket.emit('realtime:event', {
|
||||
topic: 'comment.changed',
|
||||
inputKey: stableStringify({ workspaceId: 'space', docId: 'doc' }),
|
||||
sentAt: 2,
|
||||
event: { changed: true },
|
||||
} satisfies RealtimeEvent);
|
||||
|
||||
expect(received).toEqual([{ type: 'ready' }, { changed: true }]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
test('unsubscribe leaves server room and clears status', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const subscription = manager
|
||||
.subscribe('notification.count.changed', {})
|
||||
.subscribe();
|
||||
await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(1));
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
subscriptions: 0,
|
||||
});
|
||||
expect(socket.emitted.at(-1)).toEqual({
|
||||
event: 'realtime:unsubscribe',
|
||||
payload: {
|
||||
subscriptionId: 'sub-1',
|
||||
topic: 'notification.count.changed',
|
||||
input: {},
|
||||
clientVersion: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('context switch disconnects socket and completes subscriptions', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const completed = vi.fn();
|
||||
manager
|
||||
.subscribe('notification.count.changed', {})
|
||||
.subscribe({ complete: completed });
|
||||
await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(1));
|
||||
|
||||
manager.setContext({
|
||||
endpoint: 'http://other-server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
|
||||
expect(socket.disconnected).toBe(true);
|
||||
expect(completed).toHaveBeenCalled();
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
endpoint: 'http://other-server',
|
||||
connected: false,
|
||||
subscriptions: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('subscribe registers server room again after reconnect', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const received: unknown[] = [];
|
||||
const subscription = manager
|
||||
.subscribe('notification.count.changed', {})
|
||||
.subscribe(event => received.push(event));
|
||||
await vi.waitFor(() => expect(received).toEqual([{ type: 'ready' }]));
|
||||
|
||||
socket.emit('connect');
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
socket.emitted.filter(item => item.event === 'realtime:subscribe')
|
||||
).toHaveLength(2)
|
||||
);
|
||||
expect(received).toEqual([{ type: 'ready' }, { type: 'ready' }]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
test('failed reconnect only errors the affected subscription', async () => {
|
||||
const manager = new RealtimeManager();
|
||||
manager.setContext({
|
||||
endpoint: 'http://server',
|
||||
isSelfHosted: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const first: unknown[] = [];
|
||||
const firstErrors: unknown[] = [];
|
||||
const second: unknown[] = [];
|
||||
const secondErrors: unknown[] = [];
|
||||
const firstSubscription = manager
|
||||
.subscribe('notification.count.changed', {})
|
||||
.subscribe({
|
||||
next: event => first.push(event),
|
||||
error: error => firstErrors.push(error),
|
||||
});
|
||||
const secondSubscription = manager
|
||||
.subscribe('comment.changed', { workspaceId: 'space', docId: 'doc' })
|
||||
.subscribe({
|
||||
next: event => second.push(event),
|
||||
error: error => secondErrors.push(error),
|
||||
});
|
||||
await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(2));
|
||||
|
||||
socket.subscribeAcks = [
|
||||
{ data: { subscriptionId: 'resub-1' } },
|
||||
{ error: { name: 'Forbidden', message: 'No access' } },
|
||||
];
|
||||
socket.emit('connect');
|
||||
|
||||
await vi.waitFor(() => expect(first).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(secondErrors).toHaveLength(1));
|
||||
expect(firstErrors).toEqual([]);
|
||||
expect(manager.getStatus().subscriptions).toBe(1);
|
||||
|
||||
firstSubscription.unsubscribe();
|
||||
secondSubscription.unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { RealtimeManager, stableStringify } from './manager';
|
||||
@@ -0,0 +1,336 @@
|
||||
import type {
|
||||
RealtimeConfigureInput,
|
||||
RealtimeEvent,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
RealtimeStatus,
|
||||
RealtimeSubscriptionReady,
|
||||
RealtimeTopicEventOf,
|
||||
RealtimeTopicInputOf,
|
||||
RealtimeTopicName,
|
||||
} from '@affine/realtime';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
import { SocketConnection } from '../impls/cloud/socket';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT = 10_000;
|
||||
|
||||
type RealtimeContext = RealtimeConfigureInput;
|
||||
|
||||
function normalizeError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return { name: error.name, message: error.message };
|
||||
}
|
||||
return { name: 'RealtimeError', message: String(error) };
|
||||
}
|
||||
|
||||
function rejectAck(error: { name?: string; message?: string; code?: string }) {
|
||||
const err = new Error(error.message ?? 'Realtime request failed');
|
||||
err.name = error.name ?? 'RealtimeError';
|
||||
return err;
|
||||
}
|
||||
|
||||
export class RealtimeManager {
|
||||
private context?: RealtimeContext;
|
||||
private socketConnection?: SocketConnection;
|
||||
private socketKey?: string;
|
||||
private lastError?: { name: string; message: string };
|
||||
private readonly subscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
topic: RealtimeTopicName;
|
||||
input: RealtimeTopicInputOf<RealtimeTopicName>;
|
||||
inputKey: string;
|
||||
subject$: Subject<RealtimeEvent | RealtimeSubscriptionReady>;
|
||||
}
|
||||
>();
|
||||
|
||||
setContext(context: RealtimeContext) {
|
||||
const nextContext = { ...context };
|
||||
const changed =
|
||||
!this.context ||
|
||||
this.context.endpoint !== nextContext.endpoint ||
|
||||
this.context.isSelfHosted !== nextContext.isSelfHosted ||
|
||||
this.context.authenticated !== nextContext.authenticated;
|
||||
|
||||
this.context = nextContext;
|
||||
|
||||
if (changed) {
|
||||
this.resetConnection();
|
||||
}
|
||||
}
|
||||
|
||||
async request<Op extends RealtimeRequestName>(
|
||||
op: Op,
|
||||
input: RealtimeRequestInputOf<Op>,
|
||||
options?: { timeoutMs?: number; signal?: AbortSignal }
|
||||
): Promise<RealtimeRequestOutputOf<Op>> {
|
||||
const socket = await this.connect();
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortHandler: (() => void) | undefined;
|
||||
const abort = () => {
|
||||
const error = new Error(`Realtime request aborted: ${op}`);
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
};
|
||||
if (options?.signal?.aborted) {
|
||||
throw abort();
|
||||
}
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
const error = new Error(`Realtime request timed out: ${op}`);
|
||||
error.name = 'RealtimeRequestTimeout';
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
});
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
abortHandler = () => reject(abort());
|
||||
options?.signal?.addEventListener('abort', abortHandler, { once: true });
|
||||
});
|
||||
|
||||
const ack = await Promise.race([
|
||||
socket.emitWithAck('realtime:request', {
|
||||
op,
|
||||
input,
|
||||
clientVersion: BUILD_CONFIG.appVersion,
|
||||
}),
|
||||
timeout,
|
||||
aborted,
|
||||
]).finally(() => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (abortHandler) {
|
||||
options?.signal?.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
});
|
||||
|
||||
if ('error' in ack) {
|
||||
throw rejectAck(ack.error);
|
||||
}
|
||||
|
||||
return ack.data as unknown as RealtimeRequestOutputOf<Op>;
|
||||
}
|
||||
|
||||
subscribe<Topic extends RealtimeTopicName>(
|
||||
topic: Topic,
|
||||
input: RealtimeTopicInputOf<Topic>
|
||||
): Observable<RealtimeTopicEventOf<Topic> | RealtimeSubscriptionReady> {
|
||||
return new Observable(subscriber => {
|
||||
let subscriptionId: string | undefined;
|
||||
let subject$: Subject<RealtimeEvent | RealtimeSubscriptionReady>;
|
||||
let closed = false;
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const socket = await this.connect();
|
||||
const ack = await socket.emitWithAck('realtime:subscribe', {
|
||||
topic,
|
||||
input,
|
||||
clientVersion: BUILD_CONFIG.appVersion,
|
||||
});
|
||||
if ('error' in ack) {
|
||||
throw rejectAck(ack.error);
|
||||
}
|
||||
const data = ack.data;
|
||||
subscriptionId = data.subscriptionId;
|
||||
if (closed) {
|
||||
await socket.emitWithAck('realtime:unsubscribe', {
|
||||
subscriptionId: data.subscriptionId,
|
||||
topic,
|
||||
input,
|
||||
clientVersion: BUILD_CONFIG.appVersion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
subject$ = new Subject();
|
||||
this.subscriptions.set(subscriptionId, {
|
||||
topic,
|
||||
input,
|
||||
inputKey: stableStringify(input),
|
||||
subject$,
|
||||
});
|
||||
subscriber.next({
|
||||
type: 'ready',
|
||||
});
|
||||
subject$.subscribe({
|
||||
next: event => {
|
||||
if ('type' in event) {
|
||||
subscriber.next(event);
|
||||
} else {
|
||||
subscriber.next(event.event as RealtimeTopicEventOf<Topic>);
|
||||
}
|
||||
},
|
||||
error: error => subscriber.error(error),
|
||||
complete: () => subscriber.complete(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.lastError = normalizeError(error);
|
||||
subscriber.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
setup().catch(error => subscriber.error(error));
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (!subscriptionId) {
|
||||
return;
|
||||
}
|
||||
const currentSubscriptionId = subscriptionId;
|
||||
this.subscriptions.delete(currentSubscriptionId);
|
||||
subject$?.complete();
|
||||
if (this.socketConnection?.inner?.socket.connected) {
|
||||
this.socketConnection.inner.socket
|
||||
.emitWithAck('realtime:unsubscribe', {
|
||||
subscriptionId: currentSubscriptionId,
|
||||
topic,
|
||||
input,
|
||||
clientVersion: BUILD_CONFIG.appVersion,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getStatus(): RealtimeStatus {
|
||||
return {
|
||||
endpoint: this.context?.endpoint,
|
||||
connected: this.socketConnection?.status === 'connected',
|
||||
connecting: this.socketConnection?.status === 'connecting',
|
||||
subscriptions: this.subscriptions.size,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
if (!this.context?.endpoint || !this.context.authenticated) {
|
||||
const error = new Error('Realtime is not authenticated');
|
||||
error.name = 'RealtimeUnauthenticated';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const key = `${this.context.endpoint}:${this.context.isSelfHosted}`;
|
||||
if (!this.socketConnection || this.socketKey !== key) {
|
||||
this.resetConnection();
|
||||
this.socketKey = key;
|
||||
this.socketConnection = new SocketConnection(
|
||||
this.context.endpoint,
|
||||
this.context.isSelfHosted
|
||||
);
|
||||
this.socketConnection.connect();
|
||||
}
|
||||
|
||||
await this.socketConnection.waitForConnected();
|
||||
this.socketConnection.inner.socket.off('realtime:event', this.handleEvent);
|
||||
this.socketConnection.inner.socket.on('realtime:event', this.handleEvent);
|
||||
this.socketConnection.inner.socket.off('connect', this.handleReconnect);
|
||||
this.socketConnection.inner.socket.on('connect', this.handleReconnect);
|
||||
return this.socketConnection.inner.socket;
|
||||
}
|
||||
|
||||
private readonly handleEvent = (event: RealtimeEvent) => {
|
||||
for (const subscription of this.subscriptions.values()) {
|
||||
if (
|
||||
subscription.topic === event.topic &&
|
||||
subscription.inputKey === event.inputKey
|
||||
) {
|
||||
subscription.subject$.next(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleReconnect = () => {
|
||||
this.resubscribeAll().catch(error => {
|
||||
this.lastError = normalizeError(error);
|
||||
});
|
||||
};
|
||||
|
||||
private async resubscribeAll() {
|
||||
const socket = this.socketConnection?.inner.socket;
|
||||
if (!socket?.connected || this.subscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptions = Array.from(this.subscriptions.entries());
|
||||
for (const [subscriptionId, subscription] of subscriptions) {
|
||||
try {
|
||||
const ack = await socket.emitWithAck('realtime:subscribe', {
|
||||
topic: subscription.topic,
|
||||
input: subscription.input,
|
||||
clientVersion: BUILD_CONFIG.appVersion,
|
||||
});
|
||||
if ('error' in ack) {
|
||||
throw rejectAck(ack.error);
|
||||
}
|
||||
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
this.subscriptions.set(ack.data.subscriptionId, subscription);
|
||||
subscription.subject$.next({
|
||||
type: 'ready',
|
||||
});
|
||||
} catch (error) {
|
||||
this.lastError = normalizeError(error);
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
subscription.subject$.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetConnection() {
|
||||
if (this.socketConnection) {
|
||||
this.socketConnection.maybeConnection?.socket.off(
|
||||
'realtime:event',
|
||||
this.handleEvent
|
||||
);
|
||||
this.socketConnection.maybeConnection?.socket.off(
|
||||
'connect',
|
||||
this.handleReconnect
|
||||
);
|
||||
this.socketConnection.disconnect(true);
|
||||
}
|
||||
this.socketConnection = undefined;
|
||||
this.socketKey = undefined;
|
||||
for (const subscription of this.subscriptions.values()) {
|
||||
subscription.subject$.complete();
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return 'null';
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(',')}]`;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return JSON.stringify(value.toJSON());
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.filter(key => {
|
||||
const property = record[key];
|
||||
return (
|
||||
property !== undefined &&
|
||||
typeof property !== 'function' &&
|
||||
typeof property !== 'symbol'
|
||||
);
|
||||
})
|
||||
.sort()
|
||||
.map(key => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
import type {
|
||||
RealtimeConfigureInput,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
RealtimeStatus,
|
||||
RealtimeSubscriptionReady,
|
||||
RealtimeTopicEventOf,
|
||||
RealtimeTopicInputOf,
|
||||
RealtimeTopicName,
|
||||
} from '@affine/realtime';
|
||||
import { OpClient, transfer } from '@toeverything/infra/op';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -38,6 +49,30 @@ import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops';
|
||||
|
||||
export type { StoreInitOptions as WorkerInitOptions } from './ops';
|
||||
|
||||
type RealtimeWorkerClient = {
|
||||
call<Op extends RealtimeRequestName>(
|
||||
name: 'realtime.request',
|
||||
payload: {
|
||||
op: Op;
|
||||
input: RealtimeRequestInputOf<Op>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
): Promise<RealtimeRequestOutputOf<Op>>;
|
||||
ob$<Topic extends RealtimeTopicName>(
|
||||
name: 'realtime.subscribe',
|
||||
payload: {
|
||||
topic: Topic;
|
||||
input: RealtimeTopicInputOf<Topic>;
|
||||
}
|
||||
): Observable<RealtimeTopicEventOf<Topic> | RealtimeSubscriptionReady>;
|
||||
};
|
||||
|
||||
function realtimeAbortError(op: RealtimeRequestName) {
|
||||
const error = new Error(`Realtime request aborted: ${op}`);
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
export class StoreManagerClient {
|
||||
private readonly connections = new Map<
|
||||
string,
|
||||
@@ -49,9 +84,11 @@ export class StoreManagerClient {
|
||||
|
||||
constructor(private readonly client: OpClient<WorkerManagerOps>) {
|
||||
this.telemetry = new TelemetryClient(this.client);
|
||||
this.realtime = new RealtimeClient(this.client);
|
||||
}
|
||||
|
||||
readonly telemetry: TelemetryClient;
|
||||
readonly realtime: RealtimeClient;
|
||||
|
||||
open(key: string, options: StoreInitOptions) {
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
@@ -138,6 +175,62 @@ class TelemetryClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
constructor(private readonly client: OpClient<WorkerManagerOps>) {}
|
||||
|
||||
configure(context: RealtimeConfigureInput): Promise<void> {
|
||||
return this.client.call('realtime.configure', context);
|
||||
}
|
||||
|
||||
request<Op extends RealtimeRequestName>(
|
||||
op: Op,
|
||||
input: RealtimeRequestInputOf<Op>,
|
||||
options?: { timeoutMs?: number; signal?: AbortSignal }
|
||||
): Promise<RealtimeRequestOutputOf<Op>> {
|
||||
const request = (this.client as unknown as RealtimeWorkerClient).call(
|
||||
'realtime.request',
|
||||
{
|
||||
op,
|
||||
input,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}
|
||||
);
|
||||
if (!options?.signal) {
|
||||
return request;
|
||||
}
|
||||
if (options.signal.aborted) {
|
||||
return Promise.reject(realtimeAbortError(op));
|
||||
}
|
||||
let abortHandler: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
abortHandler = () => reject(realtimeAbortError(op));
|
||||
options.signal?.addEventListener('abort', abortHandler, { once: true });
|
||||
});
|
||||
return Promise.race([request, aborted]).finally(() => {
|
||||
if (abortHandler) {
|
||||
options.signal?.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe<Topic extends RealtimeTopicName>(
|
||||
topic: Topic,
|
||||
input: RealtimeTopicInputOf<Topic>
|
||||
): Observable<RealtimeTopicEventOf<Topic> | RealtimeSubscriptionReady> {
|
||||
return (this.client as unknown as RealtimeWorkerClient).ob$(
|
||||
'realtime.subscribe',
|
||||
{
|
||||
topic,
|
||||
input,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
status(): Promise<RealtimeStatus> {
|
||||
return this.client.call('realtime.status');
|
||||
}
|
||||
}
|
||||
|
||||
export class StoreClient {
|
||||
constructor(private readonly client: OpClient<WorkerOps>) {
|
||||
this.docStorage = new WorkerDocStorage(this.client);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OpConsumer } from '@toeverything/infra/op';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { type StorageConstructor } from '../impls';
|
||||
import { RealtimeManager } from '../realtime';
|
||||
import { SpaceStorage } from '../storage';
|
||||
import type { AwarenessRecord } from '../storage/awareness';
|
||||
import { Sync } from '../sync';
|
||||
@@ -340,6 +341,7 @@ export class StoreManagerConsumer {
|
||||
{ store: StoreConsumer; refCount: number }
|
||||
>();
|
||||
private readonly telemetry = new TelemetryManager();
|
||||
private readonly realtime = new RealtimeManager();
|
||||
|
||||
constructor(
|
||||
private readonly availableStorageImplementations: StorageConstructor[]
|
||||
@@ -393,6 +395,12 @@ export class StoreManagerConsumer {
|
||||
'telemetry.pageview': event => this.telemetry.pageview(event),
|
||||
'telemetry.flush': () => this.telemetry.flush(),
|
||||
'telemetry.getQueueState': () => this.telemetry.getQueueState(),
|
||||
'realtime.configure': context => this.realtime.setContext(context),
|
||||
'realtime.request': ({ op, input, timeoutMs }) =>
|
||||
this.realtime.request(op, input, { timeoutMs }),
|
||||
'realtime.subscribe': ({ topic, input }) =>
|
||||
this.realtime.subscribe(topic, input),
|
||||
'realtime.status': () => this.realtime.getStatus(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import type {
|
||||
RealtimeConfigureInput,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
RealtimeStatus,
|
||||
RealtimeSubscriptionReady,
|
||||
RealtimeTopicEventOf,
|
||||
RealtimeTopicInputOf,
|
||||
RealtimeTopicName,
|
||||
} from '@affine/realtime';
|
||||
|
||||
import type { AvailableStorageImplementations } from '../impls';
|
||||
import type {
|
||||
AggregateResult,
|
||||
@@ -189,4 +201,25 @@ export type WorkerManagerOps = {
|
||||
'telemetry.pageview': [TelemetryEvent, { queued: boolean }];
|
||||
'telemetry.flush': [void, TelemetryAck];
|
||||
'telemetry.getQueueState': [void, TelemetryQueueState];
|
||||
'realtime.configure': [RealtimeConfigureInput, void];
|
||||
'realtime.request': [
|
||||
{
|
||||
[Op in RealtimeRequestName]: {
|
||||
op: Op;
|
||||
input: RealtimeRequestInputOf<Op>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
}[RealtimeRequestName],
|
||||
RealtimeRequestOutputOf<RealtimeRequestName>,
|
||||
];
|
||||
'realtime.subscribe': [
|
||||
{
|
||||
[Topic in RealtimeTopicName]: {
|
||||
topic: Topic;
|
||||
input: RealtimeTopicInputOf<Topic>;
|
||||
};
|
||||
}[RealtimeTopicName],
|
||||
RealtimeTopicEventOf<RealtimeTopicName> | RealtimeSubscriptionReady,
|
||||
];
|
||||
'realtime.status': [void, RealtimeStatus];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user