282 lines
9.3 KiB
Plaintext
282 lines
9.3 KiB
Plaintext
// Postgres 变更订阅参数类型(强类型导出,便于 UTS Android 复用)
|
||
export type PostgresChangesSubscribeParams = {
|
||
event : string;
|
||
schema : string;
|
||
table : string;
|
||
filter ?: string;
|
||
topic ?: string;
|
||
onChange : (payload : any) => void;
|
||
};
|
||
|
||
type PostgresChangeListener = {
|
||
topic : string;
|
||
event : string;
|
||
schema : string;
|
||
table : string;
|
||
filter : string | null;
|
||
onChange : (payload : any) => void;
|
||
};
|
||
|
||
export type AkSupaRealtimeOptions = {
|
||
url : string; // ws/wss 地址
|
||
channel : string; // 订阅频道
|
||
token ?: string; // 可选,鉴权token
|
||
apikey ?: string; // 可选,supabase apikey
|
||
onMessage : (data : UTSJSONObject) => void;
|
||
onOpen ?: (res : any) => void;
|
||
onClose ?: (res : any) => void;
|
||
onError ?: (err : any) => void;
|
||
};
|
||
|
||
export class AkSupaRealtime {
|
||
ws : SocketTask | null = null;
|
||
options : AkSupaRealtimeOptions | null = null;
|
||
isOpen : boolean = false;
|
||
heartbeatTimer : any = 0;
|
||
joinedTopics : Set<string> = new Set<string>();
|
||
listeners : Array<PostgresChangeListener> = [];
|
||
|
||
constructor(options : AkSupaRealtimeOptions) {
|
||
this.options = options;
|
||
}
|
||
|
||
connect() {
|
||
const opts = this.options;
|
||
if (opts == null) return;
|
||
// 拼接 apikey 和 vsn=1.0.0 到 ws url
|
||
let wsUrl = opts.url;
|
||
// apikey 兼容 query 已有参数和无参数两种情况
|
||
if (opts.apikey != null && opts.apikey !== "") {
|
||
const hasQuery = wsUrl.indexOf('?') != -1;
|
||
// 移除已有 apikey 参数,避免重复
|
||
wsUrl = wsUrl.replace(/([&?])apikey=[^&]*/g, '$1').replace(/[?&]$/, '');
|
||
wsUrl += (hasQuery ? '&' : '?') + 'apikey=' + encodeURIComponent('' + opts.apikey);
|
||
}
|
||
if (wsUrl.indexOf('vsn=') == -1) {
|
||
wsUrl += (wsUrl.indexOf('?') == -1 ? '?' : '&') + 'vsn=1.0.0';
|
||
}
|
||
this.ws = uni.connectSocket({
|
||
url: wsUrl,
|
||
success: (res) => { console.log(res); },
|
||
fail: (err) => { if (opts.onError != null) opts.onError?.(err); }
|
||
});
|
||
if (this.ws != null) {
|
||
const wsTask = this.ws;
|
||
wsTask?.onOpen((result : OnSocketOpenCallbackResult) => {
|
||
this.isOpen = true;
|
||
console.log('onopen', result)
|
||
if (opts.onOpen != null) opts.onOpen?.(result);
|
||
// 启动 heartbeat 定时器
|
||
this.startHeartbeat();
|
||
});
|
||
wsTask?.onMessage((msg) => {
|
||
console.log(msg)
|
||
let data : UTSJSONObject | null = null;
|
||
try {
|
||
const msgData = (typeof msg == 'object' && msg.data !== null) ? msg.data : msg;
|
||
data = typeof msgData == 'string' ? JSON.parse(msgData) as UTSJSONObject : msgData as UTSJSONObject;
|
||
} catch (e) { }
|
||
// 处理 pong
|
||
if (
|
||
data != null &&
|
||
data.event == 'phx_reply' &&
|
||
typeof data.payload == 'object' &&
|
||
data.payload != null &&
|
||
(data.payload as UTSJSONObject).status != null &&
|
||
(data.payload as UTSJSONObject).status == 'ok' &&
|
||
(data.payload as UTSJSONObject).response != null &&
|
||
(data.payload as UTSJSONObject).response == 'heartbeat'
|
||
) {
|
||
// 收到 pong,可用于续约
|
||
// 可选:重置定时器
|
||
}
|
||
console.log(data)
|
||
if (data != null) this.dispatchPostgresChange(data);
|
||
if (opts?.onMessage != null) opts.onMessage?.(data ?? ({} as UTSJSONObject));
|
||
});
|
||
wsTask?.onClose((res) => {
|
||
console.log('onclose', res)
|
||
this.isOpen = false;
|
||
this.joinedTopics.clear();
|
||
this.listeners = [];
|
||
if (opts.onClose != null) opts.onClose?.(res);
|
||
this.stopHeartbeat();
|
||
});
|
||
wsTask?.onError((err) => {
|
||
console.log(err)
|
||
if (opts.onError != null) opts.onError?.(err);
|
||
this.stopHeartbeat();
|
||
});
|
||
}
|
||
}
|
||
|
||
send(options : SendSocketMessageOptions) {
|
||
const wsTask = this.ws;
|
||
if (wsTask != null && this.isOpen) {
|
||
console.log('send:', options)
|
||
// 兼容 uni-app-x send API,支持 success/fail 回调
|
||
// 只允许 SendSocketMessageOptions 类型,避免 UTSJSONObject 混用
|
||
let sendData : any = options.data;
|
||
// 若 data 不是字符串,自动序列化
|
||
if (typeof sendData !== 'string') {
|
||
sendData = JSON.stringify(sendData);
|
||
}
|
||
options.success ?? ((res) => {
|
||
if (typeof options.success == 'function') options.success?.(res)
|
||
})
|
||
options.fail ?? ((err : any) => {
|
||
console.log(err)
|
||
const opts = this.options;
|
||
if (opts != null && opts.onError != null) opts.onError?.(err);
|
||
})
|
||
wsTask.send(options);
|
||
}
|
||
|
||
}
|
||
|
||
close(options : CloseSocketOptions) {
|
||
this.ws?.close(options);
|
||
}
|
||
|
||
/**
|
||
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE)
|
||
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
|
||
*/
|
||
/**
|
||
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE)
|
||
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
|
||
*/
|
||
/**
|
||
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE)
|
||
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
|
||
*/
|
||
/**
|
||
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE)
|
||
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
|
||
*/
|
||
/**
|
||
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE)
|
||
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
|
||
*/
|
||
public subscribePostgresChanges(params : PostgresChangesSubscribeParams) : void {
|
||
const opts = this.options;
|
||
if (this.isOpen !== true || opts == null) {
|
||
throw new Error('WebSocket 未连接');
|
||
}
|
||
const topic = params.topic != null && params.topic !== '' ? params.topic : `realtime:${params.schema}:${params.table}`;
|
||
this.joinTopicIfNeeded(topic, params);
|
||
this.listeners.push({
|
||
topic: topic,
|
||
event: params.event,
|
||
schema: params.schema,
|
||
table: params.table,
|
||
filter: params.filter != null ? params.filter : null,
|
||
onChange: params.onChange
|
||
});
|
||
}
|
||
|
||
startHeartbeat() {
|
||
this.stopHeartbeat();
|
||
console.log('make heartbeat')
|
||
// 每 30 秒发送一次 heartbeat(官方建议)
|
||
this.heartbeatTimer = setInterval(() => {
|
||
console.log('should startHeartbeat')
|
||
if (this.isOpen && this.ws != null) {
|
||
const heartbeatMsg = {
|
||
topic: 'phoenix',
|
||
event: 'heartbeat',
|
||
payload: {},
|
||
ref: Date.now().toString()
|
||
};
|
||
this.send({ data: JSON.stringify(heartbeatMsg) });
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
stopHeartbeat() {
|
||
console.log('stop heartbeat')
|
||
if (typeof this.heartbeatTimer == 'number' && this.heartbeatTimer > 0) {
|
||
clearInterval(this.heartbeatTimer as number);
|
||
this.heartbeatTimer = 0;
|
||
}
|
||
}
|
||
|
||
private joinTopicIfNeeded(topic : string, params : PostgresChangesSubscribeParams) {
|
||
if (topic == null || topic == '') return;
|
||
if (this.joinedTopics.has(topic)) return;
|
||
|
||
let changeConfig : any = null;
|
||
if (params.filter != null && params.filter !== '') {
|
||
changeConfig = {
|
||
event: params.event,
|
||
schema: params.schema,
|
||
table: params.table,
|
||
filter: params.filter
|
||
};
|
||
} else {
|
||
changeConfig = {
|
||
event: params.event,
|
||
schema: params.schema,
|
||
table: params.table
|
||
};
|
||
}
|
||
|
||
const joinMsg = {
|
||
event: 'phx_join',
|
||
payload: {
|
||
config: {
|
||
broadcast: { self: false, ack: false },
|
||
postgres_changes: [changeConfig],
|
||
presence: { key: '', enabled: false },
|
||
private: false
|
||
},
|
||
access_token: this.options != null && this.options.token != null ? this.options.token : null
|
||
},
|
||
ref: Date.now().toString(),
|
||
topic: topic
|
||
};
|
||
this.send({ data: JSON.stringify(joinMsg) });
|
||
this.joinedTopics.add(topic);
|
||
}
|
||
|
||
private dispatchPostgresChange(data : UTSJSONObject) : void {
|
||
if (data.event !== 'postgres_changes') return;
|
||
const topic = typeof data.topic == 'string' ? data.topic : '';
|
||
const payload = data.payload as UTSJSONObject | null;
|
||
if (payload == null) return;
|
||
const dataSection = payload.get('data') as UTSJSONObject | null;
|
||
let payloadEvent = payload.getString('event') as string | null;
|
||
if ((payloadEvent == null || payloadEvent == '') && dataSection != null) {
|
||
const typeValue = dataSection.getString('type') as string | null;
|
||
if (typeValue != null && typeValue !== '') payloadEvent = typeValue;
|
||
}
|
||
let schemaName = payload.getString('schema') as string | null;
|
||
if ((schemaName == null || schemaName == '') && dataSection != null) {
|
||
const dataSchema = dataSection.getString('schema') as string | null;
|
||
if (dataSchema != null && dataSchema !== '') schemaName = dataSchema;
|
||
}
|
||
let tableName = payload.getString('table') as string | null;
|
||
if ((tableName == null || tableName == '') && dataSection != null) {
|
||
const dataTable = dataSection.getString('table') as string | null;
|
||
if (dataTable != null && dataTable !== '') tableName = dataTable;
|
||
}
|
||
const filterValue = payload.getString('filter') as string | null;
|
||
for (let i = 0; i < this.listeners.length; i++) {
|
||
const listener = this.listeners[i];
|
||
if (listener.topic !== topic) continue;
|
||
if (listener.event !== '*' && payloadEvent != null && listener.event !== payloadEvent) continue;
|
||
if (schemaName != null && listener.schema !== schemaName) continue;
|
||
if (tableName != null && listener.table !== tableName) continue;
|
||
if (
|
||
listener.filter != null && listener.filter !== '' &&
|
||
filterValue != null && listener.filter !== filterValue
|
||
) continue;
|
||
if (typeof listener.onChange == 'function') {
|
||
const changeData = dataSection != null ? dataSection : payload;
|
||
listener.onChange(changeData);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export default AkSupaRealtime; |