mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 02:56:23 +08:00
feat!: affine cloud support (#3813)
Co-authored-by: Hongtao Lye <codert.sn@gmail.com> Co-authored-by: liuyi <forehalo@gmail.com> Co-authored-by: LongYinan <lynweklm@gmail.com> Co-authored-by: X1a0t <405028157@qq.com> Co-authored-by: JimmFly <yangjinfei001@gmail.com> Co-authored-by: Peng Xiao <pengxiao@outlook.com> Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com> Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: Qi <474021214@qq.com> Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { uploadAvatarMutation } from '@affine/graphql';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { Mock } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMutation, useQuery } from '../gql';
|
||||
|
||||
let fetch: Mock;
|
||||
describe('GraphQL wrapper for SWR', () => {
|
||||
beforeEach(() => {
|
||||
fetch = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { hello: 1 } }), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetch.mockReset();
|
||||
});
|
||||
|
||||
describe('useQuery', () => {
|
||||
const Component = ({ id }: { id: number }) => {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
query: {
|
||||
id: 'query',
|
||||
query: `
|
||||
query {
|
||||
hello
|
||||
}
|
||||
`,
|
||||
operationName: 'query',
|
||||
definitionName: 'query',
|
||||
},
|
||||
// @ts-expect-error forgive the fake variables
|
||||
variables: { id },
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>loading</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>error</div>;
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
return <div>number: {data!.hello}</div>;
|
||||
};
|
||||
|
||||
it('should send query correctly', async () => {
|
||||
const component = <Component id={1} />;
|
||||
const renderer = render(component);
|
||||
const el = await renderer.findByText('number: 1');
|
||||
expect(el).toMatchInlineSnapshot(`
|
||||
<div>
|
||||
number:${' '}
|
||||
1
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not send request if cache hit', async () => {
|
||||
const component = <Component id={2} />;
|
||||
const renderer = render(component);
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
|
||||
renderer.rerender(component);
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
|
||||
render(<Component id={3} />);
|
||||
|
||||
expect(fetch).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMutation', () => {
|
||||
const Component = () => {
|
||||
const { trigger, error, isMutating } = useMutation({
|
||||
mutation: {
|
||||
id: 'mutation',
|
||||
query: `
|
||||
mutation {
|
||||
hello
|
||||
}
|
||||
`,
|
||||
operationName: 'mutation',
|
||||
definitionName: 'mutation',
|
||||
},
|
||||
});
|
||||
|
||||
if (isMutating) {
|
||||
return <div>mutating</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => trigger()}>click</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
it('should trigger mutation', async () => {
|
||||
const component = <Component />;
|
||||
const renderer = render(component);
|
||||
const button = await renderer.findByText('click');
|
||||
|
||||
button.click();
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
|
||||
renderer.rerender(component);
|
||||
expect(renderer.asFragment()).toMatchInlineSnapshot(`
|
||||
<DocumentFragment>
|
||||
<div>
|
||||
mutating
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should get rid of generated types', async () => {
|
||||
function _NotActuallyRunDefinedForTypeTesting() {
|
||||
const { trigger } = useMutation({
|
||||
mutation: uploadAvatarMutation,
|
||||
});
|
||||
trigger({
|
||||
id: '1',
|
||||
avatar: new File([''], 'avatar.png'),
|
||||
});
|
||||
}
|
||||
expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { setupGlobal } from '@affine/env/global';
|
||||
import type {
|
||||
GraphQLQuery,
|
||||
MutationOptions,
|
||||
QueryOptions,
|
||||
QueryResponse,
|
||||
QueryVariables,
|
||||
} from '@affine/graphql';
|
||||
import { gqlFetcherFactory } from '@affine/graphql';
|
||||
import type { GraphQLError } from 'graphql';
|
||||
import type { Key, SWRConfiguration, SWRResponse } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import type {
|
||||
SWRMutationConfiguration,
|
||||
SWRMutationResponse,
|
||||
} from 'swr/mutation';
|
||||
import useSWRMutation from 'swr/mutation';
|
||||
|
||||
setupGlobal();
|
||||
|
||||
export const fetcher = gqlFetcherFactory(
|
||||
runtimeConfig.serverUrlPrefix + '/graphql'
|
||||
);
|
||||
|
||||
/**
|
||||
* A `useSWR` wrapper for sending graphql queries
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* import { someQuery, someQueryWithNoVars } from '@affine/graphql'
|
||||
*
|
||||
* const swrResponse1 = useQuery({
|
||||
* query: workspaceByIdQuery,
|
||||
* variables: { id: '1' }
|
||||
* })
|
||||
*
|
||||
* const swrResponse2 = useQuery({
|
||||
* query: someQueryWithNoVars
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>
|
||||
): SWRResponse<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError | GraphQLError[],
|
||||
{
|
||||
suspense: true;
|
||||
}
|
||||
>;
|
||||
export function useQuery<Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>,
|
||||
config: Omit<
|
||||
SWRConfiguration<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError | GraphQLError[],
|
||||
typeof fetcher<Query>
|
||||
>,
|
||||
'fetcher'
|
||||
>
|
||||
): SWRResponse<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError | GraphQLError[],
|
||||
{
|
||||
suspense: true;
|
||||
}
|
||||
>;
|
||||
export function useQuery<Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>,
|
||||
config?: any
|
||||
) {
|
||||
return useSWR(
|
||||
() => ['cloud', options.query.id, options.variables],
|
||||
() => fetcher(options),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A useSWRMutation wrapper for sending graphql mutations
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* import { someMutation } from '@affine/graphql'
|
||||
*
|
||||
* const { trigger } = useMutation({
|
||||
* mutation: someMutation,
|
||||
* })
|
||||
*
|
||||
* trigger({ name: 'John Doe' })
|
||||
*/
|
||||
export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
|
||||
options: Omit<MutationOptions<Mutation>, 'variables'>
|
||||
): SWRMutationResponse<
|
||||
QueryResponse<Mutation>,
|
||||
GraphQLError | GraphQLError[],
|
||||
K,
|
||||
QueryVariables<Mutation>
|
||||
>;
|
||||
export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
|
||||
options: Omit<MutationOptions<Mutation>, 'variables'>,
|
||||
config: Omit<
|
||||
SWRMutationConfiguration<
|
||||
QueryResponse<Mutation>,
|
||||
GraphQLError | GraphQLError[],
|
||||
K,
|
||||
QueryVariables<Mutation>
|
||||
>,
|
||||
'fetcher'
|
||||
>
|
||||
): SWRMutationResponse<
|
||||
QueryResponse<Mutation>,
|
||||
GraphQLError | GraphQLError[],
|
||||
K,
|
||||
QueryVariables<Mutation>
|
||||
>;
|
||||
export function useMutation(
|
||||
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
|
||||
config?: any
|
||||
) {
|
||||
return useSWRMutation(
|
||||
() => ['cloud', options.mutation.id],
|
||||
(_: unknown[], { arg }: { arg: any }) =>
|
||||
fetcher({ ...options, query: options.mutation, variables: arg }),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
export const gql = fetcher;
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { DatasourceDocAdapter } from '@affine/y-provider';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { Manager } from 'socket.io-client';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
type Awareness,
|
||||
encodeAwarenessUpdate,
|
||||
} from 'y-protocols/awareness';
|
||||
import type { Doc } from 'yjs';
|
||||
|
||||
import {
|
||||
type AwarenessChanges,
|
||||
base64ToUint8Array,
|
||||
uint8ArrayToBase64,
|
||||
} from './utils';
|
||||
|
||||
let ioManager: Manager | null = null;
|
||||
// use lazy initialization to avoid global side effect
|
||||
function getIoManager(): Manager {
|
||||
if (ioManager) {
|
||||
return ioManager;
|
||||
}
|
||||
ioManager = new Manager(runtimeConfig.serverUrlPrefix + '/', {
|
||||
autoConnect: false,
|
||||
});
|
||||
return ioManager;
|
||||
}
|
||||
|
||||
export const createAffineDataSource = (
|
||||
id: string,
|
||||
rootDoc: Doc,
|
||||
awareness: Awareness
|
||||
) => {
|
||||
if (id !== rootDoc.guid) {
|
||||
console.warn('important!! please use doc.guid as roomName');
|
||||
}
|
||||
|
||||
const socket = getIoManager().socket('/');
|
||||
|
||||
return {
|
||||
get socket() {
|
||||
return socket;
|
||||
},
|
||||
queryDocState: async (guid, options) => {
|
||||
const stateVector = options?.stateVector
|
||||
? await uint8ArrayToBase64(options.stateVector)
|
||||
: undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit(
|
||||
'doc-load',
|
||||
{
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
stateVector,
|
||||
},
|
||||
(docState: Error | { missing: string; state: string } | null) => {
|
||||
if (docState instanceof Error) {
|
||||
reject(docState);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(
|
||||
docState
|
||||
? {
|
||||
missing: base64ToUint8Array(docState.missing),
|
||||
state: docState.state
|
||||
? base64ToUint8Array(docState.state)
|
||||
: undefined,
|
||||
}
|
||||
: false
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
sendDocUpdate: async (guid: string, update: Uint8Array) => {
|
||||
socket.emit('client-update', {
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
update: await uint8ArrayToBase64(update),
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
onDocUpdate: callback => {
|
||||
socket.on('connect', () => {
|
||||
socket.emit('client-handshake', rootDoc.guid);
|
||||
});
|
||||
const onUpdate = async (message: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
update: string;
|
||||
}) => {
|
||||
if (message.workspaceId === rootDoc.guid) {
|
||||
callback(message.guid, base64ToUint8Array(message.update));
|
||||
}
|
||||
};
|
||||
socket.on('server-update', onUpdate);
|
||||
const destroyAwareness = setupAffineAwareness(socket, rootDoc, awareness);
|
||||
|
||||
socket.connect();
|
||||
return () => {
|
||||
socket.emit('client-leave', rootDoc.guid);
|
||||
socket.off('server-update', onUpdate);
|
||||
destroyAwareness();
|
||||
socket.disconnect();
|
||||
};
|
||||
},
|
||||
} satisfies DatasourceDocAdapter & { readonly socket: Socket };
|
||||
};
|
||||
|
||||
function setupAffineAwareness(
|
||||
conn: Socket,
|
||||
rootDoc: Doc,
|
||||
awareness: Awareness
|
||||
) {
|
||||
const awarenessBroadcast = ({
|
||||
workspaceId,
|
||||
awarenessUpdate,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
awarenessUpdate: string;
|
||||
}) => {
|
||||
if (workspaceId !== rootDoc.guid) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyAwarenessUpdate(
|
||||
awareness,
|
||||
base64ToUint8Array(awarenessUpdate),
|
||||
'server'
|
||||
);
|
||||
};
|
||||
|
||||
const awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
|
||||
if (origin === 'server') {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedClients = Object.values(changes).reduce((res, cur) => [
|
||||
...res,
|
||||
...cur,
|
||||
]);
|
||||
|
||||
const update = encodeAwarenessUpdate(awareness, changedClients);
|
||||
uint8ArrayToBase64(update)
|
||||
.then(encodedUpdate => {
|
||||
conn.emit('awareness-update', {
|
||||
workspaceId: rootDoc.guid,
|
||||
awarenessUpdate: encodedUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
};
|
||||
|
||||
const newClientAwarenessInitHandler = () => {
|
||||
const awarenessUpdate = encodeAwarenessUpdate(awareness, [
|
||||
awareness.clientID,
|
||||
]);
|
||||
uint8ArrayToBase64(awarenessUpdate)
|
||||
.then(encodedAwarenessUpdate => {
|
||||
conn.emit('awareness-update', {
|
||||
guid: rootDoc.guid,
|
||||
awarenessUpdate: encodedAwarenessUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
};
|
||||
|
||||
conn.on('server-awareness-broadcast', awarenessBroadcast);
|
||||
conn.on('new-client-awareness-init', newClientAwarenessInitHandler);
|
||||
awareness.on('update', awarenessUpdate);
|
||||
|
||||
conn.on('connect', () => {
|
||||
conn.emit('awareness-init', rootDoc.guid);
|
||||
});
|
||||
|
||||
return () => {
|
||||
awareness.off('update', awarenessUpdate);
|
||||
conn.off('server-awareness-broadcast', awarenessBroadcast);
|
||||
conn.off('new-client-awareness-init', newClientAwarenessInitHandler);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Doc as YDoc } from 'yjs';
|
||||
|
||||
export type SubdocEvent = {
|
||||
loaded: Set<YDoc>;
|
||||
removed: Set<YDoc>;
|
||||
added: Set<YDoc>;
|
||||
};
|
||||
|
||||
export type UpdateHandler = (update: Uint8Array, origin: unknown) => void;
|
||||
export type SubdocsHandler = (event: SubdocEvent) => void;
|
||||
export type DestroyHandler = () => void;
|
||||
|
||||
export type AwarenessChanges = Record<
|
||||
'added' | 'updated' | 'removed',
|
||||
number[]
|
||||
>;
|
||||
|
||||
export function uint8ArrayToBase64(array: Uint8Array): Promise<string> {
|
||||
return new Promise<string>(resolve => {
|
||||
// Create a blob from the Uint8Array
|
||||
const blob = new Blob([array]);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
const dataUrl = reader.result as string | null;
|
||||
if (!dataUrl) {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
// The result includes the `data:` URL prefix and the MIME type. We only want the Base64 data
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function base64ToUint8Array(base64: string) {
|
||||
const binaryString = atob(base64);
|
||||
const binaryArray = binaryString.split('').map(function (char) {
|
||||
return char.charCodeAt(0);
|
||||
});
|
||||
return new Uint8Array(binaryArray);
|
||||
}
|
||||
Reference in New Issue
Block a user