Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6eacf5fcc | |||
| 0f974701d4 | |||
| 2ad38dece3 | |||
| 32cb5bb423 | |||
| 5fa97322fb | |||
| af16473495 | |||
| 748a60ef74 | |||
| 3f71643e81 | |||
| 9f107b6876 | |||
| 4a8cd4b4b7 | |||
| 54d2cd1eb7 | |||
| 94eb289081 | |||
| e022ffc2ba | |||
| 25e92f4351 | |||
| b508cbe927 | |||
| 4cbc37c888 | |||
| 16f759c2b9 | |||
| f8fee04751 | |||
| 9406cfa0e2 | |||
| 1f310ef8f1 | |||
| 9cd10118e3 | |||
| 6308e0126d | |||
| e1310269fe | |||
| 1aadc2da21 | |||
| 37426f0708 | |||
| c124a06bc6 | |||
| 849e7f4407 | |||
| 3baf171394 | |||
| 065987c854 | |||
| c5c40e78f9 | |||
| d3330880c0 | |||
| dbbfd313ae | |||
| eabee2d658 | |||
| 95cd681380 | |||
| 9f6290f7aa | |||
| 065a253b3e | |||
| 722bf5d946 | |||
| 299e3ac33f | |||
| 951a48cf88 | |||
| 8b7fe245f0 | |||
| 5bc24ad88b | |||
| a35775499b | |||
| f9a8b61743 | |||
| ffad23e6cf | |||
| cb429b1f5f | |||
| c4e0e9b915 | |||
| 8bb4814350 | |||
| 9c7e17bdbb | |||
| cbff5a2126 | |||
| 43a335ab3a | |||
| 5f015380be | |||
| ba12ba561b | |||
| aadec22023 | |||
| 4db6fa6771 | |||
| 0f171e43e7 | |||
| 5d9e914b23 | |||
| b33ab76a9e | |||
| 78a5c53d19 | |||
| 4bae49cfb0 | |||
| 031eb78288 | |||
| 98eae1e79a | |||
| aa677a2b7c | |||
| 5a81858df5 | |||
| c263b0608c | |||
| 30126f716e | |||
| 4dc0cb311b | |||
| 84256fd8fc | |||
| 8010977d05 | |||
| 54bb12d6ff | |||
| 9ac91fd166 | |||
| b4e26d6d6a | |||
| 1885eb78e5 | |||
| 8b4c5918e9 | |||
| c6792396df | |||
| fc6829f607 | |||
| 424b742f84 | |||
| c25daba1c1 | |||
| dce2e926e4 | |||
| 27c96949a1 | |||
| c17d6dac35 | |||
| 1c0c88a7cb | |||
| 8557c769fa | |||
| bce84c6838 | |||
| ba08fcdebc | |||
| 588a9d1df6 | |||
| b1d376207a | |||
| 43d8aea4e1 | |||
| 776d6fb95d | |||
| 0f22c91499 | |||
| a0f714a561 | |||
| 9477eac268 | |||
| 8cbae75ae4 | |||
| db05fc91c4 | |||
| 6e647a3556 | |||
| 6da22ab607 | |||
| fb98b3294a | |||
| 848ef1d3d1 | |||
| 497b267b43 | |||
| d5875d5031 | |||
| b06c67ebac | |||
| 3d7e5c439d | |||
| 84f7d8d4a0 | |||
| 42e8e575d8 | |||
| d5f7fbbb9a | |||
| 0dcb9edcbe | |||
| 85ca50fc8b | |||
| b3726cb518 | |||
| ec6754be52 | |||
| 1ced20c887 | |||
| 3556594501 | |||
| dd6babdf81 | |||
| 75ce27a4bf | |||
| 435a4a0349 | |||
| b1983edcd7 | |||
| 1a9c656f2e | |||
| 569fa4fc46 | |||
| cbb10d7c19 | |||
| ab4c302cea | |||
| 0017a559ca | |||
| 270230b0ca | |||
| 6cedd53d61 | |||
| f518300d68 | |||
| 8f6f177d19 | |||
| 4e560a9a51 | |||
| 7999e370f6 | |||
| efade7a78e | |||
| 0fecf69420 | |||
| 804537c059 | |||
| aebcbe4a61 | |||
| c5cb8c1f01 | |||
| 8202ce6227 | |||
| 4598bd0e25 | |||
| 021c980a4f | |||
| c7dca75827 | |||
| 4f7b2888ab | |||
| e552a48c02 | |||
| 2ea4139974 | |||
| e225c693a8 | |||
| 6393336ea6 | |||
| d7158734d2 | |||
| 557724718c | |||
| d7a9b26873 | |||
| 511de8040a | |||
| 952e95f82f | |||
| 42115cb6be | |||
| e1206bdf4c | |||
| e32e7272ba | |||
| 3f317fffd5 | |||
| a49309566c | |||
| 0fb1d54e06 | |||
| f31ca98b2c | |||
| dfcda87196 | |||
| 108bcb41bf | |||
| 1b18961539 | |||
| 4fcfd0f52c | |||
| 8f1464c97e | |||
| 96a88911a7 | |||
| 1d5af30e78 | |||
| 8fe5b6985c | |||
| 72e02bd611 | |||
| fb7c1242a9 | |||
| 360766d8b4 | |||
| 9968dda0fa | |||
| 77b9e41bdb | |||
| 8bea58b434 | |||
| bd9397eb13 | |||
| 6e7316d2b1 | |||
| cba65bfb81 | |||
| f06f25b4db | |||
| 316625c41b | |||
| ee67c68c17 | |||
| 8fb2d8b3e8 | |||
| 75c89b040b | |||
| b6d0843e3e | |||
| 1c5e2845d1 | |||
| 7798bf7e0a | |||
| 76db7d1733 | |||
| 1db472ab01 | |||
| 23e88030be | |||
| 1644cbbfad | |||
| 84e214d087 | |||
| 0bb7e438d5 | |||
| 1ce6d2ab01 | |||
| d225a9584f | |||
| fedb37ee16 | |||
| e99196b227 | |||
| 3d6723d06c | |||
| fd7aadaf79 | |||
| 5e4878e492 | |||
| 64d4fb011d | |||
| 6671bbe793 | |||
| 14bfa3f62f | |||
| 9e4413c276 | |||
| dd91691064 | |||
| bf4794c06f | |||
| 7e04474a66 | |||
| 907d51a842 | |||
| 2114ff28c0 | |||
| fd9431f82b |
128
.gitlab-ci.yml
128
.gitlab-ci.yml
@@ -1,128 +0,0 @@
|
||||
# gitzone ci_default
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .npmci_cache/
|
||||
key: '$CI_BUILD_STAGE'
|
||||
|
||||
stages:
|
||||
- security
|
||||
- test
|
||||
- release
|
||||
- metadata
|
||||
|
||||
before_script:
|
||||
- pnpm install -g pnpm
|
||||
- pnpm install -g @shipzone/npmci
|
||||
- npmci npm prepare
|
||||
|
||||
# ====================
|
||||
# security stage
|
||||
# ====================
|
||||
# ====================
|
||||
# security stage
|
||||
# ====================
|
||||
auditProductionDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command pnpm audit --audit-level=high --prod
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
auditDevDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command pnpm audit --audit-level=high --dev
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
# ====================
|
||||
# test stage
|
||||
# ====================
|
||||
|
||||
testStable:
|
||||
stage: test
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci npm test
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
testBuild:
|
||||
stage: test
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci npm build
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
release:
|
||||
stage: release
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm publish
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
# ====================
|
||||
# metadata stage
|
||||
# ====================
|
||||
codequality:
|
||||
stage: metadata
|
||||
allow_failure: true
|
||||
only:
|
||||
- tags
|
||||
script:
|
||||
- npmci command npm install -g typescript
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- priv
|
||||
|
||||
trigger:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci trigger
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
pages:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci command npm run buildDocs
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
only:
|
||||
- tags
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- public
|
||||
allow_failure: true
|
||||
669
changelog.md
Normal file
669
changelog.md
Normal file
@@ -0,0 +1,669 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-04 - 7.8.11 - fix(web_inject)
|
||||
Improve logging in web injection (TypedRequest) and update dees-comms dependency
|
||||
|
||||
- Add debug logging to ts_web_inject to explicitly filter serviceworker_* methods and avoid infinite loops
|
||||
- Log incoming TypedRequest methods for better visibility during debugging
|
||||
- Bump dependency @design.estate/dees-comms from ^1.0.27 to ^1.0.28
|
||||
|
||||
## 2025-12-04 - 7.8.0 - feat(serviceworker)
|
||||
Add TypedRequest traffic monitoring and SW dashboard 'Requests' panel
|
||||
|
||||
- Add TypedRequest traffic monitoring interfaces and shared SW dashboard HTML (SW_DASH_HTML) to ts_interfaces/serviceworker.ts.
|
||||
- Introduce RequestLogStore (ts_web_serviceworker/classes.requestlogstore.ts) to collect, persist in-memory, and compute stats for TypedRequest traffic (logs, counts, methods, averages).
|
||||
- Add service worker backend handlers to receive and broadcast TypedRequest logs and to expose endpoints: serviceworker_typedRequestLog, serviceworker_getTypedRequestLogs, serviceworker_getTypedRequestStats, serviceworker_clearTypedRequestLogs.
|
||||
- Expose HTTP routes and fallback behaviors in the server built-in controller to serve the SW dashboard (GET /sw-dash and /sw-dash/bundle.js) and to return sensible 503 placeholders when the SW is not active.
|
||||
- Extend the service worker CacheManager and DashboardGenerator to serve TypedRequest-related endpoints (/sw-dash/requests, /sw-dash/requests/stats, /sw-dash/requests/methods) and to integrate RequestLogStore data into the dashboard APIs.
|
||||
- Add a Lit-based dashboard component sw-dash-requests (ts_swdash/sw-dash-requests.ts) and integrate it into the main sw-dash-app UI to display live TypedRequest traffic with filtering, payload toggles, pagination and clear logs action.
|
||||
- Enable client-side traffic logging from the injected reload checker (ts_web_inject/index.ts) by setting global TypedRouter hooks that send log entries to the service worker, with safeguards to avoid logging the logging requests themselves.
|
||||
- Add action manager utilities (ts_web_serviceworker_client/classes.actionmanager.ts) to log TypedRequest entries to the SW, query logs and stats, clear logs, and subscribe to real-time TypedRequest broadcasts.
|
||||
- Refactor Dashboard HTML generation to use the shared SW_DASH_HTML constant so server and service worker serve the same UI shell.
|
||||
- Integrate broadcasting of TypedRequest log events from service worker backend to connected clients so the SW dashboard updates in real time.
|
||||
|
||||
## 2025-12-04 - 7.7.1 - fix(web_serviceworker)
|
||||
Standardize DeesComms message format in service worker backend
|
||||
|
||||
- Add createMessage helper to generate consistent TypedRequest-shaped messages (includes messageId and correlation.id/phase).
|
||||
- Replace inline postMessage payloads with createMessage(...) calls across ServiceworkerBackend (status updates, new-version broadcasts, alerts, event pushes, metrics updates, resource-cached notifications).
|
||||
- Improves message consistency and enables easier correlation/tracing of DeesComms messages; behavior should remain backward-compatible.
|
||||
|
||||
## 2025-12-04 - 7.7.0 - feat(typedserver)
|
||||
Add SPA fallback support to TypedServer
|
||||
|
||||
- Introduce new IServerOptions.spaFallback boolean to enable SPA routing fallback.
|
||||
- When enabled, GET requests for paths without a file extension will serve serveDir/index.html.
|
||||
- Preserves existing HTML injection behavior: injectReload still injects devtools script and typedserver metadata into <head> when enabled.
|
||||
- Responses from SPA fallback include Cache-Control: no-cache and appHash header for cache-busting; falls through to 404 on errors.
|
||||
- Non-file routes that contain a dot (.) are not considered for SPA fallback to avoid interfering with asset requests.
|
||||
|
||||
## 2025-12-04 - 7.6.0 - feat(typedserver)
|
||||
Remove legacy Express-based servertools, drop express deps, and refactor TypedServer to SmartServe + typedrouter with CORS support
|
||||
|
||||
- Remove legacy ts/servertools module and many Express-based helpers (classes.server, handler, handlerstatic, handlerproxy, compressor, sitemap, feed, tools.*). The servertools compatibility layer is no longer available.
|
||||
- Drop express-related dependencies from package.json (@types/express, express, body-parser, cors, express-force-ssl).
|
||||
- Refactor core API: ts/index.ts no longer exports servertools and ts/plugins.ts no longer re-exports Express middleware — consumers must migrate to SmartServe/typedrequest/typedsocket primitives.
|
||||
- TypedServer rewritten: integrates with @push.rocks/smartserve ControllerRegistry, adds custom route parsing, CORS header helper and OPTIONS preflight handling, improved static file handling with optional reload injection, file watching, typedrouter and typedsocket integration.
|
||||
- UtilityWebsiteServer now registers the serviceworker versionInfo handler on typedrouter instead of using the removed servertools.serviceworker helper.
|
||||
- This is a breaking change — public APIs and dependency surface changed; bump major version.
|
||||
|
||||
## 2025-12-04 - 7.5.0 - feat(serviceworker)
|
||||
Add real-time service worker push updates and DeesComms integration (metrics, events, resource caching)
|
||||
|
||||
- Integrate DeesComms push channel for real-time SW → client communication and export/consume deesComms in relevant plugin modules.
|
||||
- Add typed push message interfaces for events, metrics snapshots and resource-cached notifications in serviceworker interfaces.
|
||||
- Implement backend push methods: pushEvent, pushMetricsUpdate (with 500ms throttle) and pushResourceCached in ServiceworkerBackend.
|
||||
- Trigger push updates from MetricsCollector and PersistentStore so metrics and logged events are broadcast to connected clients.
|
||||
- Add client-side DeesComms handlers in sw-dash app: receive metrics, event logs and resource notifications; add heartbeat and initial HTTP seed to maintain SW health state.
|
||||
- Add event push listener and cleanup in sw-dash-events component to prepend incoming events and avoid leaks.
|
||||
- Expose getServiceWorkerBackend() from SW init for internal modules to call push methods.
|
||||
- Misc: implement request deduplication and various robustness improvements (throttling, heartbeat, safer polling, removed noisy debug logs).
|
||||
|
||||
## 2025-12-04 - 7.4.1 - fix(web_serviceworker)
|
||||
Improve service worker persistence, metrics and caching robustness
|
||||
|
||||
- Ensure persistent store is initialized before use in dashboard handlers and service worker activation/handlers (calls to persistentStore.init())
|
||||
- Make serveCumulativeMetrics async and align fetchEvent.respondWith usage (remove unnecessary Promise.resolve)
|
||||
- Change persistent WebStore database name to 'losslessServiceworkerPersistent' to separate durable store from runtime store
|
||||
- Make PersistentStore.init() more resilient: add detailed logging, avoid throwing on init failure, mark initialized to prevent retry loops, and start periodic save only after load
|
||||
- Ensure logEvent awaits initialization and adds defensive logging around reading/writing the event log
|
||||
- Add request deduplication logic and improved cache handling in CacheManager (fetchWithDeduplication usage and safer respondWith)
|
||||
|
||||
## 2025-12-04 - 7.4.0 - feat(serviceworker)
|
||||
Add persistent event store, cumulative metrics and dashboard events UI for service worker observability
|
||||
|
||||
- Add PersistentStore (ts_web_serviceworker/classes.persistentstore.ts) to persist event log and cumulative metrics with retention policy and periodic saving.
|
||||
- Introduce persistent event types and interfaces for event log and cumulative metrics (ts_interfaces/serviceworker.ts).
|
||||
- Log lifecycle, update, network and speedtest events to the persistent store (install, activate, update available/applied/error, network online/offline, speedtest started/completed/failed, cache invalidation).
|
||||
- Expose persistent-store APIs via typed handlers in the service worker backend: serviceworker_getEventLog, serviceworker_getCumulativeMetrics, serviceworker_clearEventLog, serviceworker_getEventCount.
|
||||
- Serve new dashboard endpoints from the service worker: /sw-dash/events (GET), /sw-dash/events/count (GET), /sw-dash/cumulative-metrics (GET) and DELETE /sw-dash/events to clear the log (handled in classes.cachemanager and classes.dashboard).
|
||||
- Add sw-dash events panel component (ts_swdash/sw-dash-events.ts) and integrate an Events tab into the dashboard UI (ts_swdash/sw-dash-app.ts, sw-dash-overview.ts shows 1h event count).
|
||||
- Reset cumulative metrics on cache invalidation and increment swRestartCount on PersistentStore.init().
|
||||
- Record speedtest lifecycle events (started/completed/failed) and include details in the event log.
|
||||
|
||||
## 2025-12-04 - 7.3.0 - feat(serviceworker)
|
||||
Modernize SW dashboard UI and improve service worker backend and server tooling
|
||||
|
||||
- Revamped sw-dash UI: new header/logo, uptime badge, live auto-refresh indicator, reorganized panels and improved speedtest UI and controls
|
||||
- Shared styles overhaul: new theming variables, spacing scale, badges, refined progress/pulse animations and cleaner typography
|
||||
- Dashboard internals: metrics endpoint and SPA shell updated; Lit bundle loading and table sort icon changed to ↑/↓
|
||||
- Service worker: added request deduplication (in-flight request coalescing), safer caching logic, consistent CORS/caching headers, and cache revalidation
|
||||
- Metrics: richer MetricsCollector with per-resource tracking, domain/content-type stats, speedtest metrics and better summary/stat helpers
|
||||
- Update & network managers: rate-limited update checks, debounced update/revalidation tasks, online/offline checks and improved retry/backoff logic
|
||||
- TypedServer & tooling: addRoute API for custom routes, improved HTML reload script injection, TypedSocket integration and a backend speedtest handler
|
||||
- servertools: improved static/proxy handlers (more robust path extraction, compression handling) and deprecation notice for addTypedSocket()
|
||||
|
||||
## 2025-12-04 - 7.2.0 - feat(serviceworker)
|
||||
Add service worker status updates, EventBus and UI status pill for realtime observability
|
||||
|
||||
- Introduce a status update protocol for service worker <-> clients (IStatusUpdate, IMessage_Serviceworker_StatusUpdate, IRequest_Serviceworker_GetStatus).
|
||||
- Add typedserver-statuspill Lit component to display backend/serviceworker/network status in the UI, with expand/collapse details and persistent/error states.
|
||||
- Wire ReloadChecker to use the new status pill: show network/backend/serviceworker status, handle online/offline events, and subscribe to service worker status broadcasts.
|
||||
- Extend ActionManager (client) with subscribeToStatusUpdates and getServiceWorkerStatus helpers; forward serviceworker_statusUpdate broadcasts to registered callbacks.
|
||||
- Serviceworker backend: add serviceworker_getStatus handler and broadcastStatusUpdate API; subscribe to EventBus lifecycle/network/update events to broadcast status changes to clients.
|
||||
- Add EventBus for decoupled service worker internal events (ServiceWorkerEvent enum, pub/sub API, history and convenience emitters).
|
||||
- Ensure proper subscribe/unsubscribe lifecycle (ReloadChecker stops SW subscription on stop).
|
||||
- Improve cache/connection status reporting integration so status updates include details like cacheHitRate, resourceCount and connected clients.
|
||||
|
||||
## 2025-12-04 - 7.1.0 - feat(swdash)
|
||||
Add live speedtest progress UI to service worker dashboard
|
||||
|
||||
- Introduce reactive speedtest state (phase, progress, elapsed) in sw-dash-overview component
|
||||
- Start a progress interval to animate overall test progress and estimate phases (latency, download, upload)
|
||||
- Dispatch 'speedtest-complete' event and show a brief complete state before resetting UI
|
||||
- Add helper methods for phase labels and elapsed time formatting
|
||||
- Add CSS for progress bar, shimmer animation and phase pulse to sw-dash-styles
|
||||
|
||||
## 2025-12-04 - 7.0.0 - BREAKING CHANGE(serviceworker)
|
||||
Move serviceworker speedtest to time-based chunked transfers and update dashboard/server contract
|
||||
|
||||
- Change speedtest protocol to time-based chunk transfers: new request types 'download_chunk' and 'upload_chunk' plus 'latency'. Clients should call chunk requests in a loop for the desired test duration.
|
||||
- IRequest_Serviceworker_Speedtest interface updated: request fields renamed/changed (chunkSizeKB, payload) and response no longer includes durationMs or speedMbps — server now returns bytesTransferred, timestamp, and optional payload.
|
||||
- TypedServer speedtest handler updated to support 'download_chunk' and 'upload_chunk' semantics and to return bytesTransferred/timestamp/payload only (removed server-side duration/speed calculation).
|
||||
- Dashboard runSpeedtest now performs time-based tests (TEST_DURATION_MS = 5000, CHUNK_SIZE_KB = 64) by repeatedly requesting chunks and computing throughput on the client side.
|
||||
- Documentation/comments updated to clarify new speedtest behavior and default chunk sizes.
|
||||
|
||||
## 2025-12-04 - 6.8.1 - fix(web_serviceworker)
|
||||
Move service worker initialization to init.ts and remove exports from service worker entrypoint to avoid ESM bundle output
|
||||
|
||||
- Remove exports from ts_web_serviceworker/index.ts so the service worker entrypoint does not export symbols (prevents tsbundle from producing ESM output).
|
||||
- Add ts_web_serviceworker/init.ts which initializes the ServiceWorker instance and exports getServiceWorkerInstance() for internal imports.
|
||||
- Update ts_web_serviceworker/classes.dashboard.ts to import getServiceWorkerInstance from init.ts instead of index.ts.
|
||||
|
||||
## 2025-12-04 - 6.8.0 - feat(swdash)
|
||||
Add SW-Dash (Lit-based service worker dashboard), bundle & serve it; improve servertools and static handlers
|
||||
|
||||
- Add a new sw-dash frontend (ts_swdash) implemented with Lit: sw-dash-app, sw-dash-overview, sw-dash-urls, sw-dash-domains, sw-dash-types, sw-dash-table, shared styles and plugin shims.
|
||||
- Wire sw-dash into build pipeline and packaging: add ts_swdash bundle to npm build script and include ts_swdash in package files.
|
||||
- Serve the dashboard bundle: add paths (swdashBundleDir / swdashBundlePath) and a built-in route (/sw-dash/bundle.js) in BuiltInRoutesController.
|
||||
- Simplify service-worker dashboard HTML output to a minimal shell that mounts <sw-dash-app> and loads the module /sw-dash/bundle.js (reduces inline HTML/CSS/JS duplication).
|
||||
- Lazy-load service worker bundle and source map in servertools.tools.serviceworker and expose /sw-typedrequest endpoints for SW typed requests (including speedtest handler).
|
||||
- Enhance compression utilities and static serving: Compressor now caches compressed results, prioritizes preferred compression methods, provides safer zlib calls, and exposes createCompressionStream; HandlerStatic gained improved path resolution, Express 5 wildcard handling and optional compression flow.
|
||||
- Improve proxy/static handler path handling to be compatible with Express 5 wildcard parameters and more robust fallback logic.
|
||||
- Deprecate Server.addTypedSocket (no-op) and document recommended SmartServe/TypedServer integration for WebSocket support.
|
||||
- Various minor packaging/path updates (paths.ts, plugins exports) to support the new dashboard and bundles.
|
||||
|
||||
## 2025-12-04 - 6.7.0 - feat(web_serviceworker)
|
||||
Add per-resource metrics and request deduplication to service worker cache manager
|
||||
|
||||
- Introduce per-resource tracking in metrics: ICachedResource, IDomainStats, IContentTypeStats and a resourceStats map.
|
||||
- Add MetricsCollector.recordResourceAccess(...) to record hits/misses, content-type and size; provide getters: getCachedResources, getDomainStats, getContentTypeStats and getResourceCount.
|
||||
- Reset resourceStats when metrics are reset and limit resource entries via cleanupResourceStats to avoid memory bloat.
|
||||
- Add request deduplication in CacheManager (fetchWithDeduplication) to coalesce identical concurrent fetches and a periodic safety cleanup for in-flight requests.
|
||||
- Record resource accesses on cache hit and when storing new cache entries (captures content-type and body size).
|
||||
- Expose a dashboard resources endpoint (/sw-dash/resources) served by the SW dashboard to return detailed resource data for SPA views.
|
||||
|
||||
## 2025-12-04 - 6.6.0 - feat(web_serviceworker)
|
||||
Enable service worker dashboard speedtests via TypedSocket, expose ServiceWorker instance to dashboard, and add server-side speedtest handler
|
||||
|
||||
- Add `serviceworker_speedtest` typed handler in TypedServer to support download/upload/latency tests from service workers
|
||||
- Export `getServiceWorkerInstance` from the web_serviceworker entrypoint so other modules (dashboard) can access the running ServiceWorker instance
|
||||
- Make ServiceWorker.typedsocket and ServiceWorker.typedrouter public to allow the dashboard to create and fire TypedSocket requests
|
||||
- Update dashboard to run latency, download and upload tests over TypedSocket instead of POSTing to /sw-typedrequest
|
||||
- Deprecate legacy servertools.Server.addTypedSocket (now a no-op) and recommend using TypedServer with SmartServe integration for WebSocket support
|
||||
|
||||
## 2025-12-04 - 6.5.0 - feat(serviceworker)
|
||||
Add server-driven service worker cache invalidation and TypedSocket integration
|
||||
|
||||
- TypedServer: push cache invalidation messages to service worker clients (tagged 'serviceworker') before notifying frontend clients on reload
|
||||
- Service Worker: connect to TypedServer via TypedSocket; handle 'serviceworker_cacheInvalidate' typed request to clean caches and trigger client reloads
|
||||
- Web inject: add fallback to clear caches via the Cache API when global service worker helper is not available
|
||||
- Interfaces: add IRequest_Serviceworker_CacheInvalidate typedrequest interface
|
||||
- Plugins: export typedsocket in web_serviceworker plugin surface
|
||||
- Service worker connection: retry logic and improved logging for TypedSocket connection attempts
|
||||
|
||||
## 2025-12-04 - 6.4.0 - feat(serviceworker)
|
||||
Add speedtest support to service worker and dashboard
|
||||
|
||||
- Add serviceworker_speedtest typed request handler to measure download, upload and latency
|
||||
- Expose dashboard speedtest endpoint (/sw-dash/speedtest) and integrate runSpeedtest flow
|
||||
- Dashboard UI: add speedtest panel, run button, visual speed bars and online indicator
|
||||
- Metrics: introduce ISpeedtestMetrics and methods (recordSpeedtest, setOnlineStatus, getSpeedtestMetrics) and include speedtest data in metrics output
|
||||
- Server/tools: add typedrequest handling for speedtest in sw-typedrequest and route service worker dashboard path in CacheManager
|
||||
|
||||
## 2025-12-04 - 6.3.0 - feat(web_serviceworker)
|
||||
Add advanced service worker subsystems: cache deduplication, metrics, update & network managers, event bus and dashboard
|
||||
|
||||
- CacheManager: request deduplication for concurrent fetches, safer caching (preserve CORS headers), periodic in-flight cleanup and full cache cleaning API
|
||||
- Fetch handling: improved handling for same-origin vs cross-origin requests, more robust 500 debug responses when upstream fetch fails
|
||||
- UpdateManager: rate-limited update checks, offline grace period, debounced update and cache revalidation tasks, forceUpdate logic and persisted version/cache timestamps
|
||||
- NetworkManager: online/offline detection, retry/backoff, request timeouts and more resilient makeRequest implementation
|
||||
- EventBus: singleton pub/sub with history, once/onMany/onAll helpers and convenience emitters for cache/network/update events
|
||||
- MetricsCollector: comprehensive metrics for cache, network, updates and connections with helper methods and JSON/HTML dashboard endpoints (/sw-dash, /sw-dash/metrics)
|
||||
- ErrorHandler & ServiceWorkerError: structured error types, severity, context, history and helper APIs for consistent error reporting
|
||||
- ServiceWorker & backend: improved install/activate flows, clients.claim(), cache cleaning on activation, backend APIs to purge cache and trigger reloads/notifications
|
||||
- TypedServer / servertools: addRoute path pattern parsing (named params & wildcards), safer HTML injection for reload script, TypedRequest controller and service worker route helpers
|
||||
- Various safety and compatibility improvements (response cloning, header normalization, cache-control decisions, and fallback behaviors)
|
||||
|
||||
## 2025-12-04 - 6.2.0 - feat(web_serviceworker)
|
||||
Add service-worker dashboard and request deduplication; improve caching, metrics and error handling
|
||||
|
||||
- Add DashboardGenerator to serve an interactive terminal-style dashboard at /sw-dash and a metrics JSON endpoint at /sw-dash/metrics
|
||||
- Introduce request deduplication in CacheManager to coalesce concurrent network fetches and avoid duplicate requests
|
||||
- Add periodic cleanup for in-flight request tracking to prevent unbounded memory growth
|
||||
- Improve caching flow: preserve response headers (excluding cache-control headers), ensure CORS headers and Cross-Origin-Resource-Policy, and store response bodies as blobs to avoid locked stream issues
|
||||
- Provide clearer 500 error HTML responses for failed fetches to aid debugging
|
||||
- Integrate metrics and event emissions for network and cache operations (record request success/failure, cache hits/misses, and emit corresponding events)
|
||||
|
||||
## 2025-12-04 - 6.1.0 - feat(web_serviceworker)
|
||||
Enhance service worker subsystem: add metrics, event bus, error handling, config and caching/update improvements; make client connection & polling robust
|
||||
|
||||
- Introduce MetricsCollector (cache, network, update, connection) for runtime observability and APIs to retrieve metrics
|
||||
- Add EventBus singleton to emit/subscribe to internal SW events (cache hits/misses, network events, update lifecycle, connection events)
|
||||
- Add ErrorHandler and ServiceWorkerError types for consistent error classification and tracking
|
||||
- Add ServiceWorkerConfig with defaults and WebStore persistence to centralize SW settings (cache, update, network, blocked/cacheable domains)
|
||||
- CacheManager: implement request deduplication (in-flight request coalescing), periodic in-flight cleanup, record cache hit/miss metrics and safer cache storing (headers/body handling)
|
||||
- UpdateManager: rate-limited and concurrency-safe update checks, improved stale-cache handling, event emissions, debounced update and revalidation tasks, and metrics recording
|
||||
- NetworkManager: enhanced online/offline detection and robust request retries/timeouts/backoff handling
|
||||
- ServiceworkerBackend: improved client reload logic and notification handling via DeesComms and clients API
|
||||
- Serviceworker client-side: ActionManager.waitForServiceWorkerConnection now returns a structured result with timeout/retries/backoff; ServiceworkerClient gains controllable polling (AbortController), visibility-based pause/resume, manual trigger and lifecycle cleanup
|
||||
- Expose serviceworker bundle routes at both nested and root paths (/serviceworker/*splat and /serviceworker.bundle.js(.map)) in servertools
|
||||
- Add/extend typed interfaces for serviceworker metrics and connection results
|
||||
|
||||
## 2025-12-04 - 6.0.1 - fix(web_inject)
|
||||
Use TypedSocket status API in web_inject and bump dependencies
|
||||
|
||||
- ts_web_inject: switch from typedsocket.addTag + eventSubject to await typedsocket.setTag + statusSubject; update logging and handle 'reconnecting' status as backend connection loss
|
||||
- Await setTag call to ensure tag is applied before relying on socket state
|
||||
- Bump dependencies: @api.global/typedrequest -> ^3.1.11, @api.global/typedsocket -> ^4.1.0, @push.rocks/smartserve -> ^1.1.2
|
||||
|
||||
## 2025-12-03 - 6.0.0 - BREAKING CHANGE(servertools.Server.addTypedSocket)
|
||||
Deprecate Server.addTypedSocket and upgrade typedsocket to v4; make addTypedSocket a no-op and log a deprecation warning. Bump tsbundle devDependency.
|
||||
|
||||
- Upgrade dependency @api.global/typedsocket to ^4.0.0. TypedSocket v4 no longer supports attaching to an existing Express server.
|
||||
- Deprecate servertools.Server.addTypedSocket(): the method is now a no-op and emits a console.warn directing users to use TypedServer with SmartServe integration for WebSocket support.
|
||||
- Bump devDependency @git.zone/tsbundle to ^2.6.3.
|
||||
- Breaking change: any consumer code that relied on addTypedSocket to attach a WebSocket server to an existing Express instance will need to migrate to the new SmartServe/TypedServer integration.
|
||||
|
||||
## 2025-12-02 - 5.0.0 - BREAKING CHANGE(devtools)
|
||||
Switch /reloadcheck endpoint from GET to POST in DevToolsController
|
||||
|
||||
- Updated ts/controllers/controller.devtools.ts: decorator changed from @plugins.smartserve.Get('/reloadcheck') to @plugins.smartserve.Post('/reloadcheck').
|
||||
- Clients that previously performed GET requests against /reloadcheck must be updated to use POST. This is a breaking API change.
|
||||
- Bump major version to reflect the change in the public HTTP API.
|
||||
|
||||
## 2025-12-02 - 4.1.1 - fix(classes.typedserver)
|
||||
Instantiate and register DevToolsController only when injectReload is enabled; compile ControllerRegistry routes after registration
|
||||
|
||||
- DevToolsController is now created and registered only if options.injectReload is true to avoid unnecessary/invalid registrations when live reload is disabled.
|
||||
- ControllerRegistry.compileRoutes() is invoked after registering controllers to precompile decorated routes for faster route matching.
|
||||
|
||||
## 2025-12-02 - 4.1.0 - feat(TypedServer)
|
||||
Integrate SmartServe controller routing; add built-in routes controller and refactor TypedServer to use controllers and FileServer
|
||||
|
||||
- Add BuiltInRoutesController exposing /robots.txt, /manifest.json, /sitemap, /sitemap-news, /feed and /appversion
|
||||
- Refactor TypedRequestHandler into a SmartServe-decorated TypedRequestController and register it with ControllerRegistry
|
||||
- Refactor TypedServer to use SmartServe: register controller instances, use ControllerRegistry matching, and delegate WebSocket integration to SmartServe
|
||||
- Introduce FileServer-based static serving with HTML reload script injection and improved default root handling
|
||||
- Expand supported HTTP methods to include HEAD and OPTIONS
|
||||
- Remove legacy FeedHelper and consolidate sitemap/feed handling into controllers and helpers
|
||||
- Enhance servertools legacy Express utilities: improved HandlerProxy, HandlerStatic, Compressor with caching and preferred compression support
|
||||
- Service worker subsystem improvements: CacheManager, NetworkManager, UpdateManager and backend enhancements for robust caching, revalidation and client reloads
|
||||
- Web-inject LitElement properties switched from private fields to accessor syntax (typedserver_web.infoscreen)
|
||||
|
||||
## 2025-12-02 - 4.0.0 - BREAKING CHANGE(typedserver)
|
||||
Migrate to new push.rocks packages and async smartfs API; replace smartchok with smartwatch; update deps and service worker handling
|
||||
|
||||
- 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
|
||||
|
||||
- Normalize route wildcard patterns to use splat tokens (e.g. '/someroute/*' -> '/someroute/*splat', '/*' -> '/*splat') for consistent route matching
|
||||
- Update serviceworker route registration to '/serviceworker{.*}' and adjust related handler
|
||||
- Change SSL redirect route from '*' to '/*splat'
|
||||
- Adjust CORS preflight options path to '/*splat' and update tests to reflect new route patterns
|
||||
- Bump express dependency from ^4.21.2 to ^5.1.0
|
||||
- Add .claude/settings.local.json for local Claude tool permissions
|
||||
|
||||
## 2025-08-16 - 3.0.76 - fix(handlerproxy)
|
||||
Use SmartRequest API and improve proxy/asset response handling; update tests and bump dependencies; add local project configuration files
|
||||
|
||||
- Replace deprecated smartrequest.request usage with SmartRequest fluent API in ts/servertools/classes.handlerproxy.ts and add explicit handling for GET/POST/PUT/DELETE/PATCH methods.
|
||||
- Normalize proxied response body handling by using arrayBuffer()/text() and converting to Buffer to avoid body type inconsistencies.
|
||||
- Switch asset fetching in ts/utilityservers/classes.websiteserver.ts to SmartRequest + arrayBuffer for reliable binary handling.
|
||||
- Update tests (test/test.server.ts) to use SmartRequest.create() and to read response bodies via response.text(), matching the updated request API.
|
||||
- Bump dependencies: @push.rocks/smartrequest -> ^4.2.1 and body-parser -> ^2.2.0.
|
||||
- Add local project configuration files: .claude/settings.local.json and .serena/project.yml.
|
||||
|
||||
## 2025-08-16 - 3.0.75 - fix(deps)
|
||||
Update dependencies, test tooling and test imports; enhance npm test script
|
||||
|
||||
- Bump multiple runtime dependencies to newer patch/minor versions (notable updates: @cloudflare/workers-types, @push.rocks/* packages such as smartchok, smartfile, smartlog, smartpath, smartrx, and others, @tsclass/tsclass, @types/express, lit).
|
||||
- Upgrade dev tooling versions: @git.zone/tsbuild and @git.zone/tsbundle; update @git.zone/tstest to v2.3.4.
|
||||
- Improve npm test script by adding --verbose, --logfile and increased --timeout.
|
||||
- Fix test imports to use @git.zone/tstest/tapbundle in test/test.reload.ts and test/test.server.ts.
|
||||
|
||||
## 2025-04-12 - 3.0.74 - fix(commit-info)
|
||||
chore: update commit metadata (no source code changes)
|
||||
|
||||
- Uncommitted diff shows no changes in source files; the commit updates internal commit info only.
|
||||
|
||||
## 2025-04-11 - 3.0.73 - fix(metadata)
|
||||
Update repository URLs and metadata to reflect the new organization scope
|
||||
|
||||
- Changed gitscope from 'pushrocks' to 'api.global' in npmextra.json
|
||||
- Updated repository URL, bugs URL, and homepage in package.json to use code.foss.global/api.global/typedserver
|
||||
|
||||
## 2025-04-11 - 3.0.72 - fix(project)
|
||||
chore: no changes - commit metadata update
|
||||
|
||||
|
||||
## 2025-04-11 - 3.0.71 - fix(serviceworker)
|
||||
Improve error handling and logging in service worker backend and network manager; update multiple dependency versions and packageManager settings.
|
||||
|
||||
- Upgrade dependency versions in package.json (e.g. @cloudflare/workers-types, @push.rocks/smartfile, @push.rocks/smartpromise, @push.rocks/smartrequest, @tsclass/tsclass, and @types/express)
|
||||
- Add packageManager field to package.json
|
||||
- Enhance error handling in ServiceworkerBackend (using try/catch and detailed logging) during client reload, notification display, and alert message sending
|
||||
- Improve network request handling by clearing timeouts and converting errors reliably in NetworkManager
|
||||
- Wrap service worker install and activate event handlers with try/catch to log errors appropriately
|
||||
|
||||
## 2025-03-16 - 3.0.70 - fix(TypedServer)
|
||||
Improve error handling in server startup and response buffering. Validate configuration for reload injections, wrap file watching and TypedSocket initialization in try/catch blocks, enhance client notification and stop procedures, and ensure proper Buffer conversion in the proxy handler.
|
||||
|
||||
- Add validation to throw error if reload script is enabled without a serve directory
|
||||
- Wrap file watching and TypedSocket initialization in try/catch to prevent crashes during startup
|
||||
- Update the reload function to safely notify clients and handle notification errors
|
||||
- Enhance the stop procedure to aggregate cleanup tasks with error handling
|
||||
- Ensure consistent conversion of response bodies to Buffer in HandlerProxy with fallback when undefined
|
||||
- Include fallback hash generation in createServeDirHash for error resilience
|
||||
|
||||
## 2025-03-16 - 3.0.69 - fix(servertools)
|
||||
Fix compression stream creation returns, handler proxy buffer conversion, and sitemap URL concatenation
|
||||
|
||||
- Return compression stream immediately in createCompressionStream for each case instead of using break statements
|
||||
- Convert proxied response to a Buffer in handler proxy rather than throwing an error when it isn't a string
|
||||
- Fix addUrls method in sitemap to correctly concatenate new URLs without duplicating existing entries
|
||||
|
||||
## 2025-02-07 - 3.0.68 - fix(cache-manager)
|
||||
Simplify cache control headers in cache manager
|
||||
|
||||
- Removed unnecessary cache control headers while setting modern Cache-Control.
|
||||
|
||||
## 2025-02-06 - 3.0.67 - fix(serviceworker)
|
||||
Enhance header security for cached resources in service worker
|
||||
|
||||
- Added Cross-Origin-Resource-Policy header management for service worker cached resources.
|
||||
|
||||
## 2025-02-06 - 3.0.66 - fix(serviceworker)
|
||||
Improve error handling and logging in cache manager and update manager.
|
||||
|
||||
- Enhanced error handling and logging in cache management functions.
|
||||
- Corrected network request handling in update manager.
|
||||
- Added missing error handling for fetch events.
|
||||
|
||||
## 2025-02-04 - 3.0.65 - fix(readme)
|
||||
Update documentation with advanced usage and examples
|
||||
|
||||
- Added section on advanced usage including service worker and edge worker setup
|
||||
- Detailed integration examples for type-safe API requests and WebSocket communication
|
||||
- Expanded configuration options and cache strategies
|
||||
|
||||
## 2025-02-04 - 3.0.64 - fix(serviceworker)
|
||||
Improve cache handling and response header management in service worker.
|
||||
|
||||
- Addressed issue preventing caching of certain responses due to missing CORS headers.
|
||||
- Added 'Vary: Origin' header to ensure proper response handling.
|
||||
- Included 'Access-Control-Expose-Headers' for better CORS support.
|
||||
|
||||
## 2025-02-04 - 3.0.63 - fix(core)
|
||||
Refactored caching strategy for service worker to improve compatibility and performance.
|
||||
|
||||
- Removed hard and soft caching distinctions.
|
||||
- Simplified cache setup process.
|
||||
- Improved browser caching control headers.
|
||||
|
||||
## 2025-02-04 - 3.0.62 - fix(Service Worker)
|
||||
Refactor and clean up the cache logic in the Service Worker to improve maintainability and handle Safari-specific cache behavior.
|
||||
|
||||
- Refactored logic for determining cached domains, enhancing the readability and maintainability of the code.
|
||||
- Improved handling of CORS settings in caching requests, notably bypassing caching for soft cached domains in Safari to avoid CORS issues.
|
||||
- Enhanced error response creation for failed resource fetching, maintaining clarity on why and how certain resources were not fetched or cached.
|
||||
- Revised the structure of the caching logic to ensure consistent behavior across all supported browsers.
|
||||
|
||||
## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager)
|
||||
Fixed caching mechanism to better support Safari's handling of soft-cached domains.
|
||||
|
||||
- Added logic to differentiate between hard and soft cached domains.
|
||||
- Implemented special handling for soft cached domains on Safari by bypassing caching.
|
||||
- Ensured appropriate CORS headers are present in cached responses.
|
||||
- Improved error handling with informative 500 error responses.
|
||||
- Optimized caching logic to prevent redundant caching and potential issues with locked streams on Safari.
|
||||
|
||||
## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager)
|
||||
Fixed caching mechanism to better support Safari's handling of soft-cached domains.
|
||||
|
||||
- Added logic to differentiate between hard and soft cached domains.
|
||||
- Implemented special handling for soft cached domains on Safari by bypassing caching.
|
||||
- Ensured appropriate CORS headers are present in cached responses.
|
||||
- Improved error handling with informative 500 error responses.
|
||||
- Optimized caching logic to prevent redundant caching and potential issues with locked streams on Safari.
|
||||
|
||||
## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager)
|
||||
Fixed caching mechanism to better support Safari's handling of soft-cached domains.
|
||||
|
||||
- Added logic to differentiate between hard and soft cached domains.
|
||||
- Implemented special handling for soft cached domains on Safari by bypassing caching.
|
||||
- Ensured appropriate CORS headers are present in cached responses.
|
||||
- Improved error handling with informative 500 error responses.
|
||||
- Optimized caching logic to prevent redundant caching and potential issues with locked streams on Safari.
|
||||
|
||||
## 2025-02-04 - 3.0.60 - fix(cachemanager)
|
||||
Improve cache management and error handling
|
||||
|
||||
- Updated comments for clarity and consistency.
|
||||
- Enhanced error handling in `fetch` event listener.
|
||||
- Optimized cache key management and cleanup process.
|
||||
- Ensured CORS headers are set for cached responses.
|
||||
- Improved logging for caching operations.
|
||||
|
||||
## 2025-02-03 - 3.0.59 - fix(serviceworker)
|
||||
Fixed CORS and Cache Control handling for Service Worker
|
||||
|
||||
- Improved handling of CORS settings for external requests.
|
||||
- Preserved important headers while excluding caching headers.
|
||||
- Ensured the presence of CORS headers in cached responses.
|
||||
- Adjusted Cache-Control headers to prevent browser caching but allow service worker caching.
|
||||
|
||||
## 2025-02-03 - 3.0.58 - fix(network-manager)
|
||||
Refined network management logic for better offline handling.
|
||||
|
||||
- Improved logic to handle missing connections more gracefully.
|
||||
- Added detailed online/offline connection status logging.
|
||||
- Implemented a check for stale cache with a grace period for offline scenarios.
|
||||
- Network requests now use optimized retries and timeouts.
|
||||
|
||||
## 2025-02-03 - 3.0.57 - fix(updateManager)
|
||||
Refine cache management for service worker updates.
|
||||
|
||||
- Ensured cache is forcibly updated if older than defined maximum age.
|
||||
- Implemented interval checks and forced updates for cache staleness.
|
||||
- Updated version information and cache timestamps upon forced updates or validations.
|
||||
|
||||
## 2025-02-03 - 3.0.56 - fix(cachemanager)
|
||||
Adjust cache control headers and fix redundant code
|
||||
|
||||
- Remove duplicate assetbroker URLs in the cache evaluation logic.
|
||||
- Update cache control headers to improve caching behavior.
|
||||
- Increase the timeout for fetch operations to improve compatibility.
|
||||
|
||||
## 2025-01-28 - 3.0.55 - fix(server)
|
||||
Fix response content manipulation for HTML files with injectReload
|
||||
|
||||
- Moved fileString declaration inside HTML file handling block to prevent unnecessary string conversion for non-HTML files.
|
||||
- Corrected responseContent assignment to ensure modified HTML strings are converted back to Buffer format.
|
||||
|
||||
## 2025-01-28 - 3.0.54 - fix(servertools)
|
||||
Fixed an issue with compression results handling in HandlerStatic where content was always being written even if not compressed.
|
||||
|
||||
- Corrected the double writing of response in HandlerStatic.
|
||||
- Ensured that file buffers are only conditionally written based on compression availability.
|
||||
|
||||
## 2024-12-26 - 3.0.53 - fix(infohtml)
|
||||
Remove Sentry script and logo from HTML template
|
||||
|
||||
- Removed Sentry script from the HTML template.
|
||||
- Removed Lossless GmbH logo and contact info.
|
||||
- Updated footer link to point to foss.global.
|
||||
|
||||
## 2024-12-25 - 3.0.52 - fix(dependencies)
|
||||
Bump package versions in dependencies and exports.
|
||||
|
||||
- Updated package dependencies to their latest versions.
|
||||
- Added './infohtml' in package exports.
|
||||
|
||||
## 2024-08-27 - 3.0.51 - fix(core)
|
||||
Update dependencies and fix service worker cache manager and task manager functionalities
|
||||
|
||||
- Updated dependencies in package.json to their latest versions
|
||||
- Enhanced service worker cache manager to include additional scoped URLs
|
||||
- Fixed task manager to start the task manager and added update task functionality
|
||||
- Removed .gitlab-ci.yml from the repository as part of the cleanup
|
||||
|
||||
## 2024-05-25 - 3.0.43 to 3.0.50 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.43 to 3.0.50
|
||||
|
||||
## 2024-05-23 - 3.0.37 to 3.0.42 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.37 to 3.0.42
|
||||
|
||||
## 2024-05-17 - 3.0.37 - Core
|
||||
Routine update and bug fix
|
||||
|
||||
- Updated core functionalities
|
||||
|
||||
## 2024-05-14 - 3.0.33 to 3.0.36 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.33 to 3.0.36
|
||||
|
||||
## 2024-05-13 - 3.0.31 to 3.0.32 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.31 to 3.0.32
|
||||
|
||||
## 2024-05-11 - 3.0.29 to 3.0.31 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.29 to 3.0.31
|
||||
|
||||
## 2024-04-19 - 3.0.27 to 3.0.28 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.27 to 3.0.28
|
||||
|
||||
## 2024-04-14 - 3.0.27 - Documentation
|
||||
Updated Documentation
|
||||
|
||||
- Improved and updated documentation
|
||||
|
||||
## 2024-03-01 - 3.0.25 to 3.0.26 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.25 to 3.0.26
|
||||
|
||||
## 2024-02-21 - 3.0.20 to 3.0.24 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.20 to 3.0.24
|
||||
|
||||
## 2024-01-19 - 3.0.19 to 3.0.20 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.19 to 3.0.20
|
||||
|
||||
## 2024-01-09 - 3.0.14 to 3.0.18 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.14 to 3.0.18
|
||||
|
||||
## 2024-01-08 - 3.0.11 to 3.0.13 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.11 to 3.0.13
|
||||
|
||||
## 2024-01-07 - 3.0.9 to 3.0.10 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.9 to 3.0.10
|
||||
|
||||
## 2023-11-06 - 3.0.8 to 3.0.9 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.8 to 3.0.9
|
||||
|
||||
## 2023-10-23 - 3.0.6 to 3.0.7 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.6 to 3.0.7
|
||||
|
||||
## 2023-10-20 - 3.0.5 to 3.0.6 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.5 to 3.0.6
|
||||
|
||||
## 2023-09-21 - 3.0.4 to 3.0.5 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.4 to 3.0.5
|
||||
|
||||
## 2023-08-06 - 3.0.2 to 3.0.3 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.2 to 3.0.3
|
||||
|
||||
## 2023-08-03 - 3.0.1 to 3.0.0 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 3.0.1 to 3.0.0
|
||||
|
||||
## 2023-08-03 - 2.0.65 - Core
|
||||
Breaking change in core update
|
||||
|
||||
- Introduced breaking changes updating core functionalities
|
||||
|
||||
## 2023-07-02 - 2.0.59 to 2.0.64 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.59 to 2.0.64
|
||||
|
||||
## 2023-07-01 - 2.0.54 to 2.0.58 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.54 to 2.0.58
|
||||
|
||||
## 2023-06-12 - 2.0.53 - Core
|
||||
Routine update and bug fix
|
||||
|
||||
- Updated core functionalities
|
||||
|
||||
## 2023-04-10 - 2.0.52 to 2.0.53 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.52 to 2.0.53
|
||||
|
||||
## 2023-04-04 - 2.0.49 to 2.0.51 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.49 to 2.0.51
|
||||
|
||||
## 2023-03-31 - 2.0.45 to 2.0.48 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.45 to 2.0.48
|
||||
|
||||
## 2023-03-30 - 2.0.37 to 2.0.44 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.37 to 2.0.44
|
||||
|
||||
## 2023-03-29 - 2.0.33 to 2.0.36 - Core
|
||||
Routine updates and bug fixes
|
||||
|
||||
- Updated core functionalities for better performance and stability in versions 2.0.33 to 2.0.36
|
||||
@@ -5,12 +5,31 @@
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "pushrocks",
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "api.global",
|
||||
"gitrepo": "typedserver",
|
||||
"description": "easy serving of static files",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"npmPackagename": "@api.global/typedserver",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"TypeScript",
|
||||
"static file serving",
|
||||
"live reload",
|
||||
"compression",
|
||||
"typed requests",
|
||||
"HTTP server",
|
||||
"SSL",
|
||||
"cors",
|
||||
"express middleware",
|
||||
"proxy",
|
||||
"sitemap",
|
||||
"feeds",
|
||||
"robots.txt",
|
||||
"compression (gzip, deflate, brotli)"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis 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. \n\n**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.\n\n### Trademarks\n\nThis 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.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy 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.\n"
|
||||
}
|
||||
}
|
||||
117
package.json
117
package.json
@@ -1,31 +1,52 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "3.0.22",
|
||||
"description": "easy serving of static files",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"version": "7.8.15",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./infohtml": "./dist_ts/infohtml/index.js",
|
||||
"./backend": "./dist_ts/index.js",
|
||||
"./edgeworker": "./dist_ts_edgeworker/index.js",
|
||||
"./web_inject": "./dist_ts_web_inject/index.js",
|
||||
"./web_serviceworker": "./dist_ts_web_serviceworker/index.js",
|
||||
"./web_serviceworker_client": "./dist_ts_web_serviceworker_client/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npm run build && tstest test/",
|
||||
"build": "tsbuild --web --allowimplicitany && tsbundle --from ./ts_web/index.ts --to ./dist_ts_web/bundle.js",
|
||||
"buildDocs": "tsdoc"
|
||||
"test": "npm run build && tstest test/ --verbose --logfile --timeout 60",
|
||||
"build": "tsbuild tsfolders --web --allowimplicitany && npm run bundle",
|
||||
"bundle": "tsbundle --from ./ts_web_inject/index.ts --to ./dist_ts_web_inject/bundle.js && tsbundle --from ./ts_web_serviceworker/index.ts --to ./dist_ts_web_serviceworker/serviceworker.bundle.js && tsbundle --from ./ts_swdash/index.ts --to ./dist_ts_swdash/bundle.js",
|
||||
"interfaces": "tsbuild interfaces --web --allowimplicitany --skiplibcheck",
|
||||
"docs": "tsdoc aidoc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pushrocks/easyserve.git"
|
||||
"url": "https://code.foss.global/api.global/typedserver.git"
|
||||
},
|
||||
"keywords": [
|
||||
"serve",
|
||||
"browser-sync"
|
||||
"TypeScript",
|
||||
"static file serving",
|
||||
"live reload",
|
||||
"compression",
|
||||
"typed requests",
|
||||
"HTTP server",
|
||||
"SSL",
|
||||
"cors",
|
||||
"proxy",
|
||||
"sitemap",
|
||||
"feeds",
|
||||
"robots.txt",
|
||||
"compression (gzip, deflate, brotli)"
|
||||
],
|
||||
"author": "Lossless GmbH <office@lossless.com> (https://lossless.com)",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pushrocks/easyserve/issues"
|
||||
"url": "https://code.foss.global/api.global/typedserver/issues"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_swdash/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
@@ -35,49 +56,53 @@
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
"homepage": "https://github.com/pushrocks/easyserve",
|
||||
"homepage": "https://code.foss.global/api.global/typedserver",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.4",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.2",
|
||||
"@api.global/typedsocket": "^3.0.0",
|
||||
"@push.rocks/lik": "^6.0.12",
|
||||
"@push.rocks/smartchok": "^1.0.32",
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@cloudflare/workers-types": "^4.20251202.0",
|
||||
"@design.estate/dees-comms": "^1.0.30",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartenv": "^5.0.12",
|
||||
"@push.rocks/smartfeed": "^1.0.11",
|
||||
"@push.rocks/smartfile": "^11.0.4",
|
||||
"@push.rocks/smartjson": "^5.0.10",
|
||||
"@push.rocks/smartlog": "^3.0.3",
|
||||
"@push.rocks/smartlog-destination-devtools": "^1.0.10",
|
||||
"@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",
|
||||
"@push.rocks/smartmime": "^1.0.5",
|
||||
"@push.rocks/smartmatch": "^2.0.0",
|
||||
"@push.rocks/smartmime": "^2.0.4",
|
||||
"@push.rocks/smartntml": "^2.0.8",
|
||||
"@push.rocks/smartopen": "^2.0.0",
|
||||
"@push.rocks/smartpath": "^5.0.11",
|
||||
"@push.rocks/smartpromise": "^4.0.2",
|
||||
"@push.rocks/smartrequest": "^2.0.21",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartsitemap": "^2.0.3",
|
||||
"@push.rocks/smartstream": "^3.0.30",
|
||||
"@push.rocks/smarttime": "^4.0.6",
|
||||
"@push.rocks/webstore": "^2.0.13",
|
||||
"@tsclass/tsclass": "^4.0.52",
|
||||
"@types/express": "^4.17.21",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-force-ssl": "^0.3.2",
|
||||
"lit": "^3.1.2"
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartserve": "^1.1.2",
|
||||
"@push.rocks/smartsitemap": "^2.0.4",
|
||||
"@push.rocks/smartstream": "^3.2.5",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartwatch": "^5.0.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/webrequest": "^4.0.1",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lit": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.72",
|
||||
"@git.zone/tsbundle": "^2.0.15",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.86",
|
||||
"@push.rocks/tapbundle": "^5.0.15",
|
||||
"@types/node": "^20.11.19"
|
||||
"@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": [
|
||||
"last 1 chrome versions"
|
||||
]
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
}
|
||||
|
||||
12938
pnpm-lock.yaml
generated
12938
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
41
readme.hints.md
Normal file
41
readme.hints.md
Normal file
@@ -0,0 +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
|
||||
301
readme.md
301
readme.md
@@ -1,49 +1,278 @@
|
||||
# @pushrocks/typedserver
|
||||
easy serving of static files
|
||||
# @api.global/typedserver
|
||||
|
||||
## Availabililty and Links
|
||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@apiglobal/typedserver)
|
||||
* [gitlab.com (source)](https://gitlab.com/pushrocks/typedserver)
|
||||
* [github.com (source mirror)](https://github.com/pushrocks/typedserver)
|
||||
* [docs (typedoc)](https://pushrocks.gitlab.io/typedserver/)
|
||||
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.
|
||||
|
||||
## Status for master
|
||||
## Issue Reporting and Security
|
||||
|
||||
Status Category | Status Badge
|
||||
-- | --
|
||||
GitLab Pipelines | [](https://lossless.cloud)
|
||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
||||
npm | [](https://lossless.cloud)
|
||||
Snyk | [](https://lossless.cloud)
|
||||
TypeScript Support | [](https://lossless.cloud)
|
||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
||||
Code Style | [](https://lossless.cloud)
|
||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
||||
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.
|
||||
|
||||
## Usage
|
||||
## ✨ Features
|
||||
|
||||
Use TypeScript for best in class instellisense.
|
||||
- 🔒 **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
|
||||
|
||||
```javascript
|
||||
import { TypedServer } from '@apiglobal/typedserver';
|
||||
## 📦 Installation
|
||||
|
||||
let myTypedserver = new TypedServer('/some/path/to/webroot', 8080);
|
||||
myTypedserver.start().then(() => {
|
||||
// this is executed when server is running guaranteed
|
||||
myTypedserver.stop(); // .stop() will work even if not waiting for server to be fully started
|
||||
});
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm add @api.global/typedserver
|
||||
|
||||
myTypedserver.reload(); // reloads all connected browsers of this instance
|
||||
# Using npm
|
||||
npm install @api.global/typedserver
|
||||
```
|
||||
|
||||
## Contribution
|
||||
## 🚀 Quick Start
|
||||
|
||||
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
|
||||
### Basic Static File Server
|
||||
|
||||
For further information read the linked docs at the top of this readme.
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
## Legal
|
||||
> MIT licensed | **©** [Task Venture Capital GmbH](https://task.vc)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
const server = new TypedServer({
|
||||
serveDir: './public',
|
||||
cors: true,
|
||||
watch: true, // Enable file watching
|
||||
injectReload: true, // Inject live reload script
|
||||
});
|
||||
|
||||
await server.start();
|
||||
console.log('Server running on port 3000');
|
||||
```
|
||||
|
||||
### Server with All Options
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
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: { name: string; email: string };
|
||||
}
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Create a typed router
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
// Add a typed handler
|
||||
typedRouter.addTypedHandler<IGetUser>(
|
||||
new typedrequest.TypedHandler('getUser', async (data) => {
|
||||
// Your logic here
|
||||
return { name: 'John Doe', email: 'john@example.com' };
|
||||
})
|
||||
);
|
||||
|
||||
// Attach the router to the server
|
||||
server.server.addRoute('/api', new servertools.HandlerTypedRouter(typedRouter));
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### WebSocket Communication with TypedSocket
|
||||
|
||||
```typescript
|
||||
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,
|
||||
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),
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Service Worker Client
|
||||
|
||||
```typescript
|
||||
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
|
||||
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!');
|
||||
});
|
||||
```
|
||||
|
||||
## 📋 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 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 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
|
||||
import { TypedServer } from '../ts/index.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// tslint:disable-next-line:no-implicit-dependencies
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
// helper dependencies
|
||||
// tslint:disable-next-line:no-implicit-dependencies
|
||||
@@ -48,7 +48,7 @@ tap.test('should create a valid Server', async () => {
|
||||
|
||||
tap.test('should create a valid Route', async () => {
|
||||
testRoute = testServer.addRoute('/someroute');
|
||||
testRoute2 = testServer.addRoute('/someroute/*');
|
||||
testRoute2 = testServer.addRoute('/someroute/*splat');
|
||||
expect(testRoute).toBeInstanceOf(typedserver.servertools.Route);
|
||||
});
|
||||
|
||||
@@ -95,15 +95,18 @@ tap.test('should start the server allright', async () => {
|
||||
|
||||
// see if a demo request holds up
|
||||
tap.test('should issue a request', async (tools) => {
|
||||
const response = await smartrequest.postJson('http://127.0.0.1:3000/someroute', {
|
||||
headers: {
|
||||
const smartRequestInstance = smartrequest.SmartRequest.create();
|
||||
const response = await smartRequestInstance
|
||||
.url('http://127.0.0.1:3000/someroute')
|
||||
.headers({
|
||||
'X-Forwarded-Proto': 'https',
|
||||
},
|
||||
requestBody: {
|
||||
})
|
||||
.json({
|
||||
someprop: 'hi',
|
||||
},
|
||||
});
|
||||
console.log(response.body);
|
||||
})
|
||||
.post();
|
||||
const responseBody = await response.text();
|
||||
console.log(responseBody);
|
||||
});
|
||||
|
||||
tap.test('should get a file from disk', async () => {
|
||||
@@ -119,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());
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '3.0.22',
|
||||
description: 'easy serving of static files'
|
||||
version: '7.8.11',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
749
ts/classes.typedserver.ts
Normal file
749
ts/classes.typedserver.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import * as interfaces from '../dist_ts_interfaces/index.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 {
|
||||
/**
|
||||
* serve a particular directory
|
||||
*/
|
||||
serveDir?: string;
|
||||
|
||||
/**
|
||||
* inject a reload script that takes care of live reloading
|
||||
*/
|
||||
injectReload?: boolean;
|
||||
|
||||
/**
|
||||
* watch the serve directory?
|
||||
*/
|
||||
watch?: boolean;
|
||||
|
||||
cors: boolean;
|
||||
|
||||
/**
|
||||
* a default answer given in case there is no other handler.
|
||||
*/
|
||||
defaultAnswer?: () => Promise<string>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
port?: number | string;
|
||||
publicKey?: string;
|
||||
privateKey?: string;
|
||||
sitemap?: boolean;
|
||||
feed?: boolean;
|
||||
robots?: boolean;
|
||||
domain?: string;
|
||||
|
||||
/**
|
||||
* convey information about the app being served
|
||||
*/
|
||||
appVersion?: string;
|
||||
feedMetadata?: plugins.smartfeed.IFeedOptions;
|
||||
articleGetterFunction?: () => Promise<plugins.tsclass.content.IArticle[]>;
|
||||
blockWaybackMachine?: boolean;
|
||||
|
||||
/**
|
||||
* SPA fallback - serve index.html for non-file routes (e.g., /login, /dashboard)
|
||||
* Useful for single-page applications with client-side routing
|
||||
*/
|
||||
spaFallback?: boolean;
|
||||
}
|
||||
|
||||
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 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,
|
||||
injectReload: false,
|
||||
serveDir: null,
|
||||
watch: false,
|
||||
cors: true,
|
||||
};
|
||||
this.options = {
|
||||
...standardOptions,
|
||||
...optionsArg,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '([^/]+)';
|
||||
})
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* inits and starts the server
|
||||
*/
|
||||
public async start() {
|
||||
// Validate essential configuration before starting
|
||||
if (this.options.injectReload && !this.options.serveDir) {
|
||||
throw new Error(
|
||||
'You set to inject the reload script without a serve dir. This is not supported at the moment.'
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
await this.smartServe.start();
|
||||
console.log(`TypedServer listening on port ${port}`);
|
||||
|
||||
// Setup TypedSocket using SmartServe integration
|
||||
try {
|
||||
this.typedsocket = plugins.typedsocket.TypedSocket.fromSmartServe(
|
||||
this.smartServe,
|
||||
this.typedrouter
|
||||
);
|
||||
|
||||
// Setup typedrouter handlers
|
||||
this.typedrouter.addTypedHandler<interfaces.IReq_GetLatestServerChangeTime>(
|
||||
new plugins.typedrequest.TypedHandler('getLatestServerChangeTime', async () => {
|
||||
return {
|
||||
time: this.lastReload,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Speedtest handler for service worker dashboard
|
||||
// Client calls this in a loop for the test duration to get accurate time-based measurements
|
||||
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Speedtest>(
|
||||
new plugins.typedrequest.TypedHandler('serviceworker_speedtest', async (reqArg) => {
|
||||
const chunkSizeKB = reqArg.chunkSizeKB || 64;
|
||||
const sizeBytes = chunkSizeKB * 1024;
|
||||
let payload: string | undefined;
|
||||
let bytesTransferred = 0;
|
||||
|
||||
switch (reqArg.type) {
|
||||
case 'download_chunk':
|
||||
// Generate chunk data for download test
|
||||
payload = 'x'.repeat(sizeBytes);
|
||||
bytesTransferred = sizeBytes;
|
||||
break;
|
||||
case 'upload_chunk':
|
||||
// Acknowledge received upload data
|
||||
bytesTransferred = reqArg.payload?.length || 0;
|
||||
break;
|
||||
case 'latency':
|
||||
// Simple ping - minimal data
|
||||
bytesTransferred = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return { bytesTransferred, timestamp: Date.now(), payload };
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize TypedSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers to a response
|
||||
*/
|
||||
private addCorsHeaders(response: Response): Response {
|
||||
if (!this.options.cors) return response;
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Handle OPTIONS preflight for CORS
|
||||
if (method === 'OPTIONS' && this.options.cors) {
|
||||
return this.addCorsHeaders(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
// Process the request and wrap response with CORS headers
|
||||
const response = await this.handleRequestInternal(request, url, path, method);
|
||||
return this.addCorsHeaders(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal request handler - routes to appropriate sub-handlers
|
||||
*/
|
||||
private async handleRequestInternal(
|
||||
request: Request,
|
||||
url: URL,
|
||||
path: string,
|
||||
method: THttpMethod
|
||||
): Promise<Response> {
|
||||
// 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' },
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback - serve index.html for non-file routes
|
||||
if (this.options.spaFallback && this.options.serveDir && method === 'GET' && !path.includes('.')) {
|
||||
try {
|
||||
const indexPath = plugins.path.join(this.options.serveDir, 'index.html');
|
||||
let html = await plugins.fsInstance.file(indexPath).encoding('utf8').read() as string;
|
||||
|
||||
// Inject reload script if enabled
|
||||
if (this.options.injectReload && html.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 -->
|
||||
`;
|
||||
html = html.replace('<head>', injection);
|
||||
}
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
appHash: this.serveHash,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fall through to 404
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
public async reload() {
|
||||
this.lastReload = Date.now();
|
||||
if (!this.typedsocket) {
|
||||
console.warn('TypedSocket not initialized, skipping client notifications');
|
||||
return;
|
||||
}
|
||||
|
||||
// Push cache invalidation to service workers first
|
||||
try {
|
||||
const swConnections = await this.typedsocket.findAllTargetConnectionsByTag('serviceworker');
|
||||
for (const connection of swConnections) {
|
||||
const pushCacheInvalidate =
|
||||
this.typedsocket.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
|
||||
'serviceworker_cacheInvalidate',
|
||||
connection
|
||||
);
|
||||
pushCacheInvalidate.fire({
|
||||
reason: 'File change detected',
|
||||
timestamp: this.lastReload,
|
||||
}).catch(err => {
|
||||
console.warn('Failed to push cache invalidation to service worker:', err);
|
||||
});
|
||||
}
|
||||
if (swConnections.length > 0) {
|
||||
console.log(`Pushed cache invalidation to ${swConnections.length} service worker(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to notify service workers:', error);
|
||||
}
|
||||
|
||||
// Notify frontend clients
|
||||
try {
|
||||
const connections = await this.typedsocket.findAllTargetConnectionsByTag(
|
||||
'typedserver_frontend'
|
||||
);
|
||||
for (const connection of connections) {
|
||||
const pushTime =
|
||||
this.typedsocket.createTypedRequest<interfaces.IReq_PushLatestServerChangeTime>(
|
||||
'pushLatestServerChangeTime',
|
||||
connection
|
||||
);
|
||||
pushTime.fire({
|
||||
time: this.lastReload,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to notify clients about reload:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the server and cleans up resources
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.ended = true;
|
||||
|
||||
const stopWithErrorHandling = async (
|
||||
stopFn: () => Promise<unknown>,
|
||||
componentName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await stopFn();
|
||||
} catch (err) {
|
||||
console.error(`Error stopping ${componentName}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
// Stop SmartServe
|
||||
if (this.smartServe) {
|
||||
tasks.push(stopWithErrorHandling(() => this.smartServe.stop(), 'SmartServe'));
|
||||
}
|
||||
|
||||
// Stop TypedSocket (in SmartServe mode, this is a no-op but good for cleanup)
|
||||
if (this.typedsocket) {
|
||||
tasks.push(stopWithErrorHandling(() => this.typedsocket.stop(), 'TypedSocket'));
|
||||
}
|
||||
|
||||
// Stop file watcher
|
||||
if (this.smartwatchInstance) {
|
||||
tasks.push(stopWithErrorHandling(() => this.smartwatchInstance.stop(), 'file watcher'));
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a hash of the served directory for cache busting
|
||||
*/
|
||||
public async createServeDirHash() {
|
||||
try {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
227
ts/controllers/controller.builtin.ts
Normal file
227
ts/controllers/controller.builtin.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.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' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash')
|
||||
async getSwDash(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
|
||||
// Import shared HTML from interfaces
|
||||
const { SW_DASH_HTML } = await import('../../dist_ts_interfaces/serviceworker.js');
|
||||
return new Response(SW_DASH_HTML, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
|
||||
// SW-dash data routes - return empty/unavailable when SW isn't active
|
||||
@plugins.smartserve.Get('/sw-dash/metrics')
|
||||
async getSwDashMetrics(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', data: null }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/resources')
|
||||
async getSwDashResources(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', resources: [], domains: [], contentTypes: [], resourceCount: 0 }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/events')
|
||||
async getSwDashEvents(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', events: [], total: 0 }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/events/count')
|
||||
async getSwDashEventsCount(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', count: 0 }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/cumulative-metrics')
|
||||
async getSwDashCumulativeMetrics(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', data: null }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/speedtest')
|
||||
async getSwDashSpeedtest(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active - speedtest unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/bundle.js')
|
||||
async getSwDashBundle(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
|
||||
try {
|
||||
const bundleContent = (await plugins.fsInstance
|
||||
.file(paths.swdashBundlePath)
|
||||
.encoding('utf8')
|
||||
.read()) as string;
|
||||
|
||||
return new Response(bundleContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to serve sw-dash bundle:', error);
|
||||
return new Response('SW-Dash bundle not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/serviceworker.bundle.js')
|
||||
async getServiceWorkerBundle(): Promise<Response> {
|
||||
try {
|
||||
const bundleContent = (await plugins.fsInstance
|
||||
.file(paths.serviceworkerBundlePath)
|
||||
.encoding('utf8')
|
||||
.read()) as string;
|
||||
|
||||
return new Response(bundleContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to serve serviceworker bundle:', error);
|
||||
return new Response('ServiceWorker bundle not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
}
|
||||
53
ts/controllers/controller.devtools.ts
Normal file
53
ts/controllers/controller.devtools.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
45
ts/controllers/controller.typedrequest.ts
Normal file
45
ts/controllers/controller.typedrequest.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartserve.Head('')
|
||||
async handleTypedRequestHead(): Promise<Response> {
|
||||
// HEAD request for online checking from service worker
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
3
ts/controllers/index.ts
Normal file
3
ts/controllers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './controller.devtools.js';
|
||||
export * from './controller.typedrequest.js';
|
||||
export * from './controller.builtin.js';
|
||||
11
ts/index.ts
11
ts/index.ts
@@ -1,10 +1,7 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import * as servertools from './servertools/index.js';
|
||||
export * from './classes.typedserver.js';
|
||||
|
||||
export { servertools };
|
||||
|
||||
export * from './typedserver.classes.typedserver.js';
|
||||
// Type helpers
|
||||
export type Request = plugins.express.Request;
|
||||
export type Response = plugins.express.Response;
|
||||
// lets export utilityservers
|
||||
import * as utilityservers from './utilityservers/index.js';
|
||||
export { utilityservers };
|
||||
|
||||
8
ts/infohtml/00_commitinfo_data.ts
Normal file
8
ts/infohtml/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@losslessone_private/lole-infohtml',
|
||||
version: '1.0.39',
|
||||
description: 'html for displaying infos at lossless'
|
||||
}
|
||||
44
ts/infohtml/index.ts
Normal file
44
ts/infohtml/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as plugins from './infohtml.plugins.js';
|
||||
|
||||
import { simpleInfo } from './template.js';
|
||||
|
||||
export interface IHtmlInfoOptions {
|
||||
text: string;
|
||||
heading?: string;
|
||||
title?: string;
|
||||
sentryMessage?: string;
|
||||
sentryDsn?: string;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export class InfoHtml {
|
||||
// STATIC
|
||||
public static async fromSimpleText(textArg: string) {
|
||||
const infohtmlInstance = new InfoHtml({
|
||||
text: textArg,
|
||||
heading: null,
|
||||
});
|
||||
await infohtmlInstance.init();
|
||||
return infohtmlInstance;
|
||||
}
|
||||
|
||||
public static async fromOptions(optionsArg: IHtmlInfoOptions) {
|
||||
const infohtmlInstance = new InfoHtml(optionsArg);
|
||||
await infohtmlInstance.init();
|
||||
return infohtmlInstance;
|
||||
}
|
||||
|
||||
// INSTANCE
|
||||
public options: IHtmlInfoOptions;
|
||||
public smartntmlInstance: plugins.smartntml.Smartntml;
|
||||
public htmlString: string;
|
||||
constructor(optionsArg: IHtmlInfoOptions) {
|
||||
this.options = optionsArg;
|
||||
}
|
||||
|
||||
public async init() {
|
||||
this.smartntmlInstance = new plugins.smartntml.Smartntml();
|
||||
this.htmlString = await simpleInfo(this.smartntmlInstance, this.options);
|
||||
return this.htmlString;
|
||||
}
|
||||
}
|
||||
3
ts/infohtml/infohtml.plugins.ts
Normal file
3
ts/infohtml/infohtml.plugins.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as smartntml from '@push.rocks/smartntml';
|
||||
|
||||
export { smartntml };
|
||||
132
ts/infohtml/template.ts
Normal file
132
ts/infohtml/template.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as plugins from './infohtml.plugins.js';
|
||||
import { type IHtmlInfoOptions } from './index.js';
|
||||
|
||||
export const simpleInfo = async (
|
||||
smartntmlInstanceArg: plugins.smartntml.Smartntml,
|
||||
optionsArg: IHtmlInfoOptions
|
||||
) => {
|
||||
const html = plugins.smartntml.deesElement.html;
|
||||
const htmlTemplate = await plugins.smartntml.deesElement.html`
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>${optionsArg.title}</title>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
const redirectUrl = '${optionsArg.redirectTo}';
|
||||
if (redirectUrl) {
|
||||
window.location = redirectUrl;
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0px;
|
||||
background: #000000;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
border: 1px solid #e4002b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 150px;
|
||||
padding-top: 70px;
|
||||
margin: 0px auto 30px auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.content .maintext {
|
||||
margin: 10px;
|
||||
color: #ffffff;
|
||||
background: #333;
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content .maintext h1 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.content .addontext {
|
||||
margin: 10px;
|
||||
color: #ffffff;
|
||||
background: #222;
|
||||
display: block;
|
||||
padding: 10px 15px;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.content .text h1 {
|
||||
margin: 0px;
|
||||
font-weight: 100;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.content .text ul {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.legal {
|
||||
color: #fff;
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
width: 100vw;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height, target-densitydpi=device-dpi"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
${(() => {
|
||||
const returnArray: plugins.smartntml.deesElement.TemplateResult[] = [];
|
||||
if (optionsArg.heading) {
|
||||
returnArray.push(html`
|
||||
<div class="maintext">
|
||||
<h1>${optionsArg.heading}</h1>
|
||||
${optionsArg.text}
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
returnArray.push(html` <div class="maintext">${optionsArg.text}</div> `);
|
||||
}
|
||||
if (optionsArg.redirectTo) {
|
||||
returnArray.push(
|
||||
html`<div class="addontext">
|
||||
We will redirect you to ${optionsArg.redirectTo} in a few seconds.
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
return returnArray;
|
||||
})()}
|
||||
</div>
|
||||
<div class="legal">
|
||||
<a href="https://foss.global">learn more about foss.global</a> / © 2014-${new Date().getFullYear()} Task Venture Capital GmbH
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return smartntmlInstanceArg.renderTemplateResult(htmlTemplate);
|
||||
};
|
||||
15
ts/paths.ts
Normal file
15
ts/paths.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
|
||||
export const injectBundleDir = plugins.path.join(packageDir, './dist_ts_web_inject');
|
||||
export const injectBundlePath = plugins.path.join(injectBundleDir, './bundle.js');
|
||||
|
||||
export const serviceworkerBundleDir = plugins.path.join(packageDir, './dist_ts_web_serviceworker');
|
||||
export const serviceworkerBundlePath = plugins.path.join(serviceworkerBundleDir, './serviceworker.bundle.js');
|
||||
|
||||
export const swdashBundleDir = plugins.path.join(packageDir, './dist_ts_swdash');
|
||||
export const swdashBundlePath = plugins.path.join(swdashBundleDir, './bundle.js');
|
||||
@@ -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,10 @@ export {
|
||||
smartrx,
|
||||
};
|
||||
|
||||
// express
|
||||
import bodyParser from 'body-parser';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
// @ts-ignore
|
||||
import expressForceSsl from 'express-force-ssl';
|
||||
// Create a ready-to-use smartfs instance with Node.js provider
|
||||
export const fsInstance = new smartfs.SmartFs(new smartfs.SmartFsProviderNode());
|
||||
|
||||
export { bodyParser, cors, express, expressForceSsl };
|
||||
// @push.rocks/smartserve
|
||||
import * as smartserve from '@push.rocks/smartserve';
|
||||
|
||||
export { smartserve };
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export type TCompressionMethod = 'gzip' | 'deflate' | 'br' | 'none';
|
||||
export interface ICompressionResult {
|
||||
result: Buffer;
|
||||
compressionMethod: TCompressionMethod;
|
||||
}
|
||||
|
||||
export class Compressor {
|
||||
private _cache: Map<string, Buffer>;
|
||||
private MAX_CACHE_SIZE: number = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
constructor() {
|
||||
this._cache = new Map<string, Buffer>();
|
||||
}
|
||||
|
||||
private _addToCache(key: string, value: Buffer) {
|
||||
this._cache.set(key, value);
|
||||
this._manageCacheSize();
|
||||
}
|
||||
|
||||
private _manageCacheSize() {
|
||||
let currentSize = Array.from(this._cache.values()).reduce((acc, buffer) => acc + buffer.length, 0);
|
||||
|
||||
while (currentSize > this.MAX_CACHE_SIZE) {
|
||||
const firstKey = this._cache.keys().next().value;
|
||||
const firstValue = this._cache.get(firstKey)!;
|
||||
currentSize -= firstValue.length;
|
||||
this._cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async compressContent(
|
||||
content: Buffer,
|
||||
method: 'gzip' | 'deflate' | 'br' | 'none'
|
||||
): Promise<Buffer> {
|
||||
const cacheKey = content.toString('base64') + method;
|
||||
const cachedResult = this._cache.get(cacheKey);
|
||||
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const callback = (err: Error | null, result: Buffer) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
this._addToCache(cacheKey, result);
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
switch (method) {
|
||||
case 'gzip':
|
||||
plugins.zlib.gzip(content, {
|
||||
level: 1,
|
||||
},callback,);
|
||||
break;
|
||||
case 'br':
|
||||
plugins.zlib.brotliCompress(content, {}, callback);
|
||||
break;
|
||||
case 'deflate':
|
||||
plugins.zlib.deflate(content, callback);
|
||||
break;
|
||||
default:
|
||||
this._addToCache(cacheKey, content);
|
||||
resolve(content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public determineCompression(acceptEncoding: string | string[], preferredCompressionMethodsArg: TCompressionMethod[] = []) {
|
||||
// Ensure acceptEncoding is a single string
|
||||
const encodingString = Array.isArray(acceptEncoding)
|
||||
? acceptEncoding.join(', ')
|
||||
: acceptEncoding;
|
||||
|
||||
let compressionMethod: TCompressionMethod = 'none';
|
||||
|
||||
// Prioritize preferred compression methods if provided
|
||||
for (const preferredMethod of preferredCompressionMethodsArg) {
|
||||
if (new RegExp(`\\b${preferredMethod}\\b`).test(encodingString)) {
|
||||
return preferredMethod;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default prioritization if no preferred method matches
|
||||
if (/\bbr\b/.test(encodingString)) {
|
||||
compressionMethod = 'br';
|
||||
} else if (/\bgzip\b/.test(encodingString)) {
|
||||
compressionMethod = 'gzip';
|
||||
} else if (/\bdeflate\b/.test(encodingString)) {
|
||||
compressionMethod = 'deflate';
|
||||
}
|
||||
|
||||
return compressionMethod;
|
||||
}
|
||||
|
||||
public async maybeCompress(requestHeaders: plugins.http.IncomingHttpHeaders, content: Buffer, preferredCompressionMethodsArg?: TCompressionMethod[]): Promise<ICompressionResult> {
|
||||
const acceptEncoding = requestHeaders['accept-encoding'];
|
||||
const compressionMethod = this.determineCompression(acceptEncoding, preferredCompressionMethodsArg);
|
||||
const result = await this.compressContent(content, compressionMethod);
|
||||
return {
|
||||
result,
|
||||
compressionMethod,
|
||||
};
|
||||
}
|
||||
|
||||
public createCompressionStream(method: 'gzip' | 'deflate' | 'br' | 'none') {
|
||||
let compressionStream: any;
|
||||
switch (method) {
|
||||
case 'gzip':
|
||||
compressionStream = plugins.zlib.createGzip();
|
||||
case 'br':
|
||||
compressionStream = plugins.zlib.createBrotliCompress({
|
||||
chunkSize: 16 * 1024,
|
||||
params: {
|
||||
|
||||
},
|
||||
});
|
||||
case 'deflate':
|
||||
compressionStream = plugins.zlib.createDeflate();
|
||||
default:
|
||||
compressionStream = plugins.smartstream.createPassThrough();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Handler } from './classes.handler.js';
|
||||
import { Server } from './classes.server.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export class Feed {
|
||||
public smartexpressRef: Server;
|
||||
public smartfeedInstance = new plugins.smartfeed.Smartfeed();
|
||||
|
||||
public feedHandler = new Handler('GET', async (req, res) => {
|
||||
if (!this.smartexpressRef.options.feedMetadata) {
|
||||
res.status(500);
|
||||
res.write('feed metadata is missing');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!this.smartexpressRef.options.articleGetterFunction) {
|
||||
res.status(500);
|
||||
res.write('no article getter function defined.');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const xmlString = await this.smartfeedInstance.createFeedFromArticleArray(
|
||||
this.smartexpressRef.options.feedMetadata,
|
||||
await this.smartexpressRef.options.articleGetterFunction()
|
||||
);
|
||||
res.type('.xml');
|
||||
res.write(xmlString);
|
||||
res.end();
|
||||
});
|
||||
|
||||
constructor(smartexpressRefArg: Server) {
|
||||
this.smartexpressRef = smartexpressRefArg;
|
||||
this.smartexpressRef.addRouteBefore('/feed', this.feedHandler);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
export interface IHandlerFunction {
|
||||
(requestArg: Request, responseArg: Response): void;
|
||||
}
|
||||
|
||||
export type THttpMethods = 'ALL' | 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
export class Handler {
|
||||
httpMethod: THttpMethods;
|
||||
handlerFunction: IHandlerFunction;
|
||||
constructor(httpMethodArg: THttpMethods, handlerArg: IHandlerFunction) {
|
||||
this.httpMethod = httpMethodArg;
|
||||
this.handlerFunction = handlerArg;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
|
||||
export class HandlerProxy extends Handler {
|
||||
/**
|
||||
* The constuctor of HandlerProxy
|
||||
* @param remoteMountPointArg
|
||||
*/
|
||||
constructor(
|
||||
remoteMountPointArg: string,
|
||||
optionsArg?: {
|
||||
responseModifier?: interfaces.TResponseModifier;
|
||||
headers?: { [key: string]: string };
|
||||
}
|
||||
) {
|
||||
super('ALL', async (req, res) => {
|
||||
const relativeRequestPath = req.path.slice(req.route.path.length - 1);
|
||||
const proxyRequestUrl = remoteMountPointArg + relativeRequestPath;
|
||||
console.log(`proxy ${req.path} to ${proxyRequestUrl}`);
|
||||
let proxiedResponse: plugins.smartrequest.IExtendedIncomingMessage;
|
||||
try {
|
||||
proxiedResponse = await plugins.smartrequest.request(proxyRequestUrl, {
|
||||
method: req.method,
|
||||
autoJsonParse: false,
|
||||
});
|
||||
} catch {
|
||||
res.end('failed to fullfill request');
|
||||
return;
|
||||
}
|
||||
for (const header of Object.keys(proxiedResponse.headers)) {
|
||||
res.set(header, proxiedResponse.headers[header] as string);
|
||||
}
|
||||
|
||||
// set additional headers
|
||||
if (optionsArg && optionsArg.headers) {
|
||||
for (const key of Object.keys(optionsArg.headers)) {
|
||||
res.set(key, optionsArg.headers[key]);
|
||||
}
|
||||
}
|
||||
|
||||
let responseToSend: Buffer = proxiedResponse.body;
|
||||
if (typeof responseToSend !== 'string') {
|
||||
console.log(proxyRequestUrl);
|
||||
console.log(responseToSend);
|
||||
throw new Error(`Proxied response is not a string, but ${typeof responseToSend}`);
|
||||
}
|
||||
|
||||
if (optionsArg && optionsArg.responseModifier) {
|
||||
const modifiedResponse = await optionsArg.responseModifier({
|
||||
headers: res.getHeaders(),
|
||||
path: req.path,
|
||||
responseContent: responseToSend,
|
||||
});
|
||||
|
||||
// headers
|
||||
for (const key of Object.keys(res.getHeaders())) {
|
||||
if (!modifiedResponse.headers[key]) {
|
||||
res.removeHeader(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(modifiedResponse.headers)) {
|
||||
res.setHeader(key, modifiedResponse.headers[key]);
|
||||
}
|
||||
|
||||
// responseContent
|
||||
responseToSend = modifiedResponse.responseContent;
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
res.write(responseToSend);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
|
||||
import { Handler } from './classes.handler.js';
|
||||
import { Compressor, type TCompressionMethod, type ICompressionResult } from './classes.compressor.js';
|
||||
|
||||
export class HandlerStatic extends Handler {
|
||||
public compressor = new Compressor();
|
||||
constructor(
|
||||
pathArg: string,
|
||||
optionsArg?: {
|
||||
requestModifier?: interfaces.TRequestModifier;
|
||||
responseModifier?: interfaces.TResponseModifier;
|
||||
headers?: { [key: string]: string };
|
||||
serveIndexHtmlDefault?: boolean;
|
||||
enableCompression?: boolean;
|
||||
preferredCompressionMethod?: TCompressionMethod;
|
||||
}
|
||||
) {
|
||||
super('GET', async (req, res) => {
|
||||
let requestPath = req.path;
|
||||
let requestHeaders = req.headers;
|
||||
let requestBody = req.body;
|
||||
let travelData: unknown;
|
||||
if (optionsArg && optionsArg.requestModifier) {
|
||||
const modifiedRequest = await optionsArg.requestModifier({
|
||||
headers: requestHeaders,
|
||||
path: requestPath,
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
requestHeaders = modifiedRequest.headers;
|
||||
requestPath = modifiedRequest.path;
|
||||
requestBody = modifiedRequest.body;
|
||||
travelData = modifiedRequest.travelData;
|
||||
}
|
||||
|
||||
// lets compute some paths
|
||||
let filePath: string = requestPath.slice(req.route.path.length - 1); // lets slice of the root
|
||||
if (requestPath === '') {
|
||||
console.log('replaced root with index.html');
|
||||
filePath = 'index.html';
|
||||
}
|
||||
console.log(filePath);
|
||||
const joinedPath = plugins.path.join(pathArg, filePath);
|
||||
const defaultPath = plugins.path.join(pathArg, 'index.html');
|
||||
let parsedPath = plugins.path.parse(joinedPath);
|
||||
let usedPath: string;
|
||||
|
||||
// important security checks
|
||||
if (
|
||||
requestPath.includes('..') || // don't allow going up the filePath
|
||||
requestPath.includes('~') || // don't allow referencing of home directory
|
||||
!joinedPath.startsWith(pathArg) // make sure the joined path is within the directory
|
||||
) {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// set additional headers
|
||||
if (optionsArg && optionsArg.headers) {
|
||||
for (const key of Object.keys(optionsArg.headers)) {
|
||||
res.set(key, optionsArg.headers[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// lets actually care about serving, if security checks pass
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
fileBuffer = plugins.smartfile.fs.toBufferSync(joinedPath);
|
||||
usedPath = joinedPath;
|
||||
} catch (err) {
|
||||
// try serving index.html instead
|
||||
console.log(`could not resolve ${joinedPath}`);
|
||||
if (optionsArg && optionsArg.serveIndexHtmlDefault) {
|
||||
console.log(`serving default path ${defaultPath} instead of ${joinedPath}`);
|
||||
try {
|
||||
parsedPath = plugins.path.parse(defaultPath);
|
||||
fileBuffer = plugins.smartfile.fs.toBufferSync(defaultPath);
|
||||
usedPath = defaultPath;
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
res.end('File not found!');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end('File not found!');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.type(parsedPath.ext);
|
||||
|
||||
const headers = res.getHeaders();
|
||||
|
||||
// lets modify the response at last
|
||||
if (optionsArg && optionsArg.responseModifier) {
|
||||
const modifiedResponse = await optionsArg.responseModifier({
|
||||
headers: res.getHeaders(),
|
||||
path: usedPath,
|
||||
responseContent: fileBuffer,
|
||||
travelData,
|
||||
});
|
||||
|
||||
// headers
|
||||
for (const key of Object.keys(res.getHeaders())) {
|
||||
if (!modifiedResponse.headers[key]) {
|
||||
res.removeHeader(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(modifiedResponse.headers)) {
|
||||
res.setHeader(key, modifiedResponse.headers[key]);
|
||||
}
|
||||
|
||||
// responseContent
|
||||
fileBuffer = modifiedResponse.responseContent;
|
||||
}
|
||||
|
||||
// lets finally deal with compression
|
||||
let compressionResult: ICompressionResult;
|
||||
|
||||
if (optionsArg && optionsArg.enableCompression) {
|
||||
compressionResult = await this.compressor.maybeCompress(requestHeaders, fileBuffer, [optionsArg.preferredCompressionMethod]);
|
||||
} else {
|
||||
compressionResult = {
|
||||
compressionMethod: 'none',
|
||||
result: fileBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
if (compressionResult?.compressionMethod) {
|
||||
res.header('Content-Encoding', compressionResult.compressionMethod);
|
||||
}
|
||||
res.write(compressionResult.result);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
|
||||
export class HandlerTypedRouter extends Handler {
|
||||
/**
|
||||
* The constuctor of HandlerProxy
|
||||
* @param remoteMountPointArg
|
||||
*/
|
||||
constructor(typedrouter: plugins.typedrequest.TypedRouter) {
|
||||
super('POST', async (req, res) => {
|
||||
const response = await typedrouter.routeAndAddResponse(req.body);
|
||||
res.json(plugins.smartjson.stringify(response));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
import { Server } from './classes.server.js';
|
||||
|
||||
import { type IRoute as IExpressRoute } from 'express';
|
||||
|
||||
export class Route {
|
||||
public routeString: string;
|
||||
public handlerObjectMap = new plugins.lik.ObjectMap<Handler>();
|
||||
public expressMiddlewareObjectMap = new plugins.lik.ObjectMap<any>();
|
||||
public expressRoute: IExpressRoute; // will be set to server route on server start
|
||||
constructor(ServerArg: Server, routeStringArg: string) {
|
||||
this.routeString = routeStringArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* add a handler to do something with requests
|
||||
* @param handlerArg
|
||||
*/
|
||||
public addHandler(handlerArg: Handler) {
|
||||
this.handlerObjectMap.add(handlerArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* add a express middleware
|
||||
* @param middlewareArg
|
||||
*/
|
||||
public addExpressMiddleWare(middlewareArg: plugins.express.Application) {
|
||||
this.expressMiddlewareObjectMap.add(middlewareArg);
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import { Route } from './classes.route.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
import { HandlerTypedRouter } from './classes.handlertypedrouter.js';
|
||||
|
||||
// export types
|
||||
import { setupRobots } from './tools.robots.js';
|
||||
import { setupManifest } from './tools.manifest.js';
|
||||
import { Sitemap } from './classes.sitemap.js';
|
||||
import { Feed } from './classes.feed.js';
|
||||
import { type IServerOptions } from '../typedserver.classes.typedserver.js';
|
||||
export type TServerStatus = 'initiated' | 'running' | 'stopped';
|
||||
|
||||
/**
|
||||
* can be used to spawn a server to answer http/https calls
|
||||
* for constructor options see [[IServerOptions]]
|
||||
*/
|
||||
export class Server {
|
||||
public httpServer: plugins.http.Server | plugins.https.Server;
|
||||
public expressAppInstance: plugins.express.Application;
|
||||
public routeObjectMap = new Array<Route>();
|
||||
public options: IServerOptions;
|
||||
public serverStatus: TServerStatus = 'initiated';
|
||||
|
||||
public feed: Feed;
|
||||
public sitemap: Sitemap;
|
||||
|
||||
public executeAfterStartFunctions: (() => Promise<void>)[] = [];
|
||||
|
||||
// do stuff when server is ready
|
||||
private startedDeferred = plugins.smartpromise.defer();
|
||||
// tslint:disable-next-line:member-ordering
|
||||
public startedPromise = this.startedDeferred.promise;
|
||||
|
||||
private socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
||||
|
||||
constructor(optionsArg: IServerOptions) {
|
||||
this.options = {
|
||||
...optionsArg,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* allows updating of server options
|
||||
* @param optionsArg
|
||||
*/
|
||||
public updateServerOptions(optionsArg: IServerOptions) {
|
||||
Object.assign(this.options, optionsArg);
|
||||
}
|
||||
|
||||
public addTypedRequest(typedrouter: plugins.typedrequest.TypedRouter) {
|
||||
this.addRoute('/typedrequest', new HandlerTypedRouter(typedrouter));
|
||||
}
|
||||
|
||||
public addTypedSocket(typedrouter: plugins.typedrequest.TypedRouter): void {
|
||||
this.executeAfterStartFunctions.push(async () => {
|
||||
plugins.typedsocket.TypedSocket.createServer(typedrouter, this);
|
||||
});
|
||||
}
|
||||
|
||||
public addRoute(routeStringArg: string, handlerArg?: Handler) {
|
||||
const route = new Route(this, routeStringArg);
|
||||
if (handlerArg) {
|
||||
route.addHandler(handlerArg);
|
||||
}
|
||||
this.routeObjectMap.push(route);
|
||||
return route;
|
||||
}
|
||||
|
||||
public addRouteBefore(routeStringArg: string, handlerArg?: Handler) {
|
||||
const route = new Route(this, routeStringArg);
|
||||
if (handlerArg) {
|
||||
route.addHandler(handlerArg);
|
||||
}
|
||||
this.routeObjectMap.unshift(route);
|
||||
return route;
|
||||
}
|
||||
|
||||
public async start(portArg: number | string = this.options.port, doListen = true) {
|
||||
const done = plugins.smartpromise.defer();
|
||||
|
||||
if (typeof portArg === 'string') {
|
||||
portArg = parseInt(portArg);
|
||||
}
|
||||
|
||||
this.expressAppInstance = plugins.express();
|
||||
if (!this.httpServer && (!this.options.privateKey || !this.options.publicKey)) {
|
||||
console.log('Got no SSL certificates. Please ensure encryption using e.g. a reverse proxy');
|
||||
this.httpServer = plugins.http.createServer(this.expressAppInstance);
|
||||
} else if (!this.httpServer) {
|
||||
console.log('Got SSL certificate. Using it for the http server');
|
||||
this.httpServer = plugins.https.createServer(
|
||||
{
|
||||
key: this.options.privateKey,
|
||||
cert: this.options.publicKey,
|
||||
},
|
||||
this.expressAppInstance
|
||||
);
|
||||
} else {
|
||||
console.log('Using externally supplied http server');
|
||||
}
|
||||
this.httpServer.keepAliveTimeout = 600 * 1000;
|
||||
this.httpServer.headersTimeout = 20 * 1000;
|
||||
|
||||
// general request handlling
|
||||
this.expressAppInstance.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// forceSsl
|
||||
if (this.options.forceSsl) {
|
||||
this.expressAppInstance.set('forceSSLOptions', {
|
||||
enable301Redirects: true,
|
||||
trustXFPHeader: true,
|
||||
sslRequiredMessage: 'SSL Required.',
|
||||
});
|
||||
this.expressAppInstance.use(plugins.expressForceSsl);
|
||||
}
|
||||
|
||||
// cors
|
||||
if (this.options.cors) {
|
||||
const cors = plugins.cors({
|
||||
allowedHeaders: '*',
|
||||
methods: '*',
|
||||
origin: '*',
|
||||
});
|
||||
|
||||
this.expressAppInstance.use(cors);
|
||||
this.expressAppInstance.options('/*', cors);
|
||||
}
|
||||
|
||||
this.expressAppInstance.use((req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('SERVEZONE_ROUTE', 'LOSSLESS_ORIGIN_CONTAINER');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Expires', new Date(Date.now()).toUTCString());
|
||||
next();
|
||||
});
|
||||
|
||||
// body parsing
|
||||
this.expressAppInstance.use(plugins.bodyParser.json({ limit: 100000000 })); // for parsing application/json
|
||||
this.expressAppInstance.use(plugins.bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
|
||||
|
||||
// robots
|
||||
if (this.options.robots && this.options.domain) {
|
||||
await setupRobots(this, this.options.domain);
|
||||
}
|
||||
|
||||
// manifest.json
|
||||
if (this.options.manifest) {
|
||||
await setupManifest(this.expressAppInstance, this.options.manifest);
|
||||
}
|
||||
|
||||
// sitemaps
|
||||
if (this.options.sitemap) {
|
||||
this.sitemap = new Sitemap(this);
|
||||
}
|
||||
|
||||
if (this.options.feed) {
|
||||
// feed
|
||||
this.feed = new Feed(this);
|
||||
}
|
||||
|
||||
// appVersion
|
||||
if (this.options.appVersion) {
|
||||
this.expressAppInstance.use((req, res, next) => {
|
||||
res.set('appversion', this.options.appVersion);
|
||||
next();
|
||||
});
|
||||
this.addRoute(
|
||||
'/appversion',
|
||||
new Handler('GET', async (req, res) => {
|
||||
res.write(this.options.appVersion);
|
||||
res.end();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// set up routes in for express
|
||||
await this.routeObjectMap.forEach(async (routeArg) => {
|
||||
console.log(
|
||||
`"${routeArg.routeString}" maps to ${routeArg.handlerObjectMap.getArray().length} handlers`
|
||||
);
|
||||
const expressRoute = this.expressAppInstance.route(routeArg.routeString);
|
||||
routeArg.handlerObjectMap.forEach(async (handler) => {
|
||||
console.log(` -> ${handler.httpMethod}`);
|
||||
switch (handler.httpMethod) {
|
||||
case 'GET':
|
||||
expressRoute.get(handler.handlerFunction);
|
||||
return;
|
||||
case 'POST':
|
||||
expressRoute.post(handler.handlerFunction);
|
||||
return;
|
||||
case 'PUT':
|
||||
expressRoute.put(handler.handlerFunction);
|
||||
return;
|
||||
case 'ALL':
|
||||
expressRoute.all(handler.handlerFunction);
|
||||
return;
|
||||
case 'DELETE':
|
||||
expressRoute.delete(handler.handlerFunction);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (this.options.defaultAnswer) {
|
||||
this.expressAppInstance.get('/', async (request, response) => {
|
||||
response.send(await this.options.defaultAnswer());
|
||||
});
|
||||
}
|
||||
|
||||
this.httpServer.on('connection', (connection: plugins.net.Socket) => {
|
||||
this.socketMap.add(connection);
|
||||
console.log(`added connection. now ${this.socketMap.getArray().length} sockets connected.`);
|
||||
|
||||
const closeListener = () => {
|
||||
console.log('connection closed');
|
||||
cleanupConnection();
|
||||
};
|
||||
|
||||
const errorListener = () => {
|
||||
console.log('connection errored');
|
||||
cleanupConnection();
|
||||
};
|
||||
|
||||
const endListener = () => {
|
||||
console.log('connection ended');
|
||||
cleanupConnection();
|
||||
};
|
||||
|
||||
const timeoutListener = () => {
|
||||
console.log('connection timed out');
|
||||
cleanupConnection();
|
||||
};
|
||||
|
||||
connection.addListener('close', closeListener);
|
||||
connection.addListener('error', errorListener);
|
||||
connection.addListener('end', endListener);
|
||||
connection.addListener('timeout', timeoutListener);
|
||||
|
||||
const cleanupConnection = async () => {
|
||||
connection.removeListener('close', closeListener);
|
||||
connection.removeListener('error', errorListener);
|
||||
connection.removeListener('end', endListener);
|
||||
connection.removeListener('timeout', timeoutListener);
|
||||
|
||||
if (this.socketMap.checkForObject(connection)) {
|
||||
this.socketMap.remove(connection);
|
||||
console.log(`removed connection. ${this.socketMap.getArray().length} sockets remaining.`);
|
||||
await plugins.smartdelay.delayFor(0);
|
||||
if (connection.destroyed === false) {
|
||||
connection.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// finally listen on a port
|
||||
if (doListen) {
|
||||
this.httpServer.listen(portArg, '0.0.0.0', () => {
|
||||
console.log(`now listening on ${portArg}!`);
|
||||
this.startedDeferred.resolve();
|
||||
this.serverStatus = 'running';
|
||||
done.resolve();
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
'The server does not listen on a network stack and instead expects to get handed requests by other mechanics'
|
||||
);
|
||||
}
|
||||
await done.promise;
|
||||
for (const executeAfterStartFunction of this.executeAfterStartFunctions) {
|
||||
await executeAfterStartFunction();
|
||||
}
|
||||
}
|
||||
|
||||
public getHttpServer() {
|
||||
return this.httpServer;
|
||||
}
|
||||
|
||||
public getExpressAppInstance() {
|
||||
return this.expressAppInstance;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
const done = plugins.smartpromise.defer();
|
||||
if (this.httpServer) {
|
||||
this.httpServer.close(async () => {
|
||||
this.serverStatus = 'stopped';
|
||||
done.resolve();
|
||||
});
|
||||
await this.socketMap.forEach(async (socket) => {
|
||||
socket.destroy();
|
||||
});
|
||||
} else {
|
||||
throw new Error('There is no Server to be stopped. Have you started it?');
|
||||
}
|
||||
return await done.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* allows handling requests and responses that come from other
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
public async handleReqRes(req: plugins.express.Request, res: plugins.express.Response) {
|
||||
this.expressAppInstance(req, res);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Server } from './classes.server.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { type IUrlInfo } from '@push.rocks/smartsitemap';
|
||||
|
||||
export class Sitemap {
|
||||
public smartexpressRef: Server;
|
||||
public smartSitemap = new plugins.smartsitemap.SmartSitemap();
|
||||
public urls: plugins.smartsitemap.IUrlInfo[] = [];
|
||||
|
||||
/**
|
||||
* handles the normal sitemap request
|
||||
*/
|
||||
public sitemapHandler = new Handler('GET', async (req, res) => {
|
||||
const sitemapXmlString = await this.smartSitemap.createSitemapFromUrlInfoArray(this.urls);
|
||||
res.type('.xml');
|
||||
res.write(sitemapXmlString);
|
||||
res.end();
|
||||
});
|
||||
|
||||
/**
|
||||
* handles the sitemap-news request
|
||||
*/
|
||||
public sitemapNewsHandler = new Handler('GET', async (req, res) => {
|
||||
if (!this.smartexpressRef.options.articleGetterFunction) {
|
||||
res.status(500);
|
||||
res.write('no article getter function defined.');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const sitemapNewsXml = await this.smartSitemap.createSitemapNewsFromArticleArray(
|
||||
await this.smartexpressRef.options.articleGetterFunction()
|
||||
);
|
||||
res.type('.xml');
|
||||
res.write(sitemapNewsXml);
|
||||
res.end();
|
||||
});
|
||||
|
||||
constructor(smartexpressRefArg: Server) {
|
||||
this.smartexpressRef = smartexpressRefArg;
|
||||
this.smartexpressRef.addRouteBefore('/sitemap', this.sitemapHandler);
|
||||
this.smartexpressRef.addRouteBefore('/sitemap-news', this.sitemapNewsHandler);
|
||||
|
||||
// lets set the default url
|
||||
if (this.smartexpressRef.options.domain) {
|
||||
this.urls.push({
|
||||
url: `https://${this.smartexpressRef.options.domain}/`,
|
||||
timestamp: Date.now(),
|
||||
frequency: 'daily',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* replaces the current urlsArray
|
||||
* @param urlsArg
|
||||
*/
|
||||
public replaceUrls(urlsArg: IUrlInfo[]) {
|
||||
this.urls = urlsArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* adds urls to the current set of urls
|
||||
*/
|
||||
public addUrls(urlsArg: IUrlInfo[]) {
|
||||
this.urls = this.urls.concat(this.urls, urlsArg);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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';
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export const setupManifest = async (
|
||||
expressInstanceArg: plugins.express.Application,
|
||||
manifestArg: plugins.smartmanifest.ISmartManifestConstructorOptions
|
||||
) => {
|
||||
const smartmanifestInstance = new plugins.smartmanifest.SmartManifest(manifestArg);
|
||||
expressInstanceArg.get('/manifest.json', async (req, res) => {
|
||||
res.status(200);
|
||||
res.type('json');
|
||||
res.write(smartmanifestInstance.jsonString());
|
||||
res.end();
|
||||
});
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Server } from './classes.server.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
|
||||
export const setupRobots = async (smartexpressRefArg: Server, domainArg: string) => {
|
||||
smartexpressRefArg.addRouteBefore(
|
||||
'/robots.txt',
|
||||
new Handler('GET', async (req, res) => {
|
||||
res.type('text/plain');
|
||||
res.send(`
|
||||
User-agent: Googlebot-News
|
||||
Disallow: /account
|
||||
Disallow: /login
|
||||
|
||||
User-agent: *
|
||||
Disallow: /account
|
||||
Disallow: /login
|
||||
|
||||
${
|
||||
smartexpressRefArg.options.blockWaybackMachine
|
||||
? `
|
||||
User-Agent: ia_archiver
|
||||
Disallow: /
|
||||
`
|
||||
: ``
|
||||
}
|
||||
|
||||
Sitemap: https://${domainArg}/sitemap
|
||||
Sitemap: https://${domainArg}/sitemap-news
|
||||
`);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Server } from './classes.server.js';
|
||||
import { Handler } from './classes.handler.js';
|
||||
|
||||
export const redirectFrom80To443 = async () => {
|
||||
const smartexpressInstance = new Server({
|
||||
cors: true,
|
||||
forceSsl: true,
|
||||
port: 80,
|
||||
});
|
||||
|
||||
smartexpressInstance.addRoute(
|
||||
'*',
|
||||
new Handler('ALL', async (req, res) => {
|
||||
res.redirect('https://' + req.headers.host + req.url);
|
||||
})
|
||||
);
|
||||
|
||||
await smartexpressInstance.start();
|
||||
|
||||
return smartexpressInstance;
|
||||
};
|
||||
@@ -1,245 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './typedserver.paths.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as servertools from './servertools/index.js';
|
||||
import { type TCompressionMethod } from './servertools/classes.compressor.js';
|
||||
|
||||
export interface IServerOptions {
|
||||
/**
|
||||
* serve a particular directory
|
||||
*/
|
||||
serveDir?: string;
|
||||
|
||||
/**
|
||||
* inject a reload script that takes care of live reloading
|
||||
*/
|
||||
injectReload?: boolean;
|
||||
|
||||
/**
|
||||
* enable compression
|
||||
*/
|
||||
enableCompression?: boolean;
|
||||
|
||||
/**
|
||||
* choose a preferred compression method
|
||||
*/
|
||||
preferredCompressionMethod?: TCompressionMethod;
|
||||
|
||||
/**
|
||||
* watch the serve directory?
|
||||
*/
|
||||
watch?: boolean;
|
||||
|
||||
cors: boolean;
|
||||
|
||||
/**
|
||||
* a default answer given in case there is no other handler.
|
||||
* @returns
|
||||
*/
|
||||
defaultAnswer?: () => Promise<string>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
privateKey?: string;
|
||||
sitemap?: boolean;
|
||||
feed?: boolean;
|
||||
robots?: boolean;
|
||||
domain?: string;
|
||||
/**
|
||||
* convey information about the app being served
|
||||
*/
|
||||
appVersion?: string;
|
||||
feedMetadata?: plugins.smartfeed.IFeedOptions;
|
||||
articleGetterFunction?: () => Promise<plugins.tsclass.content.IArticle[]>;
|
||||
blockWaybackMachine?: boolean;
|
||||
}
|
||||
|
||||
export class TypedServer {
|
||||
// static
|
||||
// nothing here yet
|
||||
|
||||
// instance
|
||||
public options: IServerOptions;
|
||||
public server: servertools.Server;
|
||||
public smartchokInstance: plugins.smartchok.Smartchok;
|
||||
public serveDirHashSubject = new plugins.smartrx.rxjs.ReplaySubject<string>(1);
|
||||
public serveHash: string = '000000';
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public lastReload: number = Date.now();
|
||||
public ended = false;
|
||||
constructor(optionsArg: IServerOptions) {
|
||||
const standardOptions: IServerOptions = {
|
||||
port: 3000,
|
||||
injectReload: false,
|
||||
serveDir: null,
|
||||
watch: false,
|
||||
cors: true,
|
||||
};
|
||||
this.options = {
|
||||
...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.bundlePath));
|
||||
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();
|
||||
}
|
||||
})
|
||||
);
|
||||
this.server.addRoute(
|
||||
'/typedrequest',
|
||||
new servertools.HandlerTypedRouter(this.typedrouter)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* inits and starts the server
|
||||
*/
|
||||
public async start() {
|
||||
if (this.options.serveDir) {
|
||||
this.server.addRoute(
|
||||
'/*',
|
||||
new servertools.HandlerStatic(this.options.serveDir, {
|
||||
responseModifier: async (responseArg) => {
|
||||
let fileString = responseArg.responseContent.toString();
|
||||
if (plugins.path.parse(responseArg.path).ext === '.html') {
|
||||
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.');
|
||||
} else if (this.options.injectReload) {
|
||||
console.log('Could not insert typedserver script');
|
||||
}
|
||||
}
|
||||
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: Buffer.from(fileString),
|
||||
};
|
||||
},
|
||||
serveIndexHtmlDefault: true,
|
||||
enableCompression: this.options.enableCompression,
|
||||
preferredCompressionMethod: this.options.preferredCompressionMethod,
|
||||
})
|
||||
);
|
||||
} else if (this.options.injectReload) {
|
||||
throw new Error(
|
||||
'You set to inject the reload script without a serve dir. This is not supported at the moment.'
|
||||
);
|
||||
}
|
||||
if (this.options.watch && this.options.serveDir) {
|
||||
this.smartchokInstance = new plugins.smartchok.Smartchok([this.options.serveDir]);
|
||||
await this.smartchokInstance.start();
|
||||
(await this.smartchokInstance.getObservableFor('change')).subscribe(async () => {
|
||||
await this.createServeDirHash();
|
||||
this.reload();
|
||||
});
|
||||
await this.createServeDirHash();
|
||||
}
|
||||
|
||||
// lets start the server
|
||||
await this.server.start();
|
||||
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createServer(
|
||||
this.typedrouter,
|
||||
this.server
|
||||
);
|
||||
|
||||
// lets setup typedrouter
|
||||
this.typedrouter.addTypedHandler<interfaces.IReq_GetLatestServerChangeTime>(
|
||||
new plugins.typedrequest.TypedHandler('getLatestServerChangeTime', async (reqDataArg) => {
|
||||
return {
|
||||
time: this.lastReload,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// console.log('open url in browser');
|
||||
// await plugins.smartopen.openUrl(`http://testing.git.zone:${this.options.port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* reloads the page
|
||||
*/
|
||||
public async reload() {
|
||||
this.lastReload = Date.now();
|
||||
for (const connectionArg of await this.typedsocket.findAllTargetConnectionsByTag(
|
||||
'typedserver_frontend'
|
||||
)) {
|
||||
const pushTime =
|
||||
this.typedsocket.createTypedRequest<interfaces.IReq_PushLatestServerChangeTime>(
|
||||
'pushLatestServerChangeTime',
|
||||
connectionArg
|
||||
);
|
||||
pushTime.fire({
|
||||
time: this.lastReload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
this.ended = true;
|
||||
await this.server.stop();
|
||||
await this.typedsocket.stop();
|
||||
if (this.smartchokInstance) {
|
||||
await this.smartchokInstance.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public async createServeDirHash() {
|
||||
const serveDirHash = await plugins.smartfile.fs.fileTreeToHash(this.options.serveDir, '**/*');
|
||||
this.serveHash = serveDirHash;
|
||||
console.log('Current ServeDir hash: ' + serveDirHash);
|
||||
this.serveDirHashSubject.next(serveDirHash);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
|
||||
export const bundlePath = plugins.path.join(packageDir, './dist_ts_web/bundle.js');
|
||||
49
ts/utilityservers/classes.serviceserver.ts
Normal file
49
ts/utilityservers/classes.serviceserver.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { TypedServer } from '../classes.typedserver.js';
|
||||
|
||||
export interface ILoleServiceServerConstructorOptions {
|
||||
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
|
||||
serviceName: string;
|
||||
serviceVersion: string;
|
||||
serviceDomain: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// the main service server
|
||||
export class UtilityServiceServer {
|
||||
public options: ILoleServiceServerConstructorOptions;
|
||||
public typedServer: TypedServer;
|
||||
|
||||
constructor(optionsArg: ILoleServiceServerConstructorOptions) {
|
||||
this.options = optionsArg;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
console.log('starting lole-serviceserver...');
|
||||
this.typedServer = new TypedServer({
|
||||
cors: true,
|
||||
domain: this.options.serviceDomain,
|
||||
forceSsl: false,
|
||||
port: this.options.port || 3000,
|
||||
robots: true,
|
||||
defaultAnswer: async () => {
|
||||
const InfoHtml = (await import('../infohtml/index.js')).InfoHtml;
|
||||
return (
|
||||
await InfoHtml.fromSimpleText(
|
||||
`${this.options.serviceName} (version ${this.options.serviceVersion})`
|
||||
)
|
||||
).htmlString;
|
||||
},
|
||||
});
|
||||
|
||||
// Add any custom routes
|
||||
if (this.options.addCustomRoutes) {
|
||||
await this.options.addCustomRoutes(this.typedServer);
|
||||
}
|
||||
|
||||
await this.typedServer.start();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.typedServer.stop();
|
||||
}
|
||||
}
|
||||
126
ts/utilityservers/classes.websiteserver.ts
Normal file
126
ts/utilityservers/classes.websiteserver.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IUtilityWebsiteServerConstructorOptions {
|
||||
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
|
||||
appSemVer?: string;
|
||||
domain: string;
|
||||
serveDir: string;
|
||||
feedMetadata: IServerOptions['feedMetadata'];
|
||||
}
|
||||
|
||||
/**
|
||||
* the utility website server implements a best practice server for websites
|
||||
* It supports:
|
||||
* * live reload
|
||||
* * serviceworker
|
||||
* * pwa manifest
|
||||
*/
|
||||
export class UtilityWebsiteServer {
|
||||
public options: IUtilityWebsiteServerConstructorOptions;
|
||||
public typedserver: TypedServer;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(optionsArg: IUtilityWebsiteServerConstructorOptions) {
|
||||
this.options = optionsArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the website server
|
||||
*/
|
||||
public async start(portArg = 3000) {
|
||||
this.typedserver = new TypedServer({
|
||||
cors: true,
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
serveDir: this.options.serveDir,
|
||||
domain: this.options.domain,
|
||||
forceSsl: false,
|
||||
manifest: {
|
||||
name: this.options.domain,
|
||||
short_name: this.options.domain,
|
||||
start_url: '/',
|
||||
display_override: ['window-controls-overlay'],
|
||||
lang: 'en',
|
||||
background_color: '#000000',
|
||||
scope: '/',
|
||||
},
|
||||
port: portArg,
|
||||
|
||||
// features
|
||||
robots: true,
|
||||
sitemap: true,
|
||||
});
|
||||
|
||||
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] = {
|
||||
appHash: 'xxxxxx',
|
||||
appSemVer: this.options.appSemVer || 'x.x.x',
|
||||
};
|
||||
|
||||
// -> Service worker version info handler
|
||||
this.typedserver.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo>(
|
||||
new plugins.typedrequest.TypedHandler('serviceworker_versionInfo', async () => {
|
||||
return lswData;
|
||||
})
|
||||
);
|
||||
|
||||
// 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' },
|
||||
});
|
||||
});
|
||||
|
||||
// Asset broker manifest handler
|
||||
this.typedserver.addRoute(
|
||||
'/assetbroker/manifest/:manifestAsset',
|
||||
'GET',
|
||||
async (request: Request) => {
|
||||
let manifestAssetName = (request as any).params?.manifestAsset;
|
||||
if (manifestAssetName === 'favicon.png') {
|
||||
manifestAssetName = `favicon_${this.options.domain
|
||||
.replace('.', '')
|
||||
.replace('losslesscom', 'lossless')}@2x_transparent.png`;
|
||||
}
|
||||
const fullOriginAssetUrl = `https://assetbroker.lossless.one/brandfiles/00general/${manifestAssetName}`;
|
||||
console.log(`Getting ${manifestAssetName} from ${fullOriginAssetUrl}`);
|
||||
const smartRequest = plugins.smartrequest.SmartRequest.create();
|
||||
const response = await smartRequest.url(fullOriginAssetUrl).get();
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return new Response(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Add any custom routes
|
||||
if (this.options.addCustomRoutes) {
|
||||
await this.options.addCustomRoutes(this.typedserver);
|
||||
}
|
||||
|
||||
// Subscribe to serve directory hash changes
|
||||
this.typedserver.serveDirHashSubject.subscribe((appHash: string) => {
|
||||
lswData = {
|
||||
appHash,
|
||||
appSemVer: '1.0.0',
|
||||
};
|
||||
});
|
||||
|
||||
// Setup the typedrouter chain
|
||||
this.typedserver.typedrouter.addTypedRouter(this.typedrouter);
|
||||
|
||||
// Start everything
|
||||
console.log('routes are all set. Starting up now!');
|
||||
await this.typedserver.start();
|
||||
console.log('typedserver started!');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.typedserver.stop();
|
||||
}
|
||||
}
|
||||
2
ts/utilityservers/index.ts
Normal file
2
ts/utilityservers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.serviceserver.js';
|
||||
export * from './classes.websiteserver.js';
|
||||
8
ts_edgeworker/00_commitinfo_data.ts
Normal file
8
ts_edgeworker/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'cloudflare-workers',
|
||||
version: '1.0.192',
|
||||
description: 'cloudflare-workers'
|
||||
}
|
||||
83
ts_edgeworker/analytics/analyzer.ts
Normal file
83
ts_edgeworker/analytics/analyzer.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
import type { EdgeWorker } from '../classes.edgeworker.js';
|
||||
import type { WorkerEvent } from '../classes.workerevent.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { SmartlogDestination } from './smartlog.js';
|
||||
|
||||
export interface IAnalyticsData {
|
||||
requestAgent: string;
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
requestStartTime: number;
|
||||
responseStatus: number;
|
||||
responseEndTime: number;
|
||||
}
|
||||
|
||||
export class Analyzer {
|
||||
cworkerEventRef: WorkerEvent;
|
||||
|
||||
public data: IAnalyticsData = {
|
||||
requestAgent: 'unknown',
|
||||
requestMethod: 'unknown',
|
||||
requestUrl: 'unknown',
|
||||
requestStartTime: 0,
|
||||
responseStatus: 0,
|
||||
responseEndTime: 0,
|
||||
};
|
||||
|
||||
public finishedDeferred = plugins.smartpromise.defer();
|
||||
|
||||
constructor(cworkerEventRefArg: WorkerEvent) {
|
||||
this.cworkerEventRef = cworkerEventRefArg;
|
||||
this.smartlog.addLogDestination(new SmartlogDestination(this.cworkerEventRef.options.edgeWorkerRef));
|
||||
}
|
||||
public smartlog = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
runtime: "cloudflare_workers",
|
||||
zone: 'servezone',
|
||||
company: 'Lossless GmbH',
|
||||
companyunit: 'Lossless Cloud',
|
||||
containerName: 'cloudflare_workers'
|
||||
}
|
||||
});
|
||||
|
||||
public setRequestData (optionsArg: {
|
||||
requestAgent: string;
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
}) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
...{
|
||||
requestAgent: optionsArg.requestAgent,
|
||||
requestUrl: optionsArg.requestUrl,
|
||||
requestMethod: optionsArg.requestMethod,
|
||||
requestStartTime: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
public setResponseData(optionsArg: {
|
||||
responseStatus: number,
|
||||
responseEndTime: number,
|
||||
}) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
...{
|
||||
responseStatus: optionsArg.responseStatus,
|
||||
responseEndTime: optionsArg.responseEndTime
|
||||
}
|
||||
};
|
||||
this.sendLogs();
|
||||
}
|
||||
|
||||
public async sendLogs() {
|
||||
await this.smartlog.log('info', `
|
||||
Got a ${this.data.requestMethod} request from ${this.data.requestAgent} to
|
||||
${this.data.requestUrl}
|
||||
that took ${this.data.responseEndTime - this.data.requestStartTime}ms to resolve with status ${this.data.responseStatus}.`, this.data);
|
||||
this.finishedDeferred.resolve();
|
||||
}
|
||||
}
|
||||
26
ts_edgeworker/analytics/smartlog.ts
Normal file
26
ts_edgeworker/analytics/smartlog.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as smartlogInterfaces from '@push.rocks/smartlog-interfaces';
|
||||
import type { EdgeWorker } from '../classes.edgeworker.js';
|
||||
|
||||
export class SmartlogDestination implements smartlogInterfaces.ILogDestination {
|
||||
public edgeWorkerRef: EdgeWorker;
|
||||
|
||||
constructor(edgeworkerRefArg: EdgeWorker) {
|
||||
this.edgeWorkerRef = edgeworkerRefArg;
|
||||
}
|
||||
|
||||
public async handleLog(logPackageArg: smartlogInterfaces.ILogPackage) {
|
||||
if (this.edgeWorkerRef.options.smartlogConfig) {
|
||||
const requestBody: smartlogInterfaces.ILogPackageAuthenticated = {
|
||||
auth: this.edgeWorkerRef.options.smartlogConfig.token,
|
||||
logPackage: logPackageArg,
|
||||
};
|
||||
await fetch(this.edgeWorkerRef.options.smartlogConfig.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
67
ts_edgeworker/classes.domainrouter.ts
Normal file
67
ts_edgeworker/classes.domainrouter.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { WorkerEvent } from './classes.workerevent.js';
|
||||
|
||||
import * as domainInstructions from './domaininstructions/index.js';
|
||||
|
||||
export class DomainRouter {
|
||||
private smartmatches: plugins.smartmatch.SmartMatch[] = [];
|
||||
|
||||
constructor() {
|
||||
for (const key of Object.keys(domainInstructions.instructionObject)) {
|
||||
this.smartmatches.push(new plugins.smartmatch.SmartMatch(key));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param cworkerevent
|
||||
*/
|
||||
public routeToResponder(cworkerevent: WorkerEvent) {
|
||||
const match = this.smartmatches.find(smartmatchArg => {
|
||||
return smartmatchArg.match(cworkerevent.request.url);
|
||||
});
|
||||
cworkerevent.responderInstruction = match
|
||||
? domainInstructions.instructionObject[match.wildcard]
|
||||
: {
|
||||
type: 'cache'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* rendertronRouter
|
||||
*/
|
||||
public checkWetherReRouteToRendertron(cworkerevent: WorkerEvent) {
|
||||
let needsRendertron = false;
|
||||
for (const botAgentIdentifier of domainInstructions.botUserAgents) {
|
||||
if (needsRendertron) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
cworkerevent.request.headers.get('user-agent') &&
|
||||
cworkerevent.request.headers.get('user-agent').toLowerCase().includes(botAgentIdentifier.toLowerCase()) &&
|
||||
!cworkerevent.request.url.includes('lossless.one')
|
||||
) {
|
||||
needsRendertron = true;
|
||||
}
|
||||
}
|
||||
if (needsRendertron) {
|
||||
cworkerevent.routedThroughRendertron = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check wether this is a preflight request that should be handled
|
||||
*/
|
||||
public checkWetherIsPreflight (cworkerevent: WorkerEvent) {
|
||||
if (
|
||||
cworkerevent.request.method === 'OPTIONS' &&
|
||||
cworkerevent.request.headers.get('Origin') !== null &&
|
||||
cworkerevent.request.headers.get('Access-Control-Request-Method') !== null &&
|
||||
cworkerevent.request.headers.get('Access-Control-Request-Headers') !== null
|
||||
) {
|
||||
cworkerevent.isPreflight = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
73
ts_edgeworker/classes.edgeworker.ts
Normal file
73
ts_edgeworker/classes.edgeworker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// imports
|
||||
import { WorkerEvent } from './classes.workerevent.js';
|
||||
import { DomainRouter } from './classes.domainrouter.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import * as responders from './responders/index.js';
|
||||
|
||||
export interface IEdgeWorkerOptions {
|
||||
smartlogConfig?: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgeWorker {
|
||||
public options: IEdgeWorkerOptions;
|
||||
domainRouter: DomainRouter;
|
||||
|
||||
constructor(optionsArg: IEdgeWorkerOptions = {}) {
|
||||
this.options = optionsArg;
|
||||
this.domainRouter = new DomainRouter();
|
||||
addEventListener('fetch', this.fetchFunction as any);
|
||||
}
|
||||
|
||||
public async fetchFunction (eventArg: plugins.cloudflareTypes.FetchEvent) {
|
||||
if (new URL(eventArg.request.url).pathname.startsWith('/socket.io')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cworkerEvent = new WorkerEvent({
|
||||
edgeWorkerRef: this,
|
||||
event: eventArg,
|
||||
passThroughOnException: true
|
||||
});
|
||||
|
||||
// lets answer basic reuest things
|
||||
responders.timeoutResponder(cworkerEvent);
|
||||
cworkerEvent.hasResponse ? null : await responders.urlFormattingResponder(cworkerEvent);
|
||||
|
||||
// lets route the domain
|
||||
this.domainRouter.routeToResponder(cworkerEvent);
|
||||
this.domainRouter.checkWetherReRouteToRendertron(cworkerEvent);
|
||||
this.domainRouter.checkWetherIsPreflight(cworkerEvent);
|
||||
|
||||
// guardresponder
|
||||
cworkerEvent.hasResponse ? null : await responders.guardResponder(cworkerEvent);
|
||||
|
||||
// lets process all requests that need rendertron
|
||||
cworkerEvent.hasResponse ? null : await responders.rendertronResponder(cworkerEvent);
|
||||
|
||||
// lets process all requests that are preflight requests
|
||||
cworkerEvent.hasResponse ? null : await responders.preflightResponder(cworkerEvent);
|
||||
|
||||
switch (cworkerEvent.responderInstruction.type) {
|
||||
case 'cache':
|
||||
cworkerEvent.hasResponse ? null : await responders.cacheResponder(cworkerEvent);
|
||||
break;
|
||||
case 'origin':
|
||||
cworkerEvent.hasResponse ? null : await responders.originResponder(cworkerEvent);
|
||||
break;
|
||||
case 'redirect':
|
||||
cworkerEvent.hasResponse ? null : await responders.adsTxtResponder(cworkerEvent);
|
||||
break;
|
||||
case 'static':
|
||||
cworkerEvent.hasResponse ? null : await responders.staticResponder(cworkerEvent);
|
||||
break;
|
||||
case 'ads.txt':
|
||||
cworkerEvent.hasResponse ? null : await responders.adsTxtResponder(cworkerEvent);
|
||||
break;
|
||||
}
|
||||
// cworkerEvent.hasResponse ? null : await responders.kvResponder(cworkerEvent);
|
||||
cworkerEvent.hasResponse ? null : await responders.errorResponder(cworkerEvent);
|
||||
};
|
||||
}
|
||||
37
ts_edgeworker/classes.kvhandler.ts
Normal file
37
ts_edgeworker/classes.kvhandler.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
declare var lokv: plugins.cloudflareTypes.KVNamespace;
|
||||
|
||||
/**
|
||||
* an abstraction for the workerd KV store
|
||||
*/
|
||||
export class KVHandler {
|
||||
private getSafeIdentifier(urlString: string) {
|
||||
return encodeURI(urlString);
|
||||
}
|
||||
|
||||
async getFromKv(keyIdentifier: string) {
|
||||
const key = this.getSafeIdentifier(keyIdentifier);
|
||||
const valueString = await lokv.get(key);
|
||||
return valueString;
|
||||
}
|
||||
|
||||
async putInKv(keyIdentifier: string, valueForStorage: string) {
|
||||
const key = this.getSafeIdentifier(keyIdentifier);
|
||||
const value = valueForStorage;
|
||||
await lokv.put(key, value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes a key/value from the cache
|
||||
* @param keyIdentifier
|
||||
*/
|
||||
async deleteInKv(keyIdentifier: string) {
|
||||
const cacheKey = this.getSafeIdentifier(keyIdentifier);
|
||||
await lokv.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const kvHandlerInstance = new KVHandler();
|
||||
46
ts_edgeworker/classes.responsekv.ts
Normal file
46
ts_edgeworker/classes.responsekv.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { kvHandlerInstance } from './classes.kvhandler.js';
|
||||
|
||||
declare var lokv: plugins.cloudflareTypes.KVNamespace;
|
||||
|
||||
interface IKVResponseObject {
|
||||
headers: { [key: string]: string };
|
||||
version: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class ResponseKv {
|
||||
public async storeResponse(urlIdentifier: string, responseArg: any) {
|
||||
const headers: { [key: string]: string } = {};
|
||||
for (const kv of responseArg.headers.entries()) {
|
||||
headers[kv[0]] = kv[1];
|
||||
}
|
||||
const kvResponseForStorage: IKVResponseObject = {
|
||||
headers,
|
||||
version: '1.0.0',
|
||||
body: await responseArg.text()
|
||||
};
|
||||
await kvHandlerInstance.putInKv(urlIdentifier, JSON.stringify(kvResponseForStorage));
|
||||
}
|
||||
|
||||
public async getResponse(urlIdentifier: string): Promise<Response> {
|
||||
const kvValue = await kvHandlerInstance.getFromKv(urlIdentifier);
|
||||
if (kvValue) {
|
||||
let kvResponse: IKVResponseObject;
|
||||
try {
|
||||
kvResponse = JSON.parse(kvValue);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return null;
|
||||
}
|
||||
const headers = new Headers();
|
||||
for (const key of Object.keys(kvResponse.headers)) {
|
||||
headers.append(key, kvResponse.headers[key]);
|
||||
}
|
||||
headers.append('SERVEZONE_ROUTE', 'CLOUDFLARE_EDGE_LOKV');
|
||||
return new Response(kvResponse.body, { headers: headers });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
ts_edgeworker/classes.workerevent.ts
Normal file
102
ts_edgeworker/classes.workerevent.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import * as helpers from './helpers/index.js';
|
||||
import { DomainRouter } from './classes.domainrouter.js';
|
||||
import { Analyzer } from './analytics/analyzer.js';
|
||||
import type { EdgeWorker } from './classes.edgeworker.js';
|
||||
|
||||
export interface ICworkerEventOptions {
|
||||
event: plugins.cloudflareTypes.FetchEvent
|
||||
edgeWorkerRef: EdgeWorker;
|
||||
passThroughOnException?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export class WorkerEvent {
|
||||
public options: ICworkerEventOptions;
|
||||
|
||||
public analyzer: Analyzer;
|
||||
private responseDeferred: plugins.smartpromise.Deferred<any>;
|
||||
private waitUntilDeferred: plugins.smartpromise.Deferred<any>;
|
||||
|
||||
private response: Response = null;
|
||||
private waitList = [];
|
||||
|
||||
// routing settings
|
||||
public responderInstruction: interfaces.IResponderInstruction;
|
||||
public routedThroughRendertron: boolean = false;
|
||||
public isPreflight: boolean = false;
|
||||
|
||||
public request: plugins.cloudflareTypes.Request;
|
||||
|
||||
public parsedUrl: URL;
|
||||
|
||||
constructor(optionsArg: ICworkerEventOptions) {
|
||||
this.options = optionsArg;
|
||||
|
||||
// lets create an Analyzer for this request
|
||||
this.analyzer = new Analyzer(this);
|
||||
|
||||
// lets make sure we always answer
|
||||
this.options.passThroughOnException ? this.options.event.passThroughOnException() : null;
|
||||
|
||||
// lets set up some better asnyc behaviour
|
||||
this.waitUntilDeferred = plugins.smartpromise.defer();
|
||||
this.responseDeferred = plugins.smartpromise.defer();
|
||||
this.addToWaitList(this.analyzer.finishedDeferred.promise);
|
||||
|
||||
// lets entangle the event with this class instance
|
||||
this.request = this.options.event.request;
|
||||
|
||||
// lets start with analytics
|
||||
this.analyzer.setRequestData({
|
||||
requestAgent: this.request.headers.get('user-agent'),
|
||||
requestMethod: this.request.method,
|
||||
requestUrl: this.request.url
|
||||
});
|
||||
|
||||
|
||||
this.options.event.respondWith(this.responseDeferred.promise);
|
||||
this.options.event.waitUntil(this.waitUntilDeferred.promise);
|
||||
|
||||
// lets parse the url
|
||||
this.parsedUrl = new URL(this.request.url);
|
||||
|
||||
// lets check the waitlist
|
||||
this.checkWaitList();
|
||||
console.log(`Got request for ${this.request.url}`);
|
||||
}
|
||||
|
||||
get hasResponse () {
|
||||
let returnValue: boolean;
|
||||
this.response ? returnValue = true : returnValue = false;
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
public addToWaitList(promiseArg: Promise<any>) {
|
||||
this.waitList.push(promiseArg);
|
||||
}
|
||||
|
||||
private async checkWaitList() {
|
||||
await this.responseDeferred.promise;
|
||||
const currentWaitList = this.waitList;
|
||||
this.waitList = [];
|
||||
await Promise.all(currentWaitList);
|
||||
if (this.waitList.length > 0) {
|
||||
this.checkWaitList();
|
||||
} else {
|
||||
this.waitUntilDeferred.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
public setResponse (responseArg: Response) {
|
||||
this.response = responseArg;
|
||||
this.responseDeferred.resolve(responseArg);
|
||||
this.analyzer.setResponseData({
|
||||
responseStatus: this.response.status,
|
||||
responseEndTime: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
36
ts_edgeworker/domaininstructions/botuseragents.ts
Normal file
36
ts_edgeworker/domaininstructions/botuseragents.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const botUserAgents = [
|
||||
// Baidu
|
||||
'baiduspider',
|
||||
'embedly',
|
||||
|
||||
// Facebook
|
||||
'facebookexternalhit',
|
||||
|
||||
// Ghost
|
||||
'Ghost',
|
||||
|
||||
// Microsoft
|
||||
'bingbot',
|
||||
'BingPreview',
|
||||
'linkedinbot',
|
||||
'MissinglettrBot',
|
||||
'msnbot',
|
||||
'outbrain',
|
||||
'pinterest',
|
||||
'quora link preview',
|
||||
'rogerbot',
|
||||
'showyoubot',
|
||||
'slackbot',
|
||||
'TelegramBot',
|
||||
|
||||
// Twitter
|
||||
'twitterbot',
|
||||
'vkShare',
|
||||
'W3C_Validator',
|
||||
|
||||
// WhatsApp
|
||||
'whatsapp',
|
||||
|
||||
// woorank
|
||||
'woorank'
|
||||
];
|
||||
7
ts_edgeworker/domaininstructions/domaininstructions.ts
Normal file
7
ts_edgeworker/domaininstructions/domaininstructions.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
|
||||
export const instructionObject: { [key: string]: interfaces.IResponderInstruction } = {
|
||||
'*/ads.txt': {
|
||||
type: 'ads.txt',
|
||||
}
|
||||
};
|
||||
2
ts_edgeworker/domaininstructions/index.ts
Normal file
2
ts_edgeworker/domaininstructions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './botuseragents.js';
|
||||
export * from './domaininstructions.js';
|
||||
9
ts_edgeworker/helpers/checks.ts
Normal file
9
ts_edgeworker/helpers/checks.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
declare var lokv: plugins.cloudflareTypes.KVNamespace;
|
||||
export const checkLokv = () => {
|
||||
if (!lokv) {
|
||||
throw new Error('lokv not defined!');
|
||||
} else {
|
||||
console.log('lokv present!');
|
||||
}
|
||||
};
|
||||
1
ts_edgeworker/helpers/index.ts
Normal file
1
ts_edgeworker/helpers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './checks.js';
|
||||
1
ts_edgeworker/index.ts
Normal file
1
ts_edgeworker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.edgeworker.js';
|
||||
9
ts_edgeworker/interfaces/custom.ts
Normal file
9
ts_edgeworker/interfaces/custom.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { WorkerEvent } from "../classes.workerevent.js";
|
||||
|
||||
export interface IResponderInstruction {
|
||||
type: 'origin' | 'cache' | 'static' | 'redirect' | 'ads.txt';
|
||||
cacheClientSideForMin?: number;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export type TRequestResponser = (workerEventArg: WorkerEvent) => Promise<void>;
|
||||
1
ts_edgeworker/interfaces/index.ts
Normal file
1
ts_edgeworker/interfaces/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './custom.js';
|
||||
21
ts_edgeworker/plugins.ts
Normal file
21
ts_edgeworker/plugins.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// @pushrocks scope
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartlogInterfaces from '@push.rocks/smartlog-interfaces';
|
||||
import * as smartmatch from '@push.rocks/smartmatch';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
|
||||
export {
|
||||
smartdelay,
|
||||
smartlog,
|
||||
smartlogInterfaces,
|
||||
smartmatch,
|
||||
smartpromise
|
||||
};
|
||||
|
||||
// cloudflarea
|
||||
import * as cloudflareTypes from '@cloudflare/workers-types';
|
||||
|
||||
export {
|
||||
cloudflareTypes
|
||||
}
|
||||
14
ts_edgeworker/responders/adstxt.responder.ts
Normal file
14
ts_edgeworker/responders/adstxt.responder.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const adsTxtResponder: interfaces.TRequestResponser = async (cWorkerEventArg: WorkerEvent) => {
|
||||
if (cWorkerEventArg.responderInstruction.type === 'ads.txt') {
|
||||
const response = new Response('google.com, pub-4104137977476459, DIRECT, f08c47fec0942fa0\n', {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
}
|
||||
})
|
||||
cWorkerEventArg.setResponse(response);
|
||||
}
|
||||
|
||||
};
|
||||
92
ts_edgeworker/responders/cache.responder.ts
Normal file
92
ts_edgeworker/responders/cache.responder.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
import { kvHandlerInstance } from '../classes.kvhandler.js';
|
||||
|
||||
declare const fetch: plugins.cloudflareTypes.Fetcher['fetch'];
|
||||
|
||||
declare var caches: any;
|
||||
export const cacheResponder: interfaces.TRequestResponser = async (cworkerEventArg: WorkerEvent) => {
|
||||
const host = cworkerEventArg.request.headers.get('Host');
|
||||
const appHashKey = `${host.toLowerCase()}_appHash`;
|
||||
const appHash = await kvHandlerInstance.getFromKv(appHashKey);
|
||||
|
||||
const cache = caches.default;
|
||||
let response: Response = await cache.match(cworkerEventArg.request);
|
||||
|
||||
if (
|
||||
response &&
|
||||
response.headers.get('appHash') &&
|
||||
response.headers.get('appHash') !== appHash
|
||||
) {
|
||||
response = null;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
cworkerEventArg.setResponse(response);
|
||||
} else {
|
||||
response = await handleNewRequest(cworkerEventArg.request);
|
||||
if (response) {
|
||||
cworkerEventArg.addToWaitList(new Promise<void>(async (resolve, reject) => {
|
||||
const newAppHash = response.headers.get('appHash');
|
||||
if (newAppHash) {
|
||||
await kvHandlerInstance.putInKv(appHashKey, newAppHash);
|
||||
}
|
||||
resolve();
|
||||
}));
|
||||
cworkerEventArg.addToWaitList(buildCacheResponse(cache, cworkerEventArg.request, response));
|
||||
cworkerEventArg.setResponse(response);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Request} originalRequest
|
||||
*/
|
||||
const handleNewRequest = async (originalRequest: plugins.cloudflareTypes.Request): Promise<Response> => {
|
||||
console.log('answering from origin');
|
||||
const originResponse: any = await fetch(
|
||||
originalRequest
|
||||
);
|
||||
|
||||
// lets capture status
|
||||
if (originResponse.status > 399) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responseClientPassThroughStream = new TransformStream();
|
||||
originResponse.body.pipeTo(responseClientPassThroughStream.writable);
|
||||
|
||||
// build response for client
|
||||
const clientHeaders = new Headers();
|
||||
for (const kv of originResponse.headers.entries()) {
|
||||
clientHeaders.append(kv[0], kv[1]);
|
||||
}
|
||||
clientHeaders.append('SERVEZONE_ROUTE', 'LOSSLESS_EDGE_ORIGIN_INITIAL');
|
||||
const responseForClient = new Response(responseClientPassThroughStream.readable, {
|
||||
...originResponse,
|
||||
headers: clientHeaders
|
||||
});
|
||||
|
||||
// lets return the responses
|
||||
return responseForClient;
|
||||
};
|
||||
|
||||
const buildCacheResponse = async (cache, matchRequest: plugins.cloudflareTypes.Request, originResponse: any) => {
|
||||
const cacheHeaders = new Headers();
|
||||
for (const kv of originResponse.headers.entries()) {
|
||||
cacheHeaders.append(kv[0], kv[1]);
|
||||
}
|
||||
cacheHeaders.delete('SERVEZONE_ROUTE');
|
||||
cacheHeaders.append('SERVEZONE_ROUTE', 'LOSSLESS_EDGE_CACHE');
|
||||
cacheHeaders.delete('Cache-Control');
|
||||
cacheHeaders.append('Cache-Control', 'public, max-age=60');
|
||||
cacheHeaders.delete('Expires');
|
||||
cacheHeaders.append('Expires', new Date(Date.now() + 60 * 1000).toUTCString());
|
||||
|
||||
const responseForCache = new Response(await originResponse.clone().body, {
|
||||
...originResponse,
|
||||
headers: cacheHeaders
|
||||
});
|
||||
await cache.put(matchRequest, responseForCache);
|
||||
};
|
||||
6
ts_edgeworker/responders/error.responder.ts
Normal file
6
ts_edgeworker/responders/error.responder.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
export const errorResponder: interfaces.TRequestResponser = async (cWorkerEvent: WorkerEvent) => {
|
||||
const errorResponse = await fetch('https://nullresolve.lossless.one/status/firewall');
|
||||
cWorkerEvent.setResponse(errorResponse);
|
||||
};
|
||||
8
ts_edgeworker/responders/guard.responder.ts
Normal file
8
ts_edgeworker/responders/guard.responder.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
export const guardResponder: interfaces.TRequestResponser = async (cWorkerEvent: WorkerEvent) => {
|
||||
if (cWorkerEvent.parsedUrl.pathname.endsWith('.map')) {
|
||||
const errorResponse = await fetch('https://nullresolve.lossless.one/status/firewall');
|
||||
cWorkerEvent.setResponse(errorResponse);
|
||||
}
|
||||
};
|
||||
12
ts_edgeworker/responders/index.ts
Normal file
12
ts_edgeworker/responders/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './adstxt.responder.js';
|
||||
export * from './cache.responder.js';
|
||||
export * from './urlformatting.responder.js';
|
||||
export * from './error.responder.js';
|
||||
export * from './guard.responder.js';
|
||||
export * from './kv.responder.js';
|
||||
export * from './origin.responder.js';
|
||||
export * from './preflight.responder.js';
|
||||
export * from './redirect.reponder.js';
|
||||
export * from './rendertron.responder.js';
|
||||
export * from './static.responder.js';
|
||||
export * from './timeout.responder.js';
|
||||
34
ts_edgeworker/responders/kv.responder.ts
Normal file
34
ts_edgeworker/responders/kv.responder.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
import { ResponseKv } from '../classes.responsekv.js';
|
||||
|
||||
declare const fetch: plugins.cloudflareTypes.Fetcher['fetch'];
|
||||
|
||||
export const kvResponder: interfaces.TRequestResponser = async (cworkerEventArg: WorkerEvent) => {
|
||||
const responseKvInstance = new ResponseKv();
|
||||
let response = await responseKvInstance.getResponse(cworkerEventArg.request.url);
|
||||
if (response) {
|
||||
console.log('Got response from KV');
|
||||
} else {
|
||||
response = await handleNewRequest(cworkerEventArg.request, responseKvInstance);
|
||||
}
|
||||
cworkerEventArg.setResponse(response);
|
||||
};
|
||||
|
||||
const handleNewRequest = async (request: plugins.cloudflareTypes.Request, responseKvInstance: ResponseKv) => {
|
||||
const originResponse: any = await fetch(request);
|
||||
// build response for cache
|
||||
const cacheHeaders = new Headers();
|
||||
for (const kv of originResponse.headers.entries()) {
|
||||
cacheHeaders.append(kv[0], kv[1]);
|
||||
}
|
||||
cacheHeaders.append('SERVEZONE_ROUTE', 'LOSSLESS_EDGE_KVRESPONSE');
|
||||
cacheHeaders.append('Cache-Control', 'max-age=600');
|
||||
const responseForKV = new Response(await originResponse.body, {
|
||||
...originResponse,
|
||||
headers: cacheHeaders
|
||||
});
|
||||
await responseKvInstance.storeResponse(request.url, responseForKV.clone());
|
||||
return responseForKV.clone();
|
||||
};
|
||||
31
ts_edgeworker/responders/origin.responder.ts
Normal file
31
ts_edgeworker/responders/origin.responder.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
declare const fetch: plugins.cloudflareTypes.Fetcher['fetch'];
|
||||
|
||||
export const originResponder: interfaces.TRequestResponser = async (eventArg: WorkerEvent) => {
|
||||
const originResponse: any = await fetch(eventArg.request);
|
||||
// lets capture status
|
||||
if (originResponse.status > 399) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
for (const kv of originResponse.headers.entries()) {
|
||||
headers.append(kv[0], kv[1]);
|
||||
}
|
||||
headers.append('SERVEZONE_ROUTE', 'LOSSLESS_EDGE_FASTORIGIN');
|
||||
|
||||
const responsePassThroughStream = new TransformStream();
|
||||
originResponse.body.pipeTo(responsePassThroughStream.writable);
|
||||
|
||||
// response
|
||||
|
||||
const responseForClient = new Response(responsePassThroughStream.readable, {
|
||||
...originResponse,
|
||||
headers,
|
||||
});
|
||||
|
||||
eventArg.setResponse(responseForClient);
|
||||
};
|
||||
18
ts_edgeworker/responders/preflight.responder.ts
Normal file
18
ts_edgeworker/responders/preflight.responder.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const preflightResponder: interfaces.TRequestResponser = async (eventArg: WorkerEvent) => {
|
||||
if (eventArg.isPreflight) {
|
||||
const corsHeaders = new Headers();
|
||||
corsHeaders.append('SERVEZONE_ROUTE', 'LOSSLESS_EDGE_PREFLIGHT');
|
||||
corsHeaders.append('Access-Control-Allow-Origin', '*');
|
||||
corsHeaders.append('Access-Control-Allow-Methods', '*');
|
||||
corsHeaders.append('Access-Control-Allow-Headers', '*');
|
||||
|
||||
eventArg.setResponse(
|
||||
new Response(null, {
|
||||
headers: corsHeaders,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
9
ts_edgeworker/responders/redirect.reponder.ts
Normal file
9
ts_edgeworker/responders/redirect.reponder.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const redirectResponder: interfaces.TRequestResponser = async (cWorkerEventArg: WorkerEvent) => {
|
||||
if (cWorkerEventArg.responderInstruction.type === 'redirect') {
|
||||
cWorkerEventArg.setResponse(Response.redirect(cWorkerEventArg.responderInstruction.redirectUrl, 302));
|
||||
}
|
||||
|
||||
};
|
||||
22
ts_edgeworker/responders/rendertron.responder.ts
Normal file
22
ts_edgeworker/responders/rendertron.responder.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const rendertronResponder = async (cworkerevent: WorkerEvent) => {
|
||||
if (cworkerevent.routedThroughRendertron) {
|
||||
const oldHeaders: any = cworkerevent.request.headers;
|
||||
const rendertronHeaders = new Headers();
|
||||
for (const kv of oldHeaders.entries()) {
|
||||
const headerName = kv[0];
|
||||
const headerValue = headerName === 'user-agent' ? 'Lossless Rendertron' : kv[1];
|
||||
rendertronHeaders.append(headerName, headerValue);
|
||||
}
|
||||
const rendertronRequest = new Request(
|
||||
`https://rendertron.lossless.one/render/${cworkerevent.request.url}`,
|
||||
{
|
||||
method: cworkerevent.request.method,
|
||||
headers: rendertronHeaders
|
||||
}
|
||||
);
|
||||
cworkerevent.setResponse(await fetch(rendertronRequest));
|
||||
}
|
||||
};
|
||||
31
ts_edgeworker/responders/static.responder.ts
Normal file
31
ts_edgeworker/responders/static.responder.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const staticResponder: interfaces.TRequestResponser = async (cWorkerEventArg: WorkerEvent) => {
|
||||
if (cWorkerEventArg.responderInstruction.type === 'static') {
|
||||
const originResponse: any = await fetch(
|
||||
`https://statichost.lossless.one/resolve?url=${encodeURI(cWorkerEventArg.request.url)}`
|
||||
);
|
||||
|
||||
const cacheHeaders = new Headers();
|
||||
for (const kv of originResponse.headers.entries()) {
|
||||
cacheHeaders.append(kv[0], kv[1]);
|
||||
}
|
||||
cacheHeaders.delete('SERVEZONE_ROUTE');
|
||||
cacheHeaders.append('SERVEZONE_ROUTE', 'LOSSLESS_EDGE_STATICHOST');
|
||||
|
||||
if (cWorkerEventArg.responderInstruction.cacheClientSideForMin) {
|
||||
cacheHeaders.delete('Cache-Control');
|
||||
cacheHeaders.append('Cache-Control', `public, max-age=${cWorkerEventArg.responderInstruction.cacheClientSideForMin * 60}`);
|
||||
cacheHeaders.delete('Expires');
|
||||
cacheHeaders.append('Expires', new Date(Date.now() + cWorkerEventArg.responderInstruction.cacheClientSideForMin * 1000).toUTCString());
|
||||
}
|
||||
|
||||
const responseForClient = new Response(await originResponse.clone().body, {
|
||||
...originResponse,
|
||||
headers: cacheHeaders
|
||||
});
|
||||
|
||||
cWorkerEventArg.setResponse(responseForClient);
|
||||
}
|
||||
};
|
||||
23
ts_edgeworker/responders/timeout.responder.ts
Normal file
23
ts_edgeworker/responders/timeout.responder.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const timeoutResponder: interfaces.TRequestResponser = async (cWorkerEvent: WorkerEvent) => {
|
||||
await plugins.smartdelay.delayFor(10000);
|
||||
if (cWorkerEvent.routedThroughRendertron) {
|
||||
await plugins.smartdelay.delayFor(10000);
|
||||
}
|
||||
if (!cWorkerEvent.hasResponse) {
|
||||
const errorResponse = await fetch(
|
||||
`https://nullresolve.lossless.one/custom?title=${encodeURI(
|
||||
`Lossless Network: Request Cancellation!`
|
||||
)}&heading=${encodeURI(`Error: Request Cancellation`)}&text=${encodeURI(
|
||||
`Lossless Network could not decide how to respond to this request within 5 seconds. Therefore it timed out and has been canceled.
|
||||
<p>requestUrl: ${cWorkerEvent.request.url}<br>
|
||||
requestTime: ${Date.now()}<br>
|
||||
referenceNumber: xxxxxx</p>`
|
||||
)}`
|
||||
);
|
||||
cWorkerEvent.setResponse(errorResponse);
|
||||
}
|
||||
};
|
||||
21
ts_edgeworker/responders/urlformatting.responder.ts
Normal file
21
ts_edgeworker/responders/urlformatting.responder.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import { WorkerEvent } from '../classes.workerevent.js';
|
||||
|
||||
export const urlFormattingResponder: interfaces.TRequestResponser = async (eventArg: WorkerEvent) => {
|
||||
let shouldCorrect = false;
|
||||
const correctedUrl = new URL(eventArg.request.url);
|
||||
if (eventArg.parsedUrl.hostname.startsWith('www.')) {
|
||||
shouldCorrect = true;
|
||||
correctedUrl.hostname = eventArg.parsedUrl.hostname.substring(
|
||||
4,
|
||||
eventArg.parsedUrl.hostname.length
|
||||
);
|
||||
}
|
||||
if (eventArg.parsedUrl.protocol.startsWith('http:')) {
|
||||
shouldCorrect = true;
|
||||
correctedUrl.protocol = 'https:';
|
||||
}
|
||||
if (shouldCorrect) {
|
||||
eventArg.setResponse(Response.redirect(`${correctedUrl.protocol}//${correctedUrl.host}${correctedUrl.pathname}${correctedUrl.search}`, 301));
|
||||
}
|
||||
};
|
||||
7
ts_edgeworker/versionhandler.ts
Normal file
7
ts_edgeworker/versionhandler.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
export class VersionHandler {
|
||||
|
||||
}
|
||||
|
||||
export const versionHandlerInstance = new VersionHandler();
|
||||
@@ -1,3 +1,9 @@
|
||||
export * from './requestmodifier.js';
|
||||
export * from './responsemodifier.js';
|
||||
export * from './typedrequests.js';
|
||||
|
||||
import * as serviceworker from './serviceworker.js';
|
||||
|
||||
export {
|
||||
serviceworker,
|
||||
}
|
||||
5
ts_interfaces/plugins.ts
Normal file
5
ts_interfaces/plugins.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
|
||||
export {
|
||||
typedrequestInterfaces,
|
||||
}
|
||||
641
ts_interfaces/serviceworker.ts
Normal file
641
ts_interfaces/serviceworker.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface CacheStorage {
|
||||
keys: () => Promise<string[]>;
|
||||
match: any;
|
||||
open: any;
|
||||
delete: any;
|
||||
}
|
||||
export declare var caches: CacheStorage;
|
||||
|
||||
|
||||
// =============================
|
||||
// Interfaces for communication
|
||||
// =============================
|
||||
|
||||
export interface IMessage_Serviceworker_Client_UpdateInfo
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_Client_UpdateInfo
|
||||
> {
|
||||
method: 'serviceworker_newVersion';
|
||||
request: {
|
||||
appVersion: string;
|
||||
appHash: string;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IMessage_Serviceworker_Client_RequestReload
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_Client_RequestReload
|
||||
> {
|
||||
method: 'serviceworker_requestReload';
|
||||
request: {};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IRequest_Serviceworker_Backend_VersionInfo
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_Backend_VersionInfo
|
||||
> {
|
||||
method: 'serviceworker_versionInfo';
|
||||
request: {};
|
||||
response: {
|
||||
appHash: string;
|
||||
appSemVer: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ===============
|
||||
// web
|
||||
// ===============
|
||||
/**
|
||||
* purges the service workers cache
|
||||
*/
|
||||
export interface IRequest_PurgeServiceWorkerCache extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_PurgeServiceWorkerCache
|
||||
> {
|
||||
method: 'purgeServiceWorkerCache';
|
||||
request: {};
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the info in all connected tabs
|
||||
*/
|
||||
export interface IMessage_Serviceworker_Client_UpdateInfo
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_Client_UpdateInfo
|
||||
> {
|
||||
method: 'serviceworker_newVersion';
|
||||
request: {
|
||||
appVersion: string;
|
||||
appHash: string;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* requests all clients to reload
|
||||
*/
|
||||
export interface IMessage_Serviceworker_Client_RequestReload
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_Client_RequestReload
|
||||
> {
|
||||
method: 'serviceworker_requestReload';
|
||||
request: {};
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* updates version infos
|
||||
*/
|
||||
export interface IRequest_Serviceworker_Backend_VersionInfo
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_Backend_VersionInfo
|
||||
> {
|
||||
method: 'serviceworker_versionInfo';
|
||||
request: {};
|
||||
response: {
|
||||
appHash: string;
|
||||
appSemVer: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ensures a stable connection between clients and the serviceworker
|
||||
*/
|
||||
export interface IRequest_Client_Serviceworker_ConnectionPolling
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Client_Serviceworker_ConnectionPolling
|
||||
> {
|
||||
method: 'broadcastConnectionPolling',
|
||||
request: {
|
||||
tabId: string;
|
||||
},
|
||||
response: {
|
||||
serviceworkerId: string;
|
||||
}
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Metrics interfaces
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Request to get service worker metrics
|
||||
*/
|
||||
export interface IRequest_Serviceworker_Metrics
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_Metrics
|
||||
> {
|
||||
method: 'serviceworker_metrics';
|
||||
request: {};
|
||||
response: {
|
||||
cache: {
|
||||
hits: number;
|
||||
misses: number;
|
||||
errors: number;
|
||||
bytesServedFromCache: number;
|
||||
bytesFetched: number;
|
||||
averageResponseTime: number;
|
||||
};
|
||||
network: {
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
timeouts: number;
|
||||
averageLatency: number;
|
||||
totalBytesTransferred: number;
|
||||
};
|
||||
update: {
|
||||
totalChecks: number;
|
||||
successfulChecks: number;
|
||||
failedChecks: number;
|
||||
updatesFound: number;
|
||||
updatesApplied: number;
|
||||
lastCheckTimestamp: number;
|
||||
lastUpdateTimestamp: number;
|
||||
};
|
||||
connection: {
|
||||
connectedClients: number;
|
||||
totalConnectionAttempts: number;
|
||||
successfulConnections: number;
|
||||
failedConnections: number;
|
||||
};
|
||||
startTime: number;
|
||||
uptime: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Connection result interface
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Result of a service worker connection attempt
|
||||
*/
|
||||
export interface IConnectionResult {
|
||||
connected: boolean;
|
||||
error?: string;
|
||||
attempts?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Speedtest interfaces
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Cache invalidation request from server to service worker
|
||||
*/
|
||||
export interface IRequest_Serviceworker_CacheInvalidate
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_CacheInvalidate
|
||||
> {
|
||||
method: 'serviceworker_cacheInvalidate';
|
||||
request: {
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Speedtest request between service worker and backend
|
||||
*
|
||||
* Types:
|
||||
* - 'latency': Simple ping to measure round-trip time
|
||||
* - 'download_chunk': Request a chunk of data (64KB default)
|
||||
* - 'upload_chunk': Send a chunk of data to server
|
||||
*
|
||||
* The client runs a loop calling download_chunk or upload_chunk
|
||||
* until the desired test duration (e.g., 5 seconds) elapses.
|
||||
*/
|
||||
export interface IRequest_Serviceworker_Speedtest
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_Speedtest
|
||||
> {
|
||||
method: 'serviceworker_speedtest';
|
||||
request: {
|
||||
type: 'latency' | 'download_chunk' | 'upload_chunk';
|
||||
chunkSizeKB?: number; // Size of chunk in KB (default: 64)
|
||||
payload?: string; // For upload_chunk, the data to send
|
||||
};
|
||||
response: {
|
||||
bytesTransferred: number;
|
||||
timestamp: number;
|
||||
payload?: string; // For download_chunk, the data received
|
||||
};
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Status update interfaces
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Status update source types
|
||||
*/
|
||||
export type TStatusSource = 'backend' | 'serviceworker' | 'network';
|
||||
|
||||
/**
|
||||
* Status update event types
|
||||
*/
|
||||
export type TStatusType = 'connected' | 'disconnected' | 'reconnecting' | 'update' | 'cache' | 'error' | 'offline' | 'online';
|
||||
|
||||
/**
|
||||
* Status update details
|
||||
*/
|
||||
export interface IStatusDetails {
|
||||
version?: string;
|
||||
cacheHitRate?: number;
|
||||
resourceCount?: number;
|
||||
connectionType?: string;
|
||||
latencyMs?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status update payload sent from SW to clients
|
||||
*/
|
||||
export interface IStatusUpdate {
|
||||
source: TStatusSource;
|
||||
type: TStatusType;
|
||||
message: string;
|
||||
details?: IStatusDetails;
|
||||
persist?: boolean; // Stay visible until resolved
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for status updates from service worker to clients
|
||||
*/
|
||||
export interface IMessage_Serviceworker_StatusUpdate
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_StatusUpdate
|
||||
> {
|
||||
method: 'serviceworker_statusUpdate';
|
||||
request: IStatusUpdate;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get current service worker status
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetStatus
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetStatus
|
||||
> {
|
||||
method: 'serviceworker_getStatus';
|
||||
request: {};
|
||||
response: {
|
||||
isActive: boolean;
|
||||
isOnline: boolean;
|
||||
version?: string;
|
||||
cacheHitRate: number;
|
||||
resourceCount: number;
|
||||
connectionType?: string;
|
||||
connectedClients: number;
|
||||
lastUpdateCheck: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Persistent Store interfaces
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Event types for the persistent event log
|
||||
*/
|
||||
export type TEventType =
|
||||
| 'sw_installed'
|
||||
| 'sw_activated'
|
||||
| 'sw_updated'
|
||||
| 'sw_stopped'
|
||||
| 'speedtest_started'
|
||||
| 'speedtest_completed'
|
||||
| 'speedtest_failed'
|
||||
| 'backend_connected'
|
||||
| 'backend_disconnected'
|
||||
| 'cache_invalidated'
|
||||
| 'network_online'
|
||||
| 'network_offline'
|
||||
| 'update_check'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* Event log entry structure
|
||||
* Survives both SW restarts AND cache invalidation
|
||||
*/
|
||||
export interface IEventLogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: TEventType;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumulative metrics that persist across SW restarts
|
||||
* Reset on cache invalidation
|
||||
*/
|
||||
export interface ICumulativeMetrics {
|
||||
firstSeenTimestamp: number;
|
||||
totalCacheHits: number;
|
||||
totalCacheMisses: number;
|
||||
totalCacheErrors: number;
|
||||
totalBytesServedFromCache: number;
|
||||
totalBytesFetched: number;
|
||||
totalNetworkRequests: number;
|
||||
totalNetworkSuccesses: number;
|
||||
totalNetworkFailures: number;
|
||||
totalNetworkTimeouts: number;
|
||||
totalBytesTransferred: number;
|
||||
totalUpdateChecks: number;
|
||||
totalUpdatesApplied: number;
|
||||
totalSpeedtests: number;
|
||||
swRestartCount: number;
|
||||
lastUpdatedTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get event log from service worker
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetEventLog
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetEventLog
|
||||
> {
|
||||
method: 'serviceworker_getEventLog';
|
||||
request: {
|
||||
limit?: number;
|
||||
type?: TEventType;
|
||||
since?: number;
|
||||
before?: number; // For pagination: get events before this timestamp
|
||||
};
|
||||
response: {
|
||||
events: IEventLogEntry[];
|
||||
totalCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get cumulative metrics from service worker
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetCumulativeMetrics
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetCumulativeMetrics
|
||||
> {
|
||||
method: 'serviceworker_getCumulativeMetrics';
|
||||
request: {};
|
||||
response: ICumulativeMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to clear event log
|
||||
*/
|
||||
export interface IRequest_Serviceworker_ClearEventLog
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_ClearEventLog
|
||||
> {
|
||||
method: 'serviceworker_clearEventLog';
|
||||
request: {};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get event count since a timestamp
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetEventCount
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetEventCount
|
||||
> {
|
||||
method: 'serviceworker_getEventCount';
|
||||
request: {
|
||||
since: number;
|
||||
};
|
||||
response: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Push message interfaces (SW → Clients via DeesComms)
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Push notification when a new event is logged
|
||||
* Sent via DeesComms BroadcastChannel
|
||||
*/
|
||||
export interface IMessage_Serviceworker_EventLogged
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_EventLogged
|
||||
> {
|
||||
method: 'serviceworker_eventLogged';
|
||||
request: IEventLogEntry;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics snapshot for push updates
|
||||
*/
|
||||
export interface IMetricsSnapshot {
|
||||
cache: {
|
||||
hits: number;
|
||||
misses: number;
|
||||
errors: number;
|
||||
bytesServedFromCache: number;
|
||||
bytesFetched: number;
|
||||
};
|
||||
network: {
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
};
|
||||
cacheHitRate: number;
|
||||
networkSuccessRate: number;
|
||||
resourceCount: number;
|
||||
uptime: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push notification for metrics updates
|
||||
* Sent via DeesComms BroadcastChannel (throttled)
|
||||
*/
|
||||
export interface IMessage_Serviceworker_MetricsUpdate
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_MetricsUpdate
|
||||
> {
|
||||
method: 'serviceworker_metricsUpdate';
|
||||
request: IMetricsSnapshot;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push notification when a new resource is cached
|
||||
*/
|
||||
export interface IMessage_Serviceworker_ResourceCached
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_ResourceCached
|
||||
> {
|
||||
method: 'serviceworker_resourceCached';
|
||||
request: {
|
||||
url: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
cached: boolean;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
// =============================
|
||||
// TypedRequest Traffic Monitoring
|
||||
// =============================
|
||||
|
||||
/**
|
||||
* Log entry for TypedRequest traffic monitoring
|
||||
*/
|
||||
export interface ITypedRequestLogEntry {
|
||||
correlationId: string;
|
||||
method: string;
|
||||
direction: 'outgoing' | 'incoming';
|
||||
phase: 'request' | 'response';
|
||||
timestamp: number;
|
||||
durationMs?: number;
|
||||
payload: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for TypedRequest traffic
|
||||
*/
|
||||
export interface ITypedRequestStats {
|
||||
totalRequests: number;
|
||||
totalResponses: number;
|
||||
methodCounts: Record<string, { requests: number; responses: number; errors: number; avgDurationMs: number }>;
|
||||
errorCount: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for logging TypedRequest traffic from client to SW
|
||||
*/
|
||||
export interface IMessage_Serviceworker_TypedRequestLog
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_TypedRequestLog
|
||||
> {
|
||||
method: 'serviceworker_typedRequestLog';
|
||||
request: ITypedRequestLogEntry;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push notification when a TypedRequest is logged
|
||||
*/
|
||||
export interface IMessage_Serviceworker_TypedRequestLogged
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_TypedRequestLogged
|
||||
> {
|
||||
method: 'serviceworker_typedRequestLogged';
|
||||
request: ITypedRequestLogEntry;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get TypedRequest logs
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetTypedRequestLogs
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetTypedRequestLogs
|
||||
> {
|
||||
method: 'serviceworker_getTypedRequestLogs';
|
||||
request: {
|
||||
limit?: number;
|
||||
method?: string;
|
||||
since?: number;
|
||||
before?: number; // For pagination: get logs before this timestamp
|
||||
};
|
||||
response: {
|
||||
logs: ITypedRequestLogEntry[];
|
||||
totalCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get TypedRequest traffic statistics
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetTypedRequestStats
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetTypedRequestStats
|
||||
> {
|
||||
method: 'serviceworker_getTypedRequestStats';
|
||||
request: {};
|
||||
response: ITypedRequestStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to clear TypedRequest logs
|
||||
*/
|
||||
export interface IRequest_Serviceworker_ClearTypedRequestLogs
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_ClearTypedRequestLogs
|
||||
> {
|
||||
method: 'serviceworker_clearTypedRequestLogs';
|
||||
request: {};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================
|
||||
// Shared Constants
|
||||
// =============================
|
||||
|
||||
/**
|
||||
* HTML shell for the SW Dashboard - shared between server and service worker
|
||||
*/
|
||||
export const SW_DASH_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SW Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0a; min-height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<sw-dash-app></sw-dash-app>
|
||||
<script type="module" src="/sw-dash/bundle.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
13
ts_swdash/index.ts
Normal file
13
ts_swdash/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// SW-Dash: Service Worker Dashboard
|
||||
// Entry point for the Lit-based dashboard application
|
||||
|
||||
// Import the main app component (which imports all others)
|
||||
import './sw-dash-app.js';
|
||||
|
||||
// Export components for external use if needed
|
||||
export { SwDashApp } from './sw-dash-app.js';
|
||||
export { SwDashOverview } from './sw-dash-overview.js';
|
||||
export { SwDashTable } from './sw-dash-table.js';
|
||||
export { SwDashUrls } from './sw-dash-urls.js';
|
||||
export { SwDashDomains } from './sw-dash-domains.js';
|
||||
export { SwDashTypes } from './sw-dash-types.js';
|
||||
19
ts_swdash/plugins.ts
Normal file
19
ts_swdash/plugins.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Lit imports
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import type { CSSResult, TemplateResult } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
// DeesComms for push communication
|
||||
import * as deesComms from '@design.estate/dees-comms';
|
||||
|
||||
export {
|
||||
LitElement,
|
||||
html,
|
||||
css,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
deesComms,
|
||||
};
|
||||
|
||||
export type { CSSResult, TemplateResult };
|
||||
582
ts_swdash/sw-dash-app.ts
Normal file
582
ts_swdash/sw-dash-app.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import { LitElement, html, css, state, customElement, deesComms } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, terminalStyles, navStyles } from './sw-dash-styles.js';
|
||||
import type { IMetricsData } from './sw-dash-overview.js';
|
||||
import type { ICachedResource } from './sw-dash-urls.js';
|
||||
import type { IDomainStats } from './sw-dash-domains.js';
|
||||
import type { IContentTypeStats } from './sw-dash-types.js';
|
||||
import type { serviceworker } from '../dist_ts_interfaces/index.js';
|
||||
|
||||
// Import components to register them
|
||||
import './sw-dash-overview.js';
|
||||
import './sw-dash-urls.js';
|
||||
import './sw-dash-domains.js';
|
||||
import './sw-dash-types.js';
|
||||
import './sw-dash-events.js';
|
||||
import './sw-dash-requests.js';
|
||||
import './sw-dash-table.js';
|
||||
|
||||
type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events' | 'requests';
|
||||
|
||||
interface IResourceData {
|
||||
resources: ICachedResource[];
|
||||
domains: IDomainStats[];
|
||||
contentTypes: IContentTypeStats[];
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main SW Dashboard application shell
|
||||
*
|
||||
* Architecture:
|
||||
* - ONE initial HTTP seed request to /sw-dash/metrics (provides ALL data)
|
||||
* - HTTP heartbeat every 30s for SW health check
|
||||
* - Everything else via DeesComms (push from SW, requests to SW)
|
||||
*/
|
||||
@customElement('sw-dash-app')
|
||||
export class SwDashApp extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
terminalStyles,
|
||||
navStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.uptime-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.uptime-badge .value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.auto-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--accent-success);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auto-refresh .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
// Core metrics
|
||||
@state() accessor currentView: ViewType = 'overview';
|
||||
@state() accessor metrics: IMetricsData | null = null;
|
||||
@state() accessor lastRefresh = new Date().toLocaleTimeString();
|
||||
@state() accessor isConnected = false;
|
||||
|
||||
// Resource data (from initial seed)
|
||||
@state() accessor resourceData: IResourceData = {
|
||||
resources: [],
|
||||
domains: [],
|
||||
contentTypes: [],
|
||||
resourceCount: 0
|
||||
};
|
||||
|
||||
// Events data (from initial seed + push updates)
|
||||
@state() accessor events: serviceworker.IEventLogEntry[] = [];
|
||||
@state() accessor eventTotalCount = 0;
|
||||
@state() accessor eventCountLastHour = 0;
|
||||
|
||||
// Request logs data (from initial seed + push updates)
|
||||
@state() accessor requestLogs: serviceworker.ITypedRequestLogEntry[] = [];
|
||||
@state() accessor requestTotalCount = 0;
|
||||
@state() accessor requestStats: serviceworker.ITypedRequestStats | null = null;
|
||||
@state() accessor requestMethods: string[] = [];
|
||||
|
||||
// DeesComms for communication with service worker
|
||||
private comms: deesComms.DeesComms | null = null;
|
||||
|
||||
// Heartbeat interval (30 seconds) for SW health check
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly HEARTBEAT_INTERVAL_MS = 30000;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Initial HTTP seed request to wake up SW and get ALL initial data
|
||||
this.loadInitialData();
|
||||
// Setup push listeners via DeesComms
|
||||
this.setupPushListeners();
|
||||
// Start heartbeat for SW health check
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial HTTP request to seed ALL data and wake up service worker
|
||||
* This is the ONE HTTP request that provides everything:
|
||||
* - Core metrics
|
||||
* - Resources, domains, content types
|
||||
* - Events (initial 50)
|
||||
* - Request logs (initial 50), stats, methods
|
||||
*/
|
||||
private async loadInitialData(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/sw-dash/metrics');
|
||||
const data = await response.json();
|
||||
|
||||
// Core metrics
|
||||
this.metrics = data;
|
||||
|
||||
// Resource data
|
||||
this.resourceData = {
|
||||
resources: data.resources || [],
|
||||
domains: data.domains || [],
|
||||
contentTypes: data.contentTypes || [],
|
||||
resourceCount: data.resourceCount || 0,
|
||||
};
|
||||
|
||||
// Events data
|
||||
this.events = data.events || [];
|
||||
this.eventTotalCount = data.eventTotalCount || 0;
|
||||
this.eventCountLastHour = data.eventCountLastHour || 0;
|
||||
|
||||
// Request logs data
|
||||
this.requestLogs = data.requestLogs || [];
|
||||
this.requestTotalCount = data.requestTotalCount || 0;
|
||||
this.requestStats = data.requestStats || null;
|
||||
this.requestMethods = data.requestMethods || [];
|
||||
|
||||
this.lastRefresh = new Date().toLocaleTimeString();
|
||||
this.isConnected = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial data:', err);
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup DeesComms handlers for receiving push updates from SW
|
||||
* All real-time updates come through here
|
||||
*/
|
||||
private setupPushListeners(): void {
|
||||
this.comms = new deesComms.DeesComms();
|
||||
|
||||
// Handle metrics push updates
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_MetricsUpdate>(
|
||||
'serviceworker_metricsUpdate',
|
||||
async (snapshot) => {
|
||||
// Update metrics from push
|
||||
if (this.metrics) {
|
||||
this.metrics = {
|
||||
...this.metrics,
|
||||
cache: {
|
||||
...this.metrics.cache,
|
||||
hits: snapshot.cache.hits,
|
||||
misses: snapshot.cache.misses,
|
||||
errors: snapshot.cache.errors,
|
||||
bytesServedFromCache: snapshot.cache.bytesServedFromCache,
|
||||
bytesFetched: snapshot.cache.bytesFetched,
|
||||
},
|
||||
network: {
|
||||
...this.metrics.network,
|
||||
totalRequests: snapshot.network.totalRequests,
|
||||
successfulRequests: snapshot.network.successfulRequests,
|
||||
failedRequests: snapshot.network.failedRequests,
|
||||
},
|
||||
cacheHitRate: snapshot.cacheHitRate,
|
||||
networkSuccessRate: snapshot.networkSuccessRate,
|
||||
resourceCount: snapshot.resourceCount,
|
||||
uptime: snapshot.uptime,
|
||||
};
|
||||
}
|
||||
this.lastRefresh = new Date().toLocaleTimeString();
|
||||
this.isConnected = true;
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
// Handle new event logged - add to our events array
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_EventLogged>(
|
||||
'serviceworker_eventLogged',
|
||||
async (entry) => {
|
||||
// Prepend new event to array
|
||||
this.events = [entry, ...this.events];
|
||||
this.eventTotalCount++;
|
||||
// Check if event is within last hour
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
if (entry.timestamp >= oneHourAgo) {
|
||||
this.eventCountLastHour++;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
// Handle resource cached push updates
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_ResourceCached>(
|
||||
'serviceworker_resourceCached',
|
||||
async (resource) => {
|
||||
// Update resource count optimistically
|
||||
if (resource.cached && this.metrics) {
|
||||
this.metrics = {
|
||||
...this.metrics,
|
||||
resourceCount: this.metrics.resourceCount + 1,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
// Handle new TypedRequest logged - add to our logs array
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_TypedRequestLogged>(
|
||||
'serviceworker_typedRequestLogged',
|
||||
async (entry) => {
|
||||
// Prepend new log to array
|
||||
this.requestLogs = [entry, ...this.requestLogs];
|
||||
this.requestTotalCount++;
|
||||
|
||||
// Update stats optimistically
|
||||
if (this.requestStats) {
|
||||
const newStats = { ...this.requestStats };
|
||||
if (entry.phase === 'request') {
|
||||
newStats.totalRequests++;
|
||||
} else {
|
||||
newStats.totalResponses++;
|
||||
}
|
||||
if (entry.error) {
|
||||
newStats.errorCount++;
|
||||
}
|
||||
// Update method counts
|
||||
if (!newStats.methodCounts[entry.method]) {
|
||||
newStats.methodCounts[entry.method] = { requests: 0, responses: 0, errors: 0, avgDurationMs: 0 };
|
||||
// Add to methods list if new
|
||||
if (!this.requestMethods.includes(entry.method)) {
|
||||
this.requestMethods = [...this.requestMethods, entry.method];
|
||||
}
|
||||
}
|
||||
if (entry.phase === 'request') {
|
||||
newStats.methodCounts[entry.method].requests++;
|
||||
} else {
|
||||
newStats.methodCounts[entry.method].responses++;
|
||||
}
|
||||
if (entry.error) {
|
||||
newStats.methodCounts[entry.method].errors++;
|
||||
}
|
||||
this.requestStats = newStats;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat to check SW health periodically (HTTP)
|
||||
* This is the ONLY periodic HTTP request
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/sw-dash/metrics');
|
||||
if (response.ok) {
|
||||
this.isConnected = true;
|
||||
// Refresh all data from heartbeat response
|
||||
const data = await response.json();
|
||||
this.metrics = data;
|
||||
this.resourceData = {
|
||||
resources: data.resources || [],
|
||||
domains: data.domains || [],
|
||||
contentTypes: data.contentTypes || [],
|
||||
resourceCount: data.resourceCount || 0,
|
||||
};
|
||||
this.events = data.events || [];
|
||||
this.eventTotalCount = data.eventTotalCount || 0;
|
||||
this.eventCountLastHour = data.eventCountLastHour || 0;
|
||||
this.requestLogs = data.requestLogs || [];
|
||||
this.requestTotalCount = data.requestTotalCount || 0;
|
||||
this.requestStats = data.requestStats || null;
|
||||
this.requestMethods = data.requestMethods || [];
|
||||
this.lastRefresh = new Date().toLocaleTimeString();
|
||||
} else {
|
||||
this.isConnected = false;
|
||||
}
|
||||
} catch {
|
||||
this.isConnected = false;
|
||||
}
|
||||
}, this.HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "load more events" request from sw-dash-events component
|
||||
* Uses DeesComms to request older events from SW
|
||||
*/
|
||||
private async handleLoadMoreEvents(e: CustomEvent<{ before: number }>): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_GetEventLog>('serviceworker_getEventLog');
|
||||
const result = await tr.fire({
|
||||
limit: 50,
|
||||
before: e.detail.before,
|
||||
});
|
||||
// Append older events to existing array
|
||||
this.events = [...this.events, ...result.events];
|
||||
this.eventTotalCount = result.totalCount;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more events:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "clear events" request from sw-dash-events component
|
||||
* Uses DeesComms to clear event log in SW
|
||||
*/
|
||||
private async handleClearEvents(): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_ClearEventLog>('serviceworker_clearEventLog');
|
||||
await tr.fire({});
|
||||
// Clear local state
|
||||
this.events = [];
|
||||
this.eventTotalCount = 0;
|
||||
this.eventCountLastHour = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to clear events:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "load more requests" from sw-dash-requests component
|
||||
* Uses DeesComms to request older request logs from SW
|
||||
*/
|
||||
private async handleLoadMoreRequests(e: CustomEvent<{ before: number; method?: string }>): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs');
|
||||
const result = await tr.fire({
|
||||
limit: 50,
|
||||
before: e.detail.before,
|
||||
method: e.detail.method,
|
||||
});
|
||||
// Append older logs to existing array
|
||||
this.requestLogs = [...this.requestLogs, ...result.logs];
|
||||
this.requestTotalCount = result.totalCount;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more requests:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "clear requests" from sw-dash-requests component
|
||||
* Uses DeesComms to clear request logs in SW
|
||||
*/
|
||||
private async handleClearRequests(): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs');
|
||||
await tr.fire({});
|
||||
// Clear local state
|
||||
this.requestLogs = [];
|
||||
this.requestTotalCount = 0;
|
||||
this.requestStats = {
|
||||
totalRequests: 0,
|
||||
totalResponses: 0,
|
||||
methodCounts: {},
|
||||
errorCount: 0,
|
||||
avgDurationMs: 0,
|
||||
};
|
||||
this.requestMethods = [];
|
||||
} catch (err) {
|
||||
console.error('Failed to clear requests:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private setView(view: ViewType): void {
|
||||
this.currentView = view;
|
||||
// No HTTP fetch on view change - data is already loaded from initial seed
|
||||
}
|
||||
|
||||
private handleSpeedtestComplete(_e: CustomEvent): void {
|
||||
// Refresh metrics after speedtest via HTTP
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
private formatUptime(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
const d = Math.floor(h / 24);
|
||||
if (d > 0) return `${d}d ${h % 24}h`;
|
||||
if (h > 0) return `${h}h ${m % 60}m`;
|
||||
if (m > 0) return `${m}m ${s % 60}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="terminal">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">SW</div>
|
||||
<span class="title">Service Worker Dashboard</span>
|
||||
</div>
|
||||
<div class="uptime-badge">
|
||||
Uptime: <span class="value">${this.metrics ? this.formatUptime(this.metrics.uptime) : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'overview' ? 'active' : ''}"
|
||||
@click="${() => this.setView('overview')}"
|
||||
>Overview</button>
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'urls' ? 'active' : ''}"
|
||||
@click="${() => this.setView('urls')}"
|
||||
>URLs <span class="count">${this.resourceData.resourceCount}</span></button>
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'domains' ? 'active' : ''}"
|
||||
@click="${() => this.setView('domains')}"
|
||||
>Domains</button>
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'types' ? 'active' : ''}"
|
||||
@click="${() => this.setView('types')}"
|
||||
>Types</button>
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'events' ? 'active' : ''}"
|
||||
@click="${() => this.setView('events')}"
|
||||
>Events</button>
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'requests' ? 'active' : ''}"
|
||||
@click="${() => this.setView('requests')}"
|
||||
>Requests</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<div class="view ${this.currentView === 'overview' ? 'active' : ''}">
|
||||
<sw-dash-overview
|
||||
.metrics="${this.metrics}"
|
||||
.eventCountLastHour="${this.eventCountLastHour}"
|
||||
@speedtest-complete="${this.handleSpeedtestComplete}"
|
||||
></sw-dash-overview>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'urls' ? 'active' : ''}">
|
||||
<sw-dash-urls .resources="${this.resourceData.resources}"></sw-dash-urls>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'domains' ? 'active' : ''}">
|
||||
<sw-dash-domains .domains="${this.resourceData.domains}"></sw-dash-domains>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'types' ? 'active' : ''}">
|
||||
<sw-dash-types .contentTypes="${this.resourceData.contentTypes}"></sw-dash-types>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'events' ? 'active' : ''}">
|
||||
<sw-dash-events
|
||||
.events="${this.events}"
|
||||
.totalCount="${this.eventTotalCount}"
|
||||
@load-more-events="${this.handleLoadMoreEvents}"
|
||||
@clear-events="${this.handleClearEvents}"
|
||||
></sw-dash-events>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'requests' ? 'active' : ''}">
|
||||
<sw-dash-requests
|
||||
.logs="${this.requestLogs}"
|
||||
.totalCount="${this.requestTotalCount}"
|
||||
.stats="${this.requestStats}"
|
||||
.methods="${this.requestMethods}"
|
||||
@load-more-requests="${this.handleLoadMoreRequests}"
|
||||
@clear-requests="${this.handleClearRequests}"
|
||||
></sw-dash-requests>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-left">
|
||||
Last updated: ${this.lastRefresh}
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<div class="auto-refresh">
|
||||
<span class="dot"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
52
ts_swdash/sw-dash-domains.ts
Normal file
52
ts_swdash/sw-dash-domains.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { LitElement, html, css, property, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, tableStyles } from './sw-dash-styles.js';
|
||||
import { SwDashTable } from './sw-dash-table.js';
|
||||
import type { IColumnConfig } from './sw-dash-table.js';
|
||||
|
||||
export interface IDomainStats {
|
||||
domain: string;
|
||||
totalResources: number;
|
||||
totalSize: number;
|
||||
totalHits: number;
|
||||
totalMisses: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains table view component
|
||||
*/
|
||||
@customElement('sw-dash-domains')
|
||||
export class SwDashDomains extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
tableStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
@property({ type: Array }) accessor domains: IDomainStats[] = [];
|
||||
|
||||
private columns: IColumnConfig[] = [
|
||||
{ key: 'domain', label: 'Domain' },
|
||||
{ key: 'totalResources', label: 'Resources', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'totalSize', label: 'Total Size', className: 'num', formatter: SwDashTable.formatBytes },
|
||||
{ key: 'totalHits', label: 'Hits', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'totalMisses', label: 'Misses', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'hitRate', label: 'Hit Rate' },
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sw-dash-table
|
||||
.columns="${this.columns}"
|
||||
.data="${this.domains}"
|
||||
filterPlaceholder="Filter domains..."
|
||||
infoLabel="domains"
|
||||
></sw-dash-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
359
ts_swdash/sw-dash-events.ts
Normal file
359
ts_swdash/sw-dash-events.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { LitElement, html, css, property, state, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, panelStyles, tableStyles, buttonStyles } from './sw-dash-styles.js';
|
||||
|
||||
export interface IEventLogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
type TEventFilter = 'all' | 'sw_installed' | 'sw_activated' | 'sw_updated' | 'sw_stopped'
|
||||
| 'speedtest_started' | 'speedtest_completed' | 'speedtest_failed'
|
||||
| 'backend_connected' | 'backend_disconnected'
|
||||
| 'cache_invalidated' | 'network_online' | 'network_offline'
|
||||
| 'update_check' | 'error';
|
||||
|
||||
/**
|
||||
* Events panel component for sw-dash
|
||||
*
|
||||
* Receives events via property from parent (sw-dash-app).
|
||||
* Filtering is done locally.
|
||||
* Load more and clear operations dispatch events to parent.
|
||||
*/
|
||||
@customElement('sw-dash-events')
|
||||
export class SwDashEvents extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
panelStyles,
|
||||
tableStyles,
|
||||
buttonStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.events-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.event-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.event-type.sw { background: rgba(99, 102, 241, 0.15); color: var(--accent-primary); }
|
||||
.event-type.speedtest { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
|
||||
.event-type.network { background: rgba(34, 197, 94, 0.15); color: var(--accent-success); }
|
||||
.event-type.cache { background: rgba(251, 191, 36, 0.15); color: var(--accent-warning); }
|
||||
.event-type.error { background: rgba(239, 68, 68, 0.15); color: var(--accent-error); }
|
||||
|
||||
.event-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.event-message {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.event-details {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-error);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: var(--accent-error);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
// Received from parent (sw-dash-app)
|
||||
@property({ type: Array }) accessor events: IEventLogEntry[] = [];
|
||||
@property({ type: Number }) accessor totalCount = 0;
|
||||
|
||||
// Local state for filtering
|
||||
@state() accessor filter: TEventFilter = 'all';
|
||||
@state() accessor searchText = '';
|
||||
@state() accessor isLoadingMore = false;
|
||||
|
||||
private handleFilterChange(e: Event): void {
|
||||
this.filter = (e.target as HTMLSelectElement).value as TEventFilter;
|
||||
// Local filtering - no HTTP request
|
||||
}
|
||||
|
||||
private handleSearch(e: Event): void {
|
||||
this.searchText = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
}
|
||||
|
||||
private handleClear(): void {
|
||||
if (!confirm('Are you sure you want to clear the event log? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
// Dispatch event to parent to clear via DeesComms
|
||||
this.dispatchEvent(new CustomEvent('clear-events', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private loadMore(): void {
|
||||
if (this.isLoadingMore || this.events.length === 0) return;
|
||||
|
||||
this.isLoadingMore = true;
|
||||
const oldestEvent = this.events[this.events.length - 1];
|
||||
|
||||
// Dispatch event to parent to load more via DeesComms
|
||||
this.dispatchEvent(new CustomEvent('load-more-events', {
|
||||
detail: { before: oldestEvent.timestamp },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
// Reset loading state after a short delay (parent will update events prop)
|
||||
setTimeout(() => {
|
||||
this.isLoadingMore = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private getTypeClass(type: string): string {
|
||||
if (type.startsWith('sw_')) return 'sw';
|
||||
if (type.startsWith('speedtest_')) return 'speedtest';
|
||||
if (type.startsWith('network_') || type.startsWith('backend_')) return 'network';
|
||||
if (type.startsWith('cache_') || type === 'update_check') return 'cache';
|
||||
if (type === 'error') return 'error';
|
||||
return 'sw';
|
||||
}
|
||||
|
||||
private formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
private formatTypeLabel(type: string): string {
|
||||
return type.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter events locally based on type and search text
|
||||
*/
|
||||
private getFilteredEvents(): IEventLogEntry[] {
|
||||
let result = this.events;
|
||||
|
||||
// Filter by type
|
||||
if (this.filter !== 'all') {
|
||||
result = result.filter(e => e.type === this.filter);
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (this.searchText) {
|
||||
result = result.filter(e =>
|
||||
e.message.toLowerCase().includes(this.searchText) ||
|
||||
e.type.toLowerCase().includes(this.searchText) ||
|
||||
(e.details && JSON.stringify(e.details).toLowerCase().includes(this.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const filteredEvents = this.getFilteredEvents();
|
||||
|
||||
return html`
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">${this.totalCount}</span>
|
||||
<span class="stat-label">Total Events</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">${filteredEvents.length}</span>
|
||||
<span class="stat-label">Showing</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="events-header">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Filter:</span>
|
||||
<select class="filter-select" @change="${this.handleFilterChange}">
|
||||
<option value="all">All Events</option>
|
||||
<option value="sw_installed">SW Installed</option>
|
||||
<option value="sw_activated">SW Activated</option>
|
||||
<option value="sw_updated">SW Updated</option>
|
||||
<option value="speedtest_started">Speedtest Started</option>
|
||||
<option value="speedtest_completed">Speedtest Completed</option>
|
||||
<option value="speedtest_failed">Speedtest Failed</option>
|
||||
<option value="network_online">Network Online</option>
|
||||
<option value="network_offline">Network Offline</option>
|
||||
<option value="cache_invalidated">Cache Invalidated</option>
|
||||
<option value="error">Errors</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search events..."
|
||||
.value="${this.searchText}"
|
||||
@input="${this.handleSearch}"
|
||||
style="width: 200px;"
|
||||
>
|
||||
</div>
|
||||
<button class="btn clear-btn" @click="${this.handleClear}">Clear Log</button>
|
||||
</div>
|
||||
|
||||
${this.events.length === 0 ? html`
|
||||
<div class="empty-state">No events recorded</div>
|
||||
` : filteredEvents.length === 0 ? html`
|
||||
<div class="empty-state">No events match filter</div>
|
||||
` : html`
|
||||
<div class="events-list">
|
||||
${filteredEvents.map(event => html`
|
||||
<div class="event-card">
|
||||
<div class="event-header">
|
||||
<span class="event-type ${this.getTypeClass(event.type)}">${this.formatTypeLabel(event.type)}</span>
|
||||
<span class="event-time">${this.formatTimestamp(event.timestamp)}</span>
|
||||
</div>
|
||||
<div class="event-message">${event.message}</div>
|
||||
${event.details ? html`
|
||||
<div class="event-details">${JSON.stringify(event.details, null, 2)}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
${this.events.length < this.totalCount ? html`
|
||||
<div class="pagination">
|
||||
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoadingMore}">
|
||||
${this.isLoadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
<span class="page-info">${this.events.length} of ${this.totalCount} events</span>
|
||||
</div>
|
||||
` : ''}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
291
ts_swdash/sw-dash-overview.ts
Normal file
291
ts_swdash/sw-dash-overview.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { LitElement, html, css, property, state, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, panelStyles, gaugeStyles, buttonStyles, speedtestStyles } from './sw-dash-styles.js';
|
||||
import { SwDashTable } from './sw-dash-table.js';
|
||||
|
||||
export interface IMetricsData {
|
||||
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;
|
||||
};
|
||||
speedtest: {
|
||||
lastDownloadSpeedMbps: number;
|
||||
lastUploadSpeedMbps: number;
|
||||
lastLatencyMs: number;
|
||||
lastTestTimestamp: number;
|
||||
testCount: number;
|
||||
isOnline: boolean;
|
||||
};
|
||||
startTime: number;
|
||||
uptime: number;
|
||||
cacheHitRate: number;
|
||||
networkSuccessRate: number;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview panel component with metrics gauges and stats
|
||||
*/
|
||||
@customElement('sw-dash-overview')
|
||||
export class SwDashOverview extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
panelStyles,
|
||||
gaugeStyles,
|
||||
buttonStyles,
|
||||
speedtestStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-muted);
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
@property({ type: Object }) accessor metrics: IMetricsData | null = null;
|
||||
@property({ type: Number }) accessor eventCountLastHour = 0;
|
||||
@state() accessor speedtestRunning = false;
|
||||
@state() accessor speedtestPhase: 'idle' | 'latency' | 'download' | 'upload' | 'complete' = 'idle';
|
||||
@state() accessor speedtestProgress = 0;
|
||||
@state() accessor speedtestElapsed = 0;
|
||||
|
||||
// Speedtest timing constants (must match service worker)
|
||||
private static readonly TEST_DURATION_MS = 5000; // 5 seconds per test
|
||||
private progressInterval: number | null = null;
|
||||
|
||||
private async runSpeedtest(): Promise<void> {
|
||||
if (this.speedtestRunning) return;
|
||||
this.speedtestRunning = true;
|
||||
this.speedtestPhase = 'latency';
|
||||
this.speedtestProgress = 0;
|
||||
this.speedtestElapsed = 0;
|
||||
|
||||
// Start progress animation (total ~10.5s: latency ~0.5s + 5s download + 5s upload)
|
||||
const totalEstimatedMs = 10500;
|
||||
const startTime = Date.now();
|
||||
|
||||
this.progressInterval = window.setInterval(() => {
|
||||
this.speedtestElapsed = Date.now() - startTime;
|
||||
this.speedtestProgress = Math.min(100, (this.speedtestElapsed / totalEstimatedMs) * 100);
|
||||
|
||||
// Estimate phase based on elapsed time
|
||||
if (this.speedtestElapsed < 500) {
|
||||
this.speedtestPhase = 'latency';
|
||||
} else if (this.speedtestElapsed < 5500) {
|
||||
this.speedtestPhase = 'download';
|
||||
} else {
|
||||
this.speedtestPhase = 'upload';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
const response = await fetch('/sw-dash/speedtest');
|
||||
const result = await response.json();
|
||||
|
||||
this.speedtestPhase = 'complete';
|
||||
this.speedtestProgress = 100;
|
||||
|
||||
// Dispatch event to parent to update metrics
|
||||
this.dispatchEvent(new CustomEvent('speedtest-complete', {
|
||||
detail: result,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Speedtest failed:', err);
|
||||
this.speedtestPhase = 'idle';
|
||||
} finally {
|
||||
if (this.progressInterval) {
|
||||
window.clearInterval(this.progressInterval);
|
||||
this.progressInterval = null;
|
||||
}
|
||||
// Keep showing complete state briefly, then reset
|
||||
setTimeout(() => {
|
||||
this.speedtestRunning = false;
|
||||
this.speedtestPhase = 'idle';
|
||||
this.speedtestProgress = 0;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
private getPhaseLabel(): string {
|
||||
switch (this.speedtestPhase) {
|
||||
case 'latency': return 'Testing latency';
|
||||
case 'download': return 'Download test';
|
||||
case 'upload': return 'Upload test';
|
||||
case 'complete': return 'Complete';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
private formatElapsed(): string {
|
||||
const seconds = Math.floor(this.speedtestElapsed / 1000);
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (!this.metrics) {
|
||||
return html`<div class="panel"><div class="panel-content">Loading metrics...</div></div>`;
|
||||
}
|
||||
|
||||
const m = this.metrics;
|
||||
const gaugeClass = SwDashTable.getGaugeClass;
|
||||
|
||||
return html`
|
||||
<div class="grid">
|
||||
<!-- Cache Panel -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">Cache</div>
|
||||
<div class="panel-content">
|
||||
<div class="gauge">
|
||||
<div class="gauge-header">
|
||||
<span class="gauge-label">Hit Rate</span>
|
||||
<span class="gauge-value">${m.cacheHitRate}%</span>
|
||||
</div>
|
||||
<div class="gauge-bar">
|
||||
<div class="gauge-fill ${gaugeClass(m.cacheHitRate)}" style="width: ${m.cacheHitRate}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row"><span class="label">Hits</span><span class="value success">${SwDashTable.formatNumber(m.cache.hits)}</span></div>
|
||||
<div class="row"><span class="label">Misses</span><span class="value warning">${SwDashTable.formatNumber(m.cache.misses)}</span></div>
|
||||
<div class="row"><span class="label">Errors</span><span class="value ${m.cache.errors > 0 ? 'error' : ''}">${SwDashTable.formatNumber(m.cache.errors)}</span></div>
|
||||
<div class="row"><span class="label">From Cache</span><span class="value">${SwDashTable.formatBytes(m.cache.bytesServedFromCache)}</span></div>
|
||||
<div class="row"><span class="label">Fetched</span><span class="value">${SwDashTable.formatBytes(m.cache.bytesFetched)}</span></div>
|
||||
<div class="row"><span class="label">Resources</span><span class="value">${m.resourceCount}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Panel -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">Network</div>
|
||||
<div class="panel-content">
|
||||
<div class="gauge">
|
||||
<div class="gauge-header">
|
||||
<span class="gauge-label">Success Rate</span>
|
||||
<span class="gauge-value">${m.networkSuccessRate}%</span>
|
||||
</div>
|
||||
<div class="gauge-bar">
|
||||
<div class="gauge-fill ${gaugeClass(m.networkSuccessRate)}" style="width: ${m.networkSuccessRate}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row"><span class="label">Total Requests</span><span class="value">${SwDashTable.formatNumber(m.network.totalRequests)}</span></div>
|
||||
<div class="row"><span class="label">Successful</span><span class="value success">${SwDashTable.formatNumber(m.network.successfulRequests)}</span></div>
|
||||
<div class="row"><span class="label">Failed</span><span class="value ${m.network.failedRequests > 0 ? 'error' : ''}">${SwDashTable.formatNumber(m.network.failedRequests)}</span></div>
|
||||
<div class="row"><span class="label">Timeouts</span><span class="value ${m.network.timeouts > 0 ? 'warning' : ''}">${SwDashTable.formatNumber(m.network.timeouts)}</span></div>
|
||||
<div class="row"><span class="label">Avg Latency</span><span class="value">${m.network.averageLatency}ms</span></div>
|
||||
<div class="row"><span class="label">Transferred</span><span class="value">${SwDashTable.formatBytes(m.network.totalBytesTransferred)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updates Panel -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">Updates</div>
|
||||
<div class="panel-content">
|
||||
<div class="row"><span class="label">Total Checks</span><span class="value">${SwDashTable.formatNumber(m.update.totalChecks)}</span></div>
|
||||
<div class="row"><span class="label">Successful</span><span class="value success">${SwDashTable.formatNumber(m.update.successfulChecks)}</span></div>
|
||||
<div class="row"><span class="label">Failed</span><span class="value ${m.update.failedChecks > 0 ? 'error' : ''}">${SwDashTable.formatNumber(m.update.failedChecks)}</span></div>
|
||||
<div class="row"><span class="label">Updates Found</span><span class="value">${SwDashTable.formatNumber(m.update.updatesFound)}</span></div>
|
||||
<div class="row"><span class="label">Updates Applied</span><span class="value success">${SwDashTable.formatNumber(m.update.updatesApplied)}</span></div>
|
||||
<div class="row"><span class="label">Last Check</span><span class="value">${SwDashTable.formatTimestamp(m.update.lastCheckTimestamp)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connections Panel -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">Connections</div>
|
||||
<div class="panel-content">
|
||||
<div class="row"><span class="label">Active Clients</span><span class="value success">${SwDashTable.formatNumber(m.connection.connectedClients)}</span></div>
|
||||
<div class="row"><span class="label">Total Attempts</span><span class="value">${SwDashTable.formatNumber(m.connection.totalConnectionAttempts)}</span></div>
|
||||
<div class="row"><span class="label">Successful</span><span class="value success">${SwDashTable.formatNumber(m.connection.successfulConnections)}</span></div>
|
||||
<div class="row"><span class="label">Failed</span><span class="value ${m.connection.failedConnections > 0 ? 'error' : ''}">${SwDashTable.formatNumber(m.connection.failedConnections)}</span></div>
|
||||
<div class="section-divider">
|
||||
<div class="row"><span class="label">Events (1h)</span><span class="value">${this.eventCountLastHour}</span></div>
|
||||
<div class="row"><span class="label">Started</span><span class="value">${SwDashTable.formatTimestamp(m.startTime)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speedtest Panel -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">Speedtest</div>
|
||||
<div class="panel-content">
|
||||
<div class="online-indicator ${m.speedtest.isOnline ? 'online' : 'offline'}">
|
||||
<span class="online-dot"></span>
|
||||
<span>${m.speedtest.isOnline ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
${this.speedtestRunning ? html`
|
||||
<div class="speedtest-progress">
|
||||
<div class="progress-header">
|
||||
<span class="progress-phase">${this.getPhaseLabel()}</span>
|
||||
<span class="progress-time">${this.formatElapsed()}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill ${this.speedtestPhase === 'complete' ? 'complete' : ''}" style="width: ${this.speedtestProgress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="speedtest-results">
|
||||
<div class="speedtest-metric">
|
||||
<div class="speedtest-value">${m.speedtest.lastDownloadSpeedMbps.toFixed(1)}</div>
|
||||
<div class="speedtest-unit">Mbps</div>
|
||||
<div class="speedtest-label">Download</div>
|
||||
</div>
|
||||
<div class="speedtest-metric">
|
||||
<div class="speedtest-value">${m.speedtest.lastUploadSpeedMbps.toFixed(1)}</div>
|
||||
<div class="speedtest-unit">Mbps</div>
|
||||
<div class="speedtest-label">Upload</div>
|
||||
</div>
|
||||
<div class="speedtest-metric">
|
||||
<div class="speedtest-value">${m.speedtest.lastLatencyMs.toFixed(0)}</div>
|
||||
<div class="speedtest-unit">ms</div>
|
||||
<div class="speedtest-label">Latency</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-secondary" ?disabled="${this.speedtestRunning}" @click="${this.runSpeedtest}">
|
||||
${this.speedtestRunning ? 'Testing...' : 'Run Test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
912
ts_swdash/sw-dash-requests.ts
Normal file
912
ts_swdash/sw-dash-requests.ts
Normal file
@@ -0,0 +1,912 @@
|
||||
import { LitElement, html, css, property, state, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, panelStyles, tableStyles, buttonStyles } from './sw-dash-styles.js';
|
||||
|
||||
export interface ITypedRequestLogEntry {
|
||||
correlationId: string;
|
||||
method: string;
|
||||
direction: 'outgoing' | 'incoming';
|
||||
phase: 'request' | 'response';
|
||||
timestamp: number;
|
||||
durationMs?: number;
|
||||
payload: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ITypedRequestStats {
|
||||
totalRequests: number;
|
||||
totalResponses: number;
|
||||
methodCounts: Record<string, { requests: number; responses: number; errors: number; avgDurationMs: number }>;
|
||||
errorCount: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped request/response pair by correlationId
|
||||
*/
|
||||
export interface IGroupedRequest {
|
||||
correlationId: string;
|
||||
method: string;
|
||||
request?: ITypedRequestLogEntry;
|
||||
response?: ITypedRequestLogEntry;
|
||||
timestamp: number;
|
||||
durationMs?: number;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
type TRequestFilter = 'all' | 'outgoing' | 'incoming';
|
||||
type TPhaseFilter = 'all' | 'request' | 'response';
|
||||
|
||||
/**
|
||||
* TypedRequest traffic monitoring panel for sw-dash
|
||||
*
|
||||
* Receives logs, stats, and methods via properties from parent (sw-dash-app).
|
||||
* Filtering is done locally.
|
||||
* Load more and clear operations dispatch events to parent.
|
||||
*/
|
||||
@customElement('sw-dash-requests')
|
||||
export class SwDashRequests extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
panelStyles,
|
||||
tableStyles,
|
||||
buttonStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.request-card.has-error {
|
||||
border-color: var(--accent-error);
|
||||
}
|
||||
|
||||
.request-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.request-badges {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge.direction-outgoing { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
|
||||
.badge.direction-incoming { background: rgba(34, 197, 94, 0.15); color: var(--accent-success); }
|
||||
.badge.phase-request { background: rgba(251, 191, 36, 0.15); color: var(--accent-warning); }
|
||||
.badge.phase-response { background: rgba(99, 102, 241, 0.15); color: var(--accent-primary); }
|
||||
.badge.error { background: rgba(239, 68, 68, 0.15); color: var(--accent-error); }
|
||||
|
||||
.method-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.request-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.request-duration {
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.request-duration.slow {
|
||||
color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.request-duration.very-slow {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.request-error {
|
||||
font-size: 12px;
|
||||
color: var(--accent-error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-value.error {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.method-stats {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.method-stats-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.method-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.method-stat-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.method-stat-card:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.method-stat-card.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border: 1px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.method-stat-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
margin-bottom: var(--space-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-stat-details {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-error);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: var(--accent-error);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.correlation-id {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* Grouped request card */
|
||||
.request-card .request-response-badges {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.request-card .status-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status-badge.has-request {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.status-badge.has-response {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: rgba(156, 163, 175, 0.15);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.btn-show-payload {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--accent-primary);
|
||||
font-size: 11px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-show-payload:hover {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.payload-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.payload-modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-default);
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.payload-panel {
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.payload-panel-header {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.payload-panel-header .badge {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.payload-panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.payload-json {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.payload-empty {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payload-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.payload-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-error);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
// Received from parent (sw-dash-app)
|
||||
@property({ type: Array }) accessor logs: ITypedRequestLogEntry[] = [];
|
||||
@property({ type: Number }) accessor totalCount = 0;
|
||||
@property({ type: Object }) accessor stats: ITypedRequestStats | null = null;
|
||||
@property({ type: Array }) accessor methods: string[] = [];
|
||||
|
||||
// Local state for filtering
|
||||
@state() accessor directionFilter: TRequestFilter = 'all';
|
||||
@state() accessor phaseFilter: TPhaseFilter = 'all';
|
||||
@state() accessor methodFilter = '';
|
||||
@state() accessor searchText = '';
|
||||
@state() accessor isLoadingMore = false;
|
||||
|
||||
// Modal state
|
||||
@state() accessor modalOpen = false;
|
||||
@state() accessor selectedGroup: IGroupedRequest | null = null;
|
||||
|
||||
private handleDirectionFilterChange(e: Event): void {
|
||||
this.directionFilter = (e.target as HTMLSelectElement).value as TRequestFilter;
|
||||
// Local filtering - no HTTP request
|
||||
}
|
||||
|
||||
private handlePhaseFilterChange(e: Event): void {
|
||||
this.phaseFilter = (e.target as HTMLSelectElement).value as TPhaseFilter;
|
||||
// Local filtering - no HTTP request
|
||||
}
|
||||
|
||||
private handleMethodFilterChange(e: Event): void {
|
||||
this.methodFilter = (e.target as HTMLSelectElement).value;
|
||||
// Local filtering - no HTTP request
|
||||
}
|
||||
|
||||
private setMethodFilter(method: string): void {
|
||||
// Toggle: clicking the same method clears the filter
|
||||
this.methodFilter = this.methodFilter === method ? '' : method;
|
||||
}
|
||||
|
||||
private handleSearch(e: Event): void {
|
||||
this.searchText = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
}
|
||||
|
||||
private handleClear(): void {
|
||||
if (!confirm('Are you sure you want to clear the request logs? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
// Dispatch event to parent to clear via DeesComms
|
||||
this.dispatchEvent(new CustomEvent('clear-requests', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private loadMore(): void {
|
||||
if (this.isLoadingMore || this.logs.length === 0) return;
|
||||
|
||||
this.isLoadingMore = true;
|
||||
const oldestLog = this.logs[this.logs.length - 1];
|
||||
|
||||
// Dispatch event to parent to load more via DeesComms
|
||||
this.dispatchEvent(new CustomEvent('load-more-requests', {
|
||||
detail: {
|
||||
before: oldestLog.timestamp,
|
||||
method: this.methodFilter || undefined,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
// Reset loading state after a short delay (parent will update logs prop)
|
||||
setTimeout(() => {
|
||||
this.isLoadingMore = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private openPayloadModal(group: IGroupedRequest): void {
|
||||
this.selectedGroup = group;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private closeModal(): void {
|
||||
this.modalOpen = false;
|
||||
this.selectedGroup = null;
|
||||
}
|
||||
|
||||
private handleModalOverlayClick(e: Event): void {
|
||||
if ((e.target as HTMLElement).classList.contains('payload-modal-overlay')) {
|
||||
this.closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && this.modalOpen) {
|
||||
this.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
private formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
}
|
||||
|
||||
private getDurationClass(durationMs: number | undefined): string {
|
||||
if (!durationMs) return '';
|
||||
if (durationMs > 5000) return 'very-slow';
|
||||
if (durationMs > 1000) return 'slow';
|
||||
return '';
|
||||
}
|
||||
|
||||
private formatDuration(durationMs: number | undefined): string {
|
||||
if (!durationMs) return '';
|
||||
if (durationMs < 1000) return `${durationMs}ms`;
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter logs locally based on direction, phase, method, and search text
|
||||
*/
|
||||
private getFilteredLogs(): ITypedRequestLogEntry[] {
|
||||
let result = this.logs;
|
||||
|
||||
// Apply direction filter
|
||||
if (this.directionFilter !== 'all') {
|
||||
result = result.filter(l => l.direction === this.directionFilter);
|
||||
}
|
||||
|
||||
// Apply phase filter
|
||||
if (this.phaseFilter !== 'all') {
|
||||
result = result.filter(l => l.phase === this.phaseFilter);
|
||||
}
|
||||
|
||||
// Apply method filter
|
||||
if (this.methodFilter) {
|
||||
result = result.filter(l => l.method === this.methodFilter);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (this.searchText) {
|
||||
result = result.filter(l =>
|
||||
l.method.toLowerCase().includes(this.searchText) ||
|
||||
l.correlationId.toLowerCase().includes(this.searchText) ||
|
||||
(l.error && l.error.toLowerCase().includes(this.searchText)) ||
|
||||
JSON.stringify(l.payload).toLowerCase().includes(this.searchText)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group filtered logs by correlationId to show request/response pairs together
|
||||
*/
|
||||
private getGroupedLogs(): IGroupedRequest[] {
|
||||
const filtered = this.getFilteredLogs();
|
||||
const groups = new Map<string, IGroupedRequest>();
|
||||
|
||||
for (const log of filtered) {
|
||||
let group = groups.get(log.correlationId);
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
correlationId: log.correlationId,
|
||||
method: log.method,
|
||||
timestamp: log.timestamp,
|
||||
hasError: false,
|
||||
};
|
||||
groups.set(log.correlationId, group);
|
||||
}
|
||||
|
||||
if (log.phase === 'request') {
|
||||
group.request = log;
|
||||
// Update timestamp to the earliest (request time)
|
||||
if (log.timestamp < group.timestamp) {
|
||||
group.timestamp = log.timestamp;
|
||||
}
|
||||
} else if (log.phase === 'response') {
|
||||
group.response = log;
|
||||
if (log.durationMs !== undefined) {
|
||||
group.durationMs = log.durationMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (log.error) {
|
||||
group.hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by timestamp (newest first)
|
||||
return Array.from(groups.values()).sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the payload modal
|
||||
*/
|
||||
private renderModal(): TemplateResult | null {
|
||||
if (!this.modalOpen || !this.selectedGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = this.selectedGroup;
|
||||
|
||||
return html`
|
||||
<div class="payload-modal-overlay" @click="${this.handleModalOverlayClick}">
|
||||
<div class="payload-modal">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="modal-title">${group.method}</div>
|
||||
<div class="modal-subtitle">
|
||||
Correlation ID: ${group.correlationId}
|
||||
${group.durationMs !== undefined ? html` | Duration: ${this.formatDuration(group.durationMs)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close" @click="${this.closeModal}">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Request Panel (Left) -->
|
||||
<div class="payload-panel">
|
||||
<div class="payload-panel-header">
|
||||
<span class="badge phase-request">REQUEST</span>
|
||||
${group.request ? html`
|
||||
<span class="badge direction-${group.request.direction}">${group.request.direction}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
${group.request ? html`
|
||||
<div class="payload-meta">
|
||||
Timestamp: ${this.formatTimestamp(group.request.timestamp)}
|
||||
</div>
|
||||
<div class="payload-panel-content">
|
||||
<pre class="payload-json">${JSON.stringify(group.request.payload, null, 2)}</pre>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="payload-empty">No request data captured</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Response Panel (Right) -->
|
||||
<div class="payload-panel">
|
||||
<div class="payload-panel-header">
|
||||
<span class="badge phase-response">RESPONSE</span>
|
||||
${group.response ? html`
|
||||
<span class="badge direction-${group.response.direction}">${group.response.direction}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
${group.response?.error ? html`
|
||||
<div class="payload-error">Error: ${group.response.error}</div>
|
||||
` : ''}
|
||||
${group.response ? html`
|
||||
<div class="payload-meta">
|
||||
Timestamp: ${this.formatTimestamp(group.response.timestamp)}
|
||||
${group.response.durationMs !== undefined ? html` | Duration: ${this.formatDuration(group.response.durationMs)}` : ''}
|
||||
</div>
|
||||
<div class="payload-panel-content">
|
||||
<pre class="payload-json">${JSON.stringify(group.response.payload, null, 2)}</pre>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="payload-empty">No response yet (pending)</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const groupedLogs = this.getGroupedLogs();
|
||||
|
||||
return html`
|
||||
${this.renderModal()}
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">${this.stats?.totalRequests ?? 0}</span>
|
||||
<span class="stat-label">Total Requests</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">${this.stats?.totalResponses ?? 0}</span>
|
||||
<span class="stat-label">Total Responses</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value ${(this.stats?.errorCount ?? 0) > 0 ? 'error' : ''}">${this.stats?.errorCount ?? 0}</span>
|
||||
<span class="stat-label">Errors</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">${this.stats?.avgDurationMs ?? 0}ms</span>
|
||||
<span class="stat-label">Avg Duration</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">${groupedLogs.length}</span>
|
||||
<span class="stat-label">Showing</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Method Stats -->
|
||||
${this.stats && Object.keys(this.stats.methodCounts).length > 0 ? html`
|
||||
<div class="method-stats">
|
||||
<div class="method-stats-title">Methods</div>
|
||||
<div class="method-stats-grid">
|
||||
${Object.entries(this.stats.methodCounts).slice(0, 8).map(([method, data]) => html`
|
||||
<div
|
||||
class="method-stat-card ${this.methodFilter === method ? 'active' : ''}"
|
||||
@click="${() => this.setMethodFilter(method)}"
|
||||
>
|
||||
<div class="method-stat-name" title="${method}">${method}</div>
|
||||
<div class="method-stat-details">
|
||||
<span>${data.requests} req</span>
|
||||
<span>${data.responses} res</span>
|
||||
${data.errors > 0 ? html`<span style="color: var(--accent-error)">${data.errors} err</span>` : ''}
|
||||
<span>${data.avgDurationMs}ms avg</span>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="requests-header">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Direction:</span>
|
||||
<select class="filter-select" @change="${this.handleDirectionFilterChange}">
|
||||
<option value="all">All</option>
|
||||
<option value="outgoing">Outgoing</option>
|
||||
<option value="incoming">Incoming</option>
|
||||
</select>
|
||||
|
||||
<span class="filter-label">Phase:</span>
|
||||
<select class="filter-select" @change="${this.handlePhaseFilterChange}">
|
||||
<option value="all">All</option>
|
||||
<option value="request">Request</option>
|
||||
<option value="response">Response</option>
|
||||
</select>
|
||||
|
||||
<span class="filter-label">Method:</span>
|
||||
<select class="filter-select" .value="${this.methodFilter}" @change="${this.handleMethodFilterChange}">
|
||||
<option value="">All Methods</option>
|
||||
${this.methods.map(m => html`<option value="${m}" ?selected="${this.methodFilter === m}">${m}</option>`)}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
.value="${this.searchText}"
|
||||
@input="${this.handleSearch}"
|
||||
style="width: 150px;"
|
||||
>
|
||||
</div>
|
||||
<button class="btn clear-btn" @click="${this.handleClear}">Clear Logs</button>
|
||||
</div>
|
||||
|
||||
<!-- Request List (Grouped by correlationId) -->
|
||||
${this.logs.length === 0 ? html`
|
||||
<div class="empty-state">No request logs found. Traffic will appear here as TypedRequests are made.</div>
|
||||
` : groupedLogs.length === 0 ? html`
|
||||
<div class="empty-state">No logs match filter</div>
|
||||
` : html`
|
||||
<div class="requests-list">
|
||||
${groupedLogs.map(group => html`
|
||||
<div class="request-card ${group.hasError ? 'has-error' : ''}">
|
||||
<div class="request-header">
|
||||
<div>
|
||||
<div class="request-badges">
|
||||
${group.request ? html`
|
||||
<span class="badge direction-${group.request.direction}">${group.request.direction}</span>
|
||||
` : ''}
|
||||
${group.hasError ? html`<span class="badge error">error</span>` : ''}
|
||||
</div>
|
||||
<div class="method-name">${group.method}</div>
|
||||
<div class="correlation-id">${group.correlationId}</div>
|
||||
<div class="request-response-badges">
|
||||
<span class="status-badge ${group.request ? 'has-request' : 'pending'}">
|
||||
${group.request ? 'REQ' : 'REQ pending'}
|
||||
</span>
|
||||
<span class="status-badge ${group.response ? 'has-response' : 'pending'}">
|
||||
${group.response ? 'RES' : 'RES pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="request-meta">
|
||||
<span class="request-time">${this.formatTimestamp(group.timestamp)}</span>
|
||||
${group.durationMs !== undefined ? html`
|
||||
<span class="request-duration ${this.getDurationClass(group.durationMs)}">
|
||||
${this.formatDuration(group.durationMs)}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${group.response?.error ? html`
|
||||
<div class="request-error">${group.response.error}</div>
|
||||
` : ''}
|
||||
|
||||
<button class="btn-show-payload" @click="${() => this.openPayloadModal(group)}">
|
||||
Show Payload
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
${this.logs.length < this.totalCount ? html`
|
||||
<div class="pagination">
|
||||
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoadingMore}">
|
||||
${this.isLoadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
<span class="page-info">${this.logs.length} of ${this.totalCount} logs</span>
|
||||
</div>
|
||||
` : ''}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
667
ts_swdash/sw-dash-styles.ts
Normal file
667
ts_swdash/sw-dash-styles.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import { css } from './plugins.js';
|
||||
import type { CSSResult } from './plugins.js';
|
||||
|
||||
/**
|
||||
* Modern professional theme for sw-dash components
|
||||
* Inspired by Bloomberg terminals, Vercel dashboards, and shadcn/ui
|
||||
*/
|
||||
export const sharedStyles: CSSResult = css`
|
||||
:host {
|
||||
/* Neutral backgrounds - zinc scale */
|
||||
--bg-primary: #09090b;
|
||||
--bg-secondary: #18181b;
|
||||
--bg-tertiary: #27272a;
|
||||
--bg-elevated: #3f3f46;
|
||||
|
||||
/* Text colors */
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-tertiary: #71717a;
|
||||
|
||||
/* Borders */
|
||||
--border-default: #27272a;
|
||||
--border-muted: #3f3f46;
|
||||
|
||||
/* Accent colors */
|
||||
--accent-primary: #3b82f6;
|
||||
--accent-success: #22c55e;
|
||||
--accent-warning: #eab308;
|
||||
--accent-error: #ef4444;
|
||||
--accent-info: #06b6d4;
|
||||
|
||||
/* Spacing scale */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
export const terminalStyles: CSSResult = css`
|
||||
.terminal {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.uptime {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--space-5);
|
||||
min-height: 400px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-success);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: var(--accent-error);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const navStyles: CSSResult = css`
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.nav-tab .count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0 6px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-left: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-tab.active .count {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
export const panelStyles: CSSResult = css`
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.value.warning {
|
||||
color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.value.error {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.value.success {
|
||||
color: var(--accent-success);
|
||||
}
|
||||
`;
|
||||
|
||||
export const gaugeStyles: CSSResult = css`
|
||||
.gauge {
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.gauge-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gauge-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gauge-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gauge-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.gauge-fill.good {
|
||||
background: var(--accent-success);
|
||||
}
|
||||
|
||||
.gauge-fill.warning {
|
||||
background: var(--accent-warning);
|
||||
}
|
||||
|
||||
.gauge-fill.bad {
|
||||
background: var(--accent-error);
|
||||
}
|
||||
`;
|
||||
|
||||
export const tableStyles: CSSResult = css`
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.data-table th:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table th .sort-icon {
|
||||
margin-left: var(--space-1);
|
||||
opacity: 0.4;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.data-table th.sorted .sort-icon {
|
||||
opacity: 1;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.data-table td.url {
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
width: 280px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.table-info {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hit-rate-bar {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--space-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hit-rate-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hit-rate-fill.good {
|
||||
background: var(--accent-success);
|
||||
}
|
||||
|
||||
.hit-rate-fill.warning {
|
||||
background: var(--accent-warning);
|
||||
}
|
||||
|
||||
.hit-rate-fill.bad {
|
||||
background: var(--accent-error);
|
||||
}
|
||||
`;
|
||||
|
||||
export const buttonStyles: CSSResult = css`
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-muted);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
export const speedtestStyles: CSSResult = css`
|
||||
.online-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.online-indicator.online {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.online-indicator.offline {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.speedtest-results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.speedtest-metric {
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.speedtest-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.speedtest-unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.speedtest-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.speed-bar {
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
margin: var(--space-1) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.speed-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-success);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* Speedtest progress indicator */
|
||||
.speedtest-progress {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.progress-phase {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--accent-info);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.progress-phase::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.progress-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-info);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-fill.complete {
|
||||
background: var(--accent-success);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`;
|
||||
|
||||
export const statusBadgeStyles: CSSResult = css`
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.status-badge.info {
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
color: var(--accent-info);
|
||||
}
|
||||
|
||||
.status-badge .badge-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
`;
|
||||
173
ts_swdash/sw-dash-table.ts
Normal file
173
ts_swdash/sw-dash-table.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { LitElement, html, css, property, state, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, tableStyles } from './sw-dash-styles.js';
|
||||
|
||||
export interface IColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
formatter?: (value: any, row: any) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base sortable table component for sw-dash
|
||||
*/
|
||||
@customElement('sw-dash-table')
|
||||
export class SwDashTable extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
tableStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
@property({ type: Array }) accessor columns: IColumnConfig[] = [];
|
||||
@property({ type: Array }) accessor data: any[] = [];
|
||||
@property({ type: String }) accessor filterPlaceholder = 'Filter...';
|
||||
@property({ type: String }) accessor infoLabel = 'items';
|
||||
|
||||
@state() accessor sortColumn = '';
|
||||
@state() accessor sortDirection: 'asc' | 'desc' = 'desc';
|
||||
@state() accessor filterText = '';
|
||||
|
||||
// Utility formatters
|
||||
static formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
static 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];
|
||||
}
|
||||
|
||||
static 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();
|
||||
}
|
||||
|
||||
static getGaugeClass(rate: number): string {
|
||||
if (rate >= 80) return 'good';
|
||||
if (rate >= 50) return 'warning';
|
||||
return 'bad';
|
||||
}
|
||||
|
||||
private handleSort(column: string): void {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'desc';
|
||||
}
|
||||
}
|
||||
|
||||
private handleFilter(e: Event): void {
|
||||
this.filterText = (e.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private getSortedFilteredData(): any[] {
|
||||
let result = [...this.data];
|
||||
|
||||
// Filter
|
||||
if (this.filterText) {
|
||||
const search = this.filterText.toLowerCase();
|
||||
result = result.filter(row => {
|
||||
return this.columns.some(col => {
|
||||
const val = row[col.key];
|
||||
if (val == null) return false;
|
||||
return String(val).toLowerCase().includes(search);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (this.sortColumn) {
|
||||
result.sort((a, b) => {
|
||||
let valA = a[this.sortColumn];
|
||||
let valB = b[this.sortColumn];
|
||||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private renderHitRateBar(rate: number): TemplateResult {
|
||||
const cls = SwDashTable.getGaugeClass(rate);
|
||||
return html`
|
||||
<span class="hit-rate-bar">
|
||||
<span class="hit-rate-fill ${cls}" style="width: ${rate}%"></span>
|
||||
</span>${rate}%
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderCellValue(value: any, row: any, column: IColumnConfig): any {
|
||||
if (column.formatter) {
|
||||
return column.formatter(value, row);
|
||||
}
|
||||
// Special handling for hitRate
|
||||
if (column.key === 'hitRate') {
|
||||
return this.renderHitRateBar(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const sortedData = this.getSortedFilteredData();
|
||||
|
||||
return html`
|
||||
<div class="table-controls">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="${this.filterPlaceholder}"
|
||||
.value="${this.filterText}"
|
||||
@input="${this.handleFilter}"
|
||||
>
|
||||
<span class="table-info">${sortedData.length} of ${this.data.length} ${this.infoLabel}</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${this.columns.map(col => html`
|
||||
<th
|
||||
class="${this.sortColumn === col.key ? 'sorted' : ''}"
|
||||
@click="${() => col.sortable !== false && this.handleSort(col.key)}"
|
||||
>
|
||||
${col.label}
|
||||
${col.sortable !== false ? html`
|
||||
<span class="sort-icon">${this.sortColumn === col.key && this.sortDirection === 'asc' ? '↑' : '↓'}</span>
|
||||
` : ''}
|
||||
</th>
|
||||
`)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map(row => html`
|
||||
<tr>
|
||||
${this.columns.map(col => html`
|
||||
<td class="${col.className || ''}">${this.renderCellValue(row[col.key], row, col)}</td>
|
||||
`)}
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
52
ts_swdash/sw-dash-types.ts
Normal file
52
ts_swdash/sw-dash-types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { LitElement, html, css, property, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, tableStyles } from './sw-dash-styles.js';
|
||||
import { SwDashTable } from './sw-dash-table.js';
|
||||
import type { IColumnConfig } from './sw-dash-table.js';
|
||||
|
||||
export interface IContentTypeStats {
|
||||
contentType: string;
|
||||
totalResources: number;
|
||||
totalSize: number;
|
||||
totalHits: number;
|
||||
totalMisses: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content types table view component
|
||||
*/
|
||||
@customElement('sw-dash-types')
|
||||
export class SwDashTypes extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
tableStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
@property({ type: Array }) accessor contentTypes: IContentTypeStats[] = [];
|
||||
|
||||
private columns: IColumnConfig[] = [
|
||||
{ key: 'contentType', label: 'Content Type' },
|
||||
{ key: 'totalResources', label: 'Resources', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'totalSize', label: 'Total Size', className: 'num', formatter: SwDashTable.formatBytes },
|
||||
{ key: 'totalHits', label: 'Hits', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'totalMisses', label: 'Misses', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'hitRate', label: 'Hit Rate' },
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sw-dash-table
|
||||
.columns="${this.columns}"
|
||||
.data="${this.contentTypes}"
|
||||
filterPlaceholder="Filter types..."
|
||||
infoLabel="content types"
|
||||
></sw-dash-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
66
ts_swdash/sw-dash-urls.ts
Normal file
66
ts_swdash/sw-dash-urls.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { LitElement, html, css, property, customElement } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, tableStyles } from './sw-dash-styles.js';
|
||||
import { SwDashTable } from './sw-dash-table.js';
|
||||
import type { IColumnConfig } from './sw-dash-table.js';
|
||||
|
||||
export interface ICachedResource {
|
||||
url: string;
|
||||
domain: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
hitCount: number;
|
||||
missCount: number;
|
||||
lastAccessed: number;
|
||||
cachedAt: number;
|
||||
hitRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* URLs table view component
|
||||
*/
|
||||
@customElement('sw-dash-urls')
|
||||
export class SwDashUrls extends LitElement {
|
||||
public static styles: CSSResult[] = [
|
||||
sharedStyles,
|
||||
tableStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
@property({ type: Array }) accessor resources: ICachedResource[] = [];
|
||||
|
||||
private columns: IColumnConfig[] = [
|
||||
{ key: 'url', label: 'URL', className: 'url' },
|
||||
{ key: 'contentType', label: 'Type' },
|
||||
{ key: 'size', label: 'Size', className: 'num', formatter: SwDashTable.formatBytes },
|
||||
{ key: 'hitCount', label: 'Hits', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'missCount', label: 'Misses', className: 'num', formatter: SwDashTable.formatNumber },
|
||||
{ key: 'hitRate', label: 'Hit Rate' },
|
||||
{ key: 'lastAccessed', label: 'Last Access', formatter: SwDashTable.formatTimestamp },
|
||||
];
|
||||
|
||||
private getDataWithHitRate(): ICachedResource[] {
|
||||
return this.resources.map(r => {
|
||||
const total = r.hitCount + r.missCount;
|
||||
return {
|
||||
...r,
|
||||
hitRate: total > 0 ? Math.round((r.hitCount / total) * 100) : 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sw-dash-table
|
||||
.columns="${this.columns}"
|
||||
.data="${this.getDataWithHitRate()}"
|
||||
filterPlaceholder="Filter URLs..."
|
||||
infoLabel="resources"
|
||||
></sw-dash-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '3.0.22',
|
||||
description: 'easy serving of static files'
|
||||
}
|
||||
152
ts_web/index.ts
152
ts_web/index.ts
@@ -1,152 +0,0 @@
|
||||
import * as plugins from './typedserver_web.plugins.js';
|
||||
import * as interfaces from '../ts/interfaces/index.js';
|
||||
import { logger } from './typedserver_web.logger.js';
|
||||
logger.log('info', `TypedServer-Devtools initialized!`);
|
||||
|
||||
import { TypedserverInfoscreen } from './typedserver_web.infoscreen.js';
|
||||
|
||||
export class ReloadChecker {
|
||||
public reloadJustified = false;
|
||||
public backendConnectionLost = false;
|
||||
public infoscreen = new TypedserverInfoscreen();
|
||||
public store = new plugins.webstore.WebStore({
|
||||
dbName: 'apiglobal__typedserver',
|
||||
storeName: 'apiglobal__typedserver',
|
||||
});
|
||||
public storeKey = 'lastServerChange';
|
||||
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async reload() {
|
||||
// this looks a bit hacky, but apparently is the safest way to really reload stuff
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* starts the reload checker
|
||||
*/
|
||||
public async performHttpRequest() {
|
||||
logger.log('info', 'performing http check...');
|
||||
(await this.store.get(this.storeKey))
|
||||
? null
|
||||
: await this.store.set(this.storeKey, globalThis.typedserver.lastReload);
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
plugins.smartdelay.delayFor(5000).then(() => {
|
||||
controller.abort();
|
||||
});
|
||||
response = await fetch('/typedserver/reloadcheck', {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err: any) {}
|
||||
|
||||
if (response?.status !== 200) {
|
||||
this.backendConnectionLost = true;
|
||||
logger.log('warn', `got a status ${response?.status}.`);
|
||||
this.infoscreen.setText(`backend connection lost... Status ${response?.status}`);
|
||||
}
|
||||
if (response?.status === 200 && this.backendConnectionLost) {
|
||||
this.backendConnectionLost = false;
|
||||
this.infoscreen.setSuccess('regained connection to backend...');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public async checkReload(lastServerChange: number) {
|
||||
let reloadJustified = false;
|
||||
let storedLastServerChange = await this.store.get(this.storeKey);
|
||||
if (storedLastServerChange && storedLastServerChange !== lastServerChange) {
|
||||
reloadJustified = true;
|
||||
} else {
|
||||
}
|
||||
|
||||
if (reloadJustified) {
|
||||
this.store.set(this.storeKey, lastServerChange);
|
||||
const reloadText = `upgrading... ${
|
||||
globalThis.globalSw ? '(purging the sw cache first...)' : ''
|
||||
}`;
|
||||
this.infoscreen.setText(reloadText);
|
||||
if (globalThis.globalSw?.purgeCache) {
|
||||
await globalThis.globalSw.purgeCache();
|
||||
} else {
|
||||
console.log('globalThis.globalSw not found...');
|
||||
}
|
||||
this.infoscreen.setText(`cleaned caches`);
|
||||
await plugins.smartdelay.delayFor(200);
|
||||
this.reload();
|
||||
return;
|
||||
} else {
|
||||
if (this.infoscreen) {
|
||||
this.infoscreen.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async connectTypedsocket() {
|
||||
if (!this.typedsocket) {
|
||||
this.typedrouter.addTypedHandler<interfaces.IReq_PushLatestServerChangeTime>(
|
||||
new plugins.typedrequest.TypedHandler('pushLatestServerChangeTime', async (dataArg) => {
|
||||
this.checkReload(dataArg.time);
|
||||
return {};
|
||||
})
|
||||
);
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
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'
|
||||
) {
|
||||
this.backendConnectionLost = true;
|
||||
this.infoscreen.setText(`typedsocket ${eventArg}!`);
|
||||
} else if (eventArg === 'connected' && this.backendConnectionLost) {
|
||||
this.backendConnectionLost = false;
|
||||
this.infoscreen.setSuccess('typedsocket connected!');
|
||||
// lets check if a reload is necessary
|
||||
const getLatestServerChangeTime =
|
||||
this.typedsocket.createTypedRequest<interfaces.IReq_GetLatestServerChangeTime>(
|
||||
'getLatestServerChangeTime'
|
||||
);
|
||||
const response = await getLatestServerChangeTime.fire({});
|
||||
this.checkReload(response.time);
|
||||
}
|
||||
});
|
||||
logger.log('success', `ReloadChecker connected through typedsocket!`);
|
||||
}
|
||||
}
|
||||
|
||||
public started = false;
|
||||
public async start() {
|
||||
this.started = true;
|
||||
logger.log('info', `starting ReloadChecker...`);
|
||||
while (this.started) {
|
||||
const response = await this.performHttpRequest();
|
||||
if (response.status === 200) {
|
||||
logger.log('info', `ReloadChecker reached backend!`);
|
||||
await this.checkReload(parseInt(await response.text()));
|
||||
await this.connectTypedsocket();
|
||||
}
|
||||
await plugins.smartdelay.delayFor(120000);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
this.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
const reloadCheckInstance = new ReloadChecker();
|
||||
reloadCheckInstance.start();
|
||||
8
ts_web_inject/00_commitinfo_data.ts
Normal file
8
ts_web_inject/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '3.0.29',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
328
ts_web_inject/index.ts
Normal file
328
ts_web_inject/index.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import * as plugins from './typedserver_web.plugins.js';
|
||||
import * as interfaces from '../dist_ts_interfaces/index.js';
|
||||
import { logger } from './typedserver_web.logger.js';
|
||||
logger.log('info', `TypedServer-Devtools initialized!`);
|
||||
|
||||
import { TypedserverStatusPill } from './typedserver_web.statuspill.js';
|
||||
|
||||
// Import hook types from typedrequest
|
||||
type ITypedRequestLogEntry = plugins.typedrequest.ITypedRequestLogEntry;
|
||||
|
||||
export class ReloadChecker {
|
||||
public reloadJustified = false;
|
||||
public backendConnectionLost = false;
|
||||
public statusPill = new TypedserverStatusPill();
|
||||
public store = new plugins.webstore.WebStore({
|
||||
dbName: 'apiglobal__typedserver',
|
||||
storeName: 'apiglobal__typedserver',
|
||||
});
|
||||
public storeKey = 'lastServerChange';
|
||||
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private swStatusUnsubscribe: (() => void) | null = null;
|
||||
private trafficLoggingEnabled = false;
|
||||
|
||||
constructor() {
|
||||
// Listen to browser online/offline events
|
||||
window.addEventListener('online', () => {
|
||||
this.statusPill.updateStatus({
|
||||
source: 'network',
|
||||
type: 'online',
|
||||
message: 'Back online',
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.statusPill.updateStatus({
|
||||
source: 'network',
|
||||
type: 'offline',
|
||||
message: 'No internet connection',
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async reload() {
|
||||
// this looks a bit hacky, but apparently is the safest way to really reload stuff
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to service worker status updates
|
||||
*/
|
||||
public subscribeToServiceWorker(): void {
|
||||
// Check if service worker client is available
|
||||
if (globalThis.globalSw?.actionManager) {
|
||||
this.swStatusUnsubscribe = globalThis.globalSw.actionManager.subscribeToStatusUpdates((status) => {
|
||||
this.statusPill.updateStatus({
|
||||
source: status.source,
|
||||
type: status.type,
|
||||
message: status.message,
|
||||
details: status.details,
|
||||
persist: status.persist || false,
|
||||
timestamp: status.timestamp,
|
||||
});
|
||||
});
|
||||
logger.log('info', 'Subscribed to service worker status updates');
|
||||
|
||||
// Get initial SW status
|
||||
this.fetchServiceWorkerStatus();
|
||||
} else {
|
||||
logger.log('note', 'Service worker client not available yet, will retry...');
|
||||
// Retry after a delay
|
||||
setTimeout(() => this.subscribeToServiceWorker(), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and display initial service worker status
|
||||
*/
|
||||
private async fetchServiceWorkerStatus(): Promise<void> {
|
||||
if (!globalThis.globalSw?.actionManager) return;
|
||||
|
||||
try {
|
||||
const status = await globalThis.globalSw.actionManager.getServiceWorkerStatus();
|
||||
if (status) {
|
||||
this.statusPill.updateStatus({
|
||||
source: 'serviceworker',
|
||||
type: status.isActive ? 'connected' : 'disconnected',
|
||||
message: status.isActive ? 'Service worker active' : 'Service worker inactive',
|
||||
details: {
|
||||
cacheHitRate: status.cacheHitRate,
|
||||
resourceCount: status.resourceCount,
|
||||
connectionType: status.connectionType,
|
||||
},
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get SW status: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* starts the reload checker
|
||||
*/
|
||||
public async performHttpRequest() {
|
||||
logger.log('info', 'performing http check...');
|
||||
(await this.store.get(this.storeKey))
|
||||
? null
|
||||
: await this.store.set(this.storeKey, globalThis.typedserver.lastReload);
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
plugins.smartdelay.delayFor(5000).then(() => {
|
||||
controller.abort();
|
||||
});
|
||||
response = await fetch('/typedserver/reloadcheck', {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err: any) {}
|
||||
|
||||
if (response?.status !== 200) {
|
||||
this.backendConnectionLost = true;
|
||||
logger.log('warn', `got a status ${response?.status}.`);
|
||||
this.statusPill.updateStatus({
|
||||
source: 'backend',
|
||||
type: 'disconnected',
|
||||
message: `Backend connection lost (${response?.status || 'timeout'})`,
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
if (response?.status === 200 && this.backendConnectionLost) {
|
||||
this.backendConnectionLost = false;
|
||||
this.statusPill.updateStatus({
|
||||
source: 'backend',
|
||||
type: 'connected',
|
||||
message: 'Backend connection restored',
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public async checkReload(lastServerChange: number) {
|
||||
let reloadJustified = false;
|
||||
let storedLastServerChange = await this.store.get(this.storeKey);
|
||||
if (storedLastServerChange && storedLastServerChange !== lastServerChange) {
|
||||
reloadJustified = true;
|
||||
} else {
|
||||
}
|
||||
|
||||
if (reloadJustified) {
|
||||
this.store.set(this.storeKey, lastServerChange);
|
||||
const hasSw = !!globalThis.globalSw;
|
||||
this.statusPill.updateStatus({
|
||||
source: 'serviceworker',
|
||||
type: 'update',
|
||||
message: hasSw ? 'Updating app...' : 'Upgrading...',
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (globalThis.globalSw?.purgeCache) {
|
||||
await globalThis.globalSw.purgeCache();
|
||||
} else if ('caches' in window) {
|
||||
// Fallback: clear caches via Cache API when service worker client isn't initialized
|
||||
try {
|
||||
const cacheKeys = await caches.keys();
|
||||
await Promise.all(cacheKeys.map(key => caches.delete(key)));
|
||||
logger.log('ok', 'Cleared caches via Cache API fallback');
|
||||
} catch (err) {
|
||||
logger.log('warn', `Failed to clear caches via Cache API: ${err}`);
|
||||
}
|
||||
} else {
|
||||
console.log('globalThis.globalSw not found and Cache API not available...');
|
||||
}
|
||||
|
||||
this.statusPill.updateStatus({
|
||||
source: 'serviceworker',
|
||||
type: 'cache',
|
||||
message: 'Cache cleared, reloading...',
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await plugins.smartdelay.delayFor(200);
|
||||
this.reload();
|
||||
return;
|
||||
} else {
|
||||
// All good, hide after brief show
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async connectTypedsocket() {
|
||||
if (!this.typedsocket) {
|
||||
this.typedrouter.addTypedHandler<interfaces.IReq_PushLatestServerChangeTime>(
|
||||
new plugins.typedrequest.TypedHandler('pushLatestServerChangeTime', async (dataArg) => {
|
||||
this.checkReload(dataArg.time);
|
||||
return {};
|
||||
})
|
||||
);
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
this.typedrouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl()
|
||||
);
|
||||
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.statusPill.updateStatus({
|
||||
source: 'backend',
|
||||
type: statusArg === 'disconnected' ? 'disconnected' : 'reconnecting',
|
||||
message: `TypedSocket ${statusArg}`,
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (statusArg === 'connected' && this.backendConnectionLost) {
|
||||
this.backendConnectionLost = false;
|
||||
this.statusPill.updateStatus({
|
||||
source: 'backend',
|
||||
type: 'connected',
|
||||
message: 'TypedSocket connected',
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// lets check if a reload is necessary
|
||||
const getLatestServerChangeTime =
|
||||
this.typedsocket.createTypedRequest<interfaces.IReq_GetLatestServerChangeTime>(
|
||||
'getLatestServerChangeTime'
|
||||
);
|
||||
const response = await getLatestServerChangeTime.fire({});
|
||||
this.checkReload(response.time);
|
||||
}
|
||||
});
|
||||
logger.log('success', `ReloadChecker connected through typedsocket!`);
|
||||
|
||||
// Enable traffic logging for sw-dash
|
||||
this.enableTrafficLogging();
|
||||
}
|
||||
}
|
||||
|
||||
public started = false;
|
||||
public async start() {
|
||||
this.started = true;
|
||||
logger.log('info', `starting ReloadChecker...`);
|
||||
|
||||
// Subscribe to service worker status updates
|
||||
this.subscribeToServiceWorker();
|
||||
|
||||
while (this.started) {
|
||||
const response = await this.performHttpRequest();
|
||||
if (response?.status === 200) {
|
||||
logger.log('info', `ReloadChecker reached backend!`);
|
||||
await this.checkReload(parseInt(await response.text()));
|
||||
await this.connectTypedsocket();
|
||||
}
|
||||
await plugins.smartdelay.delayFor(120000);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
this.started = false;
|
||||
if (this.swStatusUnsubscribe) {
|
||||
this.swStatusUnsubscribe();
|
||||
this.swStatusUnsubscribe = null;
|
||||
}
|
||||
// Clear global hooks when stopping
|
||||
if (this.trafficLoggingEnabled) {
|
||||
plugins.typedrequest.TypedRouter.clearGlobalHooks();
|
||||
this.trafficLoggingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable TypedRequest traffic logging to the service worker
|
||||
* Sets up global hooks on TypedRouter to capture all request/response traffic
|
||||
*/
|
||||
public enableTrafficLogging(): void {
|
||||
if (this.trafficLoggingEnabled) {
|
||||
logger.log('note', 'Traffic logging already enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if service worker client is available
|
||||
if (!globalThis.globalSw?.actionManager) {
|
||||
logger.log('note', 'Service worker client not available, will retry traffic logging setup...');
|
||||
setTimeout(() => this.enableTrafficLogging(), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionManager = globalThis.globalSw.actionManager;
|
||||
|
||||
// Helper function to log entries
|
||||
const logEntry = (entry: ITypedRequestLogEntry) => {
|
||||
// Skip logging serviceworker_* methods to avoid infinite loops
|
||||
// These are internal SW communication methods, not app traffic
|
||||
if (entry.method.startsWith('serviceworker_')) {
|
||||
return;
|
||||
}
|
||||
actionManager.logTypedRequest(entry);
|
||||
};
|
||||
|
||||
// Set up global hooks on TypedRouter
|
||||
plugins.typedrequest.TypedRouter.setGlobalHooks({
|
||||
onOutgoingRequest: logEntry,
|
||||
onIncomingResponse: logEntry,
|
||||
onIncomingRequest: logEntry,
|
||||
onOutgoingResponse: logEntry,
|
||||
});
|
||||
|
||||
this.trafficLoggingEnabled = true;
|
||||
logger.log('success', 'TypedRequest traffic logging enabled');
|
||||
}
|
||||
}
|
||||
|
||||
const reloadCheckInstance = new ReloadChecker();
|
||||
reloadCheckInstance.start();
|
||||
@@ -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`
|
||||
534
ts_web_inject/typedserver_web.statuspill.ts
Normal file
534
ts_web_inject/typedserver_web.statuspill.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import * as plugins from './typedserver_web.plugins.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'typedserver-statuspill': TypedserverStatusPill;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status source types
|
||||
*/
|
||||
export type TStatusSource = 'backend' | 'serviceworker' | 'network';
|
||||
|
||||
/**
|
||||
* Status type
|
||||
*/
|
||||
export type TStatusType = 'connected' | 'disconnected' | 'reconnecting' | 'update' | 'cache' | 'error' | 'offline' | 'online';
|
||||
|
||||
/**
|
||||
* Status item with details
|
||||
*/
|
||||
export interface IStatusItem {
|
||||
source: TStatusSource;
|
||||
type: TStatusType;
|
||||
message: string;
|
||||
details?: {
|
||||
version?: string;
|
||||
cacheHitRate?: number;
|
||||
resourceCount?: number;
|
||||
connectionType?: string;
|
||||
latencyMs?: number;
|
||||
};
|
||||
persist: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern status pill component that displays connection and service worker status
|
||||
* - Shows at center-bottom on connectivity changes
|
||||
* - Stays visible during error states
|
||||
* - Expands on hover to show detailed status
|
||||
*/
|
||||
@customElement('typedserver-statuspill')
|
||||
export class TypedserverStatusPill extends LitElement {
|
||||
// Current status items by source
|
||||
@state() accessor backendStatus: IStatusItem | null = null;
|
||||
@state() accessor swStatus: IStatusItem | null = null;
|
||||
@state() accessor networkStatus: IStatusItem | null = null;
|
||||
|
||||
// UI state
|
||||
@state() accessor visible = false;
|
||||
@state() accessor expanded = false;
|
||||
@state() accessor hasError = false;
|
||||
|
||||
// Hide timeout
|
||||
private hideTimeout: number | null = null;
|
||||
private appended = false;
|
||||
|
||||
public static styles = css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
--pill-bg: rgba(20, 20, 20, 0.9);
|
||||
--pill-bg-error: rgba(180, 40, 40, 0.95);
|
||||
--pill-bg-success: rgba(40, 140, 60, 0.95);
|
||||
--pill-text: #fff;
|
||||
--pill-text-muted: rgba(255, 255, 255, 0.7);
|
||||
--pill-border: rgba(255, 255, 255, 0.1);
|
||||
--pill-accent: #4af;
|
||||
--pill-success: #4f8;
|
||||
--pill-warning: #fa4;
|
||||
--pill-error: #f44;
|
||||
}
|
||||
|
||||
.pill {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background: var(--pill-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 24px;
|
||||
padding: 10px 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--pill-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--pill-border);
|
||||
}
|
||||
|
||||
.pill.visible {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pill.error {
|
||||
background: var(--pill-bg-error);
|
||||
}
|
||||
|
||||
.pill.success {
|
||||
background: var(--pill-bg-success);
|
||||
}
|
||||
|
||||
.pill-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--pill-text-muted);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--pill-success);
|
||||
box-shadow: 0 0 6px var(--pill-success);
|
||||
}
|
||||
|
||||
.status-dot.disconnected,
|
||||
.status-dot.offline,
|
||||
.status-dot.error {
|
||||
background: var(--pill-error);
|
||||
box-shadow: 0 0 6px var(--pill-error);
|
||||
}
|
||||
|
||||
.status-dot.reconnecting,
|
||||
.status-dot.update {
|
||||
background: var(--pill-warning);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: var(--pill-text);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--pill-border);
|
||||
}
|
||||
|
||||
.pill-expanded {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--pill-border);
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill.expanded .pill-expanded {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--pill-text-muted);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--pill-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value.success {
|
||||
color: var(--pill-success);
|
||||
}
|
||||
|
||||
.detail-value.error {
|
||||
color: var(--pill-error);
|
||||
}
|
||||
|
||||
.detail-value.warning {
|
||||
color: var(--pill-warning);
|
||||
}
|
||||
|
||||
/* Click hint */
|
||||
.pill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
height: 3px;
|
||||
background: var(--pill-border);
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pill:hover::after {
|
||||
background: var(--pill-text-muted);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Update status from a specific source
|
||||
*/
|
||||
public updateStatus(status: IStatusItem): void {
|
||||
// Store by source
|
||||
switch (status.source) {
|
||||
case 'backend':
|
||||
this.backendStatus = status;
|
||||
break;
|
||||
case 'serviceworker':
|
||||
this.swStatus = status;
|
||||
break;
|
||||
case 'network':
|
||||
this.networkStatus = status;
|
||||
break;
|
||||
}
|
||||
|
||||
// Determine if we have any errors (should persist)
|
||||
this.hasError = this.hasAnyError();
|
||||
|
||||
// Show the pill
|
||||
this.show();
|
||||
|
||||
// Auto-hide after delay if not persistent
|
||||
if (!status.persist && !this.hasError) {
|
||||
this.scheduleHide(2500);
|
||||
} else {
|
||||
this.cancelHide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any status is an error state
|
||||
*/
|
||||
private hasAnyError(): boolean {
|
||||
const errorTypes: TStatusType[] = ['disconnected', 'error', 'offline'];
|
||||
return (
|
||||
(this.backendStatus && errorTypes.includes(this.backendStatus.type)) ||
|
||||
(this.networkStatus && errorTypes.includes(this.networkStatus.type)) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall status class
|
||||
*/
|
||||
private getStatusClass(): string {
|
||||
if (this.hasError) return 'error';
|
||||
|
||||
const latestStatus = this.getLatestStatus();
|
||||
if (latestStatus?.type === 'connected' || latestStatus?.type === 'online') {
|
||||
return 'success';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent status
|
||||
*/
|
||||
private getLatestStatus(): IStatusItem | null {
|
||||
const statuses = [this.backendStatus, this.swStatus, this.networkStatus].filter(Boolean) as IStatusItem[];
|
||||
if (statuses.length === 0) return null;
|
||||
return statuses.reduce((latest, current) =>
|
||||
current.timestamp > latest.timestamp ? current : latest
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the pill
|
||||
*/
|
||||
public show(): void {
|
||||
if (!this.appended) {
|
||||
document.body.appendChild(this);
|
||||
this.appended = true;
|
||||
}
|
||||
// Small delay to ensure DOM update
|
||||
requestAnimationFrame(() => {
|
||||
this.visible = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the pill
|
||||
*/
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.expanded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule auto-hide
|
||||
*/
|
||||
private scheduleHide(delayMs: number): void {
|
||||
this.cancelHide();
|
||||
this.hideTimeout = window.setTimeout(() => {
|
||||
if (!this.hasError) {
|
||||
this.hide();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel scheduled hide
|
||||
*/
|
||||
private cancelHide(): void {
|
||||
if (this.hideTimeout) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle expanded state
|
||||
*/
|
||||
private toggleExpanded(): void {
|
||||
this.expanded = !this.expanded;
|
||||
if (this.expanded) {
|
||||
this.cancelHide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all status and hide
|
||||
*/
|
||||
public clearStatus(): void {
|
||||
this.backendStatus = null;
|
||||
this.swStatus = null;
|
||||
this.networkStatus = null;
|
||||
this.hasError = false;
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set success message (auto-hides)
|
||||
*/
|
||||
public setSuccess(message: string, source: TStatusSource = 'backend'): void {
|
||||
this.updateStatus({
|
||||
source,
|
||||
type: 'connected',
|
||||
message,
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error message (persists)
|
||||
*/
|
||||
public setError(message: string, source: TStatusSource = 'backend'): void {
|
||||
this.updateStatus({
|
||||
source,
|
||||
type: 'error',
|
||||
message,
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transitional message (auto-hides)
|
||||
*/
|
||||
public setText(message: string, source: TStatusSource = 'backend'): void {
|
||||
this.updateStatus({
|
||||
source,
|
||||
type: 'reconnecting',
|
||||
message,
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render status indicators
|
||||
*/
|
||||
private renderStatusIndicators() {
|
||||
const indicators = [];
|
||||
|
||||
if (this.networkStatus) {
|
||||
indicators.push(html`
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot ${this.networkStatus.type}"></span>
|
||||
<span class="status-label">Net</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (this.backendStatus) {
|
||||
indicators.push(html`
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot ${this.backendStatus.type}"></span>
|
||||
<span class="status-label">API</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (this.swStatus) {
|
||||
indicators.push(html`
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot ${this.swStatus.type}"></span>
|
||||
<span class="status-label">SW</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return indicators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render expanded details
|
||||
*/
|
||||
private renderDetails() {
|
||||
const details = [];
|
||||
|
||||
if (this.networkStatus) {
|
||||
details.push(html`
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Network</span>
|
||||
<span class="detail-value ${this.networkStatus.type === 'online' ? 'success' : 'error'}">
|
||||
${this.networkStatus.message}
|
||||
${this.networkStatus.details?.connectionType ? ` (${this.networkStatus.details.connectionType})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (this.backendStatus) {
|
||||
details.push(html`
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Backend</span>
|
||||
<span class="detail-value ${this.backendStatus.type === 'connected' ? 'success' : this.backendStatus.type === 'reconnecting' ? 'warning' : 'error'}">
|
||||
${this.backendStatus.message}
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (this.swStatus) {
|
||||
details.push(html`
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Service Worker</span>
|
||||
<span class="detail-value ${this.swStatus.type === 'connected' ? 'success' : this.swStatus.type === 'update' ? 'warning' : ''}">
|
||||
${this.swStatus.message}
|
||||
${this.swStatus.details?.version ? ` v${this.swStatus.details.version}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
if (this.swStatus.details?.cacheHitRate !== undefined) {
|
||||
details.push(html`
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Cache Hit Rate</span>
|
||||
<span class="detail-value">${this.swStatus.details.cacheHitRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (this.swStatus.details?.resourceCount !== undefined) {
|
||||
details.push(html`
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Cached Resources</span>
|
||||
<span class="detail-value">${this.swStatus.details.resourceCount}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const latestStatus = this.getLatestStatus();
|
||||
const message = latestStatus?.message || '';
|
||||
const indicators = this.renderStatusIndicators();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="pill ${this.visible ? 'visible' : ''} ${this.getStatusClass()} ${this.expanded ? 'expanded' : ''}"
|
||||
@click="${this.toggleExpanded}"
|
||||
>
|
||||
<div class="pill-main">
|
||||
${indicators.length > 0 ? html`
|
||||
${indicators}
|
||||
${message ? html`<span class="separator"></span>` : ''}
|
||||
` : ''}
|
||||
${message ? html`<span class="status-message">${message}</span>` : ''}
|
||||
</div>
|
||||
<div class="pill-expanded">
|
||||
${this.renderDetails()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
8
ts_web_serviceworker/00_commitinfo_data.ts
Normal file
8
ts_web_serviceworker/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@losslessone_private/lole-serviceworker',
|
||||
version: '1.0.206',
|
||||
description: 'serviceworker implementation for lossless websites'
|
||||
}
|
||||
521
ts_web_serviceworker/classes.backend.ts
Normal file
521
ts_web_serviceworker/classes.backend.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
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';
|
||||
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
|
||||
import { getPersistentStore } from './classes.persistentstore.js';
|
||||
import { getRequestLogStore } from './classes.requestlogstore.js';
|
||||
|
||||
// Add type definitions for ServiceWorker APIs
|
||||
declare global {
|
||||
interface ServiceWorkerGlobalScope extends EventTarget {
|
||||
clients: Clients;
|
||||
registration: ServiceWorkerRegistration;
|
||||
}
|
||||
|
||||
// Define Clients interface
|
||||
interface Clients {
|
||||
matchAll(options?: ClientQueryOptions): Promise<Client[]>;
|
||||
openWindow(url: string): Promise<WindowClient>;
|
||||
claim(): Promise<void>;
|
||||
get(id: string): Promise<Client | undefined>;
|
||||
}
|
||||
|
||||
interface ClientQueryOptions {
|
||||
includeUncontrolled?: boolean;
|
||||
type?: 'window' | 'worker' | 'sharedworker' | 'all';
|
||||
}
|
||||
|
||||
interface Client {
|
||||
id: string;
|
||||
type: 'window' | 'worker' | 'sharedworker';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface WindowClient extends Client {
|
||||
focused: boolean;
|
||||
visibilityState: 'hidden' | 'visible' | 'prerender' | 'unloaded';
|
||||
focus(): Promise<WindowClient>;
|
||||
navigate(url: string): Promise<WindowClient>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is meant to be used only on the backend side
|
||||
*/
|
||||
export class ServiceworkerBackend {
|
||||
public deesComms = new plugins.deesComms.DeesComms();
|
||||
private swSelf: ServiceWorkerGlobalScope;
|
||||
private clientUpdateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Throttling for metrics updates (max 1 per 500ms)
|
||||
private metricsUpdateThrottle: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingMetricsUpdate = false;
|
||||
private readonly METRICS_THROTTLE_MS = 500;
|
||||
|
||||
/**
|
||||
* Helper to create properly formatted TypedRequest messages for DeesComms
|
||||
*/
|
||||
private createMessage<T>(method: string, request: T): any {
|
||||
const id = `${method}_${Date.now()}`;
|
||||
return {
|
||||
method,
|
||||
request,
|
||||
messageId: id,
|
||||
correlation: {
|
||||
id,
|
||||
phase: 'request' as const
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (event.data && event.data.type === 'wakeUpCall') {
|
||||
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);
|
||||
});
|
||||
|
||||
// Handler for getting current SW status
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetStatus>('serviceworker_getStatus', async () => {
|
||||
const metrics = getMetricsCollector();
|
||||
const metricsData = metrics.getMetrics();
|
||||
return {
|
||||
isActive: true,
|
||||
isOnline: metricsData.speedtest.isOnline,
|
||||
cacheHitRate: metrics.getCacheHitRate(),
|
||||
resourceCount: metrics.getResourceCount(),
|
||||
connectedClients: metricsData.connection.connectedClients,
|
||||
lastUpdateCheck: metricsData.update.lastCheckTimestamp,
|
||||
};
|
||||
});
|
||||
|
||||
// Handler for getting event log
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetEventLog>('serviceworker_getEventLog', async (reqArg) => {
|
||||
const persistentStore = getPersistentStore();
|
||||
return await persistentStore.getEventLog({
|
||||
limit: reqArg.limit,
|
||||
type: reqArg.type,
|
||||
since: reqArg.since,
|
||||
before: reqArg.before,
|
||||
});
|
||||
});
|
||||
|
||||
// Handler for getting cumulative metrics
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetCumulativeMetrics>('serviceworker_getCumulativeMetrics', async () => {
|
||||
const persistentStore = getPersistentStore();
|
||||
return persistentStore.getCumulativeMetrics();
|
||||
});
|
||||
|
||||
// Handler for clearing event log
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_ClearEventLog>('serviceworker_clearEventLog', async () => {
|
||||
const persistentStore = getPersistentStore();
|
||||
const success = await persistentStore.clearEventLog();
|
||||
return { success };
|
||||
});
|
||||
|
||||
// Handler for getting event count since timestamp
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetEventCount>('serviceworker_getEventCount', async (reqArg) => {
|
||||
const persistentStore = getPersistentStore();
|
||||
const count = await persistentStore.getEventCount(reqArg.since);
|
||||
return { count };
|
||||
});
|
||||
|
||||
// ================================
|
||||
// TypedRequest Traffic Monitoring
|
||||
// ================================
|
||||
|
||||
// Handler for receiving TypedRequest logs from clients
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog', async (reqArg) => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
requestLogStore.addEntry(reqArg);
|
||||
|
||||
// Broadcast to sw-dash viewers
|
||||
await this.broadcastTypedRequestLogged(reqArg);
|
||||
return {};
|
||||
});
|
||||
|
||||
// Handler for getting TypedRequest logs
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs', async (reqArg) => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
const logs = requestLogStore.getEntries({
|
||||
limit: reqArg.limit,
|
||||
method: reqArg.method,
|
||||
since: reqArg.since,
|
||||
before: reqArg.before,
|
||||
});
|
||||
const totalCount = requestLogStore.getTotalCount({
|
||||
method: reqArg.method,
|
||||
since: reqArg.since,
|
||||
});
|
||||
return { logs, totalCount };
|
||||
});
|
||||
|
||||
// Handler for getting TypedRequest statistics
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestStats>('serviceworker_getTypedRequestStats', async () => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
return requestLogStore.getStats();
|
||||
});
|
||||
|
||||
// Handler for clearing TypedRequest logs
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs', async () => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
requestLogStore.clear();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Periodically update connected clients count
|
||||
this.startClientCountUpdates();
|
||||
|
||||
// Subscribe to EventBus and broadcast status updates
|
||||
this.setupEventBusSubscriptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up subscriptions to EventBus events and broadcasts them to clients
|
||||
*/
|
||||
private setupEventBusSubscriptions(): void {
|
||||
const eventBus = getEventBus();
|
||||
const persistentStore = getPersistentStore();
|
||||
|
||||
// Network status changes
|
||||
eventBus.on(ServiceWorkerEvent.NETWORK_ONLINE, async () => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'network',
|
||||
type: 'online',
|
||||
message: 'Connection restored',
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Log to persistent store
|
||||
await persistentStore.logEvent('network_online', 'Network connection restored');
|
||||
});
|
||||
|
||||
eventBus.on(ServiceWorkerEvent.NETWORK_OFFLINE, async () => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'network',
|
||||
type: 'offline',
|
||||
message: 'Connection lost - offline mode',
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Log to persistent store
|
||||
await persistentStore.logEvent('network_offline', 'Network connection lost');
|
||||
});
|
||||
|
||||
// Update events
|
||||
eventBus.on(ServiceWorkerEvent.UPDATE_AVAILABLE, async (_event, payload: any) => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'serviceworker',
|
||||
type: 'update',
|
||||
message: 'Update available',
|
||||
details: {
|
||||
version: payload.newVersion,
|
||||
},
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Log to persistent store
|
||||
await persistentStore.logEvent('update_check', `Update available: ${payload.newVersion}`, {
|
||||
newVersion: payload.newVersion,
|
||||
});
|
||||
});
|
||||
|
||||
eventBus.on(ServiceWorkerEvent.UPDATE_APPLIED, async (_event, payload: any) => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'serviceworker',
|
||||
type: 'update',
|
||||
message: 'Update applied',
|
||||
details: {
|
||||
version: payload.newVersion,
|
||||
},
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Log to persistent store
|
||||
await persistentStore.logEvent('sw_updated', `Service worker updated to ${payload.newVersion}`, {
|
||||
newVersion: payload.newVersion,
|
||||
});
|
||||
});
|
||||
|
||||
eventBus.on(ServiceWorkerEvent.UPDATE_ERROR, async (_event, payload: any) => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'serviceworker',
|
||||
type: 'error',
|
||||
message: `Update error: ${payload.error || 'Unknown error'}`,
|
||||
persist: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Log to persistent store
|
||||
await persistentStore.logEvent('error', `Update error: ${payload.error || 'Unknown error'}`, {
|
||||
error: payload.error,
|
||||
});
|
||||
});
|
||||
|
||||
// Cache invalidation
|
||||
eventBus.on(ServiceWorkerEvent.CACHE_INVALIDATE_ALL, () => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'serviceworker',
|
||||
type: 'cache',
|
||||
message: 'Clearing cache...',
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Note: cache_invalidated event is logged in the ServiceWorker class
|
||||
});
|
||||
|
||||
// Lifecycle events
|
||||
eventBus.on(ServiceWorkerEvent.ACTIVATE, () => {
|
||||
this.broadcastStatusUpdate({
|
||||
source: 'serviceworker',
|
||||
type: 'connected',
|
||||
message: 'Service worker activated',
|
||||
persist: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Note: sw_activated event is logged in the ServiceWorker class
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a status update to all connected clients
|
||||
*/
|
||||
public async broadcastStatusUpdate(status: interfaces.serviceworker.IStatusUpdate): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_statusUpdate', status));
|
||||
logger.log('info', `Status update broadcast: ${status.source}:${status.type} - ${status.message}`);
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to broadcast status update: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a TypedRequest log entry to all connected clients (for sw-dash)
|
||||
*/
|
||||
public async broadcastTypedRequestLogged(entry: interfaces.serviceworker.ITypedRequestLogEntry): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_typedRequestLogged', entry));
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to broadcast TypedRequest log: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic updates of connected client count
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* reloads all clients
|
||||
*/
|
||||
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(this.createMessage('serviceworker_newVersion', {}));
|
||||
|
||||
// As a fallback, also use the clients API to reload clients that might not catch the broadcast
|
||||
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
|
||||
(client as any).navigate(client.url);
|
||||
logger.log('info', `Navigated client to: ${client.url}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to reload clients: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* display notification
|
||||
*/
|
||||
public async addNotification(notificationArg: {
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
try {
|
||||
// Check if we have permission to show notifications
|
||||
const permission = self.Notification?.permission;
|
||||
if (permission !== 'granted') {
|
||||
logger.log('warn', `Cannot show notification: permission is ${permission}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Type-cast self to ServiceWorkerGlobalScope
|
||||
const swSelf = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
// Use type assertion for notification options to include vibrate
|
||||
const options = {
|
||||
body: notificationArg.body,
|
||||
icon: '/favicon.ico', // Assuming there's a favicon
|
||||
badge: '/favicon.ico',
|
||||
vibrate: [200, 100, 200]
|
||||
} as NotificationOptions;
|
||||
|
||||
await swSelf.registration.showNotification(notificationArg.title, options);
|
||||
|
||||
logger.log('info', `Notification shown: ${notificationArg.title}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to show notification: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async alert(alertText: string) {
|
||||
// Since we can't directly show alerts from service worker context,
|
||||
// we'll use notifications as a fallback
|
||||
await this.addNotification({
|
||||
title: 'Alert',
|
||||
body: alertText
|
||||
});
|
||||
|
||||
// Send message to clients who might be able to show an actual alert
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_alert', { message: alertText }));
|
||||
logger.log('info', `Alert message sent to clients: ${alertText}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send alert to clients: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Push methods for real-time updates
|
||||
// ===============
|
||||
|
||||
/**
|
||||
* Pushes a new event log entry to all connected clients
|
||||
* Called immediately when an event is logged
|
||||
*/
|
||||
public async pushEvent(entry: interfaces.serviceworker.IEventLogEntry): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_eventLogged', entry));
|
||||
logger.log('note', `Pushed event to clients: ${entry.type}`);
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to push event: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a metrics update to all connected clients
|
||||
* Throttled to max 1 update per 500ms to prevent spam
|
||||
*/
|
||||
public pushMetricsUpdate(): void {
|
||||
// Mark that we have a pending update
|
||||
this.pendingMetricsUpdate = true;
|
||||
|
||||
// If we're already throttling, just wait for the next window
|
||||
if (this.metricsUpdateThrottle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the update and start throttle window
|
||||
this.sendMetricsUpdate();
|
||||
|
||||
this.metricsUpdateThrottle = setTimeout(() => {
|
||||
this.metricsUpdateThrottle = null;
|
||||
// If there was a pending update during the throttle window, send it now
|
||||
if (this.pendingMetricsUpdate) {
|
||||
this.sendMetricsUpdate();
|
||||
}
|
||||
}, this.METRICS_THROTTLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually sends the metrics update via DeesComms
|
||||
*/
|
||||
private async sendMetricsUpdate(): Promise<void> {
|
||||
this.pendingMetricsUpdate = false;
|
||||
const metrics = getMetricsCollector();
|
||||
const metricsData = metrics.getMetrics();
|
||||
|
||||
const snapshot: interfaces.serviceworker.IMetricsSnapshot = {
|
||||
cache: {
|
||||
hits: metricsData.cache.hits,
|
||||
misses: metricsData.cache.misses,
|
||||
errors: metricsData.cache.errors,
|
||||
bytesServedFromCache: metricsData.cache.bytesServedFromCache,
|
||||
bytesFetched: metricsData.cache.bytesFetched,
|
||||
},
|
||||
network: {
|
||||
totalRequests: metricsData.network.totalRequests,
|
||||
successfulRequests: metricsData.network.successfulRequests,
|
||||
failedRequests: metricsData.network.failedRequests,
|
||||
},
|
||||
cacheHitRate: metrics.getCacheHitRate(),
|
||||
networkSuccessRate: metrics.getNetworkSuccessRate(),
|
||||
resourceCount: metrics.getResourceCount(),
|
||||
uptime: metricsData.uptime,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_metricsUpdate', snapshot));
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to push metrics update: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes notification when a resource is cached
|
||||
*/
|
||||
public async pushResourceCached(url: string, contentType: string, size: number, cached: boolean): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_resourceCached', { url, contentType, size, cached }));
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to push resource cached: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
423
ts_web_serviceworker/classes.cachemanager.ts
Normal file
423
ts_web_serviceworker/classes.cachemanager.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
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;
|
||||
|
||||
public usedCacheNames = {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the service worker's fetch event to intercept and cache responses.
|
||||
*/
|
||||
private _setupCache = () => {
|
||||
// Create a matching request. For internal requests, reuse the original; for external requests, create one with CORS settings.
|
||||
const createMatchRequest = (requestArg: Request): Request => {
|
||||
let matchRequest: Request;
|
||||
try {
|
||||
if (
|
||||
requestArg.url.startsWith(
|
||||
this.losslessServiceWorkerRef.serviceWindowRef.location.origin
|
||||
)
|
||||
) {
|
||||
// Internal request
|
||||
matchRequest = requestArg;
|
||||
} else {
|
||||
// External request: create a new Request with appropriate CORS settings.
|
||||
matchRequest = new Request(requestArg.url, {
|
||||
method: requestArg.method,
|
||||
headers: requestArg.headers,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error creating match request for ${requestArg.url}: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
return matchRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a 500 error response.
|
||||
*/
|
||||
const create500Response = async (requestArg: Request, responseArg: Response): Promise<Response> => {
|
||||
try {
|
||||
const responseText = await responseArg.clone().text();
|
||||
return new Response(
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.note {
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
border-bottom: 1px solid #e4002b;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="note">
|
||||
<strong>ServiceWorker error 500</strong><br>
|
||||
</div>
|
||||
ServiceWorker is unable to fetch this request.<br>
|
||||
<br>
|
||||
<strong>Request URL:</strong> ${requestArg.url}<br>
|
||||
<strong>Response Type:</strong> ${responseArg.type}<br>
|
||||
<strong>Response Body:</strong> ${responseText}<br>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
status: 500
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error creating 500 response for ${requestArg.url}: ${err}`);
|
||||
return new Response('Internal error', { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for fetch events on the service worker's controlled window.
|
||||
this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
// /sw-dash/metrics - THE initial seed endpoint (provides ALL data)
|
||||
if (parsedUrl.pathname === '/sw-dash/metrics') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.serveMetrics());
|
||||
return;
|
||||
}
|
||||
// /sw-dash/speedtest - user-triggered speedtest
|
||||
if (parsedUrl.pathname === '/sw-dash/speedtest') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.runSpeedtest());
|
||||
return;
|
||||
}
|
||||
// /sw-dash/resources - resource data (kept for now, could be merged into metrics)
|
||||
if (parsedUrl.pathname === '/sw-dash/resources') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveResources()));
|
||||
return;
|
||||
}
|
||||
// All other /sw-dash/* routes removed - use DeesComms instead:
|
||||
// - Events: via serviceworker_getEventLog, serviceworker_clearEventLog
|
||||
// - Requests: via serviceworker_getTypedRequestLogs, serviceworker_clearTypedRequestLogs
|
||||
|
||||
// Block requests that we don't want the service worker to handle.
|
||||
if (
|
||||
parsedUrl.hostname.includes('paddle.com') ||
|
||||
parsedUrl.hostname.includes('paypal.com') ||
|
||||
parsedUrl.hostname.includes('reception.lossless.one') ||
|
||||
parsedUrl.pathname.startsWith('/socket.io') ||
|
||||
originalRequest.url.startsWith('https://umami.')
|
||||
) {
|
||||
logger.log('note', `ServiceWorker not active for ${parsedUrl.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a deferred promise for the fetch event's response.
|
||||
const done = plugins.smartpromise.defer<Response>();
|
||||
fetchEventArg.respondWith(done.promise);
|
||||
|
||||
// Determine whether this request should be cached.
|
||||
if (
|
||||
(originalRequest.method === 'GET' &&
|
||||
originalRequest.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin) &&
|
||||
!originalRequest.url.includes('/api/') &&
|
||||
!originalRequest.url.includes('smartserve/reloadcheck')) ||
|
||||
originalRequest.url.includes('https://assetbroker.') ||
|
||||
originalRequest.url.includes('https://unpkg.com') ||
|
||||
originalRequest.url.includes('https://fonts.googleapis.com') ||
|
||||
originalRequest.url.includes('https://fonts.gstatic.com')
|
||||
) {
|
||||
// Kick off an asynchronous update check.
|
||||
this.losslessServiceWorkerRef.updateManager.checkUpdate(this);
|
||||
|
||||
const matchRequest = createMatchRequest(originalRequest);
|
||||
const cachedResponse = await caches.match(matchRequest);
|
||||
const metrics = getMetricsCollector();
|
||||
const eventBus = getEventBus();
|
||||
|
||||
if (cachedResponse) {
|
||||
// Record cache hit
|
||||
const contentLength = cachedResponse.headers.get('content-length');
|
||||
const bytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
const contentType = cachedResponse.headers.get('content-type') || 'unknown';
|
||||
metrics.recordCacheHit(matchRequest.url, bytes);
|
||||
metrics.recordResourceAccess(matchRequest.url, true, contentType, bytes);
|
||||
eventBus.emitCacheHit(matchRequest.url, bytes);
|
||||
|
||||
logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`);
|
||||
done.resolve(cachedResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record cache miss
|
||||
metrics.recordCacheMiss(matchRequest.url);
|
||||
eventBus.emitCacheMiss(matchRequest.url);
|
||||
|
||||
logger.log('info', `NOTYETCACHED: Trying to cache ${matchRequest.url}`);
|
||||
let newResponse: Response;
|
||||
try {
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Check if the response should be cached. In this version, if the response status is >299 or the response is opaque, we do not cache.
|
||||
if (newResponse.status > 299 || newResponse.type === 'opaque') {
|
||||
logger.log(
|
||||
'error',
|
||||
`NOTCACHED: Can't cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})`
|
||||
);
|
||||
// Optionally, you can force a 500 response so errors are clearly visible.
|
||||
done.resolve(await create500Response(matchRequest, newResponse));
|
||||
} else {
|
||||
try {
|
||||
const cache = await caches.open(this.usedCacheNames.runtimeCacheName);
|
||||
const responseToPutToCache = newResponse.clone();
|
||||
|
||||
// Create new headers preserving all except caching-related ones.
|
||||
const headers = new Headers();
|
||||
responseToPutToCache.headers.forEach((value, key) => {
|
||||
if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure that CORS-related headers are present.
|
||||
if (!headers.has('Access-Control-Allow-Origin')) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
if (!headers.has('Access-Control-Allow-Methods')) {
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
}
|
||||
if (!headers.has('Access-Control-Allow-Headers')) {
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
|
||||
// Set Cross-Origin-Resource-Policy
|
||||
if (matchRequest.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) {
|
||||
// For same-origin resources
|
||||
headers.set('Cross-Origin-Resource-Policy', 'same-origin');
|
||||
} else {
|
||||
// For cross-origin resources that we explicitly allow
|
||||
headers.set('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
}
|
||||
|
||||
// Set caching headers - use modern Cache-Control only
|
||||
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
|
||||
// IMPORTANT: Read the full response body as a blob to avoid issues (e.g., Safari locked streams).
|
||||
const bodyBlob = await responseToPutToCache.blob();
|
||||
const newCachedResponse = new Response(bodyBlob, {
|
||||
status: responseToPutToCache.status,
|
||||
statusText: responseToPutToCache.statusText,
|
||||
headers
|
||||
});
|
||||
|
||||
await cache.put(matchRequest, newCachedResponse);
|
||||
|
||||
// Record resource access for per-resource tracking
|
||||
const cachedContentType = newResponse.headers.get('content-type') || 'unknown';
|
||||
const cachedSize = bodyBlob.size;
|
||||
metrics.recordResourceAccess(matchRequest.url, false, cachedContentType, cachedSize);
|
||||
|
||||
logger.log('ok', `NOWCACHED: Cached response for ${matchRequest.url} for subsequent requests!`);
|
||||
done.resolve(newResponse);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error caching response for ${matchRequest.url}: ${err}`);
|
||||
done.resolve(await create500Response(matchRequest, newResponse));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For requests not intended for caching, simply fetch from the origin.
|
||||
logger.log('ok', `NOTCACHED: Not caching ${originalRequest.url}. Fetching from origin...`);
|
||||
try {
|
||||
const originResponse = await fetch(originalRequest);
|
||||
done.resolve(originResponse);
|
||||
} catch (err: any) {
|
||||
logger.log('error', `Fetch error for ${originalRequest.url}: ${err}`);
|
||||
done.resolve(await create500Response(originalRequest, new Response(err.message)));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Unhandled fetch event error: ${err}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans all caches.
|
||||
* Should only be run when a new ServiceWorker is activated.
|
||||
*/
|
||||
public cleanCaches = async (reasonArg = 'no reason given'): Promise<void> => {
|
||||
try {
|
||||
logger.log('info', `MAJOR CACHEEVENT: Cleaning caches now! Reason: ${reasonArg}`);
|
||||
const cacheNames = await caches.keys();
|
||||
const deletePromises = cacheNames.map((cacheToDelete) =>
|
||||
caches.delete(cacheToDelete).then(() => {
|
||||
logger.log('ok', `Deleted cache ${cacheToDelete}`);
|
||||
})
|
||||
);
|
||||
await Promise.all(deletePromises);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error cleaning caches: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Revalidates the runtime cache by fetching fresh responses and updating the cache.
|
||||
*/
|
||||
public async revalidateCache(): Promise<void> {
|
||||
try {
|
||||
const runtimeCache = await caches.open(this.usedCacheNames.runtimeCacheName);
|
||||
const cacheKeys = await runtimeCache.keys();
|
||||
for (const requestArg of cacheKeys) {
|
||||
try {
|
||||
const clonedRequest = requestArg.clone();
|
||||
const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000);
|
||||
if (response && response.status >= 200 && response.status < 300) {
|
||||
await runtimeCache.delete(requestArg);
|
||||
await runtimeCache.put(requestArg, response);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error revalidating cache for ${requestArg.url}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error revalidating runtime cache: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
232
ts_web_serviceworker/classes.config.ts
Normal file
232
ts_web_serviceworker/classes.config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user