useRealtimeMessages.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import { RealtimeChannel, RealtimeClient } from '@supabase/realtime-js'
  2. import { sortBy, take } from 'lodash'
  3. import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react'
  4. import { toast } from 'sonner'
  5. import type { LogData } from './Messages.types'
  6. import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
  7. import { uuidv4 } from '@/lib/helpers'
  8. import { EMPTY_ARR } from '@/lib/void'
  9. import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
  10. const DEFAULT_HEADERS = { 'X-Client-Info': 'briven-js-web/studio' }
  11. function reducer(
  12. state: LogData[],
  13. action: { type: 'add'; payload: { messageType: string; metadata: any } } | { type: 'clear' }
  14. ) {
  15. if (action.type === 'clear') {
  16. return EMPTY_ARR
  17. }
  18. const newState = take(
  19. sortBy(
  20. [
  21. {
  22. id: uuidv4(),
  23. timestamp: new Date().getTime(),
  24. message: action.payload.messageType,
  25. metadata: action.payload.metadata,
  26. } as LogData,
  27. ...state,
  28. ],
  29. (l) => -l.timestamp
  30. ),
  31. 100
  32. )
  33. return newState
  34. }
  35. export interface RealtimeConfig {
  36. enabled: boolean
  37. channelName: string
  38. projectRef: string
  39. logLevel: string
  40. token: string
  41. schema: string
  42. table: string
  43. isChannelPrivate: boolean
  44. filter: string | undefined
  45. bearer: string | null
  46. enableBroadcast: boolean
  47. enablePresence: boolean
  48. enableDbChanges: boolean
  49. }
  50. export const useRealtimeMessages = (
  51. config: RealtimeConfig,
  52. setRealtimeConfig: Dispatch<SetStateAction<RealtimeConfig>>
  53. ) => {
  54. const {
  55. enabled,
  56. channelName,
  57. projectRef,
  58. logLevel,
  59. token,
  60. schema,
  61. table,
  62. isChannelPrivate,
  63. filter,
  64. bearer,
  65. enablePresence,
  66. enableDbChanges,
  67. enableBroadcast,
  68. } = config
  69. const { data: settings } = useProjectSettingsV2Query({ projectRef: projectRef })
  70. const protocol = settings?.app_config?.protocol ?? 'https'
  71. const endpoint = settings?.app_config?.endpoint
  72. // the default host is prod until the correct one comes through an API call.
  73. const host = settings ? `${protocol}://${endpoint}` : `https://${projectRef}.supabase.co`
  74. const realtimeUrl = `${host}/realtime/v1`.replace(/^http/i, 'ws')
  75. const [logData, dispatch] = useReducer(reducer, [] as LogData[])
  76. const pushMessage = (messageType: string, metadata: any) => {
  77. dispatch({ type: 'add', payload: { messageType, metadata } })
  78. }
  79. // Instantiate our client with the Realtime server and params to connect with
  80. let [client, setClient] = useState<RealtimeClient>()
  81. let [channel, setChannel] = useState<RealtimeChannel | undefined>()
  82. const roleImpersonationState = useRoleImpersonationStateSnapshot()
  83. useEffect(() => {
  84. if (!enabled) {
  85. return
  86. }
  87. const options = {
  88. vsn: '2.0.0',
  89. headers: DEFAULT_HEADERS,
  90. params: { apikey: token, log_level: logLevel },
  91. }
  92. const realtimeClient = new RealtimeClient(realtimeUrl, options)
  93. if (bearer) {
  94. realtimeClient.setAuth(bearer)
  95. }
  96. setClient(realtimeClient)
  97. return () => {
  98. realtimeClient.disconnect()
  99. setClient(undefined)
  100. }
  101. }, [enabled, bearer, host, logLevel, token])
  102. useEffect(() => {
  103. if (!client) {
  104. return
  105. }
  106. dispatch({ type: 'clear' })
  107. const newChannel = client?.channel(channelName, {
  108. config: { broadcast: { self: true }, private: isChannelPrivate },
  109. })
  110. // Hack to confirm Postgres is subscribed
  111. // Need to add 'extension' key in the 'payload'
  112. newChannel.on('system' as any, {} as any, (payload: any) => {
  113. pushMessage('SYSTEM', payload)
  114. })
  115. if (enableBroadcast) {
  116. // Listen for all (`*`) `broadcast` messages
  117. // The message name can by anything
  118. // Match on specific message names to filter for only those types of messages and do something with them
  119. newChannel.on('broadcast', { event: '*' }, (payload) => pushMessage('BROADCAST', payload))
  120. }
  121. // Listen for all (`*`) `presence` messages
  122. if (enablePresence) {
  123. newChannel.on('presence' as any, { event: '*' }, (payload) => {
  124. pushMessage('PRESENCE', payload)
  125. })
  126. }
  127. if (enableDbChanges) {
  128. let postgres_changes_opts: any = {
  129. event: '*',
  130. schema: schema,
  131. table: table,
  132. filter: undefined,
  133. }
  134. if (filter !== '') {
  135. postgres_changes_opts.filter = filter
  136. }
  137. newChannel.on('postgres_changes' as any, postgres_changes_opts, (payload: any) => {
  138. let ts = performance.now() + performance.timeOrigin
  139. let payload_ts = Date.parse(payload.commit_timestamp)
  140. let latency = ts - payload_ts
  141. pushMessage('POSTGRES', { ...payload, latency })
  142. })
  143. }
  144. // Finally, subscribe to the Channel we just setup
  145. newChannel.subscribe(async (status, err) => {
  146. if (status === 'SUBSCRIBED') {
  147. // Let LiveView know we connected so we can update the button text
  148. // pushMessageTo('#conn_info', 'broadcast_subscribed', { host: host })
  149. const role = roleImpersonationState.role?.role
  150. const computedRole =
  151. role === undefined
  152. ? 'service_role_'
  153. : role === 'anon'
  154. ? 'anon_role_'
  155. : role === 'authenticated'
  156. ? 'authenticated_role_'
  157. : 'user_name_'
  158. if (enablePresence) {
  159. const name = computedRole + Math.floor(Math.random() * 100)
  160. newChannel.send({
  161. type: 'presence',
  162. event: 'TRACK',
  163. payload: { name: name, t: performance.now() },
  164. })
  165. }
  166. } else if (status === 'CHANNEL_ERROR') {
  167. if (err?.message) {
  168. toast.error(`Failed to connect with the following error: ${err.message}`)
  169. } else {
  170. toast.error(`Failed to connect. Please check your RLS policies and try again.`)
  171. }
  172. newChannel.unsubscribe()
  173. setChannel(undefined)
  174. setRealtimeConfig({ ...config, channelName: '', enabled: false })
  175. }
  176. })
  177. setChannel(newChannel)
  178. return () => {
  179. newChannel.unsubscribe()
  180. setChannel(undefined)
  181. }
  182. }, [
  183. client,
  184. channelName,
  185. enableBroadcast,
  186. enableDbChanges,
  187. enablePresence,
  188. filter,
  189. host,
  190. schema,
  191. table,
  192. ])
  193. const sendMessage = useCallback(
  194. async (message: string, payload: any, callback: () => void) => {
  195. if (channel) {
  196. const res = await channel.send({
  197. type: 'broadcast',
  198. event: message,
  199. payload,
  200. })
  201. if (res === 'error') {
  202. toast.error('Failed to broadcast message')
  203. } else {
  204. toast.success('Successfully broadcasted message')
  205. callback()
  206. }
  207. } else {
  208. toast.error('Failed to broadcast message: channel has not been set')
  209. }
  210. },
  211. [channel]
  212. )
  213. return { logData, sendMessage }
  214. }