wip:milestone 0 fixes
Some checks failed
CI/CD Pipeline / unit-tests (push) Failing after 1m16s
CI/CD Pipeline / integration-tests (push) Failing after 2m32s
CI/CD Pipeline / lint (push) Successful in 5m22s
CI/CD Pipeline / e2e-tests (push) Has been skipped
CI/CD Pipeline / build (push) Has been skipped

This commit is contained in:
2026-03-15 12:35:42 +02:00
parent 6708cf28a7
commit cffdf8af86
61266 changed files with 4511646 additions and 1938 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
/*
This file draws heavily from https://github.com/phoenixframework/phoenix/blob/d344ec0a732ab4ee204215b31de69cf4be72e3bf/assets/js/phoenix/presence.js
License: https://github.com/phoenixframework/phoenix/blob/d344ec0a732ab4ee204215b31de69cf4be72e3bf/LICENSE.md
*/
import type { PresenceOpts, PresenceOnJoinCallback, PresenceOnLeaveCallback } from 'phoenix'
import type RealtimeChannel from './RealtimeChannel'
type Presence<T extends { [key: string]: any } = {}> = {
presence_ref: string
} & T
export type RealtimePresenceState<T extends { [key: string]: any } = {}> = {
[key: string]: Presence<T>[]
}
export type RealtimePresenceJoinPayload<T extends { [key: string]: any }> = {
event: `${REALTIME_PRESENCE_LISTEN_EVENTS.JOIN}`
key: string
currentPresences: Presence<T>[]
newPresences: Presence<T>[]
}
export type RealtimePresenceLeavePayload<T extends { [key: string]: any }> = {
event: `${REALTIME_PRESENCE_LISTEN_EVENTS.LEAVE}`
key: string
currentPresences: Presence<T>[]
leftPresences: Presence<T>[]
}
export enum REALTIME_PRESENCE_LISTEN_EVENTS {
SYNC = 'sync',
JOIN = 'join',
LEAVE = 'leave',
}
type PresenceDiff = {
joins: RealtimePresenceState
leaves: RealtimePresenceState
}
type RawPresenceState = {
[key: string]: {
metas: {
phx_ref?: string
phx_ref_prev?: string
[key: string]: any
}[]
}
}
type RawPresenceDiff = {
joins: RawPresenceState
leaves: RawPresenceState
}
type PresenceChooser<T> = (key: string, presences: Presence[]) => T
export default class RealtimePresence {
state: RealtimePresenceState = {}
pendingDiffs: RawPresenceDiff[] = []
joinRef: string | null = null
enabled: boolean = false
caller: {
onJoin: PresenceOnJoinCallback
onLeave: PresenceOnLeaveCallback
onSync: () => void
} = {
onJoin: () => {},
onLeave: () => {},
onSync: () => {},
}
/**
* Creates a Presence helper that keeps the local presence state in sync with the server.
*
* @param channel - The realtime channel to bind to.
* @param opts - Optional custom event names, e.g. `{ events: { state: 'state', diff: 'diff' } }`.
*
* @example
* ```ts
* const presence = new RealtimePresence(channel)
*
* channel.on('presence', ({ event, key }) => {
* console.log(`Presence ${event} on ${key}`)
* })
* ```
*/
constructor(
public channel: RealtimeChannel,
opts?: PresenceOpts
) {
const events = opts?.events || {
state: 'presence_state',
diff: 'presence_diff',
}
this.channel._on(events.state, {}, (newState: RawPresenceState) => {
const { onJoin, onLeave, onSync } = this.caller
this.joinRef = this.channel._joinRef()
this.state = RealtimePresence.syncState(this.state, newState, onJoin, onLeave)
this.pendingDiffs.forEach((diff) => {
this.state = RealtimePresence.syncDiff(this.state, diff, onJoin, onLeave)
})
this.pendingDiffs = []
onSync()
})
this.channel._on(events.diff, {}, (diff: RawPresenceDiff) => {
const { onJoin, onLeave, onSync } = this.caller
if (this.inPendingSyncState()) {
this.pendingDiffs.push(diff)
} else {
this.state = RealtimePresence.syncDiff(this.state, diff, onJoin, onLeave)
onSync()
}
})
this.onJoin((key, currentPresences, newPresences) => {
this.channel._trigger('presence', {
event: 'join',
key,
currentPresences,
newPresences,
})
})
this.onLeave((key, currentPresences, leftPresences) => {
this.channel._trigger('presence', {
event: 'leave',
key,
currentPresences,
leftPresences,
})
})
this.onSync(() => {
this.channel._trigger('presence', { event: 'sync' })
})
}
/**
* Used to sync the list of presences on the server with the
* client's state.
*
* An optional `onJoin` and `onLeave` callback can be provided to
* react to changes in the client's local presences across
* disconnects and reconnects with the server.
*
* @internal
*/
private static syncState(
currentState: RealtimePresenceState,
newState: RawPresenceState | RealtimePresenceState,
onJoin: PresenceOnJoinCallback,
onLeave: PresenceOnLeaveCallback
): RealtimePresenceState {
const state = this.cloneDeep(currentState)
const transformedState = this.transformState(newState)
const joins: RealtimePresenceState = {}
const leaves: RealtimePresenceState = {}
this.map(state, (key: string, presences: Presence[]) => {
if (!transformedState[key]) {
leaves[key] = presences
}
})
this.map(transformedState, (key, newPresences: Presence[]) => {
const currentPresences: Presence[] = state[key]
if (currentPresences) {
const newPresenceRefs = newPresences.map((m: Presence) => m.presence_ref)
const curPresenceRefs = currentPresences.map((m: Presence) => m.presence_ref)
const joinedPresences: Presence[] = newPresences.filter(
(m: Presence) => curPresenceRefs.indexOf(m.presence_ref) < 0
)
const leftPresences: Presence[] = currentPresences.filter(
(m: Presence) => newPresenceRefs.indexOf(m.presence_ref) < 0
)
if (joinedPresences.length > 0) {
joins[key] = joinedPresences
}
if (leftPresences.length > 0) {
leaves[key] = leftPresences
}
} else {
joins[key] = newPresences
}
})
return this.syncDiff(state, { joins, leaves }, onJoin, onLeave)
}
/**
* Used to sync a diff of presence join and leave events from the
* server, as they happen.
*
* Like `syncState`, `syncDiff` accepts optional `onJoin` and
* `onLeave` callbacks to react to a user joining or leaving from a
* device.
*
* @internal
*/
private static syncDiff(
state: RealtimePresenceState,
diff: RawPresenceDiff | PresenceDiff,
onJoin: PresenceOnJoinCallback,
onLeave: PresenceOnLeaveCallback
): RealtimePresenceState {
const { joins, leaves } = {
joins: this.transformState(diff.joins),
leaves: this.transformState(diff.leaves),
}
if (!onJoin) {
onJoin = () => {}
}
if (!onLeave) {
onLeave = () => {}
}
this.map(joins, (key, newPresences: Presence[]) => {
const currentPresences: Presence[] = state[key] ?? []
state[key] = this.cloneDeep(newPresences)
if (currentPresences.length > 0) {
const joinedPresenceRefs = state[key].map((m: Presence) => m.presence_ref)
const curPresences: Presence[] = currentPresences.filter(
(m: Presence) => joinedPresenceRefs.indexOf(m.presence_ref) < 0
)
state[key].unshift(...curPresences)
}
onJoin(key, currentPresences, newPresences)
})
this.map(leaves, (key, leftPresences: Presence[]) => {
let currentPresences: Presence[] = state[key]
if (!currentPresences) return
const presenceRefsToRemove = leftPresences.map((m: Presence) => m.presence_ref)
currentPresences = currentPresences.filter(
(m: Presence) => presenceRefsToRemove.indexOf(m.presence_ref) < 0
)
state[key] = currentPresences
onLeave(key, currentPresences, leftPresences)
if (currentPresences.length === 0) delete state[key]
})
return state
}
/** @internal */
private static map<T = any>(obj: RealtimePresenceState, func: PresenceChooser<T>): T[] {
return Object.getOwnPropertyNames(obj).map((key) => func(key, obj[key]))
}
/**
* Remove 'metas' key
* Change 'phx_ref' to 'presence_ref'
* Remove 'phx_ref' and 'phx_ref_prev'
*
* @example
* // returns {
* abc123: [
* { presence_ref: '2', user_id: 1 },
* { presence_ref: '3', user_id: 2 }
* ]
* }
* RealtimePresence.transformState({
* abc123: {
* metas: [
* { phx_ref: '2', phx_ref_prev: '1' user_id: 1 },
* { phx_ref: '3', user_id: 2 }
* ]
* }
* })
*
* @internal
*/
private static transformState(
state: RawPresenceState | RealtimePresenceState
): RealtimePresenceState {
state = this.cloneDeep(state)
return Object.getOwnPropertyNames(state).reduce((newState, key) => {
const presences = state[key]
if ('metas' in presences) {
newState[key] = presences.metas.map((presence) => {
presence['presence_ref'] = presence['phx_ref']
delete presence['phx_ref']
delete presence['phx_ref_prev']
return presence
}) as Presence[]
} else {
newState[key] = presences
}
return newState
}, {} as RealtimePresenceState)
}
/** @internal */
private static cloneDeep(obj: { [key: string]: any }) {
return JSON.parse(JSON.stringify(obj))
}
/** @internal */
private onJoin(callback: PresenceOnJoinCallback): void {
this.caller.onJoin = callback
}
/** @internal */
private onLeave(callback: PresenceOnLeaveCallback): void {
this.caller.onLeave = callback
}
/** @internal */
private onSync(callback: () => void): void {
this.caller.onSync = callback
}
/** @internal */
private inPendingSyncState(): boolean {
return !this.joinRef || this.joinRef !== this.channel._joinRef()
}
}

View File

@@ -0,0 +1,53 @@
import RealtimeClient, {
RealtimeClientOptions,
RealtimeMessage,
RealtimeRemoveChannelResponse,
WebSocketLikeConstructor,
} from './RealtimeClient'
import RealtimeChannel, {
RealtimeChannelOptions,
RealtimeChannelSendResponse,
RealtimePostgresChangesFilter,
RealtimePostgresChangesPayload,
RealtimePostgresInsertPayload,
RealtimePostgresUpdatePayload,
RealtimePostgresDeletePayload,
REALTIME_LISTEN_TYPES,
REALTIME_POSTGRES_CHANGES_LISTEN_EVENT,
REALTIME_SUBSCRIBE_STATES,
REALTIME_CHANNEL_STATES,
} from './RealtimeChannel'
import RealtimePresence, {
RealtimePresenceState,
RealtimePresenceJoinPayload,
RealtimePresenceLeavePayload,
REALTIME_PRESENCE_LISTEN_EVENTS,
} from './RealtimePresence'
import WebSocketFactory, { WebSocketLike } from './lib/websocket-factory'
export {
RealtimePresence,
RealtimeChannel,
RealtimeChannelOptions,
RealtimeChannelSendResponse,
RealtimeClient,
RealtimeClientOptions,
RealtimeMessage,
RealtimePostgresChangesFilter,
RealtimePostgresChangesPayload,
RealtimePostgresInsertPayload,
RealtimePostgresUpdatePayload,
RealtimePostgresDeletePayload,
RealtimePresenceJoinPayload,
RealtimePresenceLeavePayload,
RealtimePresenceState,
RealtimeRemoveChannelResponse,
REALTIME_LISTEN_TYPES,
REALTIME_POSTGRES_CHANGES_LISTEN_EVENT,
REALTIME_PRESENCE_LISTEN_EVENTS,
REALTIME_SUBSCRIBE_STATES,
REALTIME_CHANNEL_STATES,
WebSocketFactory,
WebSocketLike,
WebSocketLikeConstructor,
}

View File

@@ -0,0 +1,49 @@
import { version } from './version'
export const DEFAULT_VERSION = `realtime-js/${version}`
export const VSN_1_0_0: string = '1.0.0'
export const VSN_2_0_0: string = '2.0.0'
export const DEFAULT_VSN: string = VSN_2_0_0
export const VERSION = version
export const DEFAULT_TIMEOUT = 10000
export const WS_CLOSE_NORMAL = 1000
export const MAX_PUSH_BUFFER_SIZE = 100
export enum SOCKET_STATES {
connecting = 0,
open = 1,
closing = 2,
closed = 3,
}
export enum CHANNEL_STATES {
closed = 'closed',
errored = 'errored',
joined = 'joined',
joining = 'joining',
leaving = 'leaving',
}
export enum CHANNEL_EVENTS {
close = 'phx_close',
error = 'phx_error',
join = 'phx_join',
reply = 'phx_reply',
leave = 'phx_leave',
access_token = 'access_token',
}
export enum TRANSPORTS {
websocket = 'websocket',
}
export enum CONNECTION_STATE {
Connecting = 'connecting',
Open = 'open',
Closing = 'closing',
Closed = 'closed',
}

View File

@@ -0,0 +1,121 @@
import { DEFAULT_TIMEOUT } from '../lib/constants'
import type RealtimeChannel from '../RealtimeChannel'
export default class Push {
sent: boolean = false
timeoutTimer: number | undefined = undefined
ref: string = ''
receivedResp: {
status: string
response: { [key: string]: any }
} | null = null
recHooks: {
status: string
callback: Function
}[] = []
refEvent: string | null = null
/**
* Initializes the Push
*
* @param channel The Channel
* @param event The event, for example `"phx_join"`
* @param payload The payload, for example `{user_id: 123}`
* @param timeout The push timeout in milliseconds
*/
constructor(
public channel: RealtimeChannel,
public event: string,
public payload: { [key: string]: any } = {},
public timeout: number = DEFAULT_TIMEOUT
) {}
resend(timeout: number) {
this.timeout = timeout
this._cancelRefEvent()
this.ref = ''
this.refEvent = null
this.receivedResp = null
this.sent = false
this.send()
}
send() {
if (this._hasReceived('timeout')) {
return
}
this.startTimeout()
this.sent = true
this.channel.socket.push({
topic: this.channel.topic,
event: this.event,
payload: this.payload,
ref: this.ref,
join_ref: this.channel._joinRef(),
})
}
updatePayload(payload: { [key: string]: any }): void {
this.payload = { ...this.payload, ...payload }
}
receive(status: string, callback: Function) {
if (this._hasReceived(status)) {
callback(this.receivedResp?.response)
}
this.recHooks.push({ status, callback })
return this
}
startTimeout() {
if (this.timeoutTimer) {
return
}
this.ref = this.channel.socket._makeRef()
this.refEvent = this.channel._replyEventName(this.ref)
const callback = (payload: any) => {
this._cancelRefEvent()
this._cancelTimeout()
this.receivedResp = payload
this._matchReceive(payload)
}
this.channel._on(this.refEvent, {}, callback)
this.timeoutTimer = <any>setTimeout(() => {
this.trigger('timeout', {})
}, this.timeout)
}
trigger(status: string, response: any) {
if (this.refEvent) this.channel._trigger(this.refEvent, { status, response })
}
destroy() {
this._cancelRefEvent()
this._cancelTimeout()
}
private _cancelRefEvent() {
if (!this.refEvent) {
return
}
this.channel._off(this.refEvent, {})
}
private _cancelTimeout() {
clearTimeout(this.timeoutTimer)
this.timeoutTimer = undefined
}
private _matchReceive({ status, response }: { status: string; response: Function }) {
this.recHooks.filter((h) => h.status === status).forEach((h) => h.callback(response))
}
private _hasReceived(status: string) {
return this.receivedResp && this.receivedResp.status === status
}
}

View File

@@ -0,0 +1,203 @@
// This file draws heavily from https://github.com/phoenixframework/phoenix/commit/cf098e9cf7a44ee6479d31d911a97d3c7430c6fe
// License: https://github.com/phoenixframework/phoenix/blob/master/LICENSE.md
export type Msg<T> = {
join_ref?: string | null
ref?: string | null
topic: string
event: string
payload: T
}
export default class Serializer {
HEADER_LENGTH = 1
USER_BROADCAST_PUSH_META_LENGTH = 6
KINDS = { userBroadcastPush: 3, userBroadcast: 4 }
BINARY_ENCODING = 0
JSON_ENCODING = 1
BROADCAST_EVENT = 'broadcast'
allowedMetadataKeys: string[] = []
constructor(allowedMetadataKeys?: string[] | null) {
this.allowedMetadataKeys = allowedMetadataKeys ?? []
}
encode(msg: Msg<{ [key: string]: any }>, callback: (result: ArrayBuffer | string) => any) {
if (
msg.event === this.BROADCAST_EVENT &&
!(msg.payload instanceof ArrayBuffer) &&
typeof msg.payload.event === 'string'
) {
return callback(
this._binaryEncodeUserBroadcastPush(msg as Msg<{ event: string } & { [key: string]: any }>)
)
}
let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]
return callback(JSON.stringify(payload))
}
private _binaryEncodeUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) {
if (this._isArrayBuffer(message.payload?.payload)) {
return this._encodeBinaryUserBroadcastPush(message)
} else {
return this._encodeJsonUserBroadcastPush(message)
}
}
private _encodeBinaryUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) {
const userPayload = message.payload?.payload ?? new ArrayBuffer(0)
return this._encodeUserBroadcastPush(message, this.BINARY_ENCODING, userPayload)
}
private _encodeJsonUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) {
const userPayload = message.payload?.payload ?? {}
const encoder = new TextEncoder()
const encodedUserPayload = encoder.encode(JSON.stringify(userPayload)).buffer
return this._encodeUserBroadcastPush(message, this.JSON_ENCODING, encodedUserPayload)
}
private _encodeUserBroadcastPush(
message: Msg<{ event: string } & { [key: string]: any }>,
encodingType: number,
encodedPayload: ArrayBuffer
) {
const topic = message.topic
const ref = message.ref ?? ''
const joinRef = message.join_ref ?? ''
const userEvent = message.payload.event
// Filter metadata based on allowed keys
const rest = this.allowedMetadataKeys
? this._pick(message.payload, this.allowedMetadataKeys)
: {}
const metadata = Object.keys(rest).length === 0 ? '' : JSON.stringify(rest)
// Validate lengths don't exceed uint8 max value (255)
if (joinRef.length > 255) {
throw new Error(`joinRef length ${joinRef.length} exceeds maximum of 255`)
}
if (ref.length > 255) {
throw new Error(`ref length ${ref.length} exceeds maximum of 255`)
}
if (topic.length > 255) {
throw new Error(`topic length ${topic.length} exceeds maximum of 255`)
}
if (userEvent.length > 255) {
throw new Error(`userEvent length ${userEvent.length} exceeds maximum of 255`)
}
if (metadata.length > 255) {
throw new Error(`metadata length ${metadata.length} exceeds maximum of 255`)
}
const metaLength =
this.USER_BROADCAST_PUSH_META_LENGTH +
joinRef.length +
ref.length +
topic.length +
userEvent.length +
metadata.length
const header = new ArrayBuffer(this.HEADER_LENGTH + metaLength)
let view = new DataView(header)
let offset = 0
view.setUint8(offset++, this.KINDS.userBroadcastPush) // kind
view.setUint8(offset++, joinRef.length)
view.setUint8(offset++, ref.length)
view.setUint8(offset++, topic.length)
view.setUint8(offset++, userEvent.length)
view.setUint8(offset++, metadata.length)
view.setUint8(offset++, encodingType)
Array.from(joinRef, (char) => view.setUint8(offset++, char.charCodeAt(0)))
Array.from(ref, (char) => view.setUint8(offset++, char.charCodeAt(0)))
Array.from(topic, (char) => view.setUint8(offset++, char.charCodeAt(0)))
Array.from(userEvent, (char) => view.setUint8(offset++, char.charCodeAt(0)))
Array.from(metadata, (char) => view.setUint8(offset++, char.charCodeAt(0)))
var combined = new Uint8Array(header.byteLength + encodedPayload.byteLength)
combined.set(new Uint8Array(header), 0)
combined.set(new Uint8Array(encodedPayload), header.byteLength)
return combined.buffer
}
decode(rawPayload: ArrayBuffer | string, callback: Function) {
if (this._isArrayBuffer(rawPayload)) {
let result = this._binaryDecode(rawPayload as ArrayBuffer)
return callback(result)
}
if (typeof rawPayload === 'string') {
const jsonPayload = JSON.parse(rawPayload)
const [join_ref, ref, topic, event, payload] = jsonPayload
return callback({ join_ref, ref, topic, event, payload })
}
return callback({})
}
private _binaryDecode(buffer: ArrayBuffer) {
const view = new DataView(buffer)
const kind = view.getUint8(0)
const decoder = new TextDecoder()
switch (kind) {
case this.KINDS.userBroadcast:
return this._decodeUserBroadcast(buffer, view, decoder)
}
}
private _decodeUserBroadcast(
buffer: ArrayBuffer,
view: DataView,
decoder: TextDecoder
): {
join_ref: null
ref: null
topic: string
event: string
payload: { [key: string]: any }
} {
const topicSize = view.getUint8(1)
const userEventSize = view.getUint8(2)
const metadataSize = view.getUint8(3)
const payloadEncoding = view.getUint8(4)
let offset = this.HEADER_LENGTH + 4
const topic = decoder.decode(buffer.slice(offset, offset + topicSize))
offset = offset + topicSize
const userEvent = decoder.decode(buffer.slice(offset, offset + userEventSize))
offset = offset + userEventSize
const metadata = decoder.decode(buffer.slice(offset, offset + metadataSize))
offset = offset + metadataSize
const payload = buffer.slice(offset, buffer.byteLength)
const parsedPayload =
payloadEncoding === this.JSON_ENCODING ? JSON.parse(decoder.decode(payload)) : payload
const data: { [key: string]: any } = {
type: this.BROADCAST_EVENT,
event: userEvent,
payload: parsedPayload,
}
// Metadata is optional and always JSON encoded
if (metadataSize > 0) {
data['meta'] = JSON.parse(metadata)
}
return { join_ref: null, ref: null, topic: topic, event: this.BROADCAST_EVENT, payload: data }
}
private _isArrayBuffer(buffer: any): boolean {
return buffer instanceof ArrayBuffer || buffer?.constructor?.name === 'ArrayBuffer'
}
private _pick(obj: Record<string, any> | null | undefined, keys: string[]): Record<string, any> {
if (!obj || typeof obj !== 'object') {
return {}
}
return Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
}
}

View File

@@ -0,0 +1,43 @@
/**
* Creates a timer that accepts a `timerCalc` function to perform calculated timeout retries, such as exponential backoff.
*
* @example
* let reconnectTimer = new Timer(() => this.connect(), function(tries){
* return [1000, 5000, 10000][tries - 1] || 10000
* })
* reconnectTimer.scheduleTimeout() // fires after 1000
* reconnectTimer.scheduleTimeout() // fires after 5000
* reconnectTimer.reset()
* reconnectTimer.scheduleTimeout() // fires after 1000
*/
export default class Timer {
timer: number | undefined = undefined
tries: number = 0
constructor(
public callback: Function,
public timerCalc: Function
) {
this.callback = callback
this.timerCalc = timerCalc
}
reset() {
this.tries = 0
clearTimeout(this.timer)
this.timer = undefined
}
// Cancels any previous scheduleTimeout and schedules callback
scheduleTimeout() {
clearTimeout(this.timer)
this.timer = <any>setTimeout(
() => {
this.tries = this.tries + 1
this.callback()
},
this.timerCalc(this.tries + 1)
)
}
}

View File

@@ -0,0 +1,270 @@
/**
* Helpers to convert the change Payload into native JS types.
*/
// Adapted from epgsql (src/epgsql_binary.erl), this module licensed under
// 3-clause BSD found here: https://raw.githubusercontent.com/epgsql/epgsql/devel/LICENSE
export enum PostgresTypes {
abstime = 'abstime',
bool = 'bool',
date = 'date',
daterange = 'daterange',
float4 = 'float4',
float8 = 'float8',
int2 = 'int2',
int4 = 'int4',
int4range = 'int4range',
int8 = 'int8',
int8range = 'int8range',
json = 'json',
jsonb = 'jsonb',
money = 'money',
numeric = 'numeric',
oid = 'oid',
reltime = 'reltime',
text = 'text',
time = 'time',
timestamp = 'timestamp',
timestamptz = 'timestamptz',
timetz = 'timetz',
tsrange = 'tsrange',
tstzrange = 'tstzrange',
}
type Columns = {
name: string // the column name. eg: "user_id"
type: string // the column type. eg: "uuid"
flags?: string[] // any special flags for the column. eg: ["key"]
type_modifier?: number // the type modifier. eg: 4294967295
}[]
type BaseValue = null | string | number | boolean
type RecordValue = BaseValue | BaseValue[]
type Record = {
[key: string]: RecordValue
}
/**
* Takes an array of columns and an object of string values then converts each string value
* to its mapped type.
*
* @param {{name: String, type: String}[]} columns
* @param {Object} record
* @param {Object} options The map of various options that can be applied to the mapper
* @param {Array} options.skipTypes The array of types that should not be converted
*
* @example convertChangeData([{name: 'first_name', type: 'text'}, {name: 'age', type: 'int4'}], {first_name: 'Paul', age:'33'}, {})
* //=>{ first_name: 'Paul', age: 33 }
*/
export const convertChangeData = (
columns: Columns,
record: Record | null,
options: { skipTypes?: string[] } = {}
): Record => {
const skipTypes = options.skipTypes ?? []
if (!record) {
return {}
}
return Object.keys(record).reduce((acc, rec_key) => {
acc[rec_key] = convertColumn(rec_key, columns, record, skipTypes)
return acc
}, {} as Record)
}
/**
* Converts the value of an individual column.
*
* @param {String} columnName The column that you want to convert
* @param {{name: String, type: String}[]} columns All of the columns
* @param {Object} record The map of string values
* @param {Array} skipTypes An array of types that should not be converted
* @return {object} Useless information
*
* @example convertColumn('age', [{name: 'first_name', type: 'text'}, {name: 'age', type: 'int4'}], {first_name: 'Paul', age: '33'}, [])
* //=> 33
* @example convertColumn('age', [{name: 'first_name', type: 'text'}, {name: 'age', type: 'int4'}], {first_name: 'Paul', age: '33'}, ['int4'])
* //=> "33"
*/
export const convertColumn = (
columnName: string,
columns: Columns,
record: Record,
skipTypes: string[]
): RecordValue => {
const column = columns.find((x) => x.name === columnName)
const colType = column?.type
const value = record[columnName]
if (colType && !skipTypes.includes(colType)) {
return convertCell(colType, value)
}
return noop(value)
}
/**
* If the value of the cell is `null`, returns null.
* Otherwise converts the string value to the correct type.
* @param {String} type A postgres column type
* @param {String} value The cell value
*
* @example convertCell('bool', 't')
* //=> true
* @example convertCell('int8', '10')
* //=> 10
* @example convertCell('_int4', '{1,2,3,4}')
* //=> [1,2,3,4]
*/
export const convertCell = (type: string, value: RecordValue): RecordValue => {
// if data type is an array
if (type.charAt(0) === '_') {
const dataType = type.slice(1, type.length)
return toArray(value, dataType)
}
// If not null, convert to correct type.
switch (type) {
case PostgresTypes.bool:
return toBoolean(value)
case PostgresTypes.float4:
case PostgresTypes.float8:
case PostgresTypes.int2:
case PostgresTypes.int4:
case PostgresTypes.int8:
case PostgresTypes.numeric:
case PostgresTypes.oid:
return toNumber(value)
case PostgresTypes.json:
case PostgresTypes.jsonb:
return toJson(value)
case PostgresTypes.timestamp:
return toTimestampString(value) // Format to be consistent with PostgREST
case PostgresTypes.abstime: // To allow users to cast it based on Timezone
case PostgresTypes.date: // To allow users to cast it based on Timezone
case PostgresTypes.daterange:
case PostgresTypes.int4range:
case PostgresTypes.int8range:
case PostgresTypes.money:
case PostgresTypes.reltime: // To allow users to cast it based on Timezone
case PostgresTypes.text:
case PostgresTypes.time: // To allow users to cast it based on Timezone
case PostgresTypes.timestamptz: // To allow users to cast it based on Timezone
case PostgresTypes.timetz: // To allow users to cast it based on Timezone
case PostgresTypes.tsrange:
case PostgresTypes.tstzrange:
return noop(value)
default:
// Return the value for remaining types
return noop(value)
}
}
const noop = (value: RecordValue): RecordValue => {
return value
}
export const toBoolean = (value: RecordValue): RecordValue => {
switch (value) {
case 't':
return true
case 'f':
return false
default:
return value
}
}
export const toNumber = (value: RecordValue): RecordValue => {
if (typeof value === 'string') {
const parsedValue = parseFloat(value)
if (!Number.isNaN(parsedValue)) {
return parsedValue
}
}
return value
}
export const toJson = (value: RecordValue): RecordValue => {
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return value
}
}
return value
}
/**
* Converts a Postgres Array into a native JS array
*
* @example toArray('{}', 'int4')
* //=> []
* @example toArray('{"[2021-01-01,2021-12-31)","(2021-01-01,2021-12-32]"}', 'daterange')
* //=> ['[2021-01-01,2021-12-31)', '(2021-01-01,2021-12-32]']
* @example toArray([1,2,3,4], 'int4')
* //=> [1,2,3,4]
*/
export const toArray = (value: RecordValue, type: string): RecordValue => {
if (typeof value !== 'string') {
return value
}
const lastIdx = value.length - 1
const closeBrace = value[lastIdx]
const openBrace = value[0]
// Confirm value is a Postgres array by checking curly brackets
if (openBrace === '{' && closeBrace === '}') {
let arr
const valTrim = value.slice(1, lastIdx)
// TODO: find a better solution to separate Postgres array data
try {
arr = JSON.parse('[' + valTrim + ']')
} catch (_) {
// WARNING: splitting on comma does not cover all edge cases
arr = valTrim ? valTrim.split(',') : []
}
return arr.map((val: BaseValue) => convertCell(type, val))
}
return value
}
/**
* Fixes timestamp to be ISO-8601. Swaps the space between the date and time for a 'T'
* See https://github.com/supabase/supabase/issues/18
*
* @example toTimestampString('2019-09-10 00:00:00')
* //=> '2019-09-10T00:00:00'
*/
export const toTimestampString = (value: RecordValue): RecordValue => {
if (typeof value === 'string') {
return value.replace(' ', 'T')
}
return value
}
export const httpEndpointURL = (socketUrl: string): string => {
const wsUrl = new URL(socketUrl)
wsUrl.protocol = wsUrl.protocol.replace(/^ws/i, 'http')
wsUrl.pathname = wsUrl.pathname
.replace(/\/+$/, '') // remove all trailing slashes
.replace(/\/socket\/websocket$/i, '') // remove the socket/websocket path
.replace(/\/socket$/i, '') // remove the socket path
.replace(/\/websocket$/i, '') // remove the websocket path
if (wsUrl.pathname === '' || wsUrl.pathname === '/') {
wsUrl.pathname = '/api/broadcast'
} else {
wsUrl.pathname = wsUrl.pathname + '/api/broadcast'
}
return wsUrl.href
}

View File

@@ -0,0 +1,7 @@
// Generated automatically during releases by scripts/update-version-files.ts
// This file provides runtime access to the package version for:
// - HTTP request headers (e.g., X-Client-Info header for API requests)
// - Debugging and support (identifying which version is running)
// - Telemetry and logging (version reporting in errors/analytics)
// - Ensuring build artifacts match the published package version
export const version = '2.99.1'

View File

@@ -0,0 +1,192 @@
export interface WebSocketLike {
readonly CONNECTING: number
readonly OPEN: number
readonly CLOSING: number
readonly CLOSED: number
readonly readyState: number
readonly url: string
readonly protocol: string
/**
* Closes the socket, optionally providing a close code and reason.
*/
close(code?: number, reason?: string): void
/**
* Sends data through the socket using the underlying implementation.
*/
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void
onopen: ((this: any, ev: Event) => any) | null
onmessage: ((this: any, ev: MessageEvent) => any) | null
onclose: ((this: any, ev: CloseEvent) => any) | null
onerror: ((this: any, ev: Event) => any) | null
/**
* Registers an event listener on the socket (compatible with browser WebSocket API).
*/
addEventListener(type: string, listener: EventListener): void
/**
* Removes a previously registered event listener.
*/
removeEventListener(type: string, listener: EventListener): void
// Add additional properties that may exist on WebSocket implementations
binaryType?: string
bufferedAmount?: number
extensions?: string
dispatchEvent?: (event: Event) => boolean
}
export interface WebSocketEnvironment {
type: 'native' | 'ws' | 'cloudflare' | 'unsupported'
constructor?: any
error?: string
workaround?: string
}
/**
* Utilities for creating WebSocket instances across runtimes.
*/
export class WebSocketFactory {
/**
* Static-only utility prevent instantiation.
*/
private constructor() {}
private static detectEnvironment(): WebSocketEnvironment {
if (typeof WebSocket !== 'undefined') {
return { type: 'native', constructor: WebSocket }
}
if (typeof globalThis !== 'undefined' && typeof (globalThis as any).WebSocket !== 'undefined') {
return { type: 'native', constructor: (globalThis as any).WebSocket }
}
if (typeof global !== 'undefined' && typeof (global as any).WebSocket !== 'undefined') {
return { type: 'native', constructor: (global as any).WebSocket }
}
if (
typeof globalThis !== 'undefined' &&
typeof (globalThis as any).WebSocketPair !== 'undefined' &&
typeof globalThis.WebSocket === 'undefined'
) {
return {
type: 'cloudflare',
error:
'Cloudflare Workers detected. WebSocket clients are not supported in Cloudflare Workers.',
workaround:
'Use Cloudflare Workers WebSocket API for server-side WebSocket handling, or deploy to a different runtime.',
}
}
if (
(typeof globalThis !== 'undefined' && (globalThis as any).EdgeRuntime) ||
(typeof navigator !== 'undefined' && navigator.userAgent?.includes('Vercel-Edge'))
) {
return {
type: 'unsupported',
error:
'Edge runtime detected (Vercel Edge/Netlify Edge). WebSockets are not supported in edge functions.',
workaround:
'Use serverless functions or a different deployment target for WebSocket functionality.',
}
}
// Use dynamic property access to avoid Next.js Edge Runtime static analysis warnings
const _process = (globalThis as any)['process']
if (_process) {
const processVersions = _process['versions']
if (processVersions && processVersions['node']) {
// Remove 'v' prefix if present and parse the major version
const versionString = processVersions['node']
const nodeVersion = parseInt(versionString.replace(/^v/, '').split('.')[0])
// Node.js 22+ should have native WebSocket
if (nodeVersion >= 22) {
// Check if native WebSocket is available (should be in Node.js 22+)
if (typeof globalThis.WebSocket !== 'undefined') {
return { type: 'native', constructor: globalThis.WebSocket }
}
// If not available, user needs to provide it
return {
type: 'unsupported',
error: `Node.js ${nodeVersion} detected but native WebSocket not found.`,
workaround: 'Provide a WebSocket implementation via the transport option.',
}
}
// Node.js < 22 doesn't have native WebSocket
return {
type: 'unsupported',
error: `Node.js ${nodeVersion} detected without native WebSocket support.`,
workaround:
'For Node.js < 22, install "ws" package and provide it via the transport option:\n' +
'import ws from "ws"\n' +
'new RealtimeClient(url, { transport: ws })',
}
}
}
return {
type: 'unsupported',
error: 'Unknown JavaScript runtime without WebSocket support.',
workaround:
"Ensure you're running in a supported environment (browser, Node.js, Deno) or provide a custom WebSocket implementation.",
}
}
/**
* Returns the best available WebSocket constructor for the current runtime.
*
* @example
* ```ts
* const WS = WebSocketFactory.getWebSocketConstructor()
* const socket = new WS('wss://realtime.supabase.co/socket')
* ```
*/
public static getWebSocketConstructor(): typeof WebSocket {
const env = this.detectEnvironment()
if (env.constructor) {
return env.constructor
}
let errorMessage = env.error || 'WebSocket not supported in this environment.'
if (env.workaround) {
errorMessage += `\n\nSuggested solution: ${env.workaround}`
}
throw new Error(errorMessage)
}
/**
* Creates a WebSocket using the detected constructor.
*
* @example
* ```ts
* const socket = WebSocketFactory.createWebSocket('wss://realtime.supabase.co/socket')
* ```
*/
public static createWebSocket(url: string | URL, protocols?: string | string[]): WebSocketLike {
const WS = this.getWebSocketConstructor()
return new WS(url, protocols)
}
/**
* Detects whether the runtime can establish WebSocket connections.
*
* @example
* ```ts
* if (!WebSocketFactory.isWebSocketSupported()) {
* console.warn('Falling back to long polling')
* }
* ```
*/
public static isWebSocketSupported(): boolean {
try {
const env = this.detectEnvironment()
return env.type === 'native' || env.type === 'ws'
} catch {
return false
}
}
}
export default WebSocketFactory