mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
refactor: refactor NotificationsHub to use hibernation api
- Updated NotificationsHub class to extend DurableObject. - Persisted connection state into attachment instead of memory. - Removed unnecessary ping functions & server-side periodic ping logic and added auto response which integrated into the WebSocket lifecycle. - Added echo for binary ws messages (for keeplive of MessagePack). - Added ping timer functionality in the App component to manage WebSocket connections more effectively.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
|
|
||||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||||
@@ -6,11 +7,11 @@ const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
|||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
|
||||||
|
|
||||||
type HubProtocol = 'json' | 'messagepack';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
|
||||||
interface ConnectionState {
|
interface WsAttachment {
|
||||||
|
userId: string;
|
||||||
handshakeComplete: boolean;
|
handshakeComplete: boolean;
|
||||||
protocol: HubProtocol;
|
protocol: HubProtocol;
|
||||||
deviceIdentifier: string | null;
|
deviceIdentifier: string | null;
|
||||||
@@ -145,10 +146,6 @@ function buildSignalRJsonInvocation(
|
|||||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRJsonPing(): string {
|
|
||||||
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSignalRMessagePackInvocation(
|
function buildSignalRMessagePackInvocation(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
messagePayload: Record<string, unknown>,
|
messagePayload: Record<string, unknown>,
|
||||||
@@ -172,24 +169,15 @@ function buildSignalRMessagePackInvocation(
|
|||||||
return frameSignalRBinary(encodedPayload);
|
return frameSignalRBinary(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackPing(): Uint8Array {
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
return frameSignalRBinary(encodeMsgPack([6]));
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
}
|
super(ctx, env);
|
||||||
|
this.ctx.setWebSocketAutoResponse(
|
||||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
new WebSocketRequestResponsePair(
|
||||||
if (typeof data === 'string') return data;
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
export class NotificationsHub {
|
|
||||||
private readonly connections = new Map<WebSocket, ConnectionState>();
|
|
||||||
private userId = '';
|
|
||||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
|
||||||
void this.state;
|
|
||||||
void this.env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
@@ -205,14 +193,14 @@ export class NotificationsHub {
|
|||||||
payload?: Record<string, unknown> | null;
|
payload?: Record<string, unknown> | null;
|
||||||
} | null;
|
} | null;
|
||||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||||
const contextId = String(body?.contextId || '').trim() || null;
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
const payload = body?.payload && typeof body.payload === 'object'
|
const payload = body?.payload && typeof body.payload === 'object'
|
||||||
? body.payload
|
? body.payload
|
||||||
: {
|
: {
|
||||||
UserId: this.userId,
|
UserId: userId,
|
||||||
Date: revisionDate,
|
Date: revisionDate,
|
||||||
};
|
};
|
||||||
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||||
@@ -238,46 +226,27 @@ export class NotificationsHub {
|
|||||||
|
|
||||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||||
if (requestUserId) {
|
|
||||||
this.userId = requestUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.userId) {
|
if (!requestUserId) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pair = new WebSocketPair();
|
const pair = new WebSocketPair();
|
||||||
const client = pair[0];
|
const client = pair[0];
|
||||||
const server = pair[1];
|
const server = pair[1];
|
||||||
server.accept();
|
|
||||||
|
|
||||||
this.connections.set(server, {
|
const tags: string[] = [];
|
||||||
|
if (requestDeviceIdentifier) {
|
||||||
|
tags.push(`device:${requestDeviceIdentifier}`);
|
||||||
|
}
|
||||||
|
this.ctx.acceptWebSocket(server, tags);
|
||||||
|
|
||||||
|
server.serializeAttachment({
|
||||||
|
userId: requestUserId,
|
||||||
handshakeComplete: false,
|
handshakeComplete: false,
|
||||||
protocol: 'messagepack',
|
protocol: 'messagepack',
|
||||||
deviceIdentifier: requestDeviceIdentifier,
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
});
|
} satisfies WsAttachment);
|
||||||
this.ensurePingLoop();
|
|
||||||
|
|
||||||
server.addEventListener('message', (event) => {
|
|
||||||
void this.handleSocketMessage(server, event.data);
|
|
||||||
});
|
|
||||||
server.addEventListener('close', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
});
|
|
||||||
server.addEventListener('error', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
try {
|
|
||||||
server.close(1011, 'Socket error');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 101,
|
status: 101,
|
||||||
@@ -285,21 +254,23 @@ export class NotificationsHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||||
const connection = this.connections.get(socket);
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
if (!connection) return;
|
if (!attachment) return;
|
||||||
|
|
||||||
if (!connection.handshakeComplete) {
|
if (!attachment.handshakeComplete) {
|
||||||
const text = decodeIncomingMessage(rawData);
|
const text = typeof message === 'string'
|
||||||
|
? message
|
||||||
|
: new TextDecoder().decode(new Uint8Array(message));
|
||||||
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
try {
|
try {
|
||||||
const handshake = JSON.parse(frame) as { protocol?: string };
|
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||||
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||||
connection.protocol = protocol;
|
attachment.handshakeComplete = true;
|
||||||
connection.handshakeComplete = true;
|
ws.serializeAttachment(attachment);
|
||||||
socket.send(SIGNALR_HANDSHAKE_ACK);
|
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
this.broadcastDeviceStatus();
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed pre-handshake payloads.
|
// Ignore malformed pre-handshake payloads.
|
||||||
@@ -307,53 +278,48 @@ export class NotificationsHub {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private ensurePingLoop(): void {
|
if (message instanceof ArrayBuffer) {
|
||||||
if (this.pingTimer !== null) return;
|
|
||||||
this.pingTimer = setInterval(() => {
|
|
||||||
this.broadcastPing();
|
|
||||||
}, SIGNALR_PING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopPingLoopIfIdle(): void {
|
|
||||||
if (this.connections.size > 0 || this.pingTimer === null) return;
|
|
||||||
clearInterval(this.pingTimer);
|
|
||||||
this.pingTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastPing(): void {
|
|
||||||
if (this.connections.size === 0) {
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
ws.send(message);
|
||||||
socket.send(buildSignalRJsonPing());
|
|
||||||
} else {
|
|
||||||
socket.send(buildSignalRMessagePackPing());
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
// ignore send errors on echo
|
||||||
try {
|
|
||||||
socket.close(1011, 'Ping send failed');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||||
|
try {
|
||||||
|
ws.close(code, 'Durable Object is closing WebSocket');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
if (shouldBroadcast && attachment?.userId) {
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||||
|
try {
|
||||||
|
ws.close(1011, 'Socket error');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
if (shouldBroadcast && attachment?.userId) {
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOnlineDeviceIdentifiers(): string[] {
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
for (const connection of this.connections.values()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
out.add(connection.deviceIdentifier);
|
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||||
|
out.add(attachment.deviceIdentifier);
|
||||||
}
|
}
|
||||||
return Array.from(out);
|
return Array.from(out);
|
||||||
}
|
}
|
||||||
@@ -364,35 +330,36 @@ export class NotificationsHub {
|
|||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null
|
||||||
): void {
|
): void {
|
||||||
if (!this.userId || this.connections.size === 0) return;
|
const sockets = targetDeviceIdentifier
|
||||||
|
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
|
||||||
|
: this.ctx.getWebSockets();
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
if (sockets.length === 0) return;
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
for (const ws of sockets) {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment?.handshakeComplete) continue;
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
if (attachment.protocol === 'json') {
|
||||||
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
} else {
|
} else {
|
||||||
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
|
||||||
try {
|
try {
|
||||||
socket.close(1011, 'Notification send failed');
|
ws.close(1011, 'Notification send failed');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close races
|
// ignore close races
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastDeviceStatus(): void {
|
private broadcastDeviceStatus(userId: string): void {
|
||||||
this.broadcastMessage(
|
this.broadcastMessage(
|
||||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||||
{
|
{
|
||||||
UserId: this.userId,
|
UserId: userId,
|
||||||
Date: new Date().toISOString(),
|
Date: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|||||||
+23
-2
@@ -884,6 +884,15 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pingTimer: number | null = null;
|
||||||
|
|
||||||
|
const clearPingTimer = () => {
|
||||||
|
if (pingTimer !== null) {
|
||||||
|
window.clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
void refreshAuthorizedDevicesRef.current();
|
void refreshAuthorizedDevicesRef.current();
|
||||||
@@ -891,7 +900,16 @@ export default function App() {
|
|||||||
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
||||||
} catch {
|
} catch {
|
||||||
socket?.close();
|
socket?.close();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
clearPingTimer();
|
||||||
|
pingTimer = window.setInterval(() => {
|
||||||
|
try {
|
||||||
|
socket?.send(`{"type":6}${SIGNALR_RECORD_SEPARATOR}`);
|
||||||
|
} catch {
|
||||||
|
// send failure will trigger close event
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
socket.addEventListener('message', (event) => {
|
||||||
@@ -934,6 +952,7 @@ export default function App() {
|
|||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
socket = null;
|
socket = null;
|
||||||
|
clearPingTimer();
|
||||||
void refreshAuthorizedDevicesRef.current();
|
void refreshAuthorizedDevicesRef.current();
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
@@ -952,9 +971,11 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket) {
|
||||||
|
const s = socket;
|
||||||
|
socket = null;
|
||||||
try {
|
try {
|
||||||
socket.close();
|
s.close();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close races
|
// ignore close races
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user