feat(serviceworker): Add TypedRequest traffic monitoring and SW dashboard Requests panel
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
190
ts_web_serviceworker/classes.requestlogstore.ts
Normal file
190
ts_web_serviceworker/classes.requestlogstore.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user