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:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions
@@ -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');
});
});
});
+131
View File
@@ -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;
+184
View File
@@ -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);
};
}
+45
View File
@@ -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);
}