import { v4 as uuid4 } from '@lukeed/uuid' import { ASTNode, print } from 'graphql' import { Client, ClientOptions as GraphqlWsClientOptions, createClient, ExecutionResult } from 'graphql-ws' import WebSocket from 'isomorphic-ws' import { createAuth } from './create-auth' import { ClientOptions, Logger } from './types' type ObservableHandler = (value: ExecutionResult, unknown>) => void export interface SubscribeOptions { query: ASTNode variables?: Record } // Extends core client with subscriptions export const createSubscriptionClient = function ( options: ClientOptions, { _logger, _onAuthStateChange, }: { _logger: Logger; _onAuthStateChange?: ReturnType['_onAuthStateChange'] }, ) { const { apiKey, getCredentials, onError } = options let subscriptionClient: Client | undefined const _createSubscriptionClient = () => { _logger('Creating subscription client...') let websocketUrl = options.websocketUrl ?? '' // Just in case user adds trailing slash if (websocketUrl.endsWith('/')) { websocketUrl = websocketUrl.slice(0, -1) } const subClientOptions: GraphqlWsClientOptions = { url: websocketUrl, webSocketImpl: WebSocket, } // If user provides getCredentials, pass that to connectionParams if (typeof getCredentials === 'function') { // This will be called for every connection_init event and the payload will contain { token: OR apiKey: } subClientOptions.connectionParams = async () => { const credentials = await getCredentials() return { ...credentials, } } } else if (apiKey) { // If they initialized client with apiKey just pass in static object subClientOptions.connectionParams = { apiKey } } const subClient = createClient(subClientOptions) subClient.on('connecting', () => _logger('subscriptionClient connecting...')) subClient.on('connected', () => _logger('subscriptionClient connected!')) subClient.on('closed', () => _logger('subscriptionClient ---disconnected!!!---')) return subClient } /* Because we may not have the observable's raw unsubscribe function available to return to user when they create a subscribtion, we return a proxy "wrapper" function that will look-up the real unsub func from connectedUnsubscribeMap when called. */ const connectedUnsubscribeMap = new Map void>() const _subscribe = ({ query, variables }: SubscribeOptions, handler: ObservableHandler) => { const queryAsString = print(query) _logger(`subscriptionClient creating subscription ${queryAsString}`) if (options.websocketUrl === undefined) { throw new Error('Please initialize @vendia/client with the websocketUrl option in order to use subscriptions.') } // Don't create a socket connection until user tries to use subscription if (subscriptionClient === undefined) { subscriptionClient = _createSubscriptionClient() } const unsubscribe = subscriptionClient.subscribe( { query: queryAsString, variables }, { next: handler, error: (error: Error) => { _logger('subscriptionClient error:', JSON.stringify(error, null, 2)) if (typeof onError === 'function') { onError(error) } }, complete: () => { _logger('subscriptionClient complete fired') }, }, ) const observableId = uuid4() connectedUnsubscribeMap.set(observableId, unsubscribe) return createUnsubscribeWrapperFunc(observableId) } const createUnsubscribeWrapperFunc = (observableId: string) => { return () => { const unsubFunc = connectedUnsubscribeMap.get(observableId) if (unsubFunc) { _logger('Unsubscribing.') unsubFunc() return } _logger( 'Tried to unsubscribe, but subscription does not exist. This probably means unsubscribe has already been called for this subscription.', ) } } return { _subscribe, } }