feat: add webchat pwa push subscription support

This commit is contained in:
William Valentin
2026-02-18 10:46:55 -08:00
parent 02fa604c7c
commit 8234cc93f3
17 changed files with 743 additions and 2 deletions
+177
View File
@@ -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) {