Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b33ab76a9e | |||
| 78a5c53d19 | |||
| 4bae49cfb0 | |||
| 031eb78288 |
19
changelog.md
19
changelog.md
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-04 - 6.6.0 - feat(web_serviceworker)
|
||||
Enable service worker dashboard speedtests via TypedSocket, expose ServiceWorker instance to dashboard, and add server-side speedtest handler
|
||||
|
||||
- Add `serviceworker_speedtest` typed handler in TypedServer to support download/upload/latency tests from service workers
|
||||
- Export `getServiceWorkerInstance` from the web_serviceworker entrypoint so other modules (dashboard) can access the running ServiceWorker instance
|
||||
- Make ServiceWorker.typedsocket and ServiceWorker.typedrouter public to allow the dashboard to create and fire TypedSocket requests
|
||||
- Update dashboard to run latency, download and upload tests over TypedSocket instead of POSTing to /sw-typedrequest
|
||||
- Deprecate legacy servertools.Server.addTypedSocket (now a no-op) and recommend using TypedServer with SmartServe integration for WebSocket support
|
||||
|
||||
## 2025-12-04 - 6.5.0 - feat(serviceworker)
|
||||
Add server-driven service worker cache invalidation and TypedSocket integration
|
||||
|
||||
- TypedServer: push cache invalidation messages to service worker clients (tagged 'serviceworker') before notifying frontend clients on reload
|
||||
- Service Worker: connect to TypedServer via TypedSocket; handle 'serviceworker_cacheInvalidate' typed request to clean caches and trigger client reloads
|
||||
- Web inject: add fallback to clear caches via the Cache API when global service worker helper is not available
|
||||
- Interfaces: add IRequest_Serviceworker_CacheInvalidate typedrequest interface
|
||||
- Plugins: export typedsocket in web_serviceworker plugin surface
|
||||
- Service worker connection: retry logic and improved logging for TypedSocket connection attempts
|
||||
|
||||
## 2025-12-04 - 6.4.0 - feat(serviceworker)
|
||||
Add speedtest support to service worker and dashboard
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "6.4.0",
|
||||
"version": "6.6.0",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '6.4.0',
|
||||
version: '6.6.0',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -306,6 +306,35 @@ export class TypedServer {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Speedtest handler for service worker dashboard
|
||||
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Speedtest>(
|
||||
new plugins.typedrequest.TypedHandler('serviceworker_speedtest', async (reqArg) => {
|
||||
const startTime = Date.now();
|
||||
const payloadSizeKB = reqArg.payloadSizeKB || 100;
|
||||
const sizeBytes = payloadSizeKB * 1024;
|
||||
let payload: string | undefined;
|
||||
let bytesTransferred = 0;
|
||||
|
||||
switch (reqArg.type) {
|
||||
case 'download':
|
||||
payload = 'x'.repeat(sizeBytes);
|
||||
bytesTransferred = sizeBytes;
|
||||
break;
|
||||
case 'upload':
|
||||
bytesTransferred = reqArg.payload?.length || 0;
|
||||
break;
|
||||
case 'latency':
|
||||
bytesTransferred = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
const speedMbps = durationMs > 0 ? (bytesTransferred * 8) / (durationMs * 1000) : 0;
|
||||
|
||||
return { durationMs, bytesTransferred, speedMbps, timestamp: Date.now(), payload };
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize TypedSocket:', error);
|
||||
}
|
||||
@@ -500,6 +529,30 @@ export class TypedServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Push cache invalidation to service workers first
|
||||
try {
|
||||
const swConnections = await this.typedsocket.findAllTargetConnectionsByTag('serviceworker');
|
||||
for (const connection of swConnections) {
|
||||
const pushCacheInvalidate =
|
||||
this.typedsocket.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
|
||||
'serviceworker_cacheInvalidate',
|
||||
connection
|
||||
);
|
||||
pushCacheInvalidate.fire({
|
||||
reason: 'File change detected',
|
||||
timestamp: this.lastReload,
|
||||
}).catch(err => {
|
||||
console.warn('Failed to push cache invalidation to service worker:', err);
|
||||
});
|
||||
}
|
||||
if (swConnections.length > 0) {
|
||||
console.log(`Pushed cache invalidation to ${swConnections.length} service worker(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to notify service workers:', error);
|
||||
}
|
||||
|
||||
// Notify frontend clients
|
||||
try {
|
||||
const connections = await this.typedsocket.findAllTargetConnectionsByTag(
|
||||
'typedserver_frontend'
|
||||
|
||||
@@ -195,6 +195,24 @@ export interface IConnectionResult {
|
||||
// Speedtest interfaces
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Cache invalidation request from server to service worker
|
||||
*/
|
||||
export interface IRequest_Serviceworker_CacheInvalidate
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_CacheInvalidate
|
||||
> {
|
||||
method: 'serviceworker_cacheInvalidate';
|
||||
request: {
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Speedtest request between service worker and backend
|
||||
*/
|
||||
|
||||
@@ -75,8 +75,17 @@ export class ReloadChecker {
|
||||
this.infoscreen.setText(reloadText);
|
||||
if (globalThis.globalSw?.purgeCache) {
|
||||
await globalThis.globalSw.purgeCache();
|
||||
} else if ('caches' in window) {
|
||||
// Fallback: clear caches via Cache API when service worker client isn't initialized
|
||||
try {
|
||||
const cacheKeys = await caches.keys();
|
||||
await Promise.all(cacheKeys.map(key => caches.delete(key)));
|
||||
logger.log('ok', 'Cleared caches via Cache API fallback');
|
||||
} catch (err) {
|
||||
logger.log('warn', `Failed to clear caches via Cache API: ${err}`);
|
||||
}
|
||||
} else {
|
||||
console.log('globalThis.globalSw not found...');
|
||||
console.log('globalThis.globalSw not found and Cache API not available...');
|
||||
}
|
||||
this.infoscreen.setText(`cleaned caches`);
|
||||
await plugins.smartdelay.delayFor(200);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getMetricsCollector } from './classes.metrics.js';
|
||||
import { getServiceWorkerInstance } from './index.js';
|
||||
import * as interfaces from './env.js';
|
||||
|
||||
/**
|
||||
* Dashboard generator that creates a terminal-like metrics display
|
||||
@@ -43,65 +45,50 @@ export class DashboardGenerator {
|
||||
} = { isOnline: false };
|
||||
|
||||
try {
|
||||
const sw = getServiceWorkerInstance();
|
||||
|
||||
// Check if TypedSocket is connected
|
||||
if (!sw.typedsocket) {
|
||||
results.error = 'TypedSocket not connected';
|
||||
return new Response(JSON.stringify(results), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create typed request for speedtest
|
||||
const speedtestRequest = sw.typedsocket.createTypedRequest<
|
||||
interfaces.serviceworker.IRequest_Serviceworker_Speedtest
|
||||
>('serviceworker_speedtest');
|
||||
|
||||
// Latency test
|
||||
const latencyStart = Date.now();
|
||||
const latencyResponse = await fetch('/sw-typedrequest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
method: 'serviceworker_speedtest',
|
||||
request: { type: 'latency' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (latencyResponse.ok) {
|
||||
await latencyResponse.json(); // Consume response
|
||||
const latencyDuration = Date.now() - latencyStart;
|
||||
results.latency = { durationMs: latencyDuration, speedMbps: 0 };
|
||||
metrics.recordSpeedtest('latency', latencyDuration);
|
||||
results.isOnline = true;
|
||||
metrics.setOnlineStatus(true);
|
||||
}
|
||||
await speedtestRequest.fire({ type: 'latency' });
|
||||
const latencyDuration = Date.now() - latencyStart;
|
||||
results.latency = { durationMs: latencyDuration, speedMbps: 0 };
|
||||
metrics.recordSpeedtest('latency', latencyDuration);
|
||||
results.isOnline = true;
|
||||
metrics.setOnlineStatus(true);
|
||||
|
||||
// Download test (100KB)
|
||||
const downloadStart = Date.now();
|
||||
const downloadResponse = await fetch('/sw-typedrequest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
method: 'serviceworker_speedtest',
|
||||
request: { type: 'download', payloadSizeKB: 100 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const downloadData = await downloadResponse.json();
|
||||
const downloadDuration = Date.now() - downloadStart;
|
||||
const bytesTransferred = downloadData.response?.payload?.length || 0;
|
||||
// Speed in Mbps: (bytes * 8) / (ms / 1000) / 1000000 = bytes * 8 / ms / 1000
|
||||
const downloadSpeedMbps = downloadDuration > 0 ? (bytesTransferred * 8) / (downloadDuration * 1000) : 0;
|
||||
results.download = { durationMs: downloadDuration, speedMbps: downloadSpeedMbps, bytesTransferred };
|
||||
metrics.recordSpeedtest('download', downloadSpeedMbps);
|
||||
}
|
||||
const downloadResult = await speedtestRequest.fire({ type: 'download', payloadSizeKB: 100 });
|
||||
const downloadDuration = Date.now() - downloadStart;
|
||||
const bytesTransferred = downloadResult.payload?.length || 0;
|
||||
const downloadSpeedMbps = downloadDuration > 0 ? (bytesTransferred * 8) / (downloadDuration * 1000) : 0;
|
||||
results.download = { durationMs: downloadDuration, speedMbps: downloadSpeedMbps, bytesTransferred };
|
||||
metrics.recordSpeedtest('download', downloadSpeedMbps);
|
||||
|
||||
// Upload test (100KB)
|
||||
const uploadPayload = 'x'.repeat(100 * 1024);
|
||||
const uploadStart = Date.now();
|
||||
const uploadResponse = await fetch('/sw-typedrequest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
method: 'serviceworker_speedtest',
|
||||
request: { type: 'upload', payload: uploadPayload },
|
||||
}),
|
||||
});
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
const uploadDuration = Date.now() - uploadStart;
|
||||
const uploadSpeedMbps = uploadDuration > 0 ? (uploadPayload.length * 8) / (uploadDuration * 1000) : 0;
|
||||
results.upload = { durationMs: uploadDuration, speedMbps: uploadSpeedMbps, bytesTransferred: uploadPayload.length };
|
||||
metrics.recordSpeedtest('upload', uploadSpeedMbps);
|
||||
}
|
||||
await speedtestRequest.fire({ type: 'upload', payload: uploadPayload });
|
||||
const uploadDuration = Date.now() - uploadStart;
|
||||
const uploadSpeedMbps = uploadDuration > 0 ? (uploadPayload.length * 8) / (uploadDuration * 1000) : 0;
|
||||
results.upload = { durationMs: uploadDuration, speedMbps: uploadSpeedMbps, bytesTransferred: uploadPayload.length };
|
||||
metrics.recordSpeedtest('upload', uploadSpeedMbps);
|
||||
|
||||
} catch (error) {
|
||||
results.error = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -27,6 +27,10 @@ export class ServiceWorker {
|
||||
public taskManager: TaskManager;
|
||||
public store: plugins.webstore.WebStore;
|
||||
|
||||
// TypedSocket connection for server communication
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(selfArg: interfaces.ServiceWindow) {
|
||||
logger.log('info', `Service worker instantiating at ${Date.now()}`);
|
||||
this.serviceWindowRef = selfArg;
|
||||
@@ -76,16 +80,52 @@ export class ServiceWorker {
|
||||
try {
|
||||
await selfArg.clients.claim();
|
||||
logger.log('ok', 'Clients claimed successfully');
|
||||
|
||||
|
||||
await this.cacheManager.cleanCaches('new service worker loaded! :)');
|
||||
logger.log('ok', 'Caches cleaned successfully');
|
||||
|
||||
|
||||
done.resolve();
|
||||
logger.log('success', `Service worker activated at ${new Date().toISOString()}`);
|
||||
|
||||
// Connect to TypedServer for cache invalidation after activation
|
||||
this.connectToServer();
|
||||
} catch (error) {
|
||||
logger.log('error', `Service worker activation error: ${error}`);
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to TypedServer via TypedSocket for cache invalidation
|
||||
*/
|
||||
private async connectToServer(): Promise<void> {
|
||||
try {
|
||||
// Register handler for cache invalidation from server
|
||||
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
|
||||
new plugins.typedrequest.TypedHandler('serviceworker_cacheInvalidate', async (reqArg) => {
|
||||
logger.log('info', `Cache invalidation requested from server: ${reqArg.reason}`);
|
||||
await this.cacheManager.cleanCaches(reqArg.reason);
|
||||
// Notify all clients to reload
|
||||
await this.leleServiceWorkerBackend.triggerReloadAll();
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
// Connect to server via TypedSocket
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
this.typedrouter,
|
||||
this.serviceWindowRef.location.origin
|
||||
);
|
||||
|
||||
// Tag this connection as a service worker for server-side filtering
|
||||
await this.typedsocket.setTag('serviceworker', {});
|
||||
|
||||
logger.log('ok', 'Service worker connected to TypedServer via TypedSocket');
|
||||
} catch (error: any) {
|
||||
logger.log('warn', `Service worker TypedSocket connection failed: ${error?.message || error}`);
|
||||
// Retry connection after a delay
|
||||
setTimeout(() => this.connectToServer(), 10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,6 @@ declare var self: env.ServiceWindow;
|
||||
import { ServiceWorker } from './classes.serviceworker.js';
|
||||
|
||||
const sw = new ServiceWorker(self);
|
||||
|
||||
// Export getter for service worker instance (used by dashboard for TypedSocket access)
|
||||
export const getServiceWorkerInstance = (): ServiceWorker => sw;
|
||||
|
||||
@@ -5,8 +5,9 @@ export { interfaces };
|
||||
|
||||
// @apiglobal scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest };
|
||||
export { typedrequest, typedsocket };
|
||||
|
||||
// @pushrocks scope
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
|
||||
Reference in New Issue
Block a user