Compare commits

..

28 Commits

Author SHA1 Message Date
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
1c0c88a7cb v4.0.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 09:16:42 +00:00
8557c769fa BREAKING CHANGE(typedserver): Migrate to new push.rocks packages and async smartfs API; replace smartchok with smartwatch; update deps and service worker handling 2025-12-02 09:16:42 +00:00
bce84c6838 v3.0.80
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-19 21:12:16 +00:00
ba08fcdebc fix(dependencies): Bump dependencies and devDependencies to updated versions 2025-11-19 21:12:16 +00:00
588a9d1df6 3.0.79
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-03 14:54:15 +00:00
b1d376207a fix(servertools): Normalize Express wildcard parameter notation to /{*splat} across server routes and handlers; add local Claude settings 2025-09-03 14:54:15 +00:00
43d8aea4e1 3.0.78
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-03 12:33:04 +00:00
776d6fb95d fix(servertools): Fix wildcard path extraction for static/proxy handlers, correct serviceworker route, add local settings and test typo fix 2025-09-03 12:33:04 +00:00
39 changed files with 7830 additions and 4128 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -1,5 +1,142 @@
# Changelog
## 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
- Major dependency updates: @push.rocks packages upgraded (smartfile -> v13, smartfs added v1.2.0, smartenv v6, smartrequest v5, smartwatch v5, webrequest v4), Express and dev-tooling bumped.
- Replace sync smartfile APIs with async SmartFs instance (plugins.fsInstance). All file reads now use async smartfs calls.
- smartfile v13 migration: removed sync fs helpers; added plugins.fsInstance (SmartFs + Node provider).
- Renamed file watcher usage: Smartchok -> Smartwatch and property names updated (smartwatchInstance).
- WebRequest class rename handled: WebRequest -> WebrequestClient (webrequest v4).
- Service worker bundle is lazy-loaded (avoid startup sync file reads) and added routes for bundle and source map.
- Service worker & SW-related improvements: more robust caching, enhanced cache headers, offline handling, revalidation, and update forcing logic.
- createServeDirHash now uses smartfs.directory().recursive().treeHash() and truncates hash to 12 chars; fallback hash logic retained.
- HandlerStatic, HandlerProxy and route handling hardened for Express 5 wildcard params (array/various shapes supported).
- Various runtime improvements: safer start/stop handling, improved error logging, and non-blocking initialization of optional features (file watcher, TypedSocket).
- Updated tooling/dev deps: @git.zone/tsbuild/tsbundle/tstest and @types/node updated.
## 2025-11-19 - 3.0.80 - fix(dependencies)
Bump dependencies and devDependencies to updated versions
- Upgrade @push.rocks/smartfeed: ^1.0.11 → ^1.4.0
- Upgrade @push.rocks/smartfile: ^11.2.5 → ^11.2.7
- Upgrade @push.rocks/smartjson: ^5.0.20 → ^5.2.0
- Upgrade @push.rocks/smartlog: ^3.1.8 → ^3.1.10
- Upgrade @push.rocks/smartsitemap: ^2.0.3 → ^2.0.4
- Upgrade @push.rocks/taskbuffer: ^3.1.7 → ^3.4.0
- Upgrade @tsclass/tsclass: ^9.2.0 → ^9.3.0
- Upgrade @types/express: ^5.0.3 → ^5.0.5
- Dev: Upgrade @git.zone/tsbuild: ^2.6.4 → ^3.1.0
- Dev: Upgrade @git.zone/tsbundle: ^2.5.1 → ^2.5.2
- Dev: Upgrade @git.zone/tsrun: ^1.3.3 → ^2.0.0
- Dev: Upgrade @git.zone/tstest: ^2.3.4 → ^2.8.2
## 2025-09-03 - 3.0.79 - fix(servertools)
Normalize Express wildcard parameter notation to /{*splat} across server routes and handlers; add local Claude settings
- Replaced route pattern '/*splat' with '/{*splat}' in ts/classes.typedserver.ts and ts/servertools/tools.sslredirect.ts
- Updated Express options route to use '/{*splat}' in ts/servertools/classes.server.ts
- Clarified wildcard handling comments and array-join logic for splat params in ts/servertools/classes.handlerproxy.ts and ts/servertools/classes.handlerstatic.ts
- Added .claude/settings.local.json containing local tool permissions for Claude/dev onboarding
- No functional API changes — routing pattern normalization and comment/handler improvements only
## 2025-09-03 - 3.0.78 - fix(servertools)
Fix wildcard path extraction for static/proxy handlers, correct serviceworker route, add local settings and test typo fix
- Make HandlerProxy and HandlerStatic robust to Express 5 wildcard param shapes (handle req.params.splat, numeric params, req.baseUrl and root routes) to correctly compute relative paths
- Change serviceworker route registration to use '/serviceworker/*splat' (instead of previous pattern) for consistent wildcard handling
- Fix test wording typo in test/test.server.ts ('exposer' -> 'expose')
- Add .claude/settings.local.json with local tool permissions and add .serena/.gitignore to ignore /cache
## 2025-08-17 - 3.0.77 - fix(servertools)
Adjust route wildcard patterns and CORS handling; update serviceworker and SSL redirect patterns; bump express dependency; add local Claude settings

View File

@@ -1,6 +1,6 @@
{
"name": "@api.global/typedserver",
"version": "3.0.77",
"version": "6.4.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,19 +58,19 @@
],
"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",
"@cloudflare/workers-types": "^4.20250816.0",
"@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",
"@push.rocks/smartchok": "^1.1.1",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartenv": "^5.0.13",
"@push.rocks/smartfeed": "^1.0.11",
"@push.rocks/smartfile": "^11.2.5",
"@push.rocks/smartjson": "^5.0.20",
"@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartenv": "^6.0.0",
"@push.rocks/smartfeed": "^1.4.0",
"@push.rocks/smartfile": "^13.1.0",
"@push.rocks/smartfs": "^1.2.0",
"@push.rocks/smartjson": "^5.2.0",
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartlog-destination-devtools": "^1.0.12",
"@push.rocks/smartlog-interfaces": "^3.0.2",
"@push.rocks/smartmanifest": "^2.0.2",
@@ -80,28 +80,30 @@
"@push.rocks/smartopen": "^2.0.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^4.2.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartsitemap": "^2.0.3",
"@push.rocks/smartserve": "^1.1.2",
"@push.rocks/smartsitemap": "^2.0.4",
"@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smarttime": "^4.1.1",
"@push.rocks/taskbuffer": "^3.1.7",
"@push.rocks/webrequest": "^3.0.37",
"@push.rocks/smartwatch": "^5.0.0",
"@push.rocks/taskbuffer": "^3.4.0",
"@push.rocks/webrequest": "^4.0.1",
"@push.rocks/webstore": "^2.0.20",
"@tsclass/tsclass": "^9.2.0",
"@types/express": "^5.0.3",
"body-parser": "^2.2.0",
"@tsclass/tsclass": "^9.3.0",
"@types/express": "^5.0.6",
"body-parser": "^2.2.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"express": "^5.2.1",
"express-force-ssl": "^0.3.2",
"lit": "^3.3.1"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^2.3.4",
"@types/node": "^22.14.0"
"@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@types/node": "^24.10.1"
},
"private": false,
"browserslist": [

7041
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,41 @@
# Project Hints - @api.global/typedserver
## Recent Changes (December 2025)
### Dependency Updates
- `@push.rocks/smartchok` replaced with `@push.rocks/smartwatch` (renamed package, same API)
- `@push.rocks/smartfile` upgraded from v11 to v13 (major API change - `fs` module removed)
- `@push.rocks/smartfs` added for filesystem operations (v1.2.0+)
- `@push.rocks/smartenv` upgraded to v6.0.0
- `@push.rocks/smartrequest` upgraded to v5.0.1
- `@push.rocks/webrequest` upgraded to v4.0.1 (`WebRequest` renamed to `WebrequestClient`)
- Express upgraded to v5.2.1
- All `@git.zone/*` dev dependencies updated to latest
### Code Migration Notes
#### smartfile v13 Migration
- Old: `plugins.smartfile.fs.toStringSync(path)` / `plugins.smartfile.fs.toBufferSync(path)`
- New: Use `plugins.fsInstance` (SmartFs instance with Node provider)
- String: `await plugins.fsInstance.file(path).encoding('utf8').read() as string`
- Buffer: `await plugins.fsInstance.file(path).read() as Buffer`
#### smartfs treeHash
- Old: `plugins.smartfile.fs.fileTreeToHash(dir, pattern)`
- New: `await plugins.fsInstance.directory(dir).recursive().treeHash()`
#### smartwatch (formerly smartchok)
- Class renamed: `Smartchok``Smartwatch`
- API remains the same: `new Smartwatch([paths])`, `.start()`, `.stop()`, `.getObservableFor(event)`
#### webrequest v4
- Class renamed: `WebRequest``WebrequestClient`
### Architecture
- `plugins.fsInstance` is a pre-configured SmartFs instance with SmartFsProviderNode
- All file operations should be async using smartfs
- Sync file operations have been removed
### Express 5 Notes
- Wildcard routes use `/{*splat}` notation
- `req.params.splat` can be an array, use `Array.isArray()` check

405
readme.md
View File

@@ -1,249 +1,278 @@
# @api.global/typedserver
A TypeScript-based framework for serving static files with advanced features including live reloading, compression, and type-safe API requests. Part of the @api.global ecosystem, it integrates seamlessly with @api.global/typedrequest for type-safe HTTP requests and @api.global/typedsocket for WebSocket communication.
A powerful TypeScript-first web server framework featuring static file serving, live reload, compression, and seamless type-safe API integration. Part of the `@api.global` ecosystem, it provides a modern foundation for building full-stack TypeScript applications with first-class support for typed HTTP requests and WebSocket communication.
## Features
## Issue Reporting and Security
- **Type-Safe API Ecosystem**:
- HTTP Requests via @api.global/typedrequest
- WebSocket Support via @api.global/typedsocket
- Full TypeScript support across all endpoints
- **Service Worker Integration**: Advanced caching and offline capabilities
- **Edge Worker Support**: Optimized edge computing capabilities
- **Live Reload**: Automatic browser refresh on file changes
- **Compression**: Built-in support for response compression
- **CORS Management**: Flexible cross-origin resource sharing
- **TypeScript First**: Built with and for TypeScript
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Components
## ✨ Features
### Core Server (`ts/`)
- Static file serving with Express
- Type-safe request handling
- Live reload functionality
- Compression middleware
- 🔒 **Type-Safe API Ecosystem** - Full TypeScript support with `@api.global/typedrequest` and `@api.global/typedsocket`
-**Live Reload** - Automatic browser refresh on file changes during development
- 🗜️ **Compression** - Built-in support for gzip, deflate, and brotli compression
- 🌐 **CORS Management** - Flexible cross-origin resource sharing configuration
- 🔧 **Service Worker Integration** - Advanced caching and offline capabilities
- ☁️ **Edge Worker Support** - Cloudflare Workers compatible edge computing
- 📡 **WebSocket Support** - Real-time bidirectional communication via TypedSocket
- 🗺️ **Sitemap & Feeds** - Automatic sitemap and RSS feed generation
- 🤖 **Robots.txt** - Built-in robots.txt handling
### Service Worker (`ts_web_serviceworker/`)
- `CacheManager`: Advanced caching strategies
- `NetworkManager`: Request/response handling
- `UpdateManager`: Cache invalidation and updates
- `ServiceWorker`: Core service worker implementation
### Edge Worker (`ts_edgeworker/`)
- Edge computing capabilities
- Request/response transformation
- Edge caching strategies
### Web Inject (`ts_web_inject/`)
- Live reload script injection
- Runtime dependency management
- Dynamic module loading
## Installation
## 📦 Installation
```bash
# Using pnpm (recommended)
pnpm add @api.global/typedserver
# Using npm
npm install @api.global/typedserver
```
## Quick Start
## 🚀 Quick Start
### Basic Static File Server
```typescript
import { TypedServer } from '@api.global/typedserver';
const server = new TypedServer({
port: 3000,
serveDir: './public',
watch: true,
compression: true
cors: true,
watch: true, // Enable file watching
injectReload: true, // Inject live reload script
});
server.start();
await server.start();
console.log('Server running on port 3000');
```
## Type-Safe API Integration
### Server with All Options
### HTTP Requests with TypedRequest
```typescript
import { TypedRequest } from '@api.global/typedrequest';
import { TypedServer } from '@api.global/typedserver';
// Define your request/response interface
interface IUserRequest {
const server = new TypedServer({
port: 8080,
serveDir: './dist',
cors: true,
watch: true,
injectReload: true,
enableCompression: true,
preferredCompressionMethod: 'brotli',
forceSsl: false,
sitemap: true,
feed: true,
robots: true,
domain: 'example.com',
appVersion: 'v1.0.0',
manifest: {
name: 'My App',
short_name: 'myapp',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
},
});
await server.start();
```
## 🔌 Type-Safe API Integration
### Adding TypedRequest Handlers
```typescript
import { TypedServer, servertools } from '@api.global/typedserver';
import * as typedrequest from '@api.global/typedrequest';
// Define your typed request interface
interface IGetUser {
method: 'getUser';
request: { userId: string };
response: { username: string; email: string; };
response: { name: string; email: string };
}
// Create and use a typed request
const getUserRequest = new TypedRequest<IUserRequest>('/api/users', 'getUser');
const user = await getUserRequest.fire({ userId: '123' });
```
const server = new TypedServer({ serveDir: './public', cors: true });
### WebSocket Communication
```typescript
import { TypedSocket } from '@api.global/typedsocket';
// Create a typed router
const typedRouter = new typedrequest.TypedRouter();
// Server setup
const typedRouter = new TypedRouter();
const server = await TypedSocket.createServer(typedRouter);
// Client connection
const client = await TypedSocket.createClient(typedRouter, 'ws://localhost:3000');
// Type-safe real-time messaging
interface IChatMessage {
method: 'sendMessage';
request: { text: string };
response: { id: string; timestamp: number; };
}
```
## Advanced Usage
### Service Worker Setup
```typescript
import { ServiceWorker } from '@api.global/typedserver/web_serviceworker';
const sw = new ServiceWorker({
cacheStrategy: 'network-first',
offlineSupport: true
});
sw.register();
```
### Edge Worker Configuration
```typescript
import { EdgeWorker } from '@api.global/typedserver/edgeworker';
const edge = new EdgeWorker({
transforms: ['compress', 'minify'],
caching: true
});
```
## Configuration
### Server Options
```typescript
interface IServerOptions {
port?: number;
host?: string;
serveDir: string;
watch?: boolean;
compression?: boolean;
cors?: boolean | CorsOptions;
cache?: CacheOptions;
}
```
### Cache Strategies
```typescript
type CacheStrategy =
| 'network-first'
| 'cache-first'
| 'stale-while-revalidate';
```
## API Reference
See [API Documentation](https://api.global/docs/typedserver) for detailed API reference.
## Contributing
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
## License
MIT License - see LICENSE for details.
Task Venture Capital GmbH © 2024
```typescript
// Define a request type
interface MyCustomRequest {
userId: string;
}
// Define a response type
interface MyCustomResponse {
userName: string;
}
```
Next, set up a route to handle requests using these types:
```typescript
import { TypedRouter, TypedHandler } from '@api.global/typedrequest';
// Instantiate a TypedRouter
const typedRouter = new TypedRouter();
// Register a route with request and response types
typedRouter.addTypedHandler<MyCustomRequest, MyCustomResponse>(
new TypedHandler('getUser', async (requestData) => {
// Implement your logic here. For example, fetch user data from a database.
const userData = { userName: 'John Doe' }; // Dummy implementation
return { response: userData };
// Add a typed handler
typedRouter.addTypedHandler<IGetUser>(
new typedrequest.TypedHandler('getUser', async (data) => {
// Your logic here
return { name: 'John Doe', email: 'john@example.com' };
})
);
// Bind the typed router to the server
typedServer.useTypedRouter(typedRouter);
// Attach the router to the server
server.server.addRoute('/api', new servertools.HandlerTypedRouter(typedRouter));
// Now, the route is set up to handle requests with type checking
await server.start();
```
This example shows defining types for requests and responses, creating a `TypedRouter`, and adding a route with typed handling. This feature brings the benefits of TypeScript's static type checking to server-side logic, improving the development experience.
### Enabling SSL/TLS
To enable SSL/TLS, configure the `TypedServer` with the SSL options, including the paths to your SSL certificate and key files:
### WebSocket Communication with TypedSocket
```typescript
const serverOptions = {
port: 443,
serveDir: 'public',
watch: true,
injectReload: true,
import { TypedServer } from '@api.global/typedserver';
import * as typedrequest from '@api.global/typedrequest';
import * as typedsocket from '@api.global/typedsocket';
const server = new TypedServer({ serveDir: './public', cors: true });
const typedRouter = new typedrequest.TypedRouter();
await server.start();
// Create WebSocket server attached to the HTTP server
const socketServer = await typedsocket.TypedSocket.createServer(
typedRouter,
server.server
);
// Handle real-time events
interface IChatMessage {
method: 'sendMessage';
request: { text: string; room: string };
response: { messageId: string; timestamp: number };
}
typedRouter.addTypedHandler<IChatMessage>(
new typedrequest.TypedHandler('sendMessage', async (data) => {
return { messageId: crypto.randomUUID(), timestamp: Date.now() };
})
);
```
## 🛠️ Server Tools
### Custom Route Handlers
```typescript
import { servertools } from '@api.global/typedserver';
const server = new servertools.Server({
cors: true,
privateKey: fs.readFileSync('path/to/ssl/private.key'),
publicKey: fs.readFileSync('path/to/ssl/certificate.crt')
domain: 'example.com',
});
// Add a custom route with handler
server.addRoute('/api/hello', new servertools.Handler('GET', async (req, res) => {
res.json({ message: 'Hello, World!' });
}));
// Serve static files from a directory
server.addRoute('/{*splat}', new servertools.HandlerStatic('./public', {
serveIndexHtmlDefault: true,
enableCompression: true,
}));
await server.start(3000);
```
### Proxy Handler
```typescript
import { servertools } from '@api.global/typedserver';
const server = new servertools.Server({ cors: true });
// Proxy requests to another server
server.addRoute('/proxy/{*splat}', new servertools.HandlerProxy({
target: 'https://api.example.com',
}));
await server.start(3000);
```
## ☁️ Edge Worker (Cloudflare Workers)
```typescript
import { EdgeWorker, DomainRouter } from '@api.global/typedserver/edgeworker';
const router = new DomainRouter();
router.addDomainInstruction({
domainPattern: '*.example.com',
originUrl: 'https://origin.example.com',
cacheConfig: { maxAge: 3600 },
});
const worker = new EdgeWorker(router);
// In your Cloudflare Worker entry point
export default {
fetch: (request: Request, env: any, ctx: any) => worker.handleRequest(request, env, ctx),
};
const typedServer = new TypedServer(serverOptions);
startServer().catch(console.error);
```
Replace `'path/to/ssl/private.key'` and `'path/to/ssl/certificate.crt'` with the actual paths to your SSL key and certificate files. This setup ensures that your server communicates over HTTPS, encrypting the data transmitted between the client and the server.
## 🔧 Service Worker Client
### Conclusion
```typescript
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
`@api.global/typedserver` offers a streamlined way to set up a web server with TypeScript, featuring static file serving, live reloading, typed request/response handling, and SSL support. This guide covers the basic usage, but `TypedServer` is highly configurable, catering to various hosting and development needs.
const swClient = new ServiceWorkerClient();
// Register and manage service worker
await swClient.register('/serviceworker.bundle.js');
// Listen for updates
swClient.onUpdate(() => {
console.log('New version available!');
});
```
For a deeper dive into the API and more advanced configurations, refer to the official documentation and type definitions included in the package.
## 📋 Configuration Reference
### IServerOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `serveDir` | `string` | - | Directory to serve static files from |
| `port` | `number \| string` | `3000` | Port to listen on |
| `cors` | `boolean` | `true` | Enable CORS headers |
| `watch` | `boolean` | `false` | Watch files for changes |
| `injectReload` | `boolean` | `false` | Inject live reload script into HTML |
| `enableCompression` | `boolean` | `false` | Enable response compression |
| `preferredCompressionMethod` | `'gzip' \| 'deflate' \| 'brotli'` | - | Preferred compression algorithm |
| `forceSsl` | `boolean` | `false` | Redirect HTTP to HTTPS |
| `sitemap` | `boolean` | `false` | Generate sitemap at `/sitemap` |
| `feed` | `boolean` | `false` | Generate RSS feed at `/feed` |
| `robots` | `boolean` | `false` | Serve robots.txt |
| `domain` | `string` | - | Domain name for sitemap/feeds |
| `appVersion` | `string` | - | Application version string |
| `manifest` | `object` | - | Web App Manifest configuration |
| `publicKey` | `string` | - | SSL certificate |
| `privateKey` | `string` | - | SSL private key |
## 🏗️ Architecture
```
@api.global/typedserver
├── /backend - Main server exports (TypedServer, servertools)
├── /edgeworker - Cloudflare Workers edge computing
├── /web_inject - Live reload script injection
├── /web_serviceworker - Service Worker implementation
└── /web_serviceworker_client - Service Worker client utilities
```
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -122,7 +122,7 @@ tap.test('should answer a preflight request', async () => {
console.log(response.headers);
});
tap.test('should exposer a sitemap', async () => {
tap.test('should expose a sitemap', async () => {
const response = await fetch('http://127.0.0.1:3000/sitemap');
console.log(await response.text());
});

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedserver',
version: '3.0.77',
version: '6.4.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 smartchokInstance: plugins.smartchok.Smartchok;
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,43 +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);
res.write(plugins.smartfile.fs.toStringSync(paths.injectBundlePath));
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;
}
/**
@@ -144,77 +183,122 @@ 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.smartchokInstance = new plugins.smartchok.Smartchok([this.options.serveDir]);
await this.smartchokInstance.start();
(await this.smartchokInstance.getObservableFor('change')).subscribe(async () => {
// 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();
this.reload();
});
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 {
@@ -224,10 +308,188 @@ export class TypedServer {
);
} 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
*/
@@ -237,14 +499,17 @@ export class TypedServer {
console.warn('TypedSocket not initialized, skipping client notifications');
return;
}
try {
const connections = await this.typedsocket.findAllTargetConnectionsByTag('typedserver_frontend');
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,
});
@@ -259,7 +524,7 @@ export class TypedServer {
*/
public async stop(): Promise<void> {
this.ended = true;
const stopWithErrorHandling = async (
stopFn: () => Promise<unknown>,
componentName: string
@@ -270,24 +535,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.smartchokInstance) {
tasks.push(stopWithErrorHandling(() => this.smartchokInstance.stop(), 'file watcher'));
if (this.smartwatchInstance) {
tasks.push(stopWithErrorHandling(() => this.smartwatchInstance.stop(), 'file watcher'));
}
await Promise.all(tasks);
}
@@ -296,17 +561,57 @@ export class TypedServer {
*/
public async createServeDirHash() {
try {
const serveDirHash = await plugins.smartfile.fs.fileTreeToHash(this.options.serveDir, '**/*');
this.serveHash = serveDirHash;
console.log('Current ServeDir hash: ' + serveDirHash);
this.serveDirHashSubject.next(serveDirHash);
const serveDirHash = await plugins.fsInstance
.directory(this.options.serveDir)
.recursive()
.treeHash();
this.serveHash = serveDirHash.slice(0, 12);
console.log('Current ServeDir hash: ' + this.serveHash);
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

@@ -3,7 +3,6 @@ import * as http from 'http';
import * as https from 'https';
import * as net from 'net';
import * as path from 'path';
import * as stream from 'stream';
import * as zlib from 'zlib';
export { http, https, net, path, zlib };
@@ -22,10 +21,11 @@ export { typedrequest, typedrequestInterfaces, typedsocket };
// @pushrocks scope
import * as lik from '@push.rocks/lik';
import * as smartchok from '@push.rocks/smartchok';
import * as smartwatch from '@push.rocks/smartwatch';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartfeed from '@push.rocks/smartfeed';
import * as smartfile from '@push.rocks/smartfile';
import * as smartfs from '@push.rocks/smartfs';
import * as smartjson from '@push.rocks/smartjson';
import * as smartmanifest from '@push.rocks/smartmanifest';
import * as smartmime from '@push.rocks/smartmime';
@@ -40,10 +40,11 @@ import * as smarttime from '@push.rocks/smarttime';
export {
lik,
smartchok,
smartwatch,
smartdelay,
smartfeed,
smartfile,
smartfs,
smartjson,
smartmanifest,
smartmime,
@@ -57,11 +58,19 @@ export {
smartrx,
};
// express
// Create a ready-to-use smartfs instance with Node.js provider
export const fsInstance = new smartfs.SmartFs(new smartfs.SmartFsProviderNode());
// @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

@@ -16,7 +16,31 @@ export class HandlerProxy extends Handler {
}
) {
super('ALL', async (req, res) => {
const relativeRequestPath = req.path.slice(req.route.path.length - 1);
// Extract the path using Express 5's params or fallback methods
let relativeRequestPath: string;
if (req.params && req.params.splat !== undefined) {
// Express 5 wildcard route (/*splat or /{*splat})
// Handle array values - join them if array, otherwise use as-is
relativeRequestPath = Array.isArray(req.params.splat) ? req.params.splat.join('/') : String(req.params.splat || '');
} else if (req.params && req.params[0] !== undefined) {
// Numbered parameter fallback
relativeRequestPath = Array.isArray(req.params[0]) ? req.params[0].join('/') : String(req.params[0] || '');
} else if (req.baseUrl) {
// If there's a baseUrl, remove it from the path
relativeRequestPath = req.path.slice(req.baseUrl.length);
} else if (req.route && req.route.path === '/') {
// Root route - use full path minus leading slash
relativeRequestPath = req.path.slice(1);
} else {
// Fallback to the original slicing logic for compatibility
relativeRequestPath = req.path.slice(req.route.path.length - 1);
}
// Ensure relativeRequestPath is a string and has no leading slash
relativeRequestPath = String(relativeRequestPath || '');
if (relativeRequestPath.startsWith('/')) {
relativeRequestPath = relativeRequestPath.slice(1);
}
const proxyRequestUrl = remoteMountPointArg + relativeRequestPath;
console.log(`proxy ${req.path} to ${proxyRequestUrl}`);
let proxiedResponse: plugins.smartrequest.ICoreResponse;

View File

@@ -36,7 +36,31 @@ export class HandlerStatic extends Handler {
}
// lets compute some paths
let filePath: string = requestPath.slice(req.route.path.length - 1); // lets slice of the root
// Extract the path using Express 5's params or fallback methods
let filePath: string;
if (req.params && req.params.splat !== undefined) {
// Express 5 wildcard route (/*splat or /{*splat})
// Handle array values - join them if array, otherwise use as-is
filePath = Array.isArray(req.params.splat) ? req.params.splat.join('/') : String(req.params.splat || '');
} else if (req.params && req.params[0] !== undefined) {
// Numbered parameter fallback
filePath = Array.isArray(req.params[0]) ? req.params[0].join('/') : String(req.params[0] || '');
} else if (req.baseUrl) {
// If there's a baseUrl, remove it from the path
filePath = requestPath.slice(req.baseUrl.length);
} else if (req.route && req.route.path === '/') {
// Root route - use full path minus leading slash
filePath = requestPath.slice(1);
} else {
// Fallback to the original slicing logic for compatibility
filePath = requestPath.slice(req.route.path.length - 1);
}
// Ensure filePath is a string and has no leading slash
filePath = String(filePath || '');
if (filePath.startsWith('/')) {
filePath = filePath.slice(1);
}
if (requestPath === '') {
console.log('replaced root with index.html');
filePath = 'index.html';
@@ -68,7 +92,7 @@ export class HandlerStatic extends Handler {
// lets actually care about serving, if security checks pass
let fileBuffer: Buffer;
try {
fileBuffer = plugins.smartfile.fs.toBufferSync(joinedPath);
fileBuffer = await plugins.fsInstance.file(joinedPath).read() as Buffer;
usedPath = joinedPath;
} catch (err) {
// try serving index.html instead
@@ -77,7 +101,7 @@ export class HandlerStatic extends Handler {
console.log(`serving default path ${defaultPath} instead of ${joinedPath}`);
try {
parsedPath = plugins.path.parse(defaultPath);
fileBuffer = plugins.smartfile.fs.toBufferSync(defaultPath);
fileBuffer = await plugins.fsInstance.file(defaultPath).read() as Buffer;
usedPath = defaultPath;
} catch (err) {
res.writeHead(500);

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) {
@@ -132,7 +139,7 @@ export class Server {
});
this.expressAppInstance.use(cors);
this.expressAppInstance.options('/*splat', cors);
this.expressAppInstance.options('/{*splat}', cors);
}
this.expressAppInstance.use((req, res, next) => {

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,60 +1,141 @@
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';
const swBundleJs: string = plugins.smartfile.fs.toStringSync(
plugins.path.join(paths.serviceworkerBundleDir, './serviceworker.bundle.js')
);
const swBundleJsMap: string = plugins.smartfile.fs.toStringSync(
plugins.path.join(paths.serviceworkerBundleDir, './serviceworker.bundle.js.map')
);
// Lazy-loaded service worker bundle content
let swBundleJs: string | null = null;
let swBundleJsMap: string | null = null;
const loadServiceWorkerBundle = async (): Promise<void> => {
if (swBundleJs === null) {
swBundleJs = (await plugins.fsInstance
.file(plugins.path.join(paths.serviceworkerBundleDir, './serviceworker.bundle.js'))
.encoding('utf8')
.read()) as string;
}
if (swBundleJsMap === null) {
swBundleJsMap = (await plugins.fsInstance
.file(plugins.path.join(paths.serviceworkerBundleDir, './serviceworker.bundle.js.map'))
.encoding('utf8')
.read()) as string;
}
};
let swVersionInfo: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] =
null;
const serviceworkerHandler = new Handler(
'GET',
async (req, res) => {
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{.*}', 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

@@ -10,7 +10,7 @@ export const redirectFrom80To443 = async () => {
});
smartexpressInstance.addRoute(
'/*splat',
'/{*splat}',
new Handler('ALL', async (req, res) => {
res.redirect('https://' + req.headers.host + req.url);
})

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,96 @@ 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
// ===============
/**
* 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

@@ -102,17 +102,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,23 @@ 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;
}
// Block requests that we don't want the service worker to handle.
if (
parsedUrl.hostname.includes('paddle.com') ||
@@ -128,16 +253,30 @@ 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;
metrics.recordCacheHit(matchRequest.url, 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));

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,832 @@
import { getMetricsCollector } from './classes.metrics.js';
/**
* Dashboard generator that creates a terminal-like metrics display
* served directly from the service worker
*/
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',
},
});
}
/**
* 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 {
// Latency test
const latencyStart = Date.now();
const latencyResponse = await fetch('/sw-typedrequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'serviceworker_speedtest',
request: { type: 'latency' },
}),
});
if (latencyResponse.ok) {
await latencyResponse.json(); // Consume response
const latencyDuration = Date.now() - latencyStart;
results.latency = { durationMs: latencyDuration, speedMbps: 0 };
metrics.recordSpeedtest('latency', latencyDuration);
results.isOnline = true;
metrics.setOnlineStatus(true);
}
// Download test (100KB)
const downloadStart = Date.now();
const downloadResponse = await fetch('/sw-typedrequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'serviceworker_speedtest',
request: { type: 'download', payloadSizeKB: 100 },
}),
});
if (downloadResponse.ok) {
const downloadData = await downloadResponse.json();
const downloadDuration = Date.now() - downloadStart;
const bytesTransferred = downloadData.response?.payload?.length || 0;
// Speed in Mbps: (bytes * 8) / (ms / 1000) / 1000000 = bytes * 8 / ms / 1000
const downloadSpeedMbps = downloadDuration > 0 ? (bytesTransferred * 8) / (downloadDuration * 1000) : 0;
results.download = { durationMs: downloadDuration, speedMbps: downloadSpeedMbps, bytesTransferred };
metrics.recordSpeedtest('download', downloadSpeedMbps);
}
// Upload test (100KB)
const uploadPayload = 'x'.repeat(100 * 1024);
const uploadStart = Date.now();
const uploadResponse = await fetch('/sw-typedrequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'serviceworker_speedtest',
request: { type: 'upload', payload: uploadPayload },
}),
});
if (uploadResponse.ok) {
const uploadDuration = Date.now() - uploadStart;
const uploadSpeedMbps = uploadDuration > 0 ? (uploadPayload.length * 8) / (uploadDuration * 1000) : 0;
results.upload = { durationMs: uploadDuration, speedMbps: uploadSpeedMbps, bytesTransferred: uploadPayload.length };
metrics.recordSpeedtest('upload', uploadSpeedMbps);
}
} 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(),
summary: metrics.getSummary(),
});
}
/**
* Generates the complete HTML dashboard page with terminal-like styling
*/
public generateDashboardHtml(): string {
const metrics = getMetricsCollector();
const data = metrics.getMetrics();
const hitRate = metrics.getCacheHitRate();
const successRate = metrics.getNetworkSuccessRate();
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: 900px;
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;
}
.content {
padding: 15px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 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 {
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;
}
.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;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.ascii-bar {
font-family: monospace;
letter-spacing: 0;
}
.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 blink {
50% { opacity: 0; }
}
.btn {
background: #1a1a1a;
border: 1px solid #00ff00;
color: #00ff00;
padding: 8px 16px;
cursor: pointer;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
transition: all 0.2s ease;
}
.btn:hover {
background: #00ff00;
color: #000;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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);
}
.speedtest-results {
margin-top: 10px;
}
.speed-bar {
height: 8px;
background: #1a1a1a;
border: 1px solid #333;
margin: 4px 0;
}
.speed-fill {
height: 100%;
background: #00aa00;
transition: width 0.5s ease;
}
.btn-row {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="terminal">
<div class="header">
<span class="title">[SW-DASH] Service Worker Metrics</span>
<span class="uptime" id="uptime">Uptime: ${this.formatDuration(data.uptime)}</span>
</div>
<div class="content">
<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)}" style="width: ${hitRate}%"></div>
<span class="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">Avg Response:</span>
<span class="value" id="cache-response">${data.cache.averageResponseTime}ms</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)}" style="width: ${successRate}%"></div>
<span class="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 class="row">
<span class="label">Last Update:</span>
<span class="value" id="upd-last-update">${this.formatTimestamp(data.update.lastUpdateTimestamp)}</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="row">
<span class="label">Last Test:</span>
<span class="value" id="speed-last-test">${this.formatTimestamp(data.speedtest.lastTestTimestamp)}</span>
</div>
<div class="row">
<span class="label">Test Count:</span>
<span class="value" id="speed-test-count">${data.speedtest.testCount}</span>
</div>
<div class="btn-row">
<button class="btn" id="run-speedtest" onclick="runSpeedtest()">Run Test</button>
</div>
</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>
function formatNumber(num) {
return num.toLocaleString();
}
function formatBytes(bytes) {
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];
}
function formatDuration(ms) {
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';
}
function 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();
}
function getGaugeClass(rate) {
if (rate >= 80) return 'good';
if (rate >= 50) return 'warning';
return 'bad';
}
function updateDashboard(data) {
// Uptime
document.getElementById('uptime').textContent = 'Uptime: ' + formatDuration(data.uptime);
// Cache
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-response').textContent = data.cache.averageResponseTime + 'ms';
// Update cache gauge
const cacheGauge = document.querySelector('.panel:nth-child(1) .gauge-fill');
cacheGauge.style.width = data.cacheHitRate + '%';
cacheGauge.className = 'gauge-fill ' + getGaugeClass(data.cacheHitRate);
document.querySelector('.panel:nth-child(1) .gauge-text').textContent = data.cacheHitRate + '% hit rate';
// Network
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);
// Update network gauge
const netGauge = document.querySelector('.panel:nth-child(2) .gauge-fill');
netGauge.style.width = data.networkSuccessRate + '%';
netGauge.className = 'gauge-fill ' + getGaugeClass(data.networkSuccessRate);
document.querySelector('.panel:nth-child(2) .gauge-text').textContent = data.networkSuccessRate + '% success';
// Updates
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('upd-last-update').textContent = formatTimestamp(data.update.lastUpdateTimestamp);
// Connections
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);
// Speedtest
if (data.speedtest) {
const onlineDot = document.getElementById('online-dot');
const onlineStatus = document.getElementById('online-status');
onlineDot.className = 'online-dot ' + (data.speedtest.isOnline ? 'online' : 'offline');
onlineStatus.textContent = data.speedtest.isOnline ? 'Online' : 'Offline';
onlineStatus.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-last-test').textContent = formatTimestamp(data.speedtest.lastTestTimestamp);
document.getElementById('speed-test-count').textContent = formatNumber(data.speedtest.testCount);
// Update speed bars (max 100 Mbps for visualization)
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) + '%';
}
// Last refresh
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
}
// Speedtest function
let speedtestRunning = false;
async function runSpeedtest() {
if (speedtestRunning) return;
speedtestRunning = true;
const btn = document.getElementById('run-speedtest');
const originalText = btn.textContent;
btn.textContent = 'Testing...';
btn.disabled = true;
try {
const response = await fetch('/sw-dash/speedtest');
const result = await response.json();
// Update online status immediately
const onlineDot = document.getElementById('online-dot');
const onlineStatus = document.getElementById('online-status');
onlineDot.className = 'online-dot ' + (result.isOnline ? 'online' : 'offline');
onlineStatus.textContent = result.isOnline ? 'Online' : 'Offline';
onlineStatus.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';
}
document.getElementById('speed-last-test').textContent = 'just now';
if (result.error) {
console.error('Speedtest error:', result.error);
}
} catch (err) {
console.error('Failed to run speedtest:', err);
// Mark as offline on error
const onlineDot = document.getElementById('online-dot');
const onlineStatus = document.getElementById('online-status');
onlineDot.className = 'online-dot offline';
onlineStatus.textContent = 'Offline';
onlineStatus.className = 'value error';
} finally {
speedtestRunning = false;
btn.textContent = originalText;
btn.disabled = false;
}
}
// Auto-refresh every 2 seconds
setInterval(async () => {
try {
const response = await fetch('/sw-dash/metrics');
const data = await response.json();
updateDashboard(data);
} catch (err) {
console.error('Failed to fetch metrics:', err);
}
}, 2000);
</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,463 @@
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 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
// 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 = [];
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(' | ');
}
}
// Export singleton getter for convenience
export const getMetricsCollector = (): MetricsCollector => MetricsCollector.getInstance();

View File

@@ -4,7 +4,7 @@ import { logger } from './logging.js';
export class NetworkManager {
public serviceWorkerRef: ServiceWorker;
public webRequest: plugins.webrequest.WebRequest;
public webRequest: plugins.webrequest.WebrequestClient;
private isOffline: boolean = false;
private lastOnlineCheck: number = 0;
private readonly ONLINE_CHECK_INTERVAL = 30000; // 30 seconds
@@ -13,7 +13,7 @@ export class NetworkManager {
constructor(serviceWorkerRefArg: ServiceWorker) {
this.serviceWorkerRef = serviceWorkerRefArg;
this.webRequest = new plugins.webrequest.WebRequest();
this.webRequest = new plugins.webrequest.WebrequestClient();
// Listen for connection changes
this.getConnection()?.addEventListener('change', () => {

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

@@ -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",