feat: add webchat pwa push subscription support
This commit is contained in:
@@ -131,6 +131,23 @@ export interface GatewayServerConfig {
|
||||
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
|
||||
/** Optional Zalo adapter for inbound webhook events. */
|
||||
zaloHandler?: Pick<ZaloAdapter, 'handleRequest'>;
|
||||
/** Optional WebChat PWA push-subscription settings. */
|
||||
webchatPush?: {
|
||||
enabled?: boolean;
|
||||
vapidPublicKey?: string;
|
||||
maxSubscriptions?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface WebchatPushSubscriptionRecord {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
userAgent?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export class GatewayServer {
|
||||
@@ -158,6 +175,7 @@ export class GatewayServer {
|
||||
windowStartMs: number;
|
||||
}> = new Map();
|
||||
private connectionStateMap: Map<string, NodeConnectionState> = new Map();
|
||||
private webchatPushSubscriptions: Map<string, WebchatPushSubscriptionRecord> = new Map();
|
||||
private config: GatewayServerConfig;
|
||||
private startTime: number = Date.now();
|
||||
|
||||
@@ -670,6 +688,160 @@ export class GatewayServer {
|
||||
};
|
||||
}
|
||||
|
||||
private getWebchatPushConfig(): { enabled: boolean; vapidPublicKey?: string; maxSubscriptions: number } {
|
||||
const runtimeConfig = this.config.config?.server.webchat_push;
|
||||
const override = this.config.webchatPush;
|
||||
const enabled = override?.enabled ?? runtimeConfig?.enabled ?? false;
|
||||
const vapidPublicKey = override?.vapidPublicKey ?? runtimeConfig?.vapid_public_key;
|
||||
const maxSubscriptions = override?.maxSubscriptions ?? runtimeConfig?.max_subscriptions ?? 5000;
|
||||
return { enabled, vapidPublicKey, maxSubscriptions };
|
||||
}
|
||||
|
||||
private async handleWebchatPushRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||
if (!req.url?.startsWith('/webchat/push')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
|
||||
const pathname = parsed.pathname;
|
||||
const cfg = this.getWebchatPushConfig();
|
||||
|
||||
if (pathname === '/webchat/push/public-key' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
enabled: cfg.enabled,
|
||||
vapidPublicKey: cfg.vapidPublicKey ?? null,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/webchat/push/subscriptions' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
enabled: cfg.enabled,
|
||||
count: this.webchatPushSubscriptions.size,
|
||||
maxSubscriptions: cfg.maxSubscriptions,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/webchat/push/subscriptions' && req.method === 'POST') {
|
||||
if (!cfg.enabled) {
|
||||
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'WebChat push is disabled' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await this.readRequestBody(req);
|
||||
} catch (err) {
|
||||
if (err instanceof RequestBodyTooLargeError) {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Payload too large' }));
|
||||
return true;
|
||||
}
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
let parsedBody: unknown;
|
||||
try {
|
||||
parsedBody = JSON.parse(rawBody);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = parsedBody as {
|
||||
endpoint?: unknown;
|
||||
keys?: { p256dh?: unknown; auth?: unknown };
|
||||
userAgent?: unknown;
|
||||
};
|
||||
const endpoint = typeof body.endpoint === 'string' ? body.endpoint.trim() : '';
|
||||
const p256dh = typeof body.keys?.p256dh === 'string' ? body.keys.p256dh.trim() : '';
|
||||
const auth = typeof body.keys?.auth === 'string' ? body.keys.auth.trim() : '';
|
||||
const userAgent = typeof body.userAgent === 'string' ? body.userAgent.trim() : undefined;
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing subscription endpoint or keys' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.webchatPushSubscriptions.has(endpoint) && this.webchatPushSubscriptions.size >= cfg.maxSubscriptions) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Subscription cap reached (${cfg.maxSubscriptions})` }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const previous = this.webchatPushSubscriptions.get(endpoint);
|
||||
this.webchatPushSubscriptions.set(endpoint, {
|
||||
endpoint,
|
||||
keys: { p256dh, auth },
|
||||
userAgent,
|
||||
createdAt: previous?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
stored: true,
|
||||
count: this.webchatPushSubscriptions.size,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/webchat/push/subscriptions' && req.method === 'DELETE') {
|
||||
let rawBody = '';
|
||||
try {
|
||||
rawBody = await this.readRequestBody(req);
|
||||
} catch (err) {
|
||||
if (err instanceof RequestBodyTooLargeError) {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Payload too large' }));
|
||||
return true;
|
||||
}
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
let endpoint = '';
|
||||
if (rawBody.trim().length > 0) {
|
||||
try {
|
||||
const body = JSON.parse(rawBody) as { endpoint?: unknown };
|
||||
endpoint = typeof body.endpoint === 'string' ? body.endpoint.trim() : '';
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
endpoint = parsed.searchParams.get('endpoint')?.trim() ?? '';
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing subscription endpoint' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const removed = this.webchatPushSubscriptions.delete(endpoint);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
removed,
|
||||
count: this.webchatPushSubscriptions.size,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests.
|
||||
* Optionally applies auth (when authHttp is enabled and a token is configured).
|
||||
@@ -777,6 +949,11 @@ export class GatewayServer {
|
||||
}
|
||||
}
|
||||
|
||||
// WebChat PWA push-subscription endpoints (auth-protected)
|
||||
if (await this.handleWebchatPushRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uiDir = this.config.uiDir;
|
||||
|
||||
if (uiDir) {
|
||||
|
||||
Reference in New Issue
Block a user