Compare commits

...

26 Commits

Author SHA1 Message Date
0f171e43e7 v6.7.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 12:37:01 +00:00
5d9e914b23 feat(web_serviceworker): Add per-resource metrics and request deduplication to service worker cache manager 2025-12-04 12:37:01 +00:00
b33ab76a9e v6.6.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 12:16:24 +00:00
78a5c53d19 feat(web_serviceworker): Enable service worker dashboard speedtests via TypedSocket, expose ServiceWorker instance to dashboard, and add server-side speedtest handler 2025-12-04 12:16:24 +00:00
4bae49cfb0 v6.5.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 11:46:55 +00:00
031eb78288 feat(serviceworker): Add server-driven service worker cache invalidation and TypedSocket integration 2025-12-04 11:46:55 +00:00
98eae1e79a v6.4.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 11:36:27 +00:00
aa677a2b7c feat(serviceworker): Add speedtest support to service worker and dashboard 2025-12-04 11:36:27 +00:00
5a81858df5 v6.3.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 11:25:57 +00:00
c263b0608c feat(web_serviceworker): Add advanced service worker subsystems: cache deduplication, metrics, update & network managers, event bus and dashboard 2025-12-04 11:25:56 +00:00
30126f716e feat(TypedServer): Enhance file watching with glob pattern for recursive directory matching 2025-12-04 11:22:04 +00:00
4dc0cb311b v6.2.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 11:14:04 +00:00
84256fd8fc feat(web_serviceworker): Add service-worker dashboard and request deduplication; improve caching, metrics and error handling 2025-12-04 11:14:04 +00:00
8010977d05 v6.1.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 08:52:49 +00:00
54bb12d6ff feat(web_serviceworker): Enhance service worker subsystem: add metrics, event bus, error handling, config and caching/update improvements; make client connection & polling robust 2025-12-04 08:52:49 +00:00
9ac91fd166 v6.0.1
Some checks failed
Default (tags) / security (push) Failing after 20s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 00:03:23 +00:00
b4e26d6d6a fix(web_inject): Use TypedSocket status API in web_inject and bump dependencies 2025-12-04 00:03:23 +00:00
1885eb78e5 v6.0.0
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-03 09:41:56 +00:00
8b4c5918e9 BREAKING CHANGE(servertools.Server.addTypedSocket): Deprecate Server.addTypedSocket and upgrade typedsocket to v4; make addTypedSocket a no-op and log a deprecation warning. Bump tsbundle devDependency. 2025-12-03 09:41:56 +00:00
c6792396df v5.0.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-02 21:10:25 +00:00
fc6829f607 BREAKING CHANGE(devtools): Switch /reloadcheck endpoint from GET to POST in DevToolsController 2025-12-02 21:10:25 +00:00
424b742f84 v4.1.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-02 21:03:57 +00:00
c25daba1c1 fix(classes.typedserver): Instantiate and register DevToolsController only when injectReload is enabled; compile ControllerRegistry routes after registration 2025-12-02 21:03:57 +00:00
dce2e926e4 v4.1.0
Some checks failed
Default (tags) / security (push) Failing after 18s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-02 20:47:11 +00:00
27c96949a1 feat(TypedServer): Integrate SmartServe controller routing; add built-in routes controller and refactor TypedServer to use controllers and FileServer 2025-12-02 20:47:11 +00:00
c17d6dac35 feat: Refactor TypedServer to use SmartServe and introduce new request handlers
- Removed legacy servertools and Express dependencies in favor of SmartServe.
- Introduced DevToolsHandler and TypedRequestHandler for handling specific routes.
- Added support for custom route registration with regex parsing.
- Implemented sitemap and feed handling with dedicated helper classes.
- Enhanced HTML response handling with reload script injection.
- Updated UtilityServiceServer and UtilityWebsiteServer to utilize new TypedServer API.
- Removed deprecated compression options and Express-based route handling.
- Added comprehensive request handling for various endpoints including robots.txt, manifest.json, and sitemap.
- Improved error handling and response formatting across the server.
2025-12-02 20:26:34 +00:00
34 changed files with 4744 additions and 939 deletions

View File

@@ -1,5 +1,123 @@
# Changelog
## 2025-12-04 - 6.7.0 - feat(web_serviceworker)
Add per-resource metrics and request deduplication to service worker cache manager
- Introduce per-resource tracking in metrics: ICachedResource, IDomainStats, IContentTypeStats and a resourceStats map.
- Add MetricsCollector.recordResourceAccess(...) to record hits/misses, content-type and size; provide getters: getCachedResources, getDomainStats, getContentTypeStats and getResourceCount.
- Reset resourceStats when metrics are reset and limit resource entries via cleanupResourceStats to avoid memory bloat.
- Add request deduplication in CacheManager (fetchWithDeduplication) to coalesce identical concurrent fetches and a periodic safety cleanup for in-flight requests.
- Record resource accesses on cache hit and when storing new cache entries (captures content-type and body size).
- Expose a dashboard resources endpoint (/sw-dash/resources) served by the SW dashboard to return detailed resource data for SPA views.
## 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
- Add serviceworker_speedtest typed request handler to measure download, upload and latency
- Expose dashboard speedtest endpoint (/sw-dash/speedtest) and integrate runSpeedtest flow
- Dashboard UI: add speedtest panel, run button, visual speed bars and online indicator
- Metrics: introduce ISpeedtestMetrics and methods (recordSpeedtest, setOnlineStatus, getSpeedtestMetrics) and include speedtest data in metrics output
- Server/tools: add typedrequest handling for speedtest in sw-typedrequest and route service worker dashboard path in CacheManager
## 2025-12-04 - 6.3.0 - feat(web_serviceworker)
Add advanced service worker subsystems: cache deduplication, metrics, update & network managers, event bus and dashboard
- CacheManager: request deduplication for concurrent fetches, safer caching (preserve CORS headers), periodic in-flight cleanup and full cache cleaning API
- Fetch handling: improved handling for same-origin vs cross-origin requests, more robust 500 debug responses when upstream fetch fails
- UpdateManager: rate-limited update checks, offline grace period, debounced update and cache revalidation tasks, forceUpdate logic and persisted version/cache timestamps
- NetworkManager: online/offline detection, retry/backoff, request timeouts and more resilient makeRequest implementation
- EventBus: singleton pub/sub with history, once/onMany/onAll helpers and convenience emitters for cache/network/update events
- MetricsCollector: comprehensive metrics for cache, network, updates and connections with helper methods and JSON/HTML dashboard endpoints (/sw-dash, /sw-dash/metrics)
- ErrorHandler & ServiceWorkerError: structured error types, severity, context, history and helper APIs for consistent error reporting
- ServiceWorker & backend: improved install/activate flows, clients.claim(), cache cleaning on activation, backend APIs to purge cache and trigger reloads/notifications
- TypedServer / servertools: addRoute path pattern parsing (named params & wildcards), safer HTML injection for reload script, TypedRequest controller and service worker route helpers
- Various safety and compatibility improvements (response cloning, header normalization, cache-control decisions, and fallback behaviors)
## 2025-12-04 - 6.2.0 - feat(web_serviceworker)
Add service-worker dashboard and request deduplication; improve caching, metrics and error handling
- Add DashboardGenerator to serve an interactive terminal-style dashboard at /sw-dash and a metrics JSON endpoint at /sw-dash/metrics
- Introduce request deduplication in CacheManager to coalesce concurrent network fetches and avoid duplicate requests
- Add periodic cleanup for in-flight request tracking to prevent unbounded memory growth
- Improve caching flow: preserve response headers (excluding cache-control headers), ensure CORS headers and Cross-Origin-Resource-Policy, and store response bodies as blobs to avoid locked stream issues
- Provide clearer 500 error HTML responses for failed fetches to aid debugging
- Integrate metrics and event emissions for network and cache operations (record request success/failure, cache hits/misses, and emit corresponding events)
## 2025-12-04 - 6.1.0 - feat(web_serviceworker)
Enhance service worker subsystem: add metrics, event bus, error handling, config and caching/update improvements; make client connection & polling robust
- Introduce MetricsCollector (cache, network, update, connection) for runtime observability and APIs to retrieve metrics
- Add EventBus singleton to emit/subscribe to internal SW events (cache hits/misses, network events, update lifecycle, connection events)
- Add ErrorHandler and ServiceWorkerError types for consistent error classification and tracking
- Add ServiceWorkerConfig with defaults and WebStore persistence to centralize SW settings (cache, update, network, blocked/cacheable domains)
- CacheManager: implement request deduplication (in-flight request coalescing), periodic in-flight cleanup, record cache hit/miss metrics and safer cache storing (headers/body handling)
- UpdateManager: rate-limited and concurrency-safe update checks, improved stale-cache handling, event emissions, debounced update and revalidation tasks, and metrics recording
- NetworkManager: enhanced online/offline detection and robust request retries/timeouts/backoff handling
- ServiceworkerBackend: improved client reload logic and notification handling via DeesComms and clients API
- Serviceworker client-side: ActionManager.waitForServiceWorkerConnection now returns a structured result with timeout/retries/backoff; ServiceworkerClient gains controllable polling (AbortController), visibility-based pause/resume, manual trigger and lifecycle cleanup
- Expose serviceworker bundle routes at both nested and root paths (/serviceworker/*splat and /serviceworker.bundle.js(.map)) in servertools
- Add/extend typed interfaces for serviceworker metrics and connection results
## 2025-12-04 - 6.0.1 - fix(web_inject)
Use TypedSocket status API in web_inject and bump dependencies
- ts_web_inject: switch from typedsocket.addTag + eventSubject to await typedsocket.setTag + statusSubject; update logging and handle 'reconnecting' status as backend connection loss
- Await setTag call to ensure tag is applied before relying on socket state
- Bump dependencies: @api.global/typedrequest -> ^3.1.11, @api.global/typedsocket -> ^4.1.0, @push.rocks/smartserve -> ^1.1.2
## 2025-12-03 - 6.0.0 - BREAKING CHANGE(servertools.Server.addTypedSocket)
Deprecate Server.addTypedSocket and upgrade typedsocket to v4; make addTypedSocket a no-op and log a deprecation warning. Bump tsbundle devDependency.
- Upgrade dependency @api.global/typedsocket to ^4.0.0. TypedSocket v4 no longer supports attaching to an existing Express server.
- Deprecate servertools.Server.addTypedSocket(): the method is now a no-op and emits a console.warn directing users to use TypedServer with SmartServe integration for WebSocket support.
- Bump devDependency @git.zone/tsbundle to ^2.6.3.
- Breaking change: any consumer code that relied on addTypedSocket to attach a WebSocket server to an existing Express instance will need to migrate to the new SmartServe/TypedServer integration.
## 2025-12-02 - 5.0.0 - BREAKING CHANGE(devtools)
Switch /reloadcheck endpoint from GET to POST in DevToolsController
- Updated ts/controllers/controller.devtools.ts: decorator changed from @plugins.smartserve.Get('/reloadcheck') to @plugins.smartserve.Post('/reloadcheck').
- Clients that previously performed GET requests against /reloadcheck must be updated to use POST. This is a breaking API change.
- Bump major version to reflect the change in the public HTTP API.
## 2025-12-02 - 4.1.1 - fix(classes.typedserver)
Instantiate and register DevToolsController only when injectReload is enabled; compile ControllerRegistry routes after registration
- DevToolsController is now created and registered only if options.injectReload is true to avoid unnecessary/invalid registrations when live reload is disabled.
- ControllerRegistry.compileRoutes() is invoked after registering controllers to precompile decorated routes for faster route matching.
## 2025-12-02 - 4.1.0 - feat(TypedServer)
Integrate SmartServe controller routing; add built-in routes controller and refactor TypedServer to use controllers and FileServer
- Add BuiltInRoutesController exposing /robots.txt, /manifest.json, /sitemap, /sitemap-news, /feed and /appversion
- Refactor TypedRequestHandler into a SmartServe-decorated TypedRequestController and register it with ControllerRegistry
- Refactor TypedServer to use SmartServe: register controller instances, use ControllerRegistry matching, and delegate WebSocket integration to SmartServe
- Introduce FileServer-based static serving with HTML reload script injection and improved default root handling
- Expand supported HTTP methods to include HEAD and OPTIONS
- Remove legacy FeedHelper and consolidate sitemap/feed handling into controllers and helpers
- Enhance servertools legacy Express utilities: improved HandlerProxy, HandlerStatic, Compressor with caching and preferred compression support
- Service worker subsystem improvements: CacheManager, NetworkManager, UpdateManager and backend enhancements for robust caching, revalidation and client reloads
- Web-inject LitElement properties switched from private fields to accessor syntax (typedserver_web.infoscreen)
## 2025-12-02 - 4.0.0 - BREAKING CHANGE(typedserver)
Migrate to new push.rocks packages and async smartfs API; replace smartchok with smartwatch; update deps and service worker handling

View File

@@ -1,6 +1,6 @@
{
"name": "@api.global/typedserver",
"version": "4.0.0",
"version": "6.7.0",
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
"type": "module",
"exports": {
@@ -58,9 +58,9 @@
],
"homepage": "https://code.foss.global/api.global/typedserver",
"dependencies": {
"@api.global/typedrequest": "^3.1.10",
"@api.global/typedrequest": "^3.1.11",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedsocket": "^3.0.1",
"@api.global/typedsocket": "^4.1.0",
"@cloudflare/workers-types": "^4.20251202.0",
"@design.estate/dees-comms": "^1.0.27",
"@push.rocks/lik": "^6.2.2",
@@ -82,6 +82,7 @@
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartserve": "^1.1.2",
"@push.rocks/smartsitemap": "^2.0.4",
"@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smarttime": "^4.1.1",
@@ -99,7 +100,7 @@
},
"devDependencies": {
"@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsbundle": "^2.6.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@types/node": "^24.10.1"

932
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedserver',
version: '4.0.0',
version: '6.7.0',
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
}

View File

@@ -1,8 +1,9 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import * as interfaces from '../dist_ts_interfaces/index.js';
import * as servertools from './servertools/index.js';
import { type TCompressionMethod } from './servertools/classes.compressor.js';
import { DevToolsController } from './controllers/controller.devtools.js';
import { TypedRequestController } from './controllers/controller.typedrequest.js';
import { BuiltInRoutesController } from './controllers/controller.builtin.js';
export interface IServerOptions {
/**
@@ -15,16 +16,6 @@ export interface IServerOptions {
*/
injectReload?: boolean;
/**
* enable compression
*/
enableCompression?: boolean;
/**
* choose a preferred compression method
*/
preferredCompressionMethod?: TCompressionMethod;
/**
* watch the serve directory?
*/
@@ -34,7 +25,6 @@ export interface IServerOptions {
/**
* a default answer given in case there is no other handler.
* @returns
*/
defaultAnswer?: () => Promise<string>;
@@ -42,13 +32,14 @@ export interface IServerOptions {
* will try to reroute traffic to an ssl connection using headers
*/
forceSsl?: boolean;
/**
* allows serving manifests
*/
manifest?: plugins.smartmanifest.ISmartManifestConstructorOptions;
/**
* the port to listen on
* can be overwritten when actually starting the server
*/
port?: number | string;
publicKey?: string;
@@ -57,6 +48,7 @@ export interface IServerOptions {
feed?: boolean;
robots?: boolean;
domain?: string;
/**
* convey information about the app being served
*/
@@ -66,22 +58,48 @@ export interface IServerOptions {
blockWaybackMachine?: boolean;
}
export class TypedServer {
// static
// nothing here yet
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
export interface IRouteHandler {
(request: Request): Promise<Response | null>;
}
export interface IRegisteredRoute {
pattern: string;
regex: RegExp;
paramNames: string[];
method: THttpMethod;
handler: IRouteHandler;
}
export class TypedServer {
// instance
public options: IServerOptions;
public server: servertools.Server;
public smartServe: plugins.smartserve.SmartServe;
public smartwatchInstance: plugins.smartwatch.Smartwatch;
public serveDirHashSubject = new plugins.smartrx.rxjs.ReplaySubject<string>(1);
public serveHash: string = '000000';
public typedsocket: plugins.typedsocket.TypedSocket;
public typedrouter = new plugins.typedrequest.TypedRouter();
// Sitemap helper
private sitemapHelper: SitemapHelper;
private smartmanifestInstance: plugins.smartmanifest.SmartManifest;
// Decorated controllers
private devToolsController: DevToolsController;
private typedRequestController: TypedRequestController;
private builtInRoutesController: BuiltInRoutesController;
// File server for static files
private fileServer: plugins.smartserve.FileServer;
// Custom route handlers (for addRoute API)
private customRoutes: IRegisteredRoute[] = [];
public lastReload: number = Date.now();
public ended = false;
constructor(optionsArg: IServerOptions) {
const standardOptions: IServerOptions = {
port: 3000,
@@ -94,44 +112,64 @@ export class TypedServer {
...standardOptions,
...optionsArg,
};
}
this.server = new servertools.Server(this.options);
// add routes to the smartexpress instance
this.server.addRoute(
'/typedserver/:request',
new servertools.Handler('ALL', async (req, res) => {
switch (req.params.request) {
case 'devtools':
res.setHeader('Content-Type', 'text/javascript');
res.status(200);
const devtoolsContent = await plugins.fsInstance.file(paths.injectBundlePath).encoding('utf8').read();
res.write(devtoolsContent);
res.end();
break;
case 'reloadcheck':
console.log('got request for reloadcheck');
res.setHeader('Content-Type', 'text/plain');
res.status(200);
if (this.ended) {
res.write('end');
res.end();
return;
}
res.write(this.lastReload.toString());
res.end();
break;
default:
res.status(404);
res.write('Unknown request type');
res.end();
break;
}
/**
* Access sitemap URLs (for adding/replacing)
*/
public get sitemap() {
return this.sitemapHelper;
}
/**
* Add a custom route handler
* Supports Express-style path patterns like '/path/:param' and '/path/*splat'
* @param path - The route path pattern
* @param method - HTTP method (GET, POST, PUT, DELETE, PATCH, ALL)
* @param handler - Async function that receives Request and returns Response or null
*/
public addRoute(path: string, method: THttpMethod, handler: IRouteHandler): void {
// Convert Express-style path to regex
const paramNames: string[] = [];
let regexPattern = path
// Handle named parameters :param
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
paramNames.push(paramName);
return '([^/]+)';
})
);
this.server.addRoute(
'/typedrequest',
new servertools.HandlerTypedRouter(this.typedrouter)
);
// Handle wildcard *splat (matches everything including slashes)
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
paramNames.push(paramName);
return '(.*)';
});
// Ensure exact match
regexPattern = `^${regexPattern}$`;
this.customRoutes.push({
pattern: path,
regex: new RegExp(regexPattern),
paramNames,
method,
handler,
});
}
/**
* Parse route parameters from a path using a registered route
*/
private parseRouteParams(
route: IRegisteredRoute,
pathname: string
): Record<string, string> | null {
const match = pathname.match(route.regex);
if (!match) return null;
const params: Record<string, string> = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
return params;
}
/**
@@ -145,55 +183,99 @@ export class TypedServer {
);
}
if (this.options.serveDir) {
this.server.addRoute(
'/{*splat}',
new servertools.HandlerStatic(this.options.serveDir, {
responseModifier: async (responseArg) => {
if (plugins.path.parse(responseArg.path).ext === '.html') {
let fileString = responseArg.responseContent.toString();
const fileStringArray = fileString.split('<head>');
if (this.options.injectReload && fileStringArray.length === 2) {
fileStringArray[0] = `${fileStringArray[0]}<head>
<!-- injected by @apiglobal/typedserver start -->
<script async defer type="module" src="/typedserver/devtools"></script>
<script>
globalThis.typedserver = {
lastReload: ${this.lastReload},
versionInfo: ${JSON.stringify({}, null, 2)},
}
</script>
<!-- injected by @apiglobal/typedserver stop -->
`;
fileString = fileStringArray.join('');
console.log('injected typedserver script.');
responseArg.responseContent = Buffer.from(fileString);
} else if (this.options.injectReload) {
console.log('Could not insert typedserver script - no <head> tag found');
}
}
const headers = responseArg.headers;
headers.appHash = this.serveHash;
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
headers['Pragma'] = 'no-cache';
headers['Expires'] = '0';
return {
headers,
path: responseArg.path,
responseContent: responseArg.responseContent,
travelData: responseArg.travelData,
};
},
serveIndexHtmlDefault: true,
enableCompression: this.options.enableCompression,
preferredCompressionMethod: this.options.preferredCompressionMethod,
})
);
const port =
typeof this.options.port === 'string'
? parseInt(this.options.port, 10)
: this.options.port || 3000;
// Initialize optional helpers
if (this.options.sitemap) {
this.sitemapHelper = new SitemapHelper(this.options.domain);
}
if (this.options.manifest) {
this.smartmanifestInstance = new plugins.smartmanifest.SmartManifest(this.options.manifest);
}
// Initialize file server for static files
if (this.options.serveDir) {
this.fileServer = new plugins.smartserve.FileServer({
root: this.options.serveDir,
index: ['index.html'],
etag: true,
});
}
// Initialize decorated controllers
if (this.options.injectReload) {
this.devToolsController = new DevToolsController({
getLastReload: () => this.lastReload,
getEnded: () => this.ended,
});
}
this.typedRequestController = new TypedRequestController(this.typedrouter);
this.builtInRoutesController = new BuiltInRoutesController({
domain: this.options.domain,
robots: this.options.robots,
manifest: this.smartmanifestInstance,
sitemap: this.options.sitemap,
feed: this.options.feed,
appVersion: this.options.appVersion,
feedMetadata: this.options.feedMetadata,
articleGetterFunction: this.options.articleGetterFunction,
blockWaybackMachine: this.options.blockWaybackMachine,
getSitemapUrls: () => this.sitemapHelper?.urls || [],
});
// Register controllers with SmartServe's ControllerRegistry
if (this.options.injectReload) {
plugins.smartserve.ControllerRegistry.registerInstance(this.devToolsController);
}
plugins.smartserve.ControllerRegistry.registerInstance(this.typedRequestController);
plugins.smartserve.ControllerRegistry.registerInstance(this.builtInRoutesController);
// Compile routes for fast matching
plugins.smartserve.ControllerRegistry.compileRoutes();
// Build SmartServe options
const smartServeOptions: plugins.smartserve.ISmartServeOptions = {
port,
hostname: '0.0.0.0',
tls:
this.options.privateKey && this.options.publicKey
? {
key: this.options.privateKey,
cert: this.options.publicKey,
}
: undefined,
websocket: {
typedRouter: this.typedrouter,
onConnectionOpen: (peer) => {
peer.tags.add('typedserver_frontend');
console.log(`WebSocket connected: ${peer.id}`);
},
onConnectionClose: (peer) => {
console.log(`WebSocket disconnected: ${peer.id}`);
},
},
};
this.smartServe = new plugins.smartserve.SmartServe(smartServeOptions);
// Set up custom request handler that integrates with ControllerRegistry
this.smartServe.setHandler(async (request: Request): Promise<Response> => {
return this.handleRequest(request);
});
// Setup file watching
if (this.options.watch && this.options.serveDir) {
try {
this.smartwatchInstance = new plugins.smartwatch.Smartwatch([this.options.serveDir]);
// Use glob pattern to match all files recursively in serveDir
const watchGlob = this.options.serveDir.endsWith('/')
? `${this.options.serveDir}**/*`
: `${this.options.serveDir}/**/*`;
this.smartwatchInstance = new plugins.smartwatch.Smartwatch([watchGlob]);
await this.smartwatchInstance.start();
(await this.smartwatchInstance.getObservableFor('change')).subscribe(async () => {
await this.createServeDirHash();
@@ -202,20 +284,21 @@ export class TypedServer {
await this.createServeDirHash();
} catch (error) {
console.error('Failed to initialize file watching:', error);
// Continue without file watching rather than crashing
}
}
// lets start the server
await this.server.start();
// Start the server
await this.smartServe.start();
console.log(`TypedServer listening on port ${port}`);
// Setup TypedSocket using SmartServe integration
try {
this.typedsocket = await plugins.typedsocket.TypedSocket.createServer(
this.typedrouter,
this.server
this.typedsocket = plugins.typedsocket.TypedSocket.fromSmartServe(
this.smartServe,
this.typedrouter
);
// lets setup typedrouter
// Setup typedrouter handlers
this.typedrouter.addTypedHandler<interfaces.IReq_GetLatestServerChangeTime>(
new plugins.typedrequest.TypedHandler('getLatestServerChangeTime', async () => {
return {
@@ -223,12 +306,219 @@ 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);
// Continue without WebSocket support rather than crashing
}
}
/**
* Create an IRequestContext from a Request
*/
private async createContext(
request: Request,
params: Record<string, string>
): Promise<plugins.smartserve.IRequestContext> {
const url = new URL(request.url);
const method = request.method.toUpperCase() as THttpMethod;
// Parse query params
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Parse body
let body: unknown = undefined;
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
body = await request.clone().json();
} catch {
body = {};
}
}
return {
request,
body,
params,
query,
headers: request.headers,
path: url.pathname,
method,
url,
runtime: 'node' as const,
state: {},
};
}
/**
* Main request handler - routes to appropriate sub-handlers
*/
private async handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method.toUpperCase() as THttpMethod;
// First, try to match via ControllerRegistry (decorated routes)
const match = plugins.smartserve.ControllerRegistry.matchRoute(path, method);
if (match) {
try {
const context = await this.createContext(request, match.params);
const result = await match.route.handler(context);
// Handle Response or convert to Response
if (result instanceof Response) {
return result;
}
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
if (error instanceof plugins.smartserve.RouteNotFoundError) {
// Route explicitly threw "not found", continue to other handlers
} else {
console.error('Controller error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
}
// Custom routes (registered via addRoute)
for (const route of this.customRoutes) {
if (route.method === 'ALL' || route.method === method) {
const params = this.parseRouteParams(route, path);
if (params !== null) {
(request as any).params = params;
const response = await route.handler(request);
if (response) return response;
}
}
}
// HTML injection for reload (if enabled)
if (this.options.injectReload && this.options.serveDir) {
const response = await this.handleHtmlWithInjection(request);
if (response) return response;
}
// Try static file serving
if (this.fileServer && (method === 'GET' || method === 'HEAD')) {
try {
const staticResponse = await this.fileServer.serve(request);
if (staticResponse) {
return staticResponse;
}
} catch (error) {
// Fall through to 404
}
}
// Default answer for root
if (path === '/' && method === 'GET' && this.options.defaultAnswer) {
const html = await this.options.defaultAnswer();
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
}
// Not found
return new Response('Not Found', { status: 404 });
}
/**
* Handle HTML files with reload script injection
*/
private async handleHtmlWithInjection(request: Request): Promise<Response | null> {
const url = new URL(request.url);
const requestPath = url.pathname;
// Check if this is a request for an HTML file or root
if (requestPath === '/' || requestPath.endsWith('.html') || !requestPath.includes('.')) {
try {
let filePath = requestPath === '/' ? 'index.html' : requestPath.slice(1);
if (!filePath.endsWith('.html') && !filePath.includes('.')) {
filePath = plugins.path.join(filePath, 'index.html');
}
const fullPath = plugins.path.join(this.options.serveDir, filePath);
// Security check
if (!fullPath.startsWith(this.options.serveDir)) {
return new Response('Forbidden', { status: 403 });
}
let fileContent = (await plugins.fsInstance
.file(fullPath)
.encoding('utf8')
.read()) as string;
// Inject reload script
if (fileContent.includes('<head>')) {
const injection = `<head>
<!-- injected by @apiglobal/typedserver start -->
<script async defer type="module" src="/typedserver/devtools"></script>
<script>
globalThis.typedserver = {
lastReload: ${this.lastReload},
versionInfo: ${JSON.stringify({}, null, 2)},
}
</script>
<!-- injected by @apiglobal/typedserver stop -->
`;
fileContent = fileContent.replace('<head>', injection);
console.log('injected typedserver script.');
}
return new Response(fileContent, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
appHash: this.serveHash,
},
});
} catch (error) {
// Fall through to default handling
}
}
return null;
}
/**
* reloads the page
*/
@@ -238,14 +528,41 @@ export class TypedServer {
console.warn('TypedSocket not initialized, skipping client notifications');
return;
}
// Push cache invalidation to service workers first
try {
const connections = await this.typedsocket.findAllTargetConnectionsByTag('typedserver_frontend');
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'
);
for (const connection of connections) {
const pushTime = this.typedsocket.createTypedRequest<interfaces.IReq_PushLatestServerChangeTime>(
'pushLatestServerChangeTime',
connection
);
const pushTime =
this.typedsocket.createTypedRequest<interfaces.IReq_PushLatestServerChangeTime>(
'pushLatestServerChangeTime',
connection
);
pushTime.fire({
time: this.lastReload,
});
@@ -260,7 +577,7 @@ export class TypedServer {
*/
public async stop(): Promise<void> {
this.ended = true;
const stopWithErrorHandling = async (
stopFn: () => Promise<unknown>,
componentName: string
@@ -271,24 +588,24 @@ export class TypedServer {
console.error(`Error stopping ${componentName}:`, err);
}
};
const tasks: Promise<void>[] = [];
// Stop server
if (this.server) {
tasks.push(stopWithErrorHandling(() => this.server.stop(), 'server'));
// Stop SmartServe
if (this.smartServe) {
tasks.push(stopWithErrorHandling(() => this.smartServe.stop(), 'SmartServe'));
}
// Stop TypedSocket
// Stop TypedSocket (in SmartServe mode, this is a no-op but good for cleanup)
if (this.typedsocket) {
tasks.push(stopWithErrorHandling(() => this.typedsocket.stop(), 'TypedSocket'));
}
// Stop file watcher
if (this.smartwatchInstance) {
tasks.push(stopWithErrorHandling(() => this.smartwatchInstance.stop(), 'file watcher'));
}
await Promise.all(tasks);
}
@@ -306,11 +623,48 @@ export class TypedServer {
this.serveDirHashSubject.next(this.serveHash);
} catch (error) {
console.error('Failed to create serve directory hash:', error);
// Use a timestamp-based hash as fallback
const fallbackHash = Date.now().toString(16).slice(-6);
this.serveHash = fallbackHash;
console.log('Using fallback hash: ' + fallbackHash);
this.serveDirHashSubject.next(fallbackHash);
}
}
}
}
// ============================================================================
// Helper Classes
// ============================================================================
/**
* Sitemap helper class
*/
class SitemapHelper {
private smartSitemap = new plugins.smartsitemap.SmartSitemap();
public urls: plugins.smartsitemap.IUrlInfo[] = [];
constructor(domain?: string) {
if (domain) {
this.urls.push({
url: `https://${domain}/`,
timestamp: Date.now(),
frequency: 'daily',
});
}
}
async createSitemap(): Promise<string> {
return this.smartSitemap.createSitemapFromUrlInfoArray(this.urls);
}
async createSitemapNews(articles: plugins.tsclass.content.IArticle[]): Promise<string> {
return this.smartSitemap.createSitemapNewsFromArticleArray(articles);
}
replaceUrls(urlsArg: plugins.smartsitemap.IUrlInfo[]) {
this.urls = urlsArg;
}
addUrls(urlsArg: plugins.smartsitemap.IUrlInfo[]) {
this.urls = this.urls.concat(urlsArg);
}
}

View File

@@ -0,0 +1,125 @@
import * as plugins from '../plugins.js';
/**
* Built-in routes controller for TypedServer
* Handles robots.txt, manifest.json, sitemap, feed, appversion
*/
@plugins.smartserve.Route('')
export class BuiltInRoutesController {
private options: {
domain?: string;
robots?: boolean;
manifest?: plugins.smartmanifest.SmartManifest;
sitemap?: boolean;
feed?: boolean;
appVersion?: string;
feedMetadata?: plugins.smartfeed.IFeedOptions;
articleGetterFunction?: () => Promise<plugins.tsclass.content.IArticle[]>;
blockWaybackMachine?: boolean;
getSitemapUrls: () => plugins.smartsitemap.IUrlInfo[];
};
constructor(options: typeof BuiltInRoutesController.prototype.options) {
this.options = options;
}
@plugins.smartserve.Get('/robots.txt')
async getRobots(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
if (!this.options.robots || !this.options.domain) {
throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method);
}
const robotsContent = [
'User-agent: *',
'Allow: /',
`Sitemap: https://${this.options.domain}/sitemap`,
];
if (this.options.blockWaybackMachine) {
robotsContent.push('', 'User-agent: ia_archiver', 'Disallow: /');
}
return new Response(robotsContent.join('\n'), {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
}
@plugins.smartserve.Get('/manifest.json')
async getManifest(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
if (!this.options.manifest) {
throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method);
}
return new Response(this.options.manifest.jsonString(), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
@plugins.smartserve.Get('/sitemap')
async getSitemap(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
if (!this.options.sitemap || !this.options.domain) {
throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method);
}
const smartsitemap = new plugins.smartsitemap.SmartSitemap();
const urls = this.options.getSitemapUrls();
const sitemapXml = await smartsitemap.createSitemapFromUrlInfoArray(urls);
return new Response(sitemapXml, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
}
@plugins.smartserve.Get('/sitemap-news')
async getSitemapNews(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
if (!this.options.sitemap || !this.options.domain || !this.options.articleGetterFunction) {
throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method);
}
const smartsitemap = new plugins.smartsitemap.SmartSitemap();
const articles = await this.options.articleGetterFunction();
const sitemapNewsXml = await smartsitemap.createSitemapNewsFromArticleArray(articles);
return new Response(sitemapNewsXml, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
}
@plugins.smartserve.Get('/feed')
async getFeed(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
if (!this.options.feed || !this.options.feedMetadata) {
throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method);
}
const smartfeed = new plugins.smartfeed.Smartfeed();
const articles = this.options.articleGetterFunction
? await this.options.articleGetterFunction()
: [];
const feedXml = await smartfeed.createFeedFromArticleArray(
this.options.feedMetadata,
articles
);
return new Response(feedXml, {
status: 200,
headers: { 'Content-Type': 'application/atom+xml' },
});
}
@plugins.smartserve.Get('/appversion')
async getAppVersion(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
if (!this.options.appVersion) {
throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method);
}
return new Response(this.options.appVersion, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
}
}

View File

@@ -0,0 +1,53 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
/**
* DevTools controller for TypedServer
* Handles /typedserver/devtools and /typedserver/reloadcheck endpoints
*/
@plugins.smartserve.Route('/typedserver')
export class DevToolsController {
private getLastReload: () => number;
private getEnded: () => boolean;
constructor(options: { getLastReload: () => number; getEnded: () => boolean }) {
this.getLastReload = options.getLastReload;
this.getEnded = options.getEnded;
}
@plugins.smartserve.Get('/devtools')
async getDevtools(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
const devtoolsContent = (await plugins.fsInstance
.file(paths.injectBundlePath)
.encoding('utf8')
.read()) as string;
return new Response(devtoolsContent, {
status: 200,
headers: {
'Content-Type': 'text/javascript',
},
});
}
@plugins.smartserve.Post('/reloadcheck')
async reloadCheck(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
console.log('got request for reloadcheck');
if (this.getEnded()) {
return new Response('end', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
});
}
return new Response(this.getLastReload().toString(), {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
});
}
}

View File

@@ -0,0 +1,34 @@
import * as plugins from '../plugins.js';
/**
* TypedRequest controller for type-safe RPC endpoint
*/
@plugins.smartserve.Route('/typedrequest')
export class TypedRequestController {
private typedRouter: plugins.typedrequest.TypedRouter;
constructor(typedRouter: plugins.typedrequest.TypedRouter) {
this.typedRouter = typedRouter;
}
@plugins.smartserve.Post('/')
async handleTypedRequest(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
try {
const response = await this.typedRouter.routeAndAddResponse(ctx.body as plugins.typedrequestInterfaces.ITypedRequest);
return new Response(plugins.smartjson.stringify(response), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Invalid request' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
}
}

3
ts/controllers/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './controller.devtools.js';
export * from './controller.typedrequest.js';
export * from './controller.builtin.js';

View File

@@ -5,10 +5,10 @@ import * as servertools from './servertools/index.js';
export { servertools };
export * from './classes.typedserver.js';
// Type helpers
export type Request = plugins.express.Request;
export type Response = plugins.express.Response;
// Type helpers - using native Web API Request/Response types
// Native Request and Response are available in Node.js 18+ and all modern browsers
// Legacy Express types are available via servertools for backward compatibility
// lets export utilityservers
import * as utilityservers from './utilityservers/index.js';

View File

@@ -61,11 +61,16 @@ export {
// Create a ready-to-use smartfs instance with Node.js provider
export const fsInstance = new smartfs.SmartFs(new smartfs.SmartFsProviderNode());
// express
// @push.rocks/smartserve
import * as smartserve from '@push.rocks/smartserve';
export { smartserve };
// Legacy Express dependencies - kept for backward compatibility with deprecated servertools
// These will be removed in the next major version
import express from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import express from 'express';
// @ts-ignore
import expressForceSsl from 'express-force-ssl';
export { bodyParser, cors, express, expressForceSsl };
export { express, bodyParser, cors, expressForceSsl };

View File

@@ -53,10 +53,17 @@ export class Server {
this.addRoute('/typedrequest', new HandlerTypedRouter(typedrouter));
}
/**
* @deprecated This method is deprecated. Use TypedServer with SmartServe integration instead.
* TypedSocket v4 no longer supports attaching to an existing Express server.
*/
public addTypedSocket(typedrouter: plugins.typedrequest.TypedRouter): void {
this.executeAfterStartFunctions.push(async () => {
plugins.typedsocket.TypedSocket.createServer(typedrouter, this);
});
console.warn(
'[DEPRECATED] servertools.Server.addTypedSocket() is deprecated and has no effect. ' +
'Use TypedServer with SmartServe integration for WebSocket support.'
);
// TypedSocket v4 creates its own server, which would conflict with Express.
// This method is now a no-op for backward compatibility.
}
public addRoute(routeStringArg: string, handlerArg?: Handler) {

View File

@@ -1,12 +1,22 @@
export * from './classes.server.js';
export * from './classes.route.js';
export * from './classes.handler.js';
export * from './classes.handlerstatic.js';
export * from './classes.handlerproxy.js';
export * from './classes.handlertypedrouter.js';
// Core utilities that don't depend on Express
export * from './classes.compressor.js';
import * as serviceworker from './tools.serviceworker.js';
export {
serviceworker,
}
// Legacy Express-based classes - deprecated, will be removed in next major version
// These are kept for backward compatibility but should not be used for new code
// Use SmartServe decorator-based controllers instead
/** @deprecated Use SmartServe directly */
export * from './classes.server.js';
/** @deprecated Use SmartServe @Route decorator */
export * from './classes.route.js';
/** @deprecated Use SmartServe @Get/@Post decorators */
export * from './classes.handler.js';
/** @deprecated Use SmartServe static file serving */
export * from './classes.handlerstatic.js';
/** @deprecated Use SmartServe custom handler */
export * from './classes.handlerproxy.js';
/** @deprecated Use SmartServe TypedRouter integration */
export * from './classes.handlertypedrouter.js';
// Service worker utilities - uses legacy patterns, will be migrated
import * as serviceworker from './tools.serviceworker.js';
export { serviceworker };

View File

@@ -1,10 +1,8 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'
import { Handler } from './classes.handler.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import type { TypedServer } from '../classes.typedserver.js';
import { HandlerTypedRouter } from './classes.handlertypedrouter.js';
// Lazy-loaded service worker bundle content
let swBundleJs: string | null = null;
@@ -12,64 +10,132 @@ let swBundleJsMap: string | null = null;
const loadServiceWorkerBundle = async (): Promise<void> => {
if (swBundleJs === null) {
swBundleJs = await plugins.fsInstance
swBundleJs = (await plugins.fsInstance
.file(plugins.path.join(paths.serviceworkerBundleDir, './serviceworker.bundle.js'))
.encoding('utf8')
.read() as string;
.read()) as string;
}
if (swBundleJsMap === null) {
swBundleJsMap = await plugins.fsInstance
swBundleJsMap = (await plugins.fsInstance
.file(plugins.path.join(paths.serviceworkerBundleDir, './serviceworker.bundle.js.map'))
.encoding('utf8')
.read() as string;
.read()) as string;
}
};
let swVersionInfo: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] =
null;
const serviceworkerHandler = new Handler(
'GET',
async (req, res) => {
await loadServiceWorkerBundle();
if (req.path === '/serviceworker.bundle.js') {
res.status(200);
res.set('Content-Type', 'text/javascript');
res.write(swBundleJs + '\n' + `/** appSemVer: ${swVersionInfo?.appSemVer || 'not set'} */`);
} else if (req.path === '/serviceworker.bundle.js.map') {
res.status(200);
res.set('Content-Type', 'application/json');
res.write(swBundleJsMap);
}
res.end();
}
);
export const addServiceWorkerRoute = (
typedserverInstance: TypedServer,
swDataFunc: () => interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response']
) => {
// lets the version info as unique string;
// Set the version info
swVersionInfo = swDataFunc();
// the basic stuff
typedserverInstance.server.addRoute('/serviceworker/*splat', serviceworkerHandler);
// Handler function for serviceworker bundle requests
const handleServiceWorkerRequest = async (request: Request): Promise<Response> => {
await loadServiceWorkerBundle();
const url = new URL(request.url);
const path = url.pathname;
// the typed stuff
const typedrouter = new plugins.typedrequest.TypedRouter();
if (path === '/serviceworker/serviceworker.bundle.js' || path === '/serviceworker.bundle.js') {
return new Response(
swBundleJs + '\n' + `/** appSemVer: ${swVersionInfo?.appSemVer || 'not set'} */`,
{
status: 200,
headers: { 'Content-Type': 'text/javascript' },
}
);
} else if (
path === '/serviceworker/serviceworker.bundle.js.map' ||
path === '/serviceworker.bundle.js.map'
) {
return new Response(swBundleJsMap, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo>(
'serviceworker_versionInfo',
async (req) => {
const versionInfoResponse = swDataFunc();
return versionInfoResponse;
}
)
);
return null;
};
typedserverInstance.server.addRoute(
'/sw-typedrequest',
new HandlerTypedRouter(typedrouter)
);
// Service worker bundle handler - nested path
typedserverInstance.addRoute('/serviceworker/*splat', 'GET', handleServiceWorkerRequest);
// Service worker bundle handler - root level (for /serviceworker.bundle.js)
typedserverInstance.addRoute('/serviceworker.bundle.js', 'GET', handleServiceWorkerRequest);
typedserverInstance.addRoute('/serviceworker.bundle.js.map', 'GET', handleServiceWorkerRequest);
// Typed request handler for service worker
typedserverInstance.addRoute('/sw-typedrequest', 'POST', async (request: Request) => {
try {
const body = await request.json();
// Create a local typed router for service worker requests
const typedrouter = new plugins.typedrequest.TypedRouter();
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo>(
'serviceworker_versionInfo',
async () => {
return swDataFunc();
}
)
);
// Speedtest handler for measuring connection speed
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Speedtest>(
'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':
// Generate random payload for download test
payload = 'x'.repeat(sizeBytes);
bytesTransferred = sizeBytes;
break;
case 'upload':
// For upload, measure bytes received from client
bytesTransferred = reqArg.payload?.length || 0;
break;
case 'latency':
// Minimal payload for latency test
bytesTransferred = 1;
break;
}
const durationMs = Date.now() - startTime;
// Speed in Mbps: (bytes * 8 bits/byte) / (ms * 1000 to get seconds) / 1,000,000 for Mbps
const speedMbps = durationMs > 0 ? (bytesTransferred * 8) / (durationMs * 1000) : 0;
return {
durationMs,
bytesTransferred,
speedMbps,
timestamp: Date.now(),
payload, // Only for download tests
};
}
)
);
const response = await typedrouter.routeAndAddResponse(body);
return new Response(plugins.smartjson.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Invalid request' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
});
};

View File

@@ -1,9 +1,7 @@
import { TypedServer } from '../classes.typedserver.js';
import * as servertools from '../servertools/index.js';
import * as plugins from '../plugins.js';
export interface ILoleServiceServerConstructorOptions {
addCustomRoutes?: (serverArg: servertools.Server) => Promise<any>;
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
serviceName: string;
serviceVersion: string;
serviceDomain: string;
@@ -20,12 +18,12 @@ export class UtilityServiceServer {
}
public async start() {
console.log('starting lole-serviceserver...')
console.log('starting lole-serviceserver...');
this.typedServer = new TypedServer({
cors: true,
domain: this.options.serviceDomain,
forceSsl: false,
port: this.options.port || 3000,
port: this.options.port || 3000,
robots: true,
defaultAnswer: async () => {
const InfoHtml = (await import('../infohtml/index.js')).InfoHtml;
@@ -37,9 +35,9 @@ export class UtilityServiceServer {
},
});
// lets add any custom routes
// Add any custom routes
if (this.options.addCustomRoutes) {
await this.options.addCustomRoutes(this.typedServer.server);
await this.options.addCustomRoutes(this.typedServer);
}
await this.typedServer.start();

View File

@@ -1,11 +1,10 @@
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
import type { Request, Response } from '../index.js';
import * as plugins from '../plugins.js';
import * as servertools from '../servertools/index.js';
export interface IUtilityWebsiteServerConstructorOptions {
addCustomRoutes?: (serverArg: servertools.Server) => Promise<any>;
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
appSemVer?: string;
domain: string;
serveDir: string;
@@ -16,7 +15,6 @@ export interface IUtilityWebsiteServerConstructorOptions {
* the utility website server implements a best practice server for websites
* It supports:
* * live reload
* * compression
* * serviceworker
* * pwa manifest
*/
@@ -30,7 +28,7 @@ export class UtilityWebsiteServer {
}
/**
*
* Start the website server
*/
public async start(portArg = 3000) {
this.typedserver = new TypedServer({
@@ -38,8 +36,6 @@ export class UtilityWebsiteServer {
injectReload: true,
watch: true,
serveDir: this.options.serveDir,
enableCompression: true,
preferredCompressionMethod: 'gzip',
domain: this.options.domain,
forceSsl: false,
manifest: {
@@ -58,33 +54,32 @@ export class UtilityWebsiteServer {
sitemap: true,
});
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] =
{
appHash: 'xxxxxx',
appSemVer: this.options.appSemVer || 'x.x.x',
};
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] = {
appHash: 'xxxxxx',
appSemVer: this.options.appSemVer || 'x.x.x',
};
// -> /lsw* - anything regarding serviceworker
servertools.serviceworker.addServiceWorkerRoute(this.typedserver, () => {
return lswData;
});
// lets add ads.txt
this.typedserver.server.addRoute(
'/ads.txt',
new servertools.Handler('GET', async (req, res) => {
res.type('txt/plain');
const adsTxt =
['google.com, pub-4104137977476459, DIRECT, f08c47fec0942fa0'].join('\n') + '\n';
res.write(adsTxt);
res.end();
})
);
// ads.txt handler
this.typedserver.addRoute('/ads.txt', 'GET', async () => {
const adsTxt =
['google.com, pub-4104137977476459, DIRECT, f08c47fec0942fa0'].join('\n') + '\n';
return new Response(adsTxt, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
});
this.typedserver.server.addRoute(
// Asset broker manifest handler
this.typedserver.addRoute(
'/assetbroker/manifest/:manifestAsset',
new servertools.Handler('GET', async (req, res) => {
let manifestAssetName = req.params.manifestAsset;
'GET',
async (request: Request) => {
let manifestAssetName = (request as any).params?.manifestAsset;
if (manifestAssetName === 'favicon.png') {
manifestAssetName = `favicon_${this.options.domain
.replace('.', '')
@@ -95,19 +90,19 @@ export class UtilityWebsiteServer {
const smartRequest = plugins.smartrequest.SmartRequest.create();
const response = await smartRequest.url(fullOriginAssetUrl).get();
const arrayBuffer = await response.arrayBuffer();
const dataBuffer: Buffer = Buffer.from(arrayBuffer);
res.type('.png');
res.write(dataBuffer);
res.end();
})
return new Response(arrayBuffer, {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
}
);
// lets add any custom routes
// Add any custom routes
if (this.options.addCustomRoutes) {
await this.options.addCustomRoutes(this.typedserver.server);
await this.options.addCustomRoutes(this.typedserver);
}
// -> /* - serve the files
// Subscribe to serve directory hash changes
this.typedserver.serveDirHashSubject.subscribe((appHash: string) => {
lswData = {
appHash,
@@ -115,11 +110,11 @@ export class UtilityWebsiteServer {
};
});
// lets setup the typedrouter chain
// Setup the typedrouter chain
this.typedserver.typedrouter.addTypedRouter(this.typedrouter);
// lets start everything
console.log('routes are all set. Startin up now!');
// Start everything
console.log('routes are all set. Starting up now!');
await this.typedserver.start();
console.log('typedserver started!');
}
@@ -127,14 +122,4 @@ export class UtilityWebsiteServer {
public async stop() {
await this.typedserver.stop();
}
/**
* allows you to hanlde requests from other server instances without the need to listen for yourself
* note smartexpress allows you start the instance wuith passing >>false<< as second parameter to .start();
* @param req
* @param res
*/
public async handleRequest(req: Request, res: Response) {
await this.typedserver.server.handleReqRes(req, res);
}
}

View File

@@ -124,4 +124,114 @@ export interface IRequest_Client_Serviceworker_ConnectionPolling
response: {
serviceworkerId: string;
}
}
// ===============
// Metrics interfaces
// ===============
/**
* Request to get service worker metrics
*/
export interface IRequest_Serviceworker_Metrics
extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IRequest_Serviceworker_Metrics
> {
method: 'serviceworker_metrics';
request: {};
response: {
cache: {
hits: number;
misses: number;
errors: number;
bytesServedFromCache: number;
bytesFetched: number;
averageResponseTime: number;
};
network: {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
timeouts: number;
averageLatency: number;
totalBytesTransferred: number;
};
update: {
totalChecks: number;
successfulChecks: number;
failedChecks: number;
updatesFound: number;
updatesApplied: number;
lastCheckTimestamp: number;
lastUpdateTimestamp: number;
};
connection: {
connectedClients: number;
totalConnectionAttempts: number;
successfulConnections: number;
failedConnections: number;
};
startTime: number;
uptime: number;
};
}
// ===============
// Connection result interface
// ===============
/**
* Result of a service worker connection attempt
*/
export interface IConnectionResult {
connected: boolean;
error?: string;
attempts?: number;
duration?: number;
}
// ===============
// 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
*/
export interface IRequest_Serviceworker_Speedtest
extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IRequest_Serviceworker_Speedtest
> {
method: 'serviceworker_speedtest';
request: {
type: 'download' | 'upload' | 'latency';
payloadSizeKB?: number; // Size of test payload in KB (default: 100)
payload?: string; // For upload tests, the payload to send
};
response: {
durationMs: number;
bytesTransferred: number;
speedMbps: number;
timestamp: number;
payload?: string; // For download tests, the payload received
};
}

View File

@@ -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);
@@ -102,17 +111,13 @@ export class ReloadChecker {
this.typedrouter,
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl()
);
this.typedsocket.addTag('typedserver_frontend', {});
this.typedsocket.eventSubject.subscribe(async (eventArg) => {
console.log(`typedsocket event subscription: ${eventArg}`);
if (
eventArg === 'disconnected' ||
eventArg === 'disconnecting' ||
eventArg === 'timedOut'
) {
await this.typedsocket.setTag('typedserver_frontend', {});
this.typedsocket.statusSubject.subscribe(async (statusArg) => {
console.log(`typedsocket status: ${statusArg}`);
if (statusArg === 'disconnected' || statusArg === 'reconnecting') {
this.backendConnectionLost = true;
this.infoscreen.setText(`typedsocket ${eventArg}!`);
} else if (eventArg === 'connected' && this.backendConnectionLost) {
this.infoscreen.setText(`typedsocket ${statusArg}!`);
} else if (statusArg === 'connected' && this.backendConnectionLost) {
this.backendConnectionLost = false;
this.infoscreen.setSuccess('typedsocket connected!');
// lets check if a reload is necessary

View File

@@ -14,10 +14,10 @@ export class TypedserverInfoscreen extends LitElement {
//INSTANCE
@property()
private text = 'Hello';
accessor text = 'Hello';
@property()
private success = false;
accessor success = false;
public static styles = [
css`

View File

@@ -1,6 +1,7 @@
import * as plugins from './plugins.js';
import * as interfaces from '../dist_ts_interfaces/index.js';
import { logger } from './logging.js';
import { getMetricsCollector } from './classes.metrics.js';
// Add type definitions for ServiceWorker APIs
declare global {
@@ -41,11 +42,15 @@ declare global {
*/
export class ServiceworkerBackend {
public deesComms = new plugins.deesComms.DeesComms();
private swSelf: ServiceWorkerGlobalScope;
private clientUpdateInterval: ReturnType<typeof setInterval> | null = null;
constructor(optionsArg: {
self: any;
purgeCache: (reqArg: interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['request']) => Promise<interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['response']>;
}) {
this.swSelf = optionsArg.self as unknown as ServiceWorkerGlobalScope;
const metrics = getMetricsCollector();
// lets handle wakestuff
optionsArg.self.addEventListener('message', (event) => {
@@ -53,16 +58,51 @@ export class ServiceworkerBackend {
console.log('sw-backend: got wake up call');
}
});
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Client_Serviceworker_ConnectionPolling>('broadcastConnectionPolling', async reqArg => {
// Record connection attempt
metrics.recordConnectionAttempt();
metrics.recordConnectionSuccess();
// Update connected clients count
await this.updateConnectedClientsCount();
return {
serviceworkerId: '123'
};
})
});
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_PurgeServiceWorkerCache>('purgeServiceWorkerCache', async reqArg => {
console.log(`Executing purge cache in serviceworker backend.`)
return await optionsArg.purgeCache?.(reqArg);
});
// Periodically update connected clients count
this.startClientCountUpdates();
}
/**
* Start periodic updates of connected client count
*/
private startClientCountUpdates(): void {
// Update immediately
this.updateConnectedClientsCount();
// Then update every 5 seconds
this.clientUpdateInterval = setInterval(() => {
this.updateConnectedClientsCount();
}, 5000);
}
/**
* Update the connected clients count using the Clients API
*/
private async updateConnectedClientsCount(): Promise<void> {
try {
const clients = await this.swSelf.clients.matchAll({ type: 'window' });
const metrics = getMetricsCollector();
metrics.setConnectedClients(clients.length);
} catch (error) {
logger.log('warn', `Failed to update connected clients count: ${error}`);
}
}
/**
@@ -71,7 +111,7 @@ export class ServiceworkerBackend {
public async triggerReloadAll() {
try {
logger.log('info', 'Triggering reload for all clients due to new version');
// Send update message via DeesComms
// This will be picked up by clients that have registered a handler for 'serviceworker_newVersion'
await this.deesComms.postMessage({
@@ -79,13 +119,15 @@ export class ServiceworkerBackend {
request: {},
messageId: `sw_update_${Date.now()}`
});
// As a fallback, also use the clients API to reload clients that might not catch the broadcast
// We need to type-cast self since TypeScript doesn't recognize ServiceWorker API
const swSelf = self as unknown as ServiceWorkerGlobalScope;
const clients = await swSelf.clients.matchAll({ type: 'window' });
const clients = await this.swSelf.clients.matchAll({ type: 'window' });
logger.log('info', `Found ${clients.length} clients to reload`);
// Update metrics with current client count
const metrics = getMetricsCollector();
metrics.setConnectedClients(clients.length);
for (const client of clients) {
if ('navigate' in client) {
// For modern browsers, navigate to the same URL to trigger reload

View File

@@ -2,6 +2,10 @@ import * as plugins from './plugins.js';
import * as interfaces from './env.js';
import { logger } from './logging.js';
import { ServiceWorker } from './classes.serviceworker.js';
import { getMetricsCollector } from './classes.metrics.js';
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
import { getErrorHandler, ServiceWorkerErrorType } from './classes.errorhandler.js';
import { getDashboardGenerator } from './classes.dashboard.js';
export class CacheManager {
public losslessServiceWorkerRef: ServiceWorker;
@@ -10,9 +14,113 @@ export class CacheManager {
runtimeCacheName: 'runtime'
};
// Request deduplication: tracks in-flight requests to prevent duplicate fetches
private inFlightRequests: Map<string, Promise<Response>> = new Map();
private readonly INFLIGHT_CLEANUP_INTERVAL = 30000; // 30 seconds
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
constructor(losslessServiceWorkerRefArg: ServiceWorker) {
this.losslessServiceWorkerRef = losslessServiceWorkerRefArg;
this._setupCache();
this._setupInFlightCleanup();
}
/**
* Sets up periodic cleanup of stale in-flight request entries
*/
private _setupInFlightCleanup(): void {
// Clean up stale entries periodically
this.cleanupIntervalId = setInterval(() => {
// The Map should naturally clean up via .finally(), but this is a safety net
if (this.inFlightRequests.size > 100) {
logger.log('warn', `In-flight requests map has ${this.inFlightRequests.size} entries, clearing...`);
this.inFlightRequests.clear();
}
}, this.INFLIGHT_CLEANUP_INTERVAL);
}
/**
* Fetches a request with deduplication - coalesces identical concurrent requests
*/
private async fetchWithDeduplication(request: Request): Promise<Response> {
const key = `${request.method}:${request.url}`;
const metrics = getMetricsCollector();
const eventBus = getEventBus();
// Check if we already have an in-flight request for this URL
const existingRequest = this.inFlightRequests.get(key);
if (existingRequest) {
logger.log('note', `Deduplicating request for ${request.url}`);
try {
const response = await existingRequest;
// Clone the response since it may have been consumed
return response.clone();
} catch (error) {
// If the original request failed, we should try again
this.inFlightRequests.delete(key);
throw error;
}
}
// Record the new request
metrics.recordRequest(request.url);
eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_START, {
url: request.url,
method: request.method,
});
const startTime = Date.now();
// Create a new fetch promise and track it
const fetchPromise = fetch(request)
.then(async (response) => {
const duration = Date.now() - startTime;
// Try to get response size
const contentLength = response.headers.get('content-length');
const bytes = contentLength ? parseInt(contentLength, 10) : 0;
metrics.recordRequestSuccess(request.url, duration, bytes);
eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_COMPLETE, {
url: request.url,
method: request.method,
status: response.status,
duration,
bytes,
});
return response;
})
.catch((error) => {
const duration = Date.now() - startTime;
const errorHandler = getErrorHandler();
errorHandler.handleNetworkError(
`Fetch failed for ${request.url}: ${error?.message || error}`,
request.url,
error instanceof Error ? error : undefined,
{ method: request.method, duration }
);
metrics.recordRequestFailure(request.url, error?.message);
eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_ERROR, {
url: request.url,
method: request.method,
duration,
error: error?.message || 'Unknown error',
});
throw error;
})
.finally(() => {
// Remove from in-flight requests when done
this.inFlightRequests.delete(key);
});
// Track the in-flight request
this.inFlightRequests.set(key, fetchPromise);
return fetchPromise;
}
/**
@@ -96,6 +204,28 @@ export class CacheManager {
const originalRequest: Request = fetchEventArg.request;
const parsedUrl = new URL(originalRequest.url);
// Handle dashboard routes - serve directly from service worker
if (parsedUrl.pathname === '/sw-dash' || parsedUrl.pathname === '/sw-dash/') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveDashboard()));
return;
}
if (parsedUrl.pathname === '/sw-dash/metrics') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveMetrics()));
return;
}
if (parsedUrl.pathname === '/sw-dash/speedtest') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(dashboard.runSpeedtest());
return;
}
if (parsedUrl.pathname === '/sw-dash/resources') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveResources()));
return;
}
// Block requests that we don't want the service worker to handle.
if (
parsedUrl.hostname.includes('paddle.com') ||
@@ -128,16 +258,32 @@ export class CacheManager {
const matchRequest = createMatchRequest(originalRequest);
const cachedResponse = await caches.match(matchRequest);
const metrics = getMetricsCollector();
const eventBus = getEventBus();
if (cachedResponse) {
// Record cache hit
const contentLength = cachedResponse.headers.get('content-length');
const bytes = contentLength ? parseInt(contentLength, 10) : 0;
const contentType = cachedResponse.headers.get('content-type') || 'unknown';
metrics.recordCacheHit(matchRequest.url, bytes);
metrics.recordResourceAccess(matchRequest.url, true, contentType, bytes);
eventBus.emitCacheHit(matchRequest.url, bytes);
logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`);
done.resolve(cachedResponse);
return;
}
// Record cache miss
metrics.recordCacheMiss(matchRequest.url);
eventBus.emitCacheMiss(matchRequest.url);
logger.log('info', `NOTYETCACHED: Trying to cache ${matchRequest.url}`);
let newResponse: Response;
try {
newResponse = await fetch(matchRequest);
// Use deduplicated fetch to prevent concurrent requests for the same resource
newResponse = await this.fetchWithDeduplication(matchRequest);
} catch (err: any) {
logger.log('error', `Fetch error for ${matchRequest.url}: ${err}`);
newResponse = await create500Response(matchRequest, new Response(err.message));
@@ -196,6 +342,12 @@ export class CacheManager {
});
await cache.put(matchRequest, newCachedResponse);
// Record resource access for per-resource tracking
const cachedContentType = newResponse.headers.get('content-type') || 'unknown';
const cachedSize = bodyBlob.size;
metrics.recordResourceAccess(matchRequest.url, false, cachedContentType, cachedSize);
logger.log('ok', `NOWCACHED: Cached response for ${matchRequest.url} for subsequent requests!`);
done.resolve(newResponse);
} catch (err) {

View File

@@ -0,0 +1,232 @@
import * as plugins from './plugins.js';
/**
* Configuration interface for service worker settings
*/
export interface IServiceWorkerConfig {
// Cache settings
cache: {
maxAge: number; // Maximum cache age in milliseconds (default: 24 hours)
offlineGracePeriod: number; // Grace period when offline (default: 7 days)
runtimeCacheName: string; // Name of the runtime cache
};
// Update check settings
update: {
minCheckInterval: number; // Minimum interval between update checks (default: 100 seconds)
debounceTime: number; // Debounce time for update tasks (default: 2000ms)
revalidationDebounce: number; // Debounce time for revalidation (default: 6000ms)
};
// Network settings
network: {
requestTimeout: number; // Default request timeout (default: 5000ms)
maxRetries: number; // Maximum retry attempts (default: 3)
retryDelay: number; // Delay between retries (default: 1000ms)
};
// Blocked domains - requests to these domains bypass the service worker
blockedDomains: string[];
// Blocked paths - requests with these path prefixes bypass the service worker
blockedPaths: string[];
// External cacheable domains - external domains that should be cached
cacheableDomains: string[];
}
/**
* Default configuration values
*/
const DEFAULT_CONFIG: IServiceWorkerConfig = {
cache: {
maxAge: 24 * 60 * 60 * 1000, // 24 hours
offlineGracePeriod: 7 * 24 * 60 * 60 * 1000, // 7 days
runtimeCacheName: 'runtime',
},
update: {
minCheckInterval: 100000, // 100 seconds
debounceTime: 2000,
revalidationDebounce: 6000,
},
network: {
requestTimeout: 5000,
maxRetries: 3,
retryDelay: 1000,
},
blockedDomains: [
'paddle.com',
'paypal.com',
'reception.lossless.one',
'umami.',
],
blockedPaths: [
'/socket.io',
'/api/',
'smartserve/reloadcheck',
],
cacheableDomains: [
'assetbroker.',
'unpkg.com',
'fonts.googleapis.com',
'fonts.gstatic.com',
],
};
/**
* ServiceWorkerConfig manages the configuration for the service worker.
* Configuration is persisted to WebStore and can be updated at runtime.
*/
export class ServiceWorkerConfig {
private static readonly STORE_KEY = 'sw_config';
private config: IServiceWorkerConfig;
private store: plugins.webstore.WebStore;
constructor(store: plugins.webstore.WebStore) {
this.store = store;
this.config = { ...DEFAULT_CONFIG };
}
/**
* Loads configuration from WebStore, falling back to defaults
*/
public async load(): Promise<void> {
try {
if (await this.store.check(ServiceWorkerConfig.STORE_KEY)) {
const storedConfig = await this.store.get(ServiceWorkerConfig.STORE_KEY);
this.config = this.mergeConfig(DEFAULT_CONFIG, storedConfig);
}
} catch (error) {
console.warn('Failed to load service worker config, using defaults:', error);
this.config = { ...DEFAULT_CONFIG };
}
}
/**
* Saves current configuration to WebStore
*/
public async save(): Promise<void> {
await this.store.set(ServiceWorkerConfig.STORE_KEY, this.config);
}
/**
* Gets the current configuration
*/
public get(): IServiceWorkerConfig {
return this.config;
}
/**
* Updates configuration with partial values
*/
public async update(partialConfig: Partial<IServiceWorkerConfig>): Promise<void> {
this.config = this.mergeConfig(this.config, partialConfig);
await this.save();
}
/**
* Resets configuration to defaults
*/
public async reset(): Promise<void> {
this.config = { ...DEFAULT_CONFIG };
await this.save();
}
// Getters for common configuration values
public get cacheMaxAge(): number {
return this.config.cache.maxAge;
}
public get offlineGracePeriod(): number {
return this.config.cache.offlineGracePeriod;
}
public get runtimeCacheName(): string {
return this.config.cache.runtimeCacheName;
}
public get minCheckInterval(): number {
return this.config.update.minCheckInterval;
}
public get updateDebounceTime(): number {
return this.config.update.debounceTime;
}
public get revalidationDebounce(): number {
return this.config.update.revalidationDebounce;
}
public get requestTimeout(): number {
return this.config.network.requestTimeout;
}
public get maxRetries(): number {
return this.config.network.maxRetries;
}
public get retryDelay(): number {
return this.config.network.retryDelay;
}
/**
* Checks if a URL should be blocked from service worker handling
*/
public shouldBlockUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
// Check blocked domains
for (const domain of this.config.blockedDomains) {
if (parsedUrl.hostname.includes(domain)) {
return true;
}
}
// Check blocked paths
for (const path of this.config.blockedPaths) {
if (parsedUrl.pathname.includes(path)) {
return true;
}
}
// Check if URL starts with blocked domain pattern
if (url.startsWith('https://umami.')) {
return true;
}
return false;
} catch {
return false;
}
}
/**
* Checks if an external URL should be cached
*/
public shouldCacheExternalUrl(url: string): boolean {
for (const domain of this.config.cacheableDomains) {
if (url.includes(domain)) {
return true;
}
}
return false;
}
/**
* Deep merges two configuration objects
*/
private mergeConfig(
base: IServiceWorkerConfig,
override: Partial<IServiceWorkerConfig>
): IServiceWorkerConfig {
return {
cache: { ...base.cache, ...override.cache },
update: { ...base.update, ...override.update },
network: { ...base.network, ...override.network },
blockedDomains: override.blockedDomains ?? base.blockedDomains,
blockedPaths: override.blockedPaths ?? base.blockedPaths,
cacheableDomains: override.cacheableDomains ?? base.cacheableDomains,
};
}
}

View File

@@ -0,0 +1,923 @@
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
* served directly from the service worker as a single-page app
*/
export class DashboardGenerator {
/**
* Serves the dashboard HTML page
*/
public serveDashboard(): Response {
return new Response(this.generateDashboardHtml(), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves the metrics JSON endpoint
*/
public serveMetrics(): Response {
return new Response(this.generateMetricsJson(), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves detailed resource data for the SPA views
*/
public serveResources(): Response {
return new Response(this.generateResourcesJson(), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Runs a speedtest and returns the results
*/
public async runSpeedtest(): Promise<Response> {
const metrics = getMetricsCollector();
const results: {
latency?: { durationMs: number; speedMbps: number };
download?: { durationMs: number; speedMbps: number; bytesTransferred: number };
upload?: { durationMs: number; speedMbps: number; bytesTransferred: number };
error?: string;
isOnline: boolean;
} = { 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();
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 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();
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);
results.isOnline = false;
metrics.setOnlineStatus(false);
}
return new Response(JSON.stringify(results), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Generates JSON metrics response
*/
public generateMetricsJson(): string {
const metrics = getMetricsCollector();
return JSON.stringify({
...metrics.getMetrics(),
cacheHitRate: metrics.getCacheHitRate(),
networkSuccessRate: metrics.getNetworkSuccessRate(),
resourceCount: metrics.getResourceCount(),
summary: metrics.getSummary(),
});
}
/**
* Generates JSON response with detailed resource data
*/
public generateResourcesJson(): string {
const metrics = getMetricsCollector();
return JSON.stringify({
resources: metrics.getCachedResources(),
domains: metrics.getDomainStats(),
contentTypes: metrics.getContentTypeStats(),
resourceCount: metrics.getResourceCount(),
});
}
/**
* Generates the complete HTML dashboard page as a SPA with tab navigation
*/
public generateDashboardHtml(): string {
const metrics = getMetricsCollector();
const data = metrics.getMetrics();
const hitRate = metrics.getCacheHitRate();
const successRate = metrics.getNetworkSuccessRate();
const resourceCount = metrics.getResourceCount();
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;
color: #00ff00;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.4;
padding: 20px;
min-height: 100vh;
}
.terminal {
max-width: 1200px;
margin: 0 auto;
border: 1px solid #00ff00;
background: #0d0d0d;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.1);
}
.header {
border-bottom: 1px solid #00ff00;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
background: #111;
}
.title { color: #00ff00; font-weight: bold; font-size: 16px; }
.uptime { color: #888; }
/* Navigation tabs */
.nav {
display: flex;
background: #111;
border-bottom: 1px solid #333;
padding: 0 10px;
}
.nav-tab {
padding: 10px 20px;
cursor: pointer;
color: #888;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
transition: all 0.2s;
border-bottom: 2px solid transparent;
}
.nav-tab:hover { color: #00ff00; }
.nav-tab.active {
color: #00ff00;
border-bottom-color: #00ff00;
background: #1a1a1a;
}
.nav-tab .count {
background: #333;
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
margin-left: 6px;
}
.content { padding: 15px; min-height: 400px; }
.view { display: none; }
.view.active { display: block; }
/* Grid layout for overview */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 15px;
}
.panel {
border: 1px solid #333;
padding: 12px;
background: #0a0a0a;
}
.panel-title {
color: #00ffff;
font-weight: bold;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px dashed #333;
}
.row { display: flex; justify-content: space-between; padding: 3px 0; }
.label { color: #888; }
.value { color: #00ff00; }
.value.warning { color: #ffff00; }
.value.error { color: #ff4444; }
.value.success { color: #00ff00; }
/* Gauge */
.gauge { margin: 8px 0; }
.gauge-bar {
height: 16px;
background: #1a1a1a;
border: 1px solid #333;
position: relative;
font-size: 12px;
}
.gauge-fill { height: 100%; transition: width 0.3s ease; }
.gauge-fill.good { background: #00aa00; }
.gauge-fill.warning { background: #aaaa00; }
.gauge-fill.bad { background: #aa0000; }
.gauge-text {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-weight: bold;
text-shadow: 1px 1px 2px #000;
}
/* Sortable table */
.table-container { overflow-x: auto; }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table th, .data-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #333;
}
.data-table th {
background: #1a1a1a;
color: #00ffff;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.data-table th:hover { background: #252525; }
.data-table th .sort-icon { margin-left: 5px; opacity: 0.5; }
.data-table th.sorted .sort-icon { opacity: 1; color: #00ff00; }
.data-table tr:hover { background: #151515; }
.data-table td { color: #ccc; }
.data-table td.url {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-table td.num { text-align: right; color: #00ff00; }
/* Search/filter */
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 10px;
}
.search-input {
background: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
padding: 6px 10px;
font-family: inherit;
font-size: 12px;
width: 250px;
}
.search-input:focus { outline: none; border-color: #00ff00; }
.table-info { color: #888; font-size: 12px; }
/* Speed bars */
.speed-bar {
height: 8px;
background: #1a1a1a;
border: 1px solid #333;
margin: 4px 0;
}
.speed-fill { height: 100%; background: #00aa00; transition: width 0.5s ease; }
/* Online indicator */
.online-indicator { display: flex; align-items: center; gap: 8px; padding: 8px 0; margin-bottom: 10px; border-bottom: 1px dashed #333; }
.online-dot { width: 12px; height: 12px; border-radius: 50%; transition: background-color 0.3s ease; }
.online-dot.online { background: #00ff00; box-shadow: 0 0 8px rgba(0, 255, 0, 0.5); }
.online-dot.offline { background: #ff4444; box-shadow: 0 0 8px rgba(255, 68, 68, 0.5); }
/* Buttons */
.btn {
background: #1a1a1a;
border: 1px solid #00ff00;
color: #00ff00;
padding: 8px 16px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
transition: all 0.2s ease;
}
.btn:hover { background: #00ff00; color: #000; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-row { display: flex; justify-content: flex-end; margin-top: 10px; }
/* Footer */
.footer {
border-top: 1px solid #00ff00;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
background: #111;
font-size: 12px;
}
.refresh-info { color: #888; }
.status { display: flex; align-items: center; gap: 8px; }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #00ff00;
animation: pulse 2s infinite;
}
.prompt { color: #00ff00; }
.cursor {
display: inline-block;
width: 8px;
height: 14px;
background: #00ff00;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 2px;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes blink { 50% { opacity: 0; } }
/* Hit rate bar in tables */
.hit-rate-bar {
width: 60px;
height: 10px;
background: #1a1a1a;
border: 1px solid #333;
display: inline-block;
vertical-align: middle;
margin-right: 6px;
}
.hit-rate-fill { height: 100%; }
.hit-rate-fill.good { background: #00aa00; }
.hit-rate-fill.warning { background: #aaaa00; }
.hit-rate-fill.bad { background: #aa0000; }
</style>
</head>
<body>
<div class="terminal">
<div class="header">
<span class="title">[SW-DASH] Service Worker Dashboard</span>
<span class="uptime" id="uptime">Uptime: ${this.formatDuration(data.uptime)}</span>
</div>
<nav class="nav">
<button class="nav-tab active" data-view="overview">Overview</button>
<button class="nav-tab" data-view="urls">URLs <span class="count" id="url-count">${resourceCount}</span></button>
<button class="nav-tab" data-view="domains">Domains</button>
<button class="nav-tab" data-view="types">Types</button>
</nav>
<div class="content">
<!-- Overview View -->
<div id="view-overview" class="view active">
<div class="grid">
<div class="panel">
<div class="panel-title">[ CACHE ]</div>
<div class="gauge">
<div class="gauge-bar">
<div class="gauge-fill ${this.getGaugeClass(hitRate)}" id="cache-gauge" style="width: ${hitRate}%"></div>
<span class="gauge-text" id="cache-gauge-text">${hitRate}% hit rate</span>
</div>
</div>
<div class="row"><span class="label">Hits:</span><span class="value success" id="cache-hits">${this.formatNumber(data.cache.hits)}</span></div>
<div class="row"><span class="label">Misses:</span><span class="value warning" id="cache-misses">${this.formatNumber(data.cache.misses)}</span></div>
<div class="row"><span class="label">Errors:</span><span class="value ${data.cache.errors > 0 ? 'error' : ''}" id="cache-errors">${this.formatNumber(data.cache.errors)}</span></div>
<div class="row"><span class="label">From Cache:</span><span class="value" id="cache-bytes">${this.formatBytes(data.cache.bytesServedFromCache)}</span></div>
<div class="row"><span class="label">Fetched:</span><span class="value" id="cache-fetched">${this.formatBytes(data.cache.bytesFetched)}</span></div>
<div class="row"><span class="label">Resources:</span><span class="value" id="cache-resources">${resourceCount}</span></div>
</div>
<div class="panel">
<div class="panel-title">[ NETWORK ]</div>
<div class="gauge">
<div class="gauge-bar">
<div class="gauge-fill ${this.getGaugeClass(successRate)}" id="net-gauge" style="width: ${successRate}%"></div>
<span class="gauge-text" id="net-gauge-text">${successRate}% success</span>
</div>
</div>
<div class="row"><span class="label">Total Requests:</span><span class="value" id="net-total">${this.formatNumber(data.network.totalRequests)}</span></div>
<div class="row"><span class="label">Successful:</span><span class="value success" id="net-success">${this.formatNumber(data.network.successfulRequests)}</span></div>
<div class="row"><span class="label">Failed:</span><span class="value ${data.network.failedRequests > 0 ? 'error' : ''}" id="net-failed">${this.formatNumber(data.network.failedRequests)}</span></div>
<div class="row"><span class="label">Timeouts:</span><span class="value ${data.network.timeouts > 0 ? 'warning' : ''}" id="net-timeouts">${this.formatNumber(data.network.timeouts)}</span></div>
<div class="row"><span class="label">Avg Latency:</span><span class="value" id="net-latency">${data.network.averageLatency}ms</span></div>
<div class="row"><span class="label">Transferred:</span><span class="value" id="net-bytes">${this.formatBytes(data.network.totalBytesTransferred)}</span></div>
</div>
<div class="panel">
<div class="panel-title">[ UPDATES ]</div>
<div class="row"><span class="label">Total Checks:</span><span class="value" id="upd-checks">${this.formatNumber(data.update.totalChecks)}</span></div>
<div class="row"><span class="label">Successful:</span><span class="value success" id="upd-success">${this.formatNumber(data.update.successfulChecks)}</span></div>
<div class="row"><span class="label">Failed:</span><span class="value ${data.update.failedChecks > 0 ? 'error' : ''}" id="upd-failed">${this.formatNumber(data.update.failedChecks)}</span></div>
<div class="row"><span class="label">Updates Found:</span><span class="value" id="upd-found">${this.formatNumber(data.update.updatesFound)}</span></div>
<div class="row"><span class="label">Updates Applied:</span><span class="value success" id="upd-applied">${this.formatNumber(data.update.updatesApplied)}</span></div>
<div class="row"><span class="label">Last Check:</span><span class="value" id="upd-last-check">${this.formatTimestamp(data.update.lastCheckTimestamp)}</span></div>
</div>
<div class="panel">
<div class="panel-title">[ CONNECTIONS ]</div>
<div class="row"><span class="label">Active Clients:</span><span class="value success" id="conn-clients">${this.formatNumber(data.connection.connectedClients)}</span></div>
<div class="row"><span class="label">Total Attempts:</span><span class="value" id="conn-attempts">${this.formatNumber(data.connection.totalConnectionAttempts)}</span></div>
<div class="row"><span class="label">Successful:</span><span class="value success" id="conn-success">${this.formatNumber(data.connection.successfulConnections)}</span></div>
<div class="row"><span class="label">Failed:</span><span class="value ${data.connection.failedConnections > 0 ? 'error' : ''}" id="conn-failed">${this.formatNumber(data.connection.failedConnections)}</span></div>
<div class="row" style="margin-top: 15px; padding-top: 10px; border-top: 1px dashed #333;">
<span class="label">Started:</span><span class="value" id="start-time">${this.formatTimestamp(data.startTime)}</span>
</div>
</div>
<div class="panel">
<div class="panel-title">[ SPEEDTEST ]</div>
<div class="online-indicator">
<span class="online-dot ${data.speedtest.isOnline ? 'online' : 'offline'}" id="online-dot"></span>
<span class="value ${data.speedtest.isOnline ? 'success' : 'error'}" id="online-status">${data.speedtest.isOnline ? 'Online' : 'Offline'}</span>
</div>
<div class="row"><span class="label">Download:</span><span class="value" id="speed-download">${data.speedtest.lastDownloadSpeedMbps.toFixed(2)} Mbps</span></div>
<div class="speed-bar"><div class="speed-fill" id="speed-download-bar" style="width: ${Math.min(data.speedtest.lastDownloadSpeedMbps, 100)}%"></div></div>
<div class="row"><span class="label">Upload:</span><span class="value" id="speed-upload">${data.speedtest.lastUploadSpeedMbps.toFixed(2)} Mbps</span></div>
<div class="speed-bar"><div class="speed-fill" id="speed-upload-bar" style="width: ${Math.min(data.speedtest.lastUploadSpeedMbps, 100)}%"></div></div>
<div class="row"><span class="label">Latency:</span><span class="value" id="speed-latency">${data.speedtest.lastLatencyMs.toFixed(0)} ms</span></div>
<div class="btn-row"><button class="btn" id="run-speedtest">Run Test</button></div>
</div>
</div>
</div>
<!-- URLs View -->
<div id="view-urls" class="view">
<div class="table-controls">
<input type="text" class="search-input" id="url-search" placeholder="Filter URLs...">
<span class="table-info" id="url-info">Loading...</span>
</div>
<div class="table-container">
<table class="data-table" id="url-table">
<thead>
<tr>
<th data-sort="url">URL <span class="sort-icon">^</span></th>
<th data-sort="contentType">Type <span class="sort-icon">^</span></th>
<th data-sort="size">Size <span class="sort-icon">^</span></th>
<th data-sort="hitCount">Hits <span class="sort-icon">^</span></th>
<th data-sort="missCount">Misses <span class="sort-icon">^</span></th>
<th data-sort="hitRate">Hit Rate <span class="sort-icon">^</span></th>
<th data-sort="lastAccessed">Last Access <span class="sort-icon">^</span></th>
</tr>
</thead>
<tbody id="url-tbody"></tbody>
</table>
</div>
</div>
<!-- Domains View -->
<div id="view-domains" class="view">
<div class="table-controls">
<input type="text" class="search-input" id="domain-search" placeholder="Filter domains...">
<span class="table-info" id="domain-info">Loading...</span>
</div>
<div class="table-container">
<table class="data-table" id="domain-table">
<thead>
<tr>
<th data-sort="domain">Domain <span class="sort-icon">^</span></th>
<th data-sort="totalResources">Resources <span class="sort-icon">^</span></th>
<th data-sort="totalSize">Total Size <span class="sort-icon">^</span></th>
<th data-sort="totalHits">Hits <span class="sort-icon">^</span></th>
<th data-sort="totalMisses">Misses <span class="sort-icon">^</span></th>
<th data-sort="hitRate">Hit Rate <span class="sort-icon">^</span></th>
</tr>
</thead>
<tbody id="domain-tbody"></tbody>
</table>
</div>
</div>
<!-- Types View -->
<div id="view-types" class="view">
<div class="table-controls">
<input type="text" class="search-input" id="type-search" placeholder="Filter types...">
<span class="table-info" id="type-info">Loading...</span>
</div>
<div class="table-container">
<table class="data-table" id="type-table">
<thead>
<tr>
<th data-sort="contentType">Content Type <span class="sort-icon">^</span></th>
<th data-sort="totalResources">Resources <span class="sort-icon">^</span></th>
<th data-sort="totalSize">Total Size <span class="sort-icon">^</span></th>
<th data-sort="totalHits">Hits <span class="sort-icon">^</span></th>
<th data-sort="totalMisses">Misses <span class="sort-icon">^</span></th>
<th data-sort="hitRate">Hit Rate <span class="sort-icon">^</span></th>
</tr>
</thead>
<tbody id="type-tbody"></tbody>
</table>
</div>
</div>
</div>
<div class="footer">
<span class="refresh-info">
<span class="prompt">$</span> Last refresh: <span id="last-refresh">${new Date().toLocaleTimeString()}</span><span class="cursor"></span>
</span>
<div class="status">
<span class="status-dot"></span>
<span>Auto-refresh: 2s</span>
</div>
</div>
</div>
<script>
// State
let resourceData = { resources: [], domains: [], contentTypes: [] };
let sortState = {
urls: { column: 'lastAccessed', direction: 'desc' },
domains: { column: 'totalHits', direction: 'desc' },
types: { column: 'totalHits', direction: 'desc' }
};
// Utilities
const formatNumber = n => n.toLocaleString();
const formatBytes = bytes => {
if (bytes === 0) return '0 B';
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDuration = ms => {
const s = Math.floor(ms / 1000), m = Math.floor(s / 60), h = Math.floor(m / 60), d = Math.floor(h / 24);
if (d > 0) return d + 'd ' + (h % 24) + 'h';
if (h > 0) return h + 'h ' + (m % 60) + 'm';
if (m > 0) return m + 'm ' + (s % 60) + 's';
return s + 's';
};
const formatTimestamp = ts => {
if (!ts || ts === 0) return 'never';
const ago = Date.now() - ts;
if (ago < 60000) return Math.floor(ago / 1000) + 's ago';
if (ago < 3600000) return Math.floor(ago / 60000) + 'm ago';
if (ago < 86400000) return Math.floor(ago / 3600000) + 'h ago';
return new Date(ts).toLocaleDateString();
};
const getGaugeClass = rate => rate >= 80 ? 'good' : rate >= 50 ? 'warning' : 'bad';
// Navigation
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
tab.classList.add('active');
document.getElementById('view-' + tab.dataset.view).classList.add('active');
if (tab.dataset.view !== 'overview') loadResourceData();
});
});
// Sort function
function sortData(data, column, direction) {
return [...data].sort((a, b) => {
let valA = a[column], valB = b[column];
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
}
// Hit rate bar HTML
function hitRateBar(rate) {
const cls = getGaugeClass(rate);
return '<span class="hit-rate-bar"><span class="hit-rate-fill ' + cls + '" style="width:' + rate + '%"></span></span>' + rate + '%';
}
// Render URL table
function renderUrlTable(filter = '') {
const tbody = document.getElementById('url-tbody');
let data = resourceData.resources;
if (filter) data = data.filter(r => r.url.toLowerCase().includes(filter.toLowerCase()));
data = sortData(data, sortState.urls.column, sortState.urls.direction);
// Add computed hitRate
data.forEach(r => {
const total = r.hitCount + r.missCount;
r.hitRate = total > 0 ? Math.round((r.hitCount / total) * 100) : 0;
});
tbody.innerHTML = data.map(r =>
'<tr>' +
'<td class="url" title="' + r.url + '">' + r.url + '</td>' +
'<td>' + (r.contentType || 'unknown') + '</td>' +
'<td class="num">' + formatBytes(r.size) + '</td>' +
'<td class="num">' + formatNumber(r.hitCount) + '</td>' +
'<td class="num">' + formatNumber(r.missCount) + '</td>' +
'<td>' + hitRateBar(r.hitRate) + '</td>' +
'<td>' + formatTimestamp(r.lastAccessed) + '</td>' +
'</tr>'
).join('');
document.getElementById('url-info').textContent = data.length + ' of ' + resourceData.resources.length + ' resources';
}
// Render domain table
function renderDomainTable(filter = '') {
const tbody = document.getElementById('domain-tbody');
let data = resourceData.domains;
if (filter) data = data.filter(d => d.domain.toLowerCase().includes(filter.toLowerCase()));
data = sortData(data, sortState.domains.column, sortState.domains.direction);
tbody.innerHTML = data.map(d =>
'<tr>' +
'<td>' + d.domain + '</td>' +
'<td class="num">' + formatNumber(d.totalResources) + '</td>' +
'<td class="num">' + formatBytes(d.totalSize) + '</td>' +
'<td class="num">' + formatNumber(d.totalHits) + '</td>' +
'<td class="num">' + formatNumber(d.totalMisses) + '</td>' +
'<td>' + hitRateBar(d.hitRate) + '</td>' +
'</tr>'
).join('');
document.getElementById('domain-info').textContent = data.length + ' domains';
}
// Render type table
function renderTypeTable(filter = '') {
const tbody = document.getElementById('type-tbody');
let data = resourceData.contentTypes;
if (filter) data = data.filter(t => t.contentType.toLowerCase().includes(filter.toLowerCase()));
data = sortData(data, sortState.types.column, sortState.types.direction);
tbody.innerHTML = data.map(t =>
'<tr>' +
'<td>' + t.contentType + '</td>' +
'<td class="num">' + formatNumber(t.totalResources) + '</td>' +
'<td class="num">' + formatBytes(t.totalSize) + '</td>' +
'<td class="num">' + formatNumber(t.totalHits) + '</td>' +
'<td class="num">' + formatNumber(t.totalMisses) + '</td>' +
'<td>' + hitRateBar(t.hitRate) + '</td>' +
'</tr>'
).join('');
document.getElementById('type-info').textContent = data.length + ' content types';
}
// Sort handlers
function setupSortHandlers(tableId, stateKey, renderFn) {
document.querySelectorAll('#' + tableId + ' th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.sort;
if (sortState[stateKey].column === col) {
sortState[stateKey].direction = sortState[stateKey].direction === 'asc' ? 'desc' : 'asc';
} else {
sortState[stateKey].column = col;
sortState[stateKey].direction = 'desc';
}
// Update sort icons
document.querySelectorAll('#' + tableId + ' th').forEach(h => h.classList.remove('sorted'));
th.classList.add('sorted');
th.querySelector('.sort-icon').textContent = sortState[stateKey].direction === 'asc' ? '^' : 'v';
renderFn();
});
});
}
setupSortHandlers('url-table', 'urls', () => renderUrlTable(document.getElementById('url-search').value));
setupSortHandlers('domain-table', 'domains', () => renderDomainTable(document.getElementById('domain-search').value));
setupSortHandlers('type-table', 'types', () => renderTypeTable(document.getElementById('type-search').value));
// Search handlers
document.getElementById('url-search').addEventListener('input', e => renderUrlTable(e.target.value));
document.getElementById('domain-search').addEventListener('input', e => renderDomainTable(e.target.value));
document.getElementById('type-search').addEventListener('input', e => renderTypeTable(e.target.value));
// Load resource data
async function loadResourceData() {
try {
const response = await fetch('/sw-dash/resources');
resourceData = await response.json();
document.getElementById('url-count').textContent = resourceData.resourceCount;
renderUrlTable(document.getElementById('url-search').value);
renderDomainTable(document.getElementById('domain-search').value);
renderTypeTable(document.getElementById('type-search').value);
} catch (err) {
console.error('Failed to load resource data:', err);
}
}
// Update overview
function updateOverview(data) {
document.getElementById('uptime').textContent = 'Uptime: ' + formatDuration(data.uptime);
document.getElementById('cache-hits').textContent = formatNumber(data.cache.hits);
document.getElementById('cache-misses').textContent = formatNumber(data.cache.misses);
document.getElementById('cache-errors').textContent = formatNumber(data.cache.errors);
document.getElementById('cache-bytes').textContent = formatBytes(data.cache.bytesServedFromCache);
document.getElementById('cache-fetched').textContent = formatBytes(data.cache.bytesFetched);
document.getElementById('cache-resources').textContent = data.resourceCount || 0;
const cacheGauge = document.getElementById('cache-gauge');
cacheGauge.style.width = data.cacheHitRate + '%';
cacheGauge.className = 'gauge-fill ' + getGaugeClass(data.cacheHitRate);
document.getElementById('cache-gauge-text').textContent = data.cacheHitRate + '% hit rate';
document.getElementById('net-total').textContent = formatNumber(data.network.totalRequests);
document.getElementById('net-success').textContent = formatNumber(data.network.successfulRequests);
document.getElementById('net-failed').textContent = formatNumber(data.network.failedRequests);
document.getElementById('net-timeouts').textContent = formatNumber(data.network.timeouts);
document.getElementById('net-latency').textContent = data.network.averageLatency + 'ms';
document.getElementById('net-bytes').textContent = formatBytes(data.network.totalBytesTransferred);
const netGauge = document.getElementById('net-gauge');
netGauge.style.width = data.networkSuccessRate + '%';
netGauge.className = 'gauge-fill ' + getGaugeClass(data.networkSuccessRate);
document.getElementById('net-gauge-text').textContent = data.networkSuccessRate + '% success';
document.getElementById('upd-checks').textContent = formatNumber(data.update.totalChecks);
document.getElementById('upd-success').textContent = formatNumber(data.update.successfulChecks);
document.getElementById('upd-failed').textContent = formatNumber(data.update.failedChecks);
document.getElementById('upd-found').textContent = formatNumber(data.update.updatesFound);
document.getElementById('upd-applied').textContent = formatNumber(data.update.updatesApplied);
document.getElementById('upd-last-check').textContent = formatTimestamp(data.update.lastCheckTimestamp);
document.getElementById('conn-clients').textContent = formatNumber(data.connection.connectedClients);
document.getElementById('conn-attempts').textContent = formatNumber(data.connection.totalConnectionAttempts);
document.getElementById('conn-success').textContent = formatNumber(data.connection.successfulConnections);
document.getElementById('conn-failed').textContent = formatNumber(data.connection.failedConnections);
document.getElementById('start-time').textContent = formatTimestamp(data.startTime);
if (data.speedtest) {
document.getElementById('online-dot').className = 'online-dot ' + (data.speedtest.isOnline ? 'online' : 'offline');
document.getElementById('online-status').textContent = data.speedtest.isOnline ? 'Online' : 'Offline';
document.getElementById('online-status').className = 'value ' + (data.speedtest.isOnline ? 'success' : 'error');
document.getElementById('speed-download').textContent = data.speedtest.lastDownloadSpeedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-upload').textContent = data.speedtest.lastUploadSpeedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-latency').textContent = data.speedtest.lastLatencyMs.toFixed(0) + ' ms';
document.getElementById('speed-download-bar').style.width = Math.min(data.speedtest.lastDownloadSpeedMbps, 100) + '%';
document.getElementById('speed-upload-bar').style.width = Math.min(data.speedtest.lastUploadSpeedMbps, 100) + '%';
}
document.getElementById('url-count').textContent = data.resourceCount || 0;
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
}
// Speedtest
let speedtestRunning = false;
document.getElementById('run-speedtest').addEventListener('click', async () => {
if (speedtestRunning) return;
speedtestRunning = true;
const btn = document.getElementById('run-speedtest');
btn.textContent = 'Testing...';
btn.disabled = true;
try {
const response = await fetch('/sw-dash/speedtest');
const result = await response.json();
document.getElementById('online-dot').className = 'online-dot ' + (result.isOnline ? 'online' : 'offline');
document.getElementById('online-status').textContent = result.isOnline ? 'Online' : 'Offline';
document.getElementById('online-status').className = 'value ' + (result.isOnline ? 'success' : 'error');
if (result.download) {
document.getElementById('speed-download').textContent = result.download.speedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-download-bar').style.width = Math.min(result.download.speedMbps, 100) + '%';
}
if (result.upload) {
document.getElementById('speed-upload').textContent = result.upload.speedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-upload-bar').style.width = Math.min(result.upload.speedMbps, 100) + '%';
}
if (result.latency) {
document.getElementById('speed-latency').textContent = result.latency.durationMs.toFixed(0) + ' ms';
}
} catch (err) {
console.error('Speedtest failed:', err);
document.getElementById('online-dot').className = 'online-dot offline';
document.getElementById('online-status').textContent = 'Offline';
document.getElementById('online-status').className = 'value error';
} finally {
speedtestRunning = false;
btn.textContent = 'Run Test';
btn.disabled = false;
}
});
// Auto-refresh
setInterval(async () => {
try {
const response = await fetch('/sw-dash/metrics');
const data = await response.json();
updateOverview(data);
} catch (err) {
console.error('Failed to fetch metrics:', err);
}
}, 2000);
// Initial load
loadResourceData();
</script>
</body>
</html>`;
}
/**
* Format bytes to human-readable string
*/
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Format duration to human-readable string
*/
private formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
/**
* Format timestamp to relative time string
*/
private formatTimestamp(ts: number): string {
if (!ts || ts === 0) return 'never';
const ago = Date.now() - ts;
if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`;
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
if (ago < 86400000) return `${Math.floor(ago / 3600000)}h ago`;
return new Date(ts).toLocaleDateString();
}
/**
* Format number with thousands separator
*/
private formatNumber(num: number): string {
return num.toLocaleString();
}
/**
* Get gauge class based on percentage
*/
private getGaugeClass(rate: number): string {
if (rate >= 80) return 'good';
if (rate >= 50) return 'warning';
return 'bad';
}
}
// Export singleton getter
let dashboardInstance: DashboardGenerator | null = null;
export const getDashboardGenerator = (): DashboardGenerator => {
if (!dashboardInstance) {
dashboardInstance = new DashboardGenerator();
}
return dashboardInstance;
};

View File

@@ -0,0 +1,333 @@
import { logger } from './logging.js';
/**
* Error types for categorizing service worker errors
*/
export enum ServiceWorkerErrorType {
NETWORK = 'NETWORK',
CACHE = 'CACHE',
UPDATE = 'UPDATE',
CONNECTION = 'CONNECTION',
TIMEOUT = 'TIMEOUT',
UNKNOWN = 'UNKNOWN',
}
/**
* Error severity levels
*/
export enum ErrorSeverity {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
/**
* Interface for error context
*/
export interface IErrorContext {
url?: string;
method?: string;
statusCode?: number;
attempt?: number;
maxAttempts?: number;
duration?: number;
componentName?: string;
additionalInfo?: Record<string, unknown>;
}
/**
* Service Worker Error class with type categorization and context
*/
export class ServiceWorkerError extends Error {
public readonly type: ServiceWorkerErrorType;
public readonly severity: ErrorSeverity;
public readonly context: IErrorContext;
public readonly timestamp: number;
public readonly originalError?: Error;
constructor(
message: string,
type: ServiceWorkerErrorType = ServiceWorkerErrorType.UNKNOWN,
severity: ErrorSeverity = ErrorSeverity.ERROR,
context: IErrorContext = {},
originalError?: Error
) {
super(message);
this.name = 'ServiceWorkerError';
this.type = type;
this.severity = severity;
this.context = context;
this.timestamp = Date.now();
this.originalError = originalError;
// Maintain proper stack trace in V8 environments
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ServiceWorkerError);
}
}
/**
* Creates a formatted log message
*/
public toLogMessage(): string {
const parts = [
`[${this.type}]`,
this.message,
];
if (this.context.url) {
parts.push(`URL: ${this.context.url}`);
}
if (this.context.method) {
parts.push(`Method: ${this.context.method}`);
}
if (this.context.statusCode !== undefined) {
parts.push(`Status: ${this.context.statusCode}`);
}
if (this.context.attempt !== undefined && this.context.maxAttempts !== undefined) {
parts.push(`Attempt: ${this.context.attempt}/${this.context.maxAttempts}`);
}
if (this.context.duration !== undefined) {
parts.push(`Duration: ${this.context.duration}ms`);
}
return parts.join(' | ');
}
/**
* Converts to a plain object for serialization
*/
public toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
type: this.type,
severity: this.severity,
context: this.context,
timestamp: this.timestamp,
stack: this.stack,
originalError: this.originalError?.message,
};
}
}
/**
* Error handler for consistent error handling across service worker components
*/
export class ErrorHandler {
private static instance: ErrorHandler;
private errorHistory: ServiceWorkerError[] = [];
private readonly maxHistorySize = 100;
private constructor() {}
/**
* Gets the singleton instance
*/
public static getInstance(): ErrorHandler {
if (!ErrorHandler.instance) {
ErrorHandler.instance = new ErrorHandler();
}
return ErrorHandler.instance;
}
/**
* Handles an error with consistent logging and tracking
*/
public handle(
error: Error | ServiceWorkerError | string,
type: ServiceWorkerErrorType = ServiceWorkerErrorType.UNKNOWN,
severity: ErrorSeverity = ErrorSeverity.ERROR,
context: IErrorContext = {}
): ServiceWorkerError {
let swError: ServiceWorkerError;
if (error instanceof ServiceWorkerError) {
swError = error;
} else if (error instanceof Error) {
swError = new ServiceWorkerError(error.message, type, severity, context, error);
} else {
swError = new ServiceWorkerError(String(error), type, severity, context);
}
// Log the error
this.logError(swError);
// Track the error
this.trackError(swError);
return swError;
}
/**
* Creates and handles a network error
*/
public handleNetworkError(
message: string,
url: string,
originalError?: Error,
context: Partial<IErrorContext> = {}
): ServiceWorkerError {
return this.handle(
originalError || message,
ServiceWorkerErrorType.NETWORK,
ErrorSeverity.WARN,
{ url, ...context }
);
}
/**
* Creates and handles a cache error
*/
public handleCacheError(
message: string,
url?: string,
originalError?: Error,
context: Partial<IErrorContext> = {}
): ServiceWorkerError {
return this.handle(
originalError || message,
ServiceWorkerErrorType.CACHE,
ErrorSeverity.ERROR,
{ url, ...context }
);
}
/**
* Creates and handles an update error
*/
public handleUpdateError(
message: string,
originalError?: Error,
context: Partial<IErrorContext> = {}
): ServiceWorkerError {
return this.handle(
originalError || message,
ServiceWorkerErrorType.UPDATE,
ErrorSeverity.ERROR,
context
);
}
/**
* Creates and handles a connection error
*/
public handleConnectionError(
message: string,
originalError?: Error,
context: Partial<IErrorContext> = {}
): ServiceWorkerError {
return this.handle(
originalError || message,
ServiceWorkerErrorType.CONNECTION,
ErrorSeverity.WARN,
context
);
}
/**
* Creates and handles a timeout error
*/
public handleTimeoutError(
message: string,
url?: string,
duration?: number,
context: Partial<IErrorContext> = {}
): ServiceWorkerError {
return this.handle(
message,
ServiceWorkerErrorType.TIMEOUT,
ErrorSeverity.WARN,
{ url, duration, ...context }
);
}
/**
* Gets the error history
*/
public getErrorHistory(): ServiceWorkerError[] {
return [...this.errorHistory];
}
/**
* Gets errors by type
*/
public getErrorsByType(type: ServiceWorkerErrorType): ServiceWorkerError[] {
return this.errorHistory.filter((e) => e.type === type);
}
/**
* Gets errors within a time range
*/
public getRecentErrors(withinMs: number): ServiceWorkerError[] {
const cutoff = Date.now() - withinMs;
return this.errorHistory.filter((e) => e.timestamp >= cutoff);
}
/**
* Clears the error history
*/
public clearHistory(): void {
this.errorHistory = [];
}
/**
* Gets error statistics
*/
public getStats(): Record<ServiceWorkerErrorType, number> {
const stats: Record<ServiceWorkerErrorType, number> = {
[ServiceWorkerErrorType.NETWORK]: 0,
[ServiceWorkerErrorType.CACHE]: 0,
[ServiceWorkerErrorType.UPDATE]: 0,
[ServiceWorkerErrorType.CONNECTION]: 0,
[ServiceWorkerErrorType.TIMEOUT]: 0,
[ServiceWorkerErrorType.UNKNOWN]: 0,
};
for (const error of this.errorHistory) {
stats[error.type]++;
}
return stats;
}
/**
* Logs an error with the appropriate severity
*/
private logError(error: ServiceWorkerError): void {
const logMessage = error.toLogMessage();
switch (error.severity) {
case ErrorSeverity.DEBUG:
logger.log('note', logMessage);
break;
case ErrorSeverity.INFO:
logger.log('info', logMessage);
break;
case ErrorSeverity.WARN:
logger.log('warn', logMessage);
break;
case ErrorSeverity.ERROR:
case ErrorSeverity.FATAL:
logger.log('error', logMessage);
break;
}
}
/**
* Tracks an error in the history
*/
private trackError(error: ServiceWorkerError): void {
this.errorHistory.push(error);
// Trim history if needed
if (this.errorHistory.length > this.maxHistorySize) {
this.errorHistory = this.errorHistory.slice(-this.maxHistorySize);
}
}
}
// Export singleton getter for convenience
export const getErrorHandler = (): ErrorHandler => ErrorHandler.getInstance();

View File

@@ -0,0 +1,409 @@
import { logger } from './logging.js';
/**
* Event types for service worker internal communication
*/
export enum ServiceWorkerEvent {
// Cache events
CACHE_HIT = 'cache:hit',
CACHE_MISS = 'cache:miss',
CACHE_ERROR = 'cache:error',
CACHE_INVALIDATE = 'cache:invalidate',
CACHE_INVALIDATE_ALL = 'cache:invalidate_all',
CACHE_REVALIDATE = 'cache:revalidate',
// Update events
UPDATE_CHECK_START = 'update:check_start',
UPDATE_CHECK_COMPLETE = 'update:check_complete',
UPDATE_AVAILABLE = 'update:available',
UPDATE_APPLIED = 'update:applied',
UPDATE_ERROR = 'update:error',
// Network events
NETWORK_REQUEST_START = 'network:request_start',
NETWORK_REQUEST_COMPLETE = 'network:request_complete',
NETWORK_REQUEST_ERROR = 'network:request_error',
NETWORK_ONLINE = 'network:online',
NETWORK_OFFLINE = 'network:offline',
// Connection events
CLIENT_CONNECTED = 'connection:client_connected',
CLIENT_DISCONNECTED = 'connection:client_disconnected',
// Lifecycle events
INSTALL = 'lifecycle:install',
ACTIVATE = 'lifecycle:activate',
READY = 'lifecycle:ready',
}
/**
* Event payload interfaces
*/
export interface ICacheEventPayload {
url: string;
method?: string;
bytes?: number;
error?: string;
}
export interface IUpdateEventPayload {
oldVersion?: string;
newVersion?: string;
oldHash?: string;
newHash?: string;
error?: string;
}
export interface INetworkEventPayload {
url: string;
method?: string;
status?: number;
duration?: number;
bytes?: number;
error?: string;
}
export interface IConnectionEventPayload {
clientId?: string;
tabId?: string;
}
export interface ILifecycleEventPayload {
timestamp: number;
}
/**
* Union type for all event payloads
*/
export type TEventPayload =
| ICacheEventPayload
| IUpdateEventPayload
| INetworkEventPayload
| IConnectionEventPayload
| ILifecycleEventPayload
| Record<string, unknown>;
/**
* Event listener callback type
*/
export type TEventListener<T extends TEventPayload = TEventPayload> = (
event: ServiceWorkerEvent,
payload: T
) => void | Promise<void>;
/**
* Subscription interface
*/
export interface ISubscription {
unsubscribe: () => void;
}
/**
* Event bus for decoupled communication between service worker components.
* Implements a simple pub/sub pattern.
*/
export class EventBus {
private static instance: EventBus;
private listeners: Map<ServiceWorkerEvent, Set<TEventListener>>;
private globalListeners: Set<TEventListener>;
private eventHistory: Array<{ event: ServiceWorkerEvent; payload: TEventPayload; timestamp: number }>;
private readonly maxHistorySize = 100;
private debugMode = false;
private constructor() {
this.listeners = new Map();
this.globalListeners = new Set();
this.eventHistory = [];
}
/**
* Gets the singleton instance
*/
public static getInstance(): EventBus {
if (!EventBus.instance) {
EventBus.instance = new EventBus();
}
return EventBus.instance;
}
/**
* Enables or disables debug mode (logs all events)
*/
public setDebugMode(enabled: boolean): void {
this.debugMode = enabled;
}
/**
* Emits an event to all subscribed listeners
*/
public emit<T extends TEventPayload>(event: ServiceWorkerEvent, payload: T): void {
if (this.debugMode) {
logger.log('note', `[EventBus] Emit: ${event} ${JSON.stringify(payload)}`);
}
// Record in history
this.recordEvent(event, payload);
// Notify specific listeners
const specificListeners = this.listeners.get(event);
if (specificListeners) {
for (const listener of specificListeners) {
try {
const result = listener(event, payload);
if (result instanceof Promise) {
result.catch((err) => {
logger.log('error', `[EventBus] Async listener error for ${event}: ${err}`);
});
}
} catch (err) {
logger.log('error', `[EventBus] Listener error for ${event}: ${err}`);
}
}
}
// Notify global listeners
for (const listener of this.globalListeners) {
try {
const result = listener(event, payload);
if (result instanceof Promise) {
result.catch((err) => {
logger.log('error', `[EventBus] Global async listener error for ${event}: ${err}`);
});
}
} catch (err) {
logger.log('error', `[EventBus] Global listener error for ${event}: ${err}`);
}
}
}
/**
* Subscribes to a specific event
*/
public on<T extends TEventPayload>(
event: ServiceWorkerEvent,
listener: TEventListener<T>
): ISubscription {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener as TEventListener);
return {
unsubscribe: () => {
this.off(event, listener as TEventListener);
},
};
}
/**
* Subscribes to multiple events at once
*/
public onMany<T extends TEventPayload>(
events: ServiceWorkerEvent[],
listener: TEventListener<T>
): ISubscription {
const subscriptions = events.map((event) =>
this.on(event, listener as TEventListener)
);
return {
unsubscribe: () => {
subscriptions.forEach((sub) => sub.unsubscribe());
},
};
}
/**
* Subscribes to all events
*/
public onAll<T extends TEventPayload>(listener: TEventListener<T>): ISubscription {
this.globalListeners.add(listener as TEventListener);
return {
unsubscribe: () => {
this.globalListeners.delete(listener as TEventListener);
},
};
}
/**
* Subscribes to an event for only one emission
*/
public once<T extends TEventPayload>(
event: ServiceWorkerEvent,
listener: TEventListener<T>
): ISubscription {
const onceListener: TEventListener = (evt, payload) => {
this.off(event, onceListener);
return listener(evt, payload as T);
};
return this.on(event, onceListener);
}
/**
* Unsubscribes a listener from an event
*/
public off(event: ServiceWorkerEvent, listener: TEventListener): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.listeners.delete(event);
}
}
}
/**
* Removes all listeners for an event
*/
public removeAllListeners(event?: ServiceWorkerEvent): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
this.globalListeners.clear();
}
}
/**
* Gets the count of listeners for an event
*/
public listenerCount(event: ServiceWorkerEvent): number {
const listeners = this.listeners.get(event);
return (listeners?.size ?? 0) + this.globalListeners.size;
}
/**
* Gets the event history
*/
public getHistory(): Array<{ event: ServiceWorkerEvent; payload: TEventPayload; timestamp: number }> {
return [...this.eventHistory];
}
/**
* Gets events of a specific type from history
*/
public getHistoryByType(event: ServiceWorkerEvent): Array<{ payload: TEventPayload; timestamp: number }> {
return this.eventHistory
.filter((entry) => entry.event === event)
.map(({ payload, timestamp }) => ({ payload, timestamp }));
}
/**
* Clears the event history
*/
public clearHistory(): void {
this.eventHistory = [];
}
/**
* Waits for an event to be emitted (returns a promise)
*/
public waitFor<T extends TEventPayload>(
event: ServiceWorkerEvent,
timeout?: number
): Promise<T> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const subscription = this.once<T>(event, (_, payload) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(payload);
});
if (timeout) {
timeoutId = setTimeout(() => {
subscription.unsubscribe();
reject(new Error(`Timeout waiting for event: ${event}`));
}, timeout);
}
});
}
/**
* Records an event in history
*/
private recordEvent(event: ServiceWorkerEvent, payload: TEventPayload): void {
this.eventHistory.push({
event,
payload,
timestamp: Date.now(),
});
// Trim history if needed
if (this.eventHistory.length > this.maxHistorySize) {
this.eventHistory = this.eventHistory.slice(-this.maxHistorySize);
}
}
// ===================
// Convenience Methods
// ===================
/**
* Emits a cache hit event
*/
public emitCacheHit(url: string, bytes?: number): void {
this.emit(ServiceWorkerEvent.CACHE_HIT, { url, bytes });
}
/**
* Emits a cache miss event
*/
public emitCacheMiss(url: string): void {
this.emit(ServiceWorkerEvent.CACHE_MISS, { url });
}
/**
* Emits a cache error event
*/
public emitCacheError(url: string, error?: string): void {
this.emit(ServiceWorkerEvent.CACHE_ERROR, { url, error });
}
/**
* Emits a cache invalidation event
*/
public emitCacheInvalidate(url?: string): void {
if (url) {
this.emit(ServiceWorkerEvent.CACHE_INVALIDATE, { url });
} else {
this.emit(ServiceWorkerEvent.CACHE_INVALIDATE_ALL, {});
}
}
/**
* Emits an update available event
*/
public emitUpdateAvailable(oldVersion: string, newVersion: string, oldHash: string, newHash: string): void {
this.emit(ServiceWorkerEvent.UPDATE_AVAILABLE, { oldVersion, newVersion, oldHash, newHash });
}
/**
* Emits an update applied event
*/
public emitUpdateApplied(newVersion: string, newHash: string): void {
this.emit(ServiceWorkerEvent.UPDATE_APPLIED, { newVersion, newHash });
}
/**
* Emits a network online event
*/
public emitNetworkOnline(): void {
this.emit(ServiceWorkerEvent.NETWORK_ONLINE, {});
}
/**
* Emits a network offline event
*/
public emitNetworkOffline(): void {
this.emit(ServiceWorkerEvent.NETWORK_OFFLINE, {});
}
}
// Export singleton getter for convenience
export const getEventBus = (): EventBus => EventBus.getInstance();

View File

@@ -0,0 +1,678 @@
import { logger } from './logging.js';
/**
* Interface for cache metrics
*/
export interface ICacheMetrics {
hits: number;
misses: number;
errors: number;
bytesServedFromCache: number;
bytesFetched: number;
averageResponseTime: number;
}
/**
* Interface for per-resource tracking
*/
export interface ICachedResource {
url: string;
domain: string;
contentType: string;
size: number;
hitCount: number;
missCount: number;
lastAccessed: number;
cachedAt: number;
}
/**
* Interface for domain statistics
*/
export interface IDomainStats {
domain: string;
totalResources: number;
totalSize: number;
totalHits: number;
totalMisses: number;
hitRate: number;
}
/**
* Interface for content-type statistics
*/
export interface IContentTypeStats {
contentType: string;
totalResources: number;
totalSize: number;
totalHits: number;
totalMisses: number;
hitRate: number;
}
/**
* Interface for network metrics
*/
export interface INetworkMetrics {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
timeouts: number;
averageLatency: number;
totalBytesTransferred: number;
}
/**
* Interface for update metrics
*/
export interface IUpdateMetrics {
totalChecks: number;
successfulChecks: number;
failedChecks: number;
updatesFound: number;
updatesApplied: number;
lastCheckTimestamp: number;
lastUpdateTimestamp: number;
}
/**
* Interface for connection metrics
*/
export interface IConnectionMetrics {
connectedClients: number;
totalConnectionAttempts: number;
successfulConnections: number;
failedConnections: number;
}
/**
* Interface for speedtest metrics
*/
export interface ISpeedtestMetrics {
lastDownloadSpeedMbps: number;
lastUploadSpeedMbps: number;
lastLatencyMs: number;
lastTestTimestamp: number;
testCount: number;
isOnline: boolean;
}
/**
* Combined metrics interface
*/
export interface IServiceWorkerMetrics {
cache: ICacheMetrics;
network: INetworkMetrics;
update: IUpdateMetrics;
connection: IConnectionMetrics;
speedtest: ISpeedtestMetrics;
startTime: number;
uptime: number;
}
/**
* Response time entry for calculating averages
*/
interface IResponseTimeEntry {
url: string;
duration: number;
timestamp: number;
}
/**
* Metrics collector for service worker observability
*/
export class MetricsCollector {
private static instance: MetricsCollector;
// Cache metrics
private cacheHits = 0;
private cacheMisses = 0;
private cacheErrors = 0;
private bytesServedFromCache = 0;
private bytesFetched = 0;
// Network metrics
private totalRequests = 0;
private successfulRequests = 0;
private failedRequests = 0;
private timeouts = 0;
private totalBytesTransferred = 0;
// Update metrics
private totalUpdateChecks = 0;
private successfulUpdateChecks = 0;
private failedUpdateChecks = 0;
private updatesFound = 0;
private updatesApplied = 0;
private lastCheckTimestamp = 0;
private lastUpdateTimestamp = 0;
// Connection metrics
private connectedClients = 0;
private totalConnectionAttempts = 0;
private successfulConnections = 0;
private failedConnections = 0;
// Speedtest metrics
private lastDownloadSpeedMbps = 0;
private lastUploadSpeedMbps = 0;
private lastLatencyMs = 0;
private lastSpeedtestTimestamp = 0;
private speedtestCount = 0;
private isOnline = true;
// Response time tracking
private responseTimes: IResponseTimeEntry[] = [];
private readonly maxResponseTimeEntries = 1000;
private readonly responseTimeWindow = 5 * 60 * 1000; // 5 minutes
// Per-resource tracking
private resourceStats: Map<string, ICachedResource> = new Map();
private readonly maxResourceEntries = 500;
// Start time
private readonly startTime: number;
private constructor() {
this.startTime = Date.now();
}
/**
* Gets the singleton instance
*/
public static getInstance(): MetricsCollector {
if (!MetricsCollector.instance) {
MetricsCollector.instance = new MetricsCollector();
}
return MetricsCollector.instance;
}
// ===================
// Cache Metrics
// ===================
public recordCacheHit(url: string, bytes: number = 0): void {
this.cacheHits++;
this.bytesServedFromCache += bytes;
logger.log('note', `[Metrics] Cache hit: ${url} (${bytes} bytes)`);
}
public recordCacheMiss(url: string): void {
this.cacheMisses++;
logger.log('note', `[Metrics] Cache miss: ${url}`);
}
public recordCacheError(url: string, error?: string): void {
this.cacheErrors++;
logger.log('warn', `[Metrics] Cache error: ${url} - ${error || 'unknown'}`);
}
public recordBytesFetched(bytes: number): void {
this.bytesFetched += bytes;
}
// ===================
// Network Metrics
// ===================
public recordRequest(_url: string): void {
this.totalRequests++;
}
public recordRequestSuccess(url: string, duration: number, bytes: number = 0): void {
this.successfulRequests++;
this.totalBytesTransferred += bytes;
this.recordResponseTime(url, duration);
}
public recordRequestFailure(url: string, error?: string): void {
this.failedRequests++;
logger.log('warn', `[Metrics] Request failed: ${url} - ${error || 'unknown'}`);
}
public recordTimeout(url: string, duration: number): void {
this.timeouts++;
logger.log('warn', `[Metrics] Request timeout: ${url} after ${duration}ms`);
}
// ===================
// Update Metrics
// ===================
public recordUpdateCheck(success: boolean): void {
this.totalUpdateChecks++;
this.lastCheckTimestamp = Date.now();
if (success) {
this.successfulUpdateChecks++;
} else {
this.failedUpdateChecks++;
}
}
public recordUpdateFound(): void {
this.updatesFound++;
}
public recordUpdateApplied(): void {
this.updatesApplied++;
this.lastUpdateTimestamp = Date.now();
}
// ===================
// Connection Metrics
// ===================
public recordConnectionAttempt(): void {
this.totalConnectionAttempts++;
}
public recordConnectionSuccess(): void {
this.successfulConnections++;
this.connectedClients++;
}
public recordConnectionFailure(): void {
this.failedConnections++;
}
public recordClientDisconnect(): void {
this.connectedClients = Math.max(0, this.connectedClients - 1);
}
public setConnectedClients(count: number): void {
this.connectedClients = count;
}
// ===================
// Speedtest Metrics
// ===================
public recordSpeedtest(type: 'download' | 'upload' | 'latency', value: number): void {
this.speedtestCount++;
this.lastSpeedtestTimestamp = Date.now();
this.isOnline = true;
switch (type) {
case 'download':
this.lastDownloadSpeedMbps = value;
logger.log('info', `[Metrics] Speedtest download: ${value.toFixed(2)} Mbps`);
break;
case 'upload':
this.lastUploadSpeedMbps = value;
logger.log('info', `[Metrics] Speedtest upload: ${value.toFixed(2)} Mbps`);
break;
case 'latency':
this.lastLatencyMs = value;
logger.log('info', `[Metrics] Speedtest latency: ${value.toFixed(0)} ms`);
break;
}
}
public setOnlineStatus(online: boolean): void {
this.isOnline = online;
logger.log('info', `[Metrics] Online status: ${online ? 'online' : 'offline'}`);
}
public getSpeedtestMetrics(): ISpeedtestMetrics {
return {
lastDownloadSpeedMbps: this.lastDownloadSpeedMbps,
lastUploadSpeedMbps: this.lastUploadSpeedMbps,
lastLatencyMs: this.lastLatencyMs,
lastTestTimestamp: this.lastSpeedtestTimestamp,
testCount: this.speedtestCount,
isOnline: this.isOnline,
};
}
// ===================
// Response Time Tracking
// ===================
private recordResponseTime(url: string, duration: number): void {
const entry: IResponseTimeEntry = {
url,
duration,
timestamp: Date.now(),
};
this.responseTimes.push(entry);
// Trim old entries
this.cleanupResponseTimes();
}
private cleanupResponseTimes(): void {
const cutoff = Date.now() - this.responseTimeWindow;
// Remove old entries
this.responseTimes = this.responseTimes.filter(
(entry) => entry.timestamp >= cutoff
);
// Keep within max size
if (this.responseTimes.length > this.maxResponseTimeEntries) {
this.responseTimes = this.responseTimes.slice(-this.maxResponseTimeEntries);
}
}
private calculateAverageResponseTime(): number {
if (this.responseTimes.length === 0) {
return 0;
}
const sum = this.responseTimes.reduce((acc, entry) => acc + entry.duration, 0);
return Math.round(sum / this.responseTimes.length);
}
private calculateAverageLatency(): number {
// Same as response time for now
return this.calculateAverageResponseTime();
}
// ===================
// Metrics Retrieval
// ===================
/**
* Gets all metrics
*/
public getMetrics(): IServiceWorkerMetrics {
const now = Date.now();
this.cleanupResponseTimes();
return {
cache: {
hits: this.cacheHits,
misses: this.cacheMisses,
errors: this.cacheErrors,
bytesServedFromCache: this.bytesServedFromCache,
bytesFetched: this.bytesFetched,
averageResponseTime: this.calculateAverageResponseTime(),
},
network: {
totalRequests: this.totalRequests,
successfulRequests: this.successfulRequests,
failedRequests: this.failedRequests,
timeouts: this.timeouts,
averageLatency: this.calculateAverageLatency(),
totalBytesTransferred: this.totalBytesTransferred,
},
update: {
totalChecks: this.totalUpdateChecks,
successfulChecks: this.successfulUpdateChecks,
failedChecks: this.failedUpdateChecks,
updatesFound: this.updatesFound,
updatesApplied: this.updatesApplied,
lastCheckTimestamp: this.lastCheckTimestamp,
lastUpdateTimestamp: this.lastUpdateTimestamp,
},
connection: {
connectedClients: this.connectedClients,
totalConnectionAttempts: this.totalConnectionAttempts,
successfulConnections: this.successfulConnections,
failedConnections: this.failedConnections,
},
speedtest: {
lastDownloadSpeedMbps: this.lastDownloadSpeedMbps,
lastUploadSpeedMbps: this.lastUploadSpeedMbps,
lastLatencyMs: this.lastLatencyMs,
lastTestTimestamp: this.lastSpeedtestTimestamp,
testCount: this.speedtestCount,
isOnline: this.isOnline,
},
startTime: this.startTime,
uptime: now - this.startTime,
};
}
/**
* Gets cache hit rate as a percentage
*/
public getCacheHitRate(): number {
const total = this.cacheHits + this.cacheMisses;
if (total === 0) {
return 0;
}
return Math.round((this.cacheHits / total) * 100);
}
/**
* Gets network success rate as a percentage
*/
public getNetworkSuccessRate(): number {
if (this.totalRequests === 0) {
return 100;
}
return Math.round((this.successfulRequests / this.totalRequests) * 100);
}
/**
* Resets all metrics
*/
public reset(): void {
this.cacheHits = 0;
this.cacheMisses = 0;
this.cacheErrors = 0;
this.bytesServedFromCache = 0;
this.bytesFetched = 0;
this.totalRequests = 0;
this.successfulRequests = 0;
this.failedRequests = 0;
this.timeouts = 0;
this.totalBytesTransferred = 0;
this.totalUpdateChecks = 0;
this.successfulUpdateChecks = 0;
this.failedUpdateChecks = 0;
this.updatesFound = 0;
this.updatesApplied = 0;
this.lastCheckTimestamp = 0;
this.lastUpdateTimestamp = 0;
this.totalConnectionAttempts = 0;
this.successfulConnections = 0;
this.failedConnections = 0;
this.lastDownloadSpeedMbps = 0;
this.lastUploadSpeedMbps = 0;
this.lastLatencyMs = 0;
this.lastSpeedtestTimestamp = 0;
this.speedtestCount = 0;
// Note: isOnline is not reset as it reflects current state
this.responseTimes = [];
this.resourceStats.clear();
logger.log('info', '[Metrics] All metrics reset');
}
/**
* Gets a summary string for logging
*/
public getSummary(): string {
const metrics = this.getMetrics();
return [
`Cache: ${this.getCacheHitRate()}% hit rate (${metrics.cache.hits}/${metrics.cache.hits + metrics.cache.misses})`,
`Network: ${this.getNetworkSuccessRate()}% success (${metrics.network.successfulRequests}/${metrics.network.totalRequests})`,
`Updates: ${metrics.update.updatesFound} found, ${metrics.update.updatesApplied} applied`,
`Uptime: ${Math.round(metrics.uptime / 1000)}s`,
].join(' | ');
}
// ===================
// Per-Resource Tracking
// ===================
/**
* Extracts domain from URL
*/
private extractDomain(url: string): string {
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname;
} catch {
return 'unknown';
}
}
/**
* Records a resource access (cache hit or miss) with details
*/
public recordResourceAccess(
url: string,
isHit: boolean,
contentType: string = 'unknown',
size: number = 0
): void {
const now = Date.now();
const domain = this.extractDomain(url);
let resource = this.resourceStats.get(url);
if (!resource) {
resource = {
url,
domain,
contentType,
size,
hitCount: 0,
missCount: 0,
lastAccessed: now,
cachedAt: now,
};
this.resourceStats.set(url, resource);
}
// Update resource stats
if (isHit) {
resource.hitCount++;
} else {
resource.missCount++;
}
resource.lastAccessed = now;
// Update content-type and size if provided (may come from response headers)
if (contentType !== 'unknown') {
resource.contentType = contentType;
}
if (size > 0) {
resource.size = size;
}
// Trim old entries if needed
this.cleanupResourceStats();
}
/**
* Cleans up old resource entries to prevent memory bloat
*/
private cleanupResourceStats(): void {
if (this.resourceStats.size <= this.maxResourceEntries) {
return;
}
// Convert to array and sort by lastAccessed (oldest first)
const entries = Array.from(this.resourceStats.entries())
.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed);
// Remove oldest entries until we're under the limit
const toRemove = entries.slice(0, entries.length - this.maxResourceEntries);
for (const [url] of toRemove) {
this.resourceStats.delete(url);
}
}
/**
* Gets all cached resources
*/
public getCachedResources(): ICachedResource[] {
return Array.from(this.resourceStats.values());
}
/**
* Gets domain statistics
*/
public getDomainStats(): IDomainStats[] {
const domainMap = new Map<string, IDomainStats>();
for (const resource of this.resourceStats.values()) {
let stats = domainMap.get(resource.domain);
if (!stats) {
stats = {
domain: resource.domain,
totalResources: 0,
totalSize: 0,
totalHits: 0,
totalMisses: 0,
hitRate: 0,
};
domainMap.set(resource.domain, stats);
}
stats.totalResources++;
stats.totalSize += resource.size;
stats.totalHits += resource.hitCount;
stats.totalMisses += resource.missCount;
}
// Calculate hit rates
for (const stats of domainMap.values()) {
const total = stats.totalHits + stats.totalMisses;
stats.hitRate = total > 0 ? Math.round((stats.totalHits / total) * 100) : 0;
}
return Array.from(domainMap.values());
}
/**
* Gets content-type statistics
*/
public getContentTypeStats(): IContentTypeStats[] {
const typeMap = new Map<string, IContentTypeStats>();
for (const resource of this.resourceStats.values()) {
// Normalize content-type (extract base type)
const baseType = resource.contentType.split(';')[0].trim() || 'unknown';
let stats = typeMap.get(baseType);
if (!stats) {
stats = {
contentType: baseType,
totalResources: 0,
totalSize: 0,
totalHits: 0,
totalMisses: 0,
hitRate: 0,
};
typeMap.set(baseType, stats);
}
stats.totalResources++;
stats.totalSize += resource.size;
stats.totalHits += resource.hitCount;
stats.totalMisses += resource.missCount;
}
// Calculate hit rates
for (const stats of typeMap.values()) {
const total = stats.totalHits + stats.totalMisses;
stats.hitRate = total > 0 ? Math.round((stats.totalHits / total) * 100) : 0;
}
return Array.from(typeMap.values());
}
/**
* Gets resource count
*/
public getResourceCount(): number {
return this.resourceStats.size;
}
}
// Export singleton getter for convenience
export const getMetricsCollector = (): MetricsCollector => MetricsCollector.getInstance();

View File

@@ -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);
}
}
}

View File

@@ -3,6 +3,9 @@ import * as interfaces from '../dist_ts_interfaces/index.js';
import { ServiceWorker } from './classes.serviceworker.js';
import { logger } from './logging.js';
import { CacheManager } from './classes.cachemanager.js';
import { getMetricsCollector } from './classes.metrics.js';
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
import { getErrorHandler, ServiceWorkerErrorType } from './classes.errorhandler.js';
export class UpdateManager {
public lastUpdateCheck: number = 0;
@@ -10,6 +13,10 @@ export class UpdateManager {
public serviceworkerRef: ServiceWorker;
// Rate limiting for update checks
private isCheckInProgress = false;
private pendingCheckPromise: Promise<boolean> | null = null;
constructor(serviceWorkerRefArg: ServiceWorker) {
this.serviceworkerRef = serviceWorkerRefArg;
}
@@ -22,75 +29,144 @@ export class UpdateManager {
private readonly OFFLINE_GRACE_PERIOD = 7 * 24 * 60 * 60 * 1000; // 7 days grace period when offline
private lastCacheTimestamp: number = 0;
public async checkUpdate(cacheManager: CacheManager): Promise<boolean> {
const lswVersionInfoKey = 'versionInfo';
const cacheTimestampKey = 'cacheTimestamp';
// Initialize or load version info
if (!this.lastVersionInfo && !(await this.serviceworkerRef.store.check(lswVersionInfoKey))) {
this.lastVersionInfo = {
appHash: '',
appSemVer: 'v0.0.0',
};
} else if (!this.lastVersionInfo && (await this.serviceworkerRef.store.check(lswVersionInfoKey))) {
this.lastVersionInfo = await this.serviceworkerRef.store.get(lswVersionInfoKey);
}
// Load or initialize cache timestamp
if (await this.serviceworkerRef.store.check(cacheTimestampKey)) {
this.lastCacheTimestamp = await this.serviceworkerRef.store.get(cacheTimestampKey);
}
/**
* Public method to trigger an update check (rate-limited)
*/
public async checkUpdate(_cacheManager: CacheManager): Promise<boolean> {
const now = Date.now();
const millisSinceLastCheck = now - this.lastUpdateCheck;
const cacheAge = now - this.lastCacheTimestamp;
// Check if we need to handle stale cache
if (cacheAge > this.MAX_CACHE_AGE) {
const isOnline = await this.serviceworkerRef.networkManager.checkOnlineStatus();
if (isOnline) {
logger.log('info', `Cache is older than ${this.MAX_CACHE_AGE}ms, forcing update...`);
await this.forceUpdate(cacheManager);
return true;
} else if (cacheAge > this.OFFLINE_GRACE_PERIOD) {
// If we're offline and beyond grace period, warn but continue serving cached content
logger.log('warn', `Cache is stale and device is offline. Cache age: ${cacheAge}ms. Using cached content with warning.`);
// We could potentially show a warning to the user here
return false;
} else {
logger.log('info', `Cache is stale but device is offline. Within grace period. Using cached content.`);
return false;
}
}
// Regular update check interval
if (millisSinceLastCheck < this.MIN_CHECK_INTERVAL && cacheAge < this.MAX_CACHE_AGE) {
// Rate limit: skip if we checked recently
if (millisSinceLastCheck < this.MIN_CHECK_INTERVAL) {
return false;
}
logger.log('info', 'checking for update of the app by comparing app hashes...');
this.lastUpdateCheck = now;
const currentVersionInfo = await this.getVersionInfoFromServer();
logger.log('info', `old versionInfo: ${JSON.stringify(this.lastVersionInfo)}`);
logger.log('info', `current versionInfo: ${JSON.stringify(currentVersionInfo)}`);
const needsUpdate = this.lastVersionInfo.appHash !== currentVersionInfo.appHash ? true : false;
if (needsUpdate) {
logger.log('info', 'Caches need to be updated');
logger.log('info', 'starting a debounced update task');
this.performAsyncUpdateDebouncedTask.trigger();
this.lastVersionInfo = currentVersionInfo;
await this.serviceworkerRef.store.set(lswVersionInfoKey, this.lastVersionInfo);
// Update cache timestamp
this.lastCacheTimestamp = now;
await this.serviceworkerRef.store.set('cacheTimestamp', now);
} else {
logger.log('ok', 'caches are still valid, performing revalidation in a bit...');
this.performAsyncCacheRevalidationDebouncedTask.trigger();
// Update cache timestamp after successful revalidation
this.lastCacheTimestamp = now;
await this.serviceworkerRef.store.set('cacheTimestamp', now);
// If a check is in progress, return the existing promise
if (this.pendingCheckPromise) {
return this.pendingCheckPromise;
}
// Perform the check
this.pendingCheckPromise = this.performUpdateCheck().finally(() => {
this.pendingCheckPromise = null;
});
return this.pendingCheckPromise;
}
/**
* Internal method that performs the actual update check
*/
private async performUpdateCheck(): Promise<boolean> {
// Prevent concurrent checks
if (this.isCheckInProgress) {
logger.log('note', 'Update check already in progress, skipping...');
return false;
}
this.isCheckInProgress = true;
const metrics = getMetricsCollector();
const eventBus = getEventBus();
const errorHandler = getErrorHandler();
try {
eventBus.emit(ServiceWorkerEvent.UPDATE_CHECK_START, { timestamp: Date.now() });
const lswVersionInfoKey = 'versionInfo';
const cacheTimestampKey = 'cacheTimestamp';
// Initialize or load version info
if (!this.lastVersionInfo && !(await this.serviceworkerRef.store.check(lswVersionInfoKey))) {
this.lastVersionInfo = {
appHash: '',
appSemVer: 'v0.0.0',
};
} else if (!this.lastVersionInfo && (await this.serviceworkerRef.store.check(lswVersionInfoKey))) {
this.lastVersionInfo = await this.serviceworkerRef.store.get(lswVersionInfoKey);
}
// Load or initialize cache timestamp
if (await this.serviceworkerRef.store.check(cacheTimestampKey)) {
this.lastCacheTimestamp = await this.serviceworkerRef.store.get(cacheTimestampKey);
}
const now = Date.now();
const cacheAge = now - this.lastCacheTimestamp;
// Check if we need to handle stale cache
if (cacheAge > this.MAX_CACHE_AGE) {
const isOnline = await this.serviceworkerRef.networkManager.checkOnlineStatus();
if (isOnline) {
logger.log('info', `Cache is older than ${this.MAX_CACHE_AGE}ms, forcing update...`);
await this.forceUpdate(this.serviceworkerRef.cacheManager);
metrics.recordUpdateCheck(true);
return true;
} else if (cacheAge > this.OFFLINE_GRACE_PERIOD) {
logger.log('warn', `Cache is stale and device is offline. Cache age: ${cacheAge}ms. Using cached content with warning.`);
metrics.recordUpdateCheck(false);
return false;
} else {
logger.log('info', `Cache is stale but device is offline. Within grace period. Using cached content.`);
metrics.recordUpdateCheck(false);
return false;
}
}
logger.log('info', 'checking for update of the app by comparing app hashes...');
this.lastUpdateCheck = Date.now();
const currentVersionInfo = await this.getVersionInfoFromServer();
logger.log('info', `old versionInfo: ${JSON.stringify(this.lastVersionInfo)}`);
logger.log('info', `current versionInfo: ${JSON.stringify(currentVersionInfo)}`);
const needsUpdate = this.lastVersionInfo.appHash !== currentVersionInfo.appHash;
if (needsUpdate) {
logger.log('info', 'Caches need to be updated');
logger.log('info', 'starting a debounced update task');
metrics.recordUpdateFound();
eventBus.emitUpdateAvailable(
this.lastVersionInfo.appSemVer,
currentVersionInfo.appSemVer,
this.lastVersionInfo.appHash,
currentVersionInfo.appHash
);
this.performAsyncUpdateDebouncedTask.trigger();
this.lastVersionInfo = currentVersionInfo;
await this.serviceworkerRef.store.set(lswVersionInfoKey, this.lastVersionInfo);
// Update cache timestamp
this.lastCacheTimestamp = now;
await this.serviceworkerRef.store.set('cacheTimestamp', now);
} else {
logger.log('ok', 'caches are still valid, performing revalidation in a bit...');
this.performAsyncCacheRevalidationDebouncedTask.trigger();
// Update cache timestamp after successful revalidation
this.lastCacheTimestamp = now;
await this.serviceworkerRef.store.set('cacheTimestamp', now);
}
metrics.recordUpdateCheck(true);
eventBus.emit(ServiceWorkerEvent.UPDATE_CHECK_COMPLETE, {
needsUpdate,
timestamp: Date.now()
});
return needsUpdate;
} catch (error) {
const err = errorHandler.handleUpdateError(
`Update check failed: ${error?.message || error}`,
error instanceof Error ? error : undefined
);
metrics.recordUpdateCheck(false);
eventBus.emit(ServiceWorkerEvent.UPDATE_ERROR, { error: err.message });
return false;
} finally {
this.isCheckInProgress = false;
}
}
@@ -140,9 +216,18 @@ export class UpdateManager {
name: 'performAsyncUpdate',
taskFunction: async () => {
logger.log('info', 'trying to update PWA with serviceworker');
const metrics = getMetricsCollector();
const eventBus = getEventBus();
await this.serviceworkerRef.cacheManager.cleanCaches('a new app version has been communicated by the server.');
// lets notify all current clients about the update
await this.serviceworkerRef.leleServiceWorkerBackend.triggerReloadAll();
metrics.recordUpdateApplied();
eventBus.emitUpdateApplied(
this.lastVersionInfo?.appSemVer || 'unknown',
this.lastVersionInfo?.appHash || 'unknown'
);
},
debounceTimeInMillis: 2000,
});

View File

@@ -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;

View File

@@ -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';

View File

@@ -2,6 +2,25 @@ import * as plugins from './plugins.js';
import * as interfaces from '../dist_ts_interfaces/index.js';
import { logger } from './logging.js';
/**
* Connection options for service worker connection attempts
*/
export interface IConnectionOptions {
timeoutMs: number; // Total timeout for all attempts (default: 30000)
maxRetries: number; // Maximum number of retry attempts (default: 10)
initialDelayMs: number; // Initial delay between retries (default: 500)
maxDelayMs: number; // Maximum delay between retries (default: 5000)
backoffMultiplier: number; // Multiplier for exponential backoff (default: 1.5)
}
const DEFAULT_CONNECTION_OPTIONS: IConnectionOptions = {
timeoutMs: 30000,
maxRetries: 10,
initialDelayMs: 500,
maxDelayMs: 5000,
backoffMultiplier: 1.5,
};
/**
* MessageManager implements two ways of serviceworker communication
* * the serviceWorker method
@@ -20,28 +39,96 @@ export class ActionManager {
});
}
public async waitForServiceWorkerConnection () {
console.log('waiting for service worker connection...')
/**
* Waits for service worker connection with timeout and retry logic.
* Returns a result object instead of blocking forever.
*/
public async waitForServiceWorkerConnection(
options: Partial<IConnectionOptions> = {}
): Promise<interfaces.serviceworker.IConnectionResult> {
const opts = { ...DEFAULT_CONNECTION_OPTIONS, ...options };
const startTime = Date.now();
let attempt = 0;
let currentDelay = opts.initialDelayMs;
logger.log('info', 'Waiting for service worker connection...');
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Client_Serviceworker_ConnectionPolling>('broadcastConnectionPolling');
let connected = false;
while (!connected) {
tr.fire({
tabId: '123'
}).then(response => {
if (response.serviceworkerId) {
console.log('connected to serviceworker!');
connected = true;
}
}).catch();
await plugins.smartdelay.delayFor(777);
if (!connected) {
// lets wake it up.
navigator.serviceWorker.controller.postMessage({
type: 'wakeUpCall',
});
while (attempt < opts.maxRetries) {
// Check total timeout
const elapsed = Date.now() - startTime;
if (elapsed >= opts.timeoutMs) {
logger.log('warn', `Service worker connection timeout after ${elapsed}ms (${attempt} attempts)`);
return {
connected: false,
error: 'timeout',
attempts: attempt,
duration: elapsed,
};
}
attempt++;
try {
// Create a per-request timeout
const requestTimeout = Math.min(currentDelay * 2, opts.maxDelayMs);
const response = await Promise.race([
tr.fire({ tabId: crypto.randomUUID?.() || String(Date.now()) }),
new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), requestTimeout)
),
]);
if (response && response.serviceworkerId) {
const duration = Date.now() - startTime;
logger.log('ok', `Connected to service worker after ${attempt} attempt(s) (${duration}ms)`);
return {
connected: true,
attempts: attempt,
duration,
};
}
} catch (error) {
// Request failed or timed out, continue with retry
logger.log('note', `Connection attempt ${attempt} failed: ${error?.message || 'unknown error'}`);
}
// Try to wake up the service worker
if (navigator.serviceWorker.controller) {
try {
navigator.serviceWorker.controller.postMessage({
type: 'wakeUpCall',
});
} catch {
// Ignore errors when posting message
}
}
// Wait before next attempt with exponential backoff
await plugins.smartdelay.delayFor(currentDelay);
currentDelay = Math.min(currentDelay * opts.backoffMultiplier, opts.maxDelayMs);
}
const duration = Date.now() - startTime;
logger.log('warn', `Service worker connection failed after ${opts.maxRetries} attempts (${duration}ms)`);
return {
connected: false,
error: 'max_retries_exceeded',
attempts: attempt,
duration,
};
}
/**
* Legacy method for backward compatibility - blocks until connected or gives up
* @deprecated Use waitForServiceWorkerConnection() instead which returns a result
*/
public async waitForServiceWorkerConnectionBlocking(): Promise<void> {
const result = await this.waitForServiceWorkerConnection();
if (!result.connected) {
logger.log('warn', `Failed to connect to service worker: ${result.error}`);
}
console.log('ok, got serviceworker connection.')
}
public async purgeServiceWorkerCache () {

View File

@@ -5,28 +5,54 @@ import { NotificationManager } from './classes.notificationmanager.js';
import { ActionManager } from './classes.actionmanager.js';
import { GlobalSW } from './classes.globalsw.js'
/**
* Polling options for service worker update checks
*/
export interface IPollingOptions {
intervalMs: number; // Interval between update checks (default: 60000 - 1 minute)
pauseWhenHidden: boolean; // Pause polling when page is hidden (default: true)
initialDelayMs: number; // Initial delay before first poll (default: 2000)
}
const DEFAULT_POLLING_OPTIONS: IPollingOptions = {
intervalMs: 60000, // 1 minute
pauseWhenHidden: true,
initialDelayMs: 2000,
};
export class ServiceworkerClient {
// STATIC
public static async createServiceWorker(): Promise<ServiceworkerClient> {
private static pollingController: AbortController | null = null;
private static swRegistration: ServiceWorkerRegistration | null = null;
private static isPollingActive = false;
public static async createServiceWorker(
pollingOptions: Partial<IPollingOptions> = {}
): Promise<ServiceworkerClient> {
if ('serviceWorker' in navigator) {
try {
logger.log('info', 'trying to register serviceworker');
// this is some magic for Parcel to not pick up the serviceworker
const serviceworkerInNavigator: ServiceWorkerContainer = navigator.serviceWorker;
const swRegistration: ServiceWorkerRegistration = await serviceworkerInNavigator.register('/serviceworker.bundle.js', {
this.swRegistration = await serviceworkerInNavigator.register('/serviceworker.bundle.js', {
scope: '/',
updateViaCache: 'none'
});
plugins.smartdelay.delayFor(2000).then(async () => {
swRegistration.onupdatefound = () => {
logger.log('info', 'update found for service worker!');
logger.log('warn', 'trying to find convenient time to update');
};
while(true) {
await plugins.smartdelay.delayFor(60000);
swRegistration.update();
}
});
this.swRegistration.onupdatefound = () => {
logger.log('info', 'update found for service worker!');
logger.log('warn', 'trying to find convenient time to update');
};
// Start polling with controllable abort mechanism
const opts = { ...DEFAULT_POLLING_OPTIONS, ...pollingOptions };
this.startPolling(opts);
// Set up visibility change handler to pause/resume polling
if (opts.pauseWhenHidden) {
this.setupVisibilityHandler(opts);
}
logger.log('ok', 'serviceworker registered');
await navigator.serviceWorker.ready;
logger.log('ok', 'serviceworker is ready!');
@@ -41,6 +67,120 @@ export class ServiceworkerClient {
}
}
/**
* Starts the update polling loop with an AbortController
*/
private static startPolling(opts: IPollingOptions): void {
// Cancel any existing polling
this.stopPolling();
this.pollingController = new AbortController();
this.isPollingActive = true;
const signal = this.pollingController.signal;
const poll = async () => {
// Initial delay
await plugins.smartdelay.delayFor(opts.initialDelayMs);
while (!signal.aborted && this.swRegistration) {
try {
// Check for updates
await this.swRegistration.update();
logger.log('note', 'Service worker update check completed');
} catch (err) {
if (!signal.aborted) {
logger.log('warn', `Service worker update check failed: ${err?.message || err}`);
}
}
// Wait for next interval, but check abort signal
if (!signal.aborted) {
await this.delayWithAbort(opts.intervalMs, signal);
}
}
this.isPollingActive = false;
logger.log('info', 'Service worker polling stopped');
};
// Start polling (fire and forget)
poll().catch((err) => {
if (!signal.aborted) {
logger.log('error', `Service worker polling error: ${err?.message || err}`);
}
});
}
/**
* Stops the polling loop
*/
public static stopPolling(): void {
if (this.pollingController) {
this.pollingController.abort();
this.pollingController = null;
}
this.isPollingActive = false;
}
/**
* Sets up visibility change handler to pause/resume polling
*/
private static setupVisibilityHandler(opts: IPollingOptions): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Page is hidden, stop polling to save resources
logger.log('note', 'Page hidden, pausing service worker polling');
this.stopPolling();
} else if (document.visibilityState === 'visible') {
// Page is visible again, resume polling
if (!this.isPollingActive && this.swRegistration) {
logger.log('note', 'Page visible, resuming service worker polling');
this.startPolling(opts);
}
}
});
}
/**
* Delay that can be aborted
*/
private static delayWithAbort(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve();
}, ms);
signal.addEventListener('abort', () => {
clearTimeout(timeout);
resolve();
}, { once: true });
});
}
/**
* Manually triggers an update check
*/
public static async triggerUpdateCheck(): Promise<void> {
if (this.swRegistration) {
try {
await this.swRegistration.update();
logger.log('ok', 'Manual service worker update check completed');
} catch (err) {
logger.log('error', `Manual update check failed: ${err?.message || err}`);
throw err;
}
} else {
logger.log('warn', 'Cannot trigger update check: no service worker registration');
}
}
/**
* Gets whether polling is currently active
*/
public static get isPolling(): boolean {
return this.isPollingActive;
}
private static async waitForController() {
const done = new plugins.smartpromise.Deferred();
const checkReady = () => {
@@ -66,4 +206,11 @@ export class ServiceworkerClient {
this.actionManager = new ActionManager();
this.globalSw = new GlobalSW(this);
}
/**
* Cleanup method to stop polling when the client is no longer needed
*/
public destroy(): void {
ServiceworkerClient.stopPolling();
}
}

View File

@@ -11,14 +11,17 @@ export type {
import { logger } from './logging.js';
logger.log('note', 'mainthread console initialized!');
import { ServiceworkerClient } from './classes.serviceworkerclient.js';
import { ServiceworkerClient, type IPollingOptions } from './classes.serviceworkerclient.js';
import { type IConnectionOptions } from './classes.actionmanager.js';
export type {
ServiceworkerClient
ServiceworkerClient,
IPollingOptions,
IConnectionOptions,
}
export const getServiceworkerClient = async () => {
const swClient = await ServiceworkerClient.createServiceWorker(); // lets setup the service worker
export const getServiceworkerClient = async (pollingOptions?: Partial<IPollingOptions>) => {
const swClient = await ServiceworkerClient.createServiceWorker(pollingOptions); // lets setup the service worker
logger.log('ok', 'service worker ready!'); // and wait for it to be ready
return swClient;
};

View File

@@ -1,7 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",