diff --git a/lib/facts/identity-search.js b/lib/facts/identity-search.js new file mode 100644 --- /dev/null +++ b/lib/facts/identity-search.js @@ -0,0 +1,9 @@ +// @flow + +import { isDev } from '../utils/dev-utils.js'; + +const identitySearchURL: string = isDev + ? 'wss://identity.staging.commtechnologies.org:51004' + : 'wss://identity.commtechnologies.org:51004'; + +export { identitySearchURL }; diff --git a/lib/identity-search/identity-search-context.js b/lib/identity-search/identity-search-context.js new file mode 100644 --- /dev/null +++ b/lib/identity-search/identity-search-context.js @@ -0,0 +1,186 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import { identitySearchURL } from '../facts/identity-search.js'; +import { identitySearchHeartbeatTimeout } from '../shared/timeouts.js'; +import type { IdentitySearchAuthMessage } from '../types/identity-search/auth-message-types.js'; +import { + type IdentitySearchMessageToClient, + identitySearchMessageToClientTypes, + identitySearchMessageToServerTypes, + identitySearchMessageToClientValidator, +} from '../types/identity-search/messages.js'; +import type { Heartbeat } from '../types/websocket/heartbeat-types.js'; +import { useGetIdentitySearchAuthMessage } from '../utils/identity-search-utils.js'; + +export type IdentitySearchSocketListener = ( + message: IdentitySearchMessageToClient, +) => mixed; + +type IdentitySearchContextType = { + +addListener: (listener: IdentitySearchSocketListener) => void, + +removeListener: (listener: IdentitySearchSocketListener) => void, + +connected: boolean, +}; + +const IdentitySearchContext: React.Context = + React.createContext(); + +type Props = { + +children: React.Node, +}; + +function IdentitySearchProvider(props: Props): React.Node { + const { children } = props; + const [connected, setConnected] = React.useState(false); + const listeners = React.useRef>(new Set()); + const getIdentitySearchAuthMessage = useGetIdentitySearchAuthMessage(); + const [identitySearchAuthMessage, setIdentitySearchAuthMessage] = + React.useState(null); + const socket = React.useRef(null); + const heartbeatTimeoutID = React.useRef(); + + const stopHeartbeatTimeout = React.useCallback(() => { + if (heartbeatTimeoutID.current) { + clearTimeout(heartbeatTimeoutID.current); + heartbeatTimeoutID.current = null; + } + }, []); + + const resetHeartbeatTimeout = React.useCallback(() => { + stopHeartbeatTimeout(); + heartbeatTimeoutID.current = setTimeout(() => { + socket.current?.close(); + setConnected(false); + }, identitySearchHeartbeatTimeout); + }, [stopHeartbeatTimeout]); + + React.useEffect(() => { + void (async () => { + const newAuthMessage = await getIdentitySearchAuthMessage(); + setIdentitySearchAuthMessage(newAuthMessage); + })(); + }, [getIdentitySearchAuthMessage]); + + React.useEffect(() => { + if (connected || !identitySearchAuthMessage) { + return; + } + + const identitySearchSocket = new WebSocket(identitySearchURL); + + identitySearchSocket.onopen = () => { + identitySearchSocket.send(JSON.stringify(identitySearchAuthMessage)); + }; + + identitySearchSocket.onclose = () => { + setConnected(false); + }; + + identitySearchSocket.onerror = e => { + setConnected(false); + console.log('Identity Search socket error', e.message); + }; + + identitySearchSocket.onmessage = (event: MessageEvent) => { + if (typeof event.data !== 'string') { + console.log('socket received a non-string message'); + return; + } + let rawMessage; + try { + rawMessage = JSON.parse(event.data); + } catch (e) { + console.log('error while parsing Identity Search message:', e.message); + return; + } + + if (!identitySearchMessageToClientValidator.is(rawMessage)) { + console.log('invalid Identity Search message'); + return; + } + + const message: IdentitySearchMessageToClient = rawMessage; + + resetHeartbeatTimeout(); + + for (const listener of listeners.current) { + listener(message); + } + + if ( + message.type === + identitySearchMessageToClientTypes.CONNECTION_INITIALIZATION_RESPONSE + ) { + if (message.status.type === 'Success' && !connected) { + setConnected(true); + } else if (message.status.type === 'Success' && connected) { + console.log( + 'received ConnectionInitializationResponse with status:', + 'Success for already connected socket', + ); + } else { + setConnected(false); + console.log( + 'creating session with Identity Search error:', + message.status.data, + ); + } + } else if ( + message.type === identitySearchMessageToClientTypes.HEARTBEAT + ) { + const heartbeat: Heartbeat = { + type: identitySearchMessageToServerTypes.HEARTBEAT, + }; + socket.current?.send(JSON.stringify(heartbeat)); + } + }; + + socket.current = identitySearchSocket; + }, [ + connected, + identitySearchAuthMessage, + resetHeartbeatTimeout, + stopHeartbeatTimeout, + ]); + + const addListener = React.useCallback( + (listener: IdentitySearchSocketListener) => { + listeners.current.add(listener); + }, + [], + ); + + const removeListener = React.useCallback( + (listener: IdentitySearchSocketListener) => { + listeners.current.delete(listener); + }, + [], + ); + + const value: IdentitySearchContextType = React.useMemo( + () => ({ + connected, + addListener, + removeListener, + }), + [connected, addListener, removeListener], + ); + + return ( + + {children} + + ); +} + +function useIdentitySearch(): IdentitySearchContextType { + const context = React.useContext(IdentitySearchContext); + invariant(context, 'IdentitySearchContext not found'); + + return context; +} + +export { IdentitySearchProvider, useIdentitySearch }; diff --git a/lib/shared/timeouts.js b/lib/shared/timeouts.js --- a/lib/shared/timeouts.js +++ b/lib/shared/timeouts.js @@ -36,3 +36,7 @@ // Time after which the client consider the Tunnelbroker connection // as unhealthy and chooses to close the socket. export const tunnelbrokerHeartbeatTimeout = 9000; // in milliseconds + +// Time after which the client consider the Identity Search connection +// as unhealthy and chooses to close the socket. +export const identitySearchHeartbeatTimeout = 9000; // in milliseconds diff --git a/lib/utils/identity-search-utils.js b/lib/utils/identity-search-utils.js new file mode 100644 --- /dev/null +++ b/lib/utils/identity-search-utils.js @@ -0,0 +1,36 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import { IdentityClientContext } from '../shared/identity-client-context.js'; +import { type IdentitySearchAuthMessage } from '../types/identity-search/auth-message-types.js'; + +export function useGetIdentitySearchAuthMessage(): () => Promise { + const identityContext = React.useContext(IdentityClientContext); + invariant(identityContext, 'Identity context should be set'); + const getAuthMetadata = identityContext.getAuthMetadata; + + return React.useCallback(async () => { + if (!getAuthMetadata) { + return null; + } + + const authMetadata = await getAuthMetadata(); + + if ( + !authMetadata.userID || + !authMetadata.deviceID || + !authMetadata.accessToken + ) { + throw new Error('Auth metadata is incomplete'); + } + + return { + type: 'IdentitySearchAuthMessage', + userID: authMetadata?.userID, + deviceID: authMetadata?.deviceID, + accessToken: authMetadata?.accessToken, + }; + }, [getAuthMetadata]); +}