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
+93
View File
@@ -554,6 +554,99 @@ describe('GatewayServer request body limits', () => {
});
});
describe('GatewayServer WebChat push endpoints', () => {
const PUSH_PORT = 18894;
let pushServer: GatewayServer;
beforeAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
pushServer = new GatewayServer({
port: PUSH_PORT,
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'Test prompt',
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
auth: { token: 'push-secret' },
authHttp: true,
uiDir: resolve(import.meta.dirname, 'ui'),
webchatPush: {
enabled: true,
vapidPublicKey: 'BO_test_public_key',
maxSubscriptions: 2,
},
});
await pushServer.start();
});
afterAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
await pushServer.stop();
});
it('returns push public key metadata when authenticated', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const res = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/public-key`, {
headers: { Authorization: 'Bearer push-secret' },
});
expect(res.status).toBe(200);
const body = await res.json() as { enabled: boolean; vapidPublicKey: string | null };
expect(body.enabled).toBe(true);
expect(body.vapidPublicKey).toBe('BO_test_public_key');
});
it('stores and deletes webchat push subscriptions', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const headers = { Authorization: 'Bearer push-secret', 'Content-Type': 'application/json' };
const payload = {
endpoint: 'https://example.invalid/sub/1',
keys: {
p256dh: 'p256dh-sample',
auth: 'auth-sample',
},
userAgent: 'vitest',
};
const putRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
expect(putRes.status).toBe(200);
const putBody = await putRes.json() as { stored: boolean; count: number };
expect(putBody.stored).toBe(true);
expect(putBody.count).toBe(1);
const listRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, {
headers: { Authorization: 'Bearer push-secret' },
});
expect(listRes.status).toBe(200);
const listBody = await listRes.json() as { count: number; maxSubscriptions: number };
expect(listBody.count).toBe(1);
expect(listBody.maxSubscriptions).toBe(2);
const delRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, {
method: 'DELETE',
headers,
body: JSON.stringify({ endpoint: payload.endpoint }),
});
expect(delRes.status).toBe(200);
const delBody = await delRes.json() as { removed: boolean; count: number };
expect(delBody.removed).toBe(true);
expect(delBody.count).toBe(0);
});
});
describe('GatewayServer WebSocket ingress rate limiting', () => {
const RATE_PORT = 18895;
let rateServer: GatewayServer;
+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) {
+1
View File
@@ -9,6 +9,7 @@ const CONTENT_TYPES: Record<string, string> = {
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
+2
View File
@@ -4,6 +4,7 @@
* Hash-based routing with page lifecycle management.
*/
import { getClient } from './lib/ws-client.js';
import { registerPwaServiceWorker } from './lib/pwa.js';
const routes = new Map();
let currentPage = null;
@@ -55,6 +56,7 @@ async function render() {
export function initRouter() {
contentEl = document.getElementById('content');
window.addEventListener('hashchange', render);
void registerPwaServiceWorker().catch(() => undefined);
render();
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Flynn icon">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#25c2a0"/>
<stop offset="100%" stop-color="#1877f2"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="48" fill="#0b0f14"/>
<path d="M58 72h140v28H90v28h90v28H90v52H58z" fill="url(#g)"/>
<circle cx="188" cy="188" r="16" fill="#25c2a0"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+3
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#101216">
<title>Flynn</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="flynn-icon.svg">
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
+158
View File
@@ -0,0 +1,158 @@
function readGatewayToken() {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
return token && token.trim() ? token.trim() : null;
}
function withToken(path) {
const token = readGatewayToken();
if (!token) {
return path;
}
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}token=${encodeURIComponent(token)}`;
}
async function requestJson(path, options) {
const response = await fetch(withToken(path), {
...(options ?? {}),
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
});
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message = typeof body?.error === 'string'
? body.error
: `Request failed (${response.status})`;
throw new Error(message);
}
return body;
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function isPushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
export async function registerPwaServiceWorker() {
if (!('serviceWorker' in navigator)) {
return null;
}
return navigator.serviceWorker.register(withToken('/sw.js'));
}
export async function getPushStatus() {
const supported = isPushSupported();
if (!supported) {
return {
supported: false,
permission: 'unsupported',
subscribed: false,
enabled: false,
configured: false,
message: 'Push is not supported by this browser.',
};
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
const publicKeyPayload = await requestJson('/webchat/push/public-key', { method: 'GET' });
const configured = Boolean(publicKeyPayload?.vapidPublicKey);
return {
supported: true,
permission: Notification.permission,
subscribed: Boolean(subscription),
enabled: Boolean(publicKeyPayload?.enabled),
configured,
message: null,
};
}
export async function enablePushNotifications() {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported by this browser.');
}
const publicKeyPayload = await requestJson('/webchat/push/public-key', { method: 'GET' });
if (!publicKeyPayload?.enabled) {
throw new Error('WebChat push is disabled on the gateway.');
}
if (!publicKeyPayload?.vapidPublicKey) {
throw new Error('Gateway is missing server.webchat_push.vapid_public_key.');
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission was not granted.');
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKeyPayload.vapidPublicKey),
});
}
const json = subscription.toJSON();
await requestJson('/webchat/push/subscriptions', {
method: 'POST',
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: json.keys ?? {},
userAgent: navigator.userAgent,
}),
});
return getPushStatus();
}
export async function disablePushNotifications() {
if (!('serviceWorker' in navigator)) {
return {
supported: false,
permission: 'unsupported',
subscribed: false,
enabled: false,
configured: false,
message: 'Push is not supported by this browser.',
};
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await requestJson('/webchat/push/subscriptions', {
method: 'DELETE',
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
return getPushStatus();
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Flynn WebChat",
"short_name": "Flynn",
"start_url": "/#/chat",
"display": "standalone",
"background_color": "#101216",
"theme_color": "#101216",
"description": "Flynn WebChat companion UI",
"icons": [
{
"src": "/flynn-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+88
View File
@@ -4,6 +4,12 @@
* Read-only config view (redacted), editable hook patterns,
* tool list, and channel overview.
*/
import {
isPushSupported,
getPushStatus,
enablePushNotifications,
disablePushNotifications,
} from '../lib/pwa.js';
function escapeHtml(text) {
const div = document.createElement('div');
@@ -14,6 +20,75 @@ function escapeHtml(text) {
let _client = null;
let _el = null;
function describePushStatus(status) {
if (!status.supported) {
return status.message || 'Push notifications are not supported in this browser.';
}
if (!status.enabled) {
return 'Gateway push is disabled (`server.webchat_push.enabled: false`).';
}
if (!status.configured) {
return 'Gateway push key is missing (`server.webchat_push.vapid_public_key`).';
}
if (status.permission === 'denied') {
return 'Browser notifications are blocked. Allow notifications in browser settings.';
}
if (status.subscribed) {
return 'Push notifications are enabled for this browser.';
}
return 'Push is configured. Click Enable to subscribe this browser.';
}
async function renderPushStatus() {
const statusEl = _el.querySelector('#push-status');
const enableBtn = _el.querySelector('#push-enable');
const disableBtn = _el.querySelector('#push-disable');
if (!statusEl || !enableBtn || !disableBtn) {
return;
}
try {
const status = await getPushStatus();
statusEl.textContent = describePushStatus(status);
statusEl.className = status.subscribed ? 'text-sm text-success' : 'text-sm text-muted';
enableBtn.disabled = !status.supported || !status.enabled || !status.configured || status.subscribed;
disableBtn.disabled = !status.supported || !status.subscribed;
} catch (err) {
statusEl.textContent = `Push status error: ${err.message}`;
statusEl.className = 'text-sm text-error';
enableBtn.disabled = true;
disableBtn.disabled = true;
}
}
async function onEnablePush() {
const statusEl = _el.querySelector('#push-status');
if (!statusEl) {return;}
statusEl.textContent = 'Enabling push notifications...';
statusEl.className = 'text-sm text-muted';
try {
await enablePushNotifications();
await renderPushStatus();
} catch (err) {
statusEl.textContent = `Enable failed: ${err.message}`;
statusEl.className = 'text-sm text-error';
}
}
async function onDisablePush() {
const statusEl = _el.querySelector('#push-status');
if (!statusEl) {return;}
statusEl.textContent = 'Disabling push notifications...';
statusEl.className = 'text-sm text-muted';
try {
await disablePushNotifications();
await renderPushStatus();
} catch (err) {
statusEl.textContent = `Disable failed: ${err.message}`;
statusEl.className = 'text-sm text-error';
}
}
async function loadSettings() {
if (!_client || !_el) {return;}
@@ -52,6 +127,16 @@ async function loadSettings() {
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<h2 class="section-title">WebChat Push Notifications</h2>
<div class="settings-section">
${isPushSupported() ? '' : '<div class="text-sm text-muted">This browser does not support PushManager APIs.</div>'}
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button id="push-enable" class="btn btn-primary" type="button">Enable Push</button>
<button id="push-disable" class="btn btn-secondary" type="button">Disable Push</button>
</div>
<div id="push-status" class="text-sm text-muted"></div>
</div>
<h2 class="section-title">Hook Patterns</h2>
<div class="settings-section">
<div class="hook-editor">
@@ -133,6 +218,9 @@ async function loadSettings() {
// Bind save hooks
_el.querySelector('#hooks-save').addEventListener('click', saveHooks);
_el.querySelector('#push-enable').addEventListener('click', onEnablePush);
_el.querySelector('#push-disable').addEventListener('click', onDisablePush);
await renderPushStatus();
}
async function saveHooks() {
+92
View File
@@ -0,0 +1,92 @@
const CACHE_NAME = 'flynn-webchat-v1';
const token = new URL(self.location.href).searchParams.get('token');
const withToken = (path) => {
if (!token) {
return path;
}
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}token=${encodeURIComponent(token)}`;
};
const OFFLINE_ASSETS = ['/', '/index.html', '/style.css', '/app.js', '/manifest.webmanifest'].map(withToken);
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(OFFLINE_ASSETS))
.catch(() => undefined),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
)),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
event.respondWith((async () => {
const cached = await caches.match(event.request, { ignoreSearch: true });
if (cached) {
return cached;
}
try {
return await fetch(event.request);
} catch {
const fallback = await caches.match('/index.html', { ignoreSearch: true });
return fallback || new Response('Offline', { status: 503 });
}
})());
});
self.addEventListener('push', (event) => {
let payload = {
title: 'Flynn',
body: 'You have a new update.',
};
try {
const parsed = event.data?.json();
if (parsed && typeof parsed === 'object') {
payload = {
...payload,
...parsed,
};
}
} catch {
const text = event.data?.text();
if (text) {
payload.body = text;
}
}
event.waitUntil(self.registration.showNotification(payload.title, {
body: payload.body,
tag: payload.tag || 'flynn-webchat',
renotify: false,
data: payload.data || {},
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil((async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.length > 0) {
await clients[0].focus();
return;
}
await self.clients.openWindow('/#/chat');
})());
});