feat(serviceworker): Add TypedRequest traffic monitoring and SW dashboard Requests panel

This commit is contained in:
2025-12-04 19:25:55 +00:00
parent d3330880c0
commit c5c40e78f9
12 changed files with 1337 additions and 22 deletions

View File

@@ -4,6 +4,7 @@ import { logger } from './logging.js';
import { getMetricsCollector } from './classes.metrics.js';
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
import { getPersistentStore } from './classes.persistentstore.js';
import { getRequestLogStore } from './classes.requestlogstore.js';
// Add type definitions for ServiceWorker APIs
declare global {
@@ -142,6 +143,48 @@ export class ServiceworkerBackend {
return { count };
});
// ================================
// TypedRequest Traffic Monitoring
// ================================
// Handler for receiving TypedRequest logs from clients
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog', async (reqArg) => {
const requestLogStore = getRequestLogStore();
requestLogStore.addEntry(reqArg);
// Broadcast to sw-dash viewers
await this.broadcastTypedRequestLogged(reqArg);
return {};
});
// Handler for getting TypedRequest logs
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs', async (reqArg) => {
const requestLogStore = getRequestLogStore();
const logs = requestLogStore.getEntries({
limit: reqArg.limit,
method: reqArg.method,
since: reqArg.since,
});
const totalCount = requestLogStore.getTotalCount({
method: reqArg.method,
since: reqArg.since,
});
return { logs, totalCount };
});
// Handler for getting TypedRequest statistics
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestStats>('serviceworker_getTypedRequestStats', async () => {
const requestLogStore = getRequestLogStore();
return requestLogStore.getStats();
});
// Handler for clearing TypedRequest logs
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs', async () => {
const requestLogStore = getRequestLogStore();
requestLogStore.clear();
return { success: true };
});
// Periodically update connected clients count
this.startClientCountUpdates();
@@ -267,6 +310,17 @@ export class ServiceworkerBackend {
}
}
/**
* Broadcasts a TypedRequest log entry to all connected clients (for sw-dash)
*/
public async broadcastTypedRequestLogged(entry: interfaces.serviceworker.ITypedRequestLogEntry): Promise<void> {
try {
await this.deesComms.postMessage(this.createMessage('serviceworker_typedRequestLogged', entry));
} catch (error) {
logger.log('warn', `Failed to broadcast TypedRequest log: ${error}`);
}
}
/**
* Start periodic updates of connected client count
*/

View File

@@ -247,6 +247,29 @@ export class CacheManager {
return;
}
// TypedRequest traffic monitoring endpoints
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'GET') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestLogs(parsedUrl.searchParams)));
return;
}
if (parsedUrl.pathname === '/sw-dash/requests/stats') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestStats()));
return;
}
if (parsedUrl.pathname === '/sw-dash/requests/methods') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestMethods()));
return;
}
// DELETE method for clearing TypedRequest logs
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'DELETE') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.clearTypedRequestLogs()));
return;
}
// Block requests that we don't want the service worker to handle.
if (
parsedUrl.hostname.includes('paddle.com') ||

View File

@@ -1,6 +1,7 @@
import { getMetricsCollector } from './classes.metrics.js';
import { getServiceWorkerInstance } from './init.js';
import { getPersistentStore } from './classes.persistentstore.js';
import { getRequestLogStore } from './classes.requestlogstore.js';
import * as interfaces from './env.js';
import type { serviceworker } from '../dist_ts_interfaces/index.js';
@@ -119,6 +120,76 @@ export class DashboardGenerator {
});
}
// ================================
// TypedRequest Traffic Endpoints
// ================================
/**
* Serves TypedRequest traffic logs
*/
public serveTypedRequestLogs(searchParams: URLSearchParams): Response {
const requestLogStore = getRequestLogStore();
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!, 10) : undefined;
const method = searchParams.get('method') || undefined;
const since = searchParams.get('since') ? parseInt(searchParams.get('since')!, 10) : undefined;
const logs = requestLogStore.getEntries({ limit, method, since });
const totalCount = requestLogStore.getTotalCount({ method, since });
return new Response(JSON.stringify({ logs, totalCount }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves TypedRequest traffic statistics
*/
public serveTypedRequestStats(): Response {
const requestLogStore = getRequestLogStore();
const stats = requestLogStore.getStats();
return new Response(JSON.stringify(stats), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Clears TypedRequest traffic logs
*/
public clearTypedRequestLogs(): Response {
const requestLogStore = getRequestLogStore();
requestLogStore.clear();
return new Response(JSON.stringify({ success: true }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves unique method names from TypedRequest logs
*/
public serveTypedRequestMethods(): Response {
const requestLogStore = getRequestLogStore();
const methods = requestLogStore.getMethods();
return new Response(JSON.stringify({ methods }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
// Speedtest configuration
private static readonly TEST_DURATION_MS = 5000; // 5 seconds per test
private static readonly CHUNK_SIZE_KB = 64; // 64KB chunks
@@ -262,25 +333,7 @@ export class DashboardGenerator {
* Generates a minimal HTML shell that loads the Lit-based dashboard bundle
*/
public generateDashboardHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SW Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
min-height: 100vh;
}
</style>
</head>
<body>
<sw-dash-app></sw-dash-app>
<script type="module" src="/sw-dash/bundle.js"></script>
</body>
</html>`;
return interfaces.serviceworker.SW_DASH_HTML;
}
}

View File

@@ -0,0 +1,190 @@
import { logger } from './logging.js';
import * as interfaces from '../dist_ts_interfaces/index.js';
/**
* Store for TypedRequest traffic logs
* Keeps recent request/response logs in memory for dashboard display
*/
export class RequestLogStore {
private logs: interfaces.serviceworker.ITypedRequestLogEntry[] = [];
private maxEntries: number;
// Statistics
private stats: interfaces.serviceworker.ITypedRequestStats = {
totalRequests: 0,
totalResponses: 0,
methodCounts: {},
errorCount: 0,
avgDurationMs: 0,
};
// For calculating rolling average
private totalDuration = 0;
private durationCount = 0;
constructor(maxEntries = 500) {
this.maxEntries = maxEntries;
logger.log('info', `RequestLogStore initialized with max ${maxEntries} entries`);
}
/**
* Add a new log entry
*/
public addEntry(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
// Add to log
this.logs.push(entry);
// Trim if over max
if (this.logs.length > this.maxEntries) {
this.logs.shift();
}
// Update statistics
this.updateStats(entry);
}
/**
* Update statistics based on new entry
*/
private updateStats(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
// Count request/response
if (entry.phase === 'request') {
this.stats.totalRequests++;
} else {
this.stats.totalResponses++;
}
// Count errors
if (entry.error) {
this.stats.errorCount++;
}
// Update duration average
if (entry.durationMs !== undefined) {
this.totalDuration += entry.durationMs;
this.durationCount++;
this.stats.avgDurationMs = Math.round(this.totalDuration / this.durationCount);
}
// Update per-method counts
if (!this.stats.methodCounts[entry.method]) {
this.stats.methodCounts[entry.method] = {
requests: 0,
responses: 0,
errors: 0,
avgDurationMs: 0,
};
}
const methodStats = this.stats.methodCounts[entry.method];
if (entry.phase === 'request') {
methodStats.requests++;
} else {
methodStats.responses++;
}
if (entry.error) {
methodStats.errors++;
}
// Per-method duration tracking (simplified - just uses latest duration)
if (entry.durationMs !== undefined) {
// Rolling average for method
const currentAvg = methodStats.avgDurationMs || 0;
const count = methodStats.responses || 1;
methodStats.avgDurationMs = Math.round((currentAvg * (count - 1) + entry.durationMs) / count);
}
}
/**
* Get log entries with optional filters
*/
public getEntries(options?: {
limit?: number;
method?: string;
since?: number;
}): interfaces.serviceworker.ITypedRequestLogEntry[] {
let result = [...this.logs];
// Filter by method
if (options?.method) {
result = result.filter((e) => e.method === options.method);
}
// Filter by timestamp
if (options?.since) {
result = result.filter((e) => e.timestamp >= options.since);
}
// Sort by timestamp descending (newest first)
result.sort((a, b) => b.timestamp - a.timestamp);
// Apply limit
if (options?.limit && options.limit > 0) {
result = result.slice(0, options.limit);
}
return result;
}
/**
* Get total count of entries (for pagination)
*/
public getTotalCount(options?: { method?: string; since?: number }): number {
if (!options?.method && !options?.since) {
return this.logs.length;
}
let count = 0;
for (const entry of this.logs) {
if (options.method && entry.method !== options.method) continue;
if (options.since && entry.timestamp < options.since) continue;
count++;
}
return count;
}
/**
* Get statistics
*/
public getStats(): interfaces.serviceworker.ITypedRequestStats {
return { ...this.stats };
}
/**
* Clear all logs and reset stats
*/
public clear(): void {
this.logs = [];
this.stats = {
totalRequests: 0,
totalResponses: 0,
methodCounts: {},
errorCount: 0,
avgDurationMs: 0,
};
this.totalDuration = 0;
this.durationCount = 0;
logger.log('info', 'RequestLogStore cleared');
}
/**
* Get unique method names
*/
public getMethods(): string[] {
return Object.keys(this.stats.methodCounts);
}
}
// Singleton instance
let requestLogStoreInstance: RequestLogStore | null = null;
/**
* Get the singleton RequestLogStore instance
*/
export function getRequestLogStore(): RequestLogStore {
if (!requestLogStoreInstance) {
requestLogStoreInstance = new RequestLogStore();
}
return requestLogStoreInstance;
}