Compare commits

..

50 Commits

Author SHA1 Message Date
623e40c5b7 v7.11.0
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 12:19:01 +00:00
94532c3c68 feat(typedserver): Add configurable response compression (Brotli + Gzip) with defaults enabled and documentation 2025-12-08 12:19:01 +00:00
e8e4f81747 v7.10.2
Some checks failed
Default (tags) / security (push) Failing after 17s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 21:30:36 +00:00
f8b4c355d5 fix(docs): Update README with routing examples and utility server config; bump @cloudflare/workers-types and @push.rocks/smartserve versions 2025-12-05 21:30:36 +00:00
980ccfe949 v7.10.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 15:46:33 +00:00
4a76c8f738 fix(typedserver): Use smartserve ControllerRegistry for custom routes and remove custom route parsing 2025-12-05 15:46:33 +00:00
05b1f0a395 v7.10.0
Some checks failed
Default (tags) / security (push) Failing after 37s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 15:27:54 +00:00
d060d99146 feat(website-server): Add configurable ads.txt support to website server 2025-12-05 15:27:54 +00:00
94c6e47e6e v7.9.0
Some checks failed
Default (tags) / security (push) Failing after 39s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 13:13:59 +00:00
ffb00cdb71 feat(typedserver): Add configurable security headers and default SPA behavior
Introduce structured security headers support (CSP, HSTS, X-Frame-Options, COOP/COEP/CORP, Permissions-Policy, Referrer-Policy, X-XSS-Protection, etc.) and apply them to responses and OPTIONS preflight. Expose configuration via the server API and document usage. Also update UtilityWebsiteServer defaults (SPA fallback enabled by default) and related docs.
2025-12-05 13:13:59 +00:00
2f064c7ea8 v7.8.18
Some checks failed
Default (tags) / security (push) Failing after 40s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 10:00:40 +00:00
76b5cb5142 fix(readme): Update README to reflect new features and updated examples (SPA/PWA/Edge/ServiceWorker) and clarify API usage 2025-12-05 10:00:40 +00:00
790b468188 7.8.17
Some checks failed
Default (tags) / security (push) Failing after 40s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 09:26:29 +00:00
24d6d6d2e7 fix(typedserver): Update WebSocket peer tag and add error handling for pushTime 2025-12-05 09:26:27 +00:00
a86fd6c1f3 7.8.16
Some checks failed
Default (tags) / security (push) Failing after 43s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 00:18:07 +00:00
d04179ccbe feat(sw-dash): Add right-click context menu for request cards
- Add dees-catalog dependency for DeesContextmenu component
- Right-click on message card shows context menu with options:
  - Copy Full Message (request + response with all data)
  - Copy Request Payload
  - Copy Response Payload
  - Copy Correlation ID
  - Copy Method Name
  - Filter by Method
  - Show Payload (opens modal)
2025-12-05 00:18:07 +00:00
d6eacf5fcc 7.8.15
Some checks failed
Default (tags) / security (push) Failing after 42s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 00:14:55 +00:00
0f974701d4 feat(sw-dash): Click method tile to filter by that method 2025-12-05 00:14:55 +00:00
2ad38dece3 7.8.14
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 00:09:37 +00:00
32cb5bb423 feat(sw-dash): Group requests by correlationId with full-page payload modal
- Group request/response entries by correlationId for unified display
- Add IGroupedRequest interface for paired request/response data
- Replace inline payload toggle with Show Payload button
- Create full-page modal with request data on left, response on right
- Support keyboard escape to close modal
- Show REQ/RES status badges in grouped cards
2025-12-05 00:09:37 +00:00
5fa97322fb 7.8.13
Some checks failed
Default (tags) / security (push) Failing after 47s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 23:59:54 +00:00
af16473495 fix(requestlogstore): enhance log entry validation to prevent service worker pollution 2025-12-04 23:59:47 +00:00
748a60ef74 v7.8.11
Some checks failed
Default (tags) / security (push) Failing after 58s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 23:14:23 +00:00
3f71643e81 fix(web_inject): Improve logging in web injection (TypedRequest) and update dees-comms dependency 2025-12-04 23:14:23 +00:00
9f107b6876 7.8.10
Some checks failed
Default (tags) / security (push) Failing after 1m0s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 22:53:20 +00:00
4a8cd4b4b7 fix: update @api.global/typedrequest to version 3.2.2 and prevent infinite loops in logging 2025-12-04 22:50:09 +00:00
54d2cd1eb7 7.8.9
Some checks failed
Default (tags) / security (push) Failing after 35s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 22:25:43 +00:00
94eb289081 fix: refine logging to skip serviceworker methods and prevent infinite loops 2025-12-04 22:25:38 +00:00
e022ffc2ba 7.8.8
Some checks failed
Default (tags) / security (push) Failing after 50s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 22:20:55 +00:00
25e92f4351 chore: update @api.global/typedrequest to version 3.2.1 2025-12-04 22:20:44 +00:00
b508cbe927 7.8.7
Some checks failed
Default (tags) / security (push) Failing after 52s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 21:54:08 +00:00
4cbc37c888 feat: implement handler initialization for cache invalidation in ServiceWorker 2025-12-04 21:53:45 +00:00
16f759c2b9 7.8.6
Some checks failed
Default (tags) / security (push) Failing after 52s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 21:40:08 +00:00
f8fee04751 chore: update @api.global/typedrequest to version 3.2.0 and @push.rocks/taskbuffer to version 3.5.0 2025-12-04 21:40:05 +00:00
9406cfa0e2 7.8.5
Some checks failed
Default (tags) / security (push) Failing after 55s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 21:33:09 +00:00
1f310ef8f1 refactor: Remove SW-TypedRequest controller and update related references 2025-12-04 21:33:02 +00:00
9cd10118e3 7.8.4
Some checks failed
Default (tags) / security (push) Failing after 51s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 21:04:38 +00:00
6308e0126d feat(controller): Add SW-TypedRequest controller for service worker communication 2025-12-04 21:04:33 +00:00
e1310269fe 7.8.3
Some checks failed
Default (tags) / security (push) Failing after 53s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 20:56:34 +00:00
1aadc2da21 feat(serviceworker): Add endpoint to serve serviceworker bundle with error handling 2025-12-04 20:56:16 +00:00
37426f0708 7.8.2
Some checks failed
Default (tags) / security (push) Failing after 55s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 20:17:18 +00:00
c124a06bc6 feat(dashboard): Add error handling to serveMetrics method for improved resilience 2025-12-04 20:17:10 +00:00
849e7f4407 7.8.1
Some checks failed
Default (tags) / security (push) Failing after 34s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 20:07:42 +00:00
3baf171394 feat(serviceworker): Enhance event and request logging with pagination support 2025-12-04 20:07:40 +00:00
065987c854 v7.8.0
Some checks failed
Default (tags) / security (push) Failing after 17s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 19:25:55 +00:00
c5c40e78f9 feat(serviceworker): Add TypedRequest traffic monitoring and SW dashboard Requests panel 2025-12-04 19:25:55 +00:00
d3330880c0 v7.7.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 18:07:52 +00:00
dbbfd313ae fix(web_serviceworker): Standardize DeesComms message format in service worker backend 2025-12-04 18:07:52 +00:00
eabee2d658 v7.7.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 17:26:34 +00:00
95cd681380 feat(typedserver): Add SPA fallback support to TypedServer 2025-12-04 17:26:34 +00:00
29 changed files with 3919 additions and 567 deletions

View File

@@ -1,5 +1,102 @@
# Changelog
## 2025-12-08 - 7.11.0 - feat(typedserver)
Add configurable response compression (Brotli + Gzip) with defaults enabled and documentation
- Expose a new compression option on IServerOptions (plugins.smartserve.ICompressionConfig | boolean).
- Pass the compression setting through to SmartServe (smartServeOptions.compression = this.options.compression).
- Add compression option to UtilityWebsiteServer and forward it when creating SmartServe options.
- Update README: new Compression section with global config examples, per-route decorator usage, and options reference.
- Add a small readme.todo.md with service worker wake/reload TODO notes.
## 2025-12-05 - 7.10.2 - fix(docs)
Update README with routing examples and utility server config; bump @cloudflare/workers-types and @push.rocks/smartserve versions
- Bumped dependency @cloudflare/workers-types to ^4.20251205.0
- Bumped dependency @push.rocks/smartserve to ^1.3.0
- Expanded README: added decorator-based routing examples (Route/Get/Post) using smartserve
- Added programmatic routing examples (addRoute) and SPA/wildcard route samples
- Enhanced UtilityWebsiteServer and UtilityServiceServer docs: default port, ads.txt, feedMetadata, addCustomRoutes example and other config options
- Clarified security headers descriptions and configuration reference
- Updated Quick Start console message to show running port ("Server running on port 3000!")
- Documented EdgeWorker/domain routing caching example and noted service worker version update behavior
- Adjusted TypedSocket example tag to use 'allClients' in README
## 2025-12-05 - 7.10.1 - fix(typedserver)
Use smartserve ControllerRegistry for custom routes and remove custom route parsing
- addRoute now delegates to plugins.smartserve.ControllerRegistry instead of building its own regex-based matcher
- Backwards compatibility: incoming smartserve IRequestContext is converted to a Request and ctx.params is attached to request.params before invoking the handler
- Removed internal IRegisteredRoute, customRoutes storage, and parseRouteParams helper
- Request handling now uses ControllerRegistry.matchRoute and registered controllers are compiled via ControllerRegistry.compileRoutes()
## 2025-12-05 - 7.10.0 - feat(website-server)
Add configurable ads.txt support to website server
- Introduce adsTxt?: string[] option to the server options to allow configuring ads.txt entries.
- Serve /ads.txt only when adsTxt is provided; the route is not registered if no entries are configured.
- Replace previous hard-coded Google ads.txt entry with values joined from the provided adsTxt array and served as text/plain.
- Preserves existing behavior when adsTxt is not set (no /ads.txt endpoint will be exposed).
## 2025-12-05 - 7.9.0 - feat(typedserver)
Add configurable security headers and default SPA behavior
Introduce structured security headers support (CSP, HSTS, X-Frame-Options, COOP/COEP/CORP, Permissions-Policy, Referrer-Policy, X-XSS-Protection, etc.) and apply them to responses and OPTIONS preflight. Expose configuration via the server API and document usage. Also update UtilityWebsiteServer defaults (SPA fallback enabled by default) and related docs.
- Add ISecurityHeaders and IContentSecurityPolicy TypeScript interfaces to configure CSP, HSTS and other security-related headers.
- Implement buildCspHeader to serialize CSP config and applyResponseHeaders to add CORS and all configured security headers to outgoing responses.
- Apply security headers to OPTIONS preflight responses and all other responses by default when securityHeaders option is provided.
- Add securityHeaders option to IServerOptions and wire it through TypedServer and UtilityWebsiteServer constructors.
- Update UtilityWebsiteServer: renamed template to UtilityWebsiteServer, enable SPA fallback by default, expose options (cors, spaFallback, securityHeaders, forceSsl, port, feedMetadata, etc.) and forward them into the TypedServer instance.
- Documentation: add Security Headers section and example usage to readme.md; document the UtilityWebsiteServer defaults and example.
- Ensure CORS headers are only added when cors option is enabled.
## 2025-12-05 - 7.8.18 - fix(readme)
Update README to reflect new features and updated examples (SPA/PWA/Edge/ServiceWorker) and clarify API usage
- Rewrite project introduction and features list to highlight Service Worker, Edge Workers, SPA support, and PWA readiness
- Replace and expand example sections: Basic Server, Full Configuration, TypedRequest handlers, WebSocket usage, Edge Worker entrypoint, and Service Worker client usage
- Update configuration reference: remove legacy compression flags, add spaFallback, defaultAnswer, feedMetadata, and blockWaybackMachine options
- Document package exports and add examples for utility servers (WebsiteServer, ServiceServer)
- Clarify TypedRequest/TypedSocket usage by showing server.typedrouter and service worker client initializer (getServiceworkerClient)
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@api.global/typedserver",
"version": "7.6.0",
"version": "7.11.0",
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
"type": "module",
"exports": {
@@ -58,11 +58,12 @@
],
"homepage": "https://code.foss.global/api.global/typedserver",
"dependencies": {
"@api.global/typedrequest": "^3.1.11",
"@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.27",
"@cloudflare/workers-types": "^4.20251205.0",
"@design.estate/dees-catalog": "^2.0.3",
"@design.estate/dees-comms": "^1.0.30",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartenv": "^6.0.0",
@@ -82,12 +83,12 @@
"@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/smartserve": "^1.3.0",
"@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.4.0",
"@push.rocks/taskbuffer": "^3.5.0",
"@push.rocks/webrequest": "^4.0.1",
"@push.rocks/webstore": "^2.0.20",
"@tsclass/tsclass": "^9.3.0",

981
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

504
readme.md
View File

@@ -1,6 +1,6 @@
# @api.global/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.
A powerful TypeScript-first web server framework for building modern full-stack applications. Features static file serving, live reload, type-safe API integration, decorator-based routing, service worker support, and edge computing capabilities. Part of the `@api.global` ecosystem.
## Issue Reporting and Security
@@ -8,15 +8,16 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
## ✨ Features
- 🔒 **Type-Safe API Ecosystem** - Full TypeScript support with `@api.global/typedrequest` and `@api.global/typedsocket`
- 🔒 **Type-Safe API** - Full TypeScript support with `@api.global/typedrequest` and `@api.global/typedsocket`
- 🎯 **Decorator Routing** - Clean, expressive routing with `@Route`, `@Get`, `@Post` decorators via smartserve
- 🛡️ **Security Headers** - Built-in CSP, HSTS, X-Frame-Options, and comprehensive security configuration
-**Live Reload** - Automatic browser refresh on file changes during development
- 🗜 **Compression** - Built-in support for gzip, deflate, and brotli compression
- 🌐 **CORS Management** - Flexible cross-origin resource sharing configuration
- 🔧 **Service Worker Integration** - Advanced caching and offline capabilities
- **Edge Worker Support** - Cloudflare Workers compatible edge computing
- 📡 **WebSocket Support** - Real-time bidirectional communication via TypedSocket
- 🗺️ **Sitemap & Feeds** - Automatic sitemap and RSS feed generation
- 🤖 **Robots.txt** - Built-in robots.txt handling
- 🛠 **Service Worker** - Advanced caching, offline support, and background sync
- ☁️ **Edge Workers** - Cloudflare Workers compatible edge computing with domain routing
- 📡 **WebSocket** - Real-time bidirectional communication via TypedSocket
- 🗺 **SEO Tools** - Built-in sitemap, RSS feed, and robots.txt generation
- 🎯 **SPA Support** - Single-page application fallback routing
- 📱 **PWA Ready** - Web App Manifest generation for progressive web apps
## 📦 Installation
@@ -30,7 +31,7 @@ npm install @api.global/typedserver
## 🚀 Quick Start
### Basic Static File Server
### Basic Server
```typescript
import { TypedServer } from '@api.global/typedserver';
@@ -43,10 +44,10 @@ const server = new TypedServer({
});
await server.start();
console.log('Server running on port 3000');
console.log('Server running on port 3000!');
```
### Server with All Options
### Full Configuration
```typescript
import { TypedServer } from '@api.global/typedserver';
@@ -55,15 +56,23 @@ const server = new TypedServer({
port: 8080,
serveDir: './dist',
cors: true,
// Development
watch: true,
injectReload: true,
enableCompression: true,
preferredCompressionMethod: 'brotli',
forceSsl: false,
// Production
forceSsl: true,
spaFallback: true, // Serve index.html for client-side routes
// SEO
sitemap: true,
feed: true,
robots: true,
domain: 'example.com',
blockWaybackMachine: false,
// PWA
appVersion: 'v1.0.0',
manifest: {
name: 'My App',
@@ -78,16 +87,95 @@ const server = new TypedServer({
await server.start();
```
## 🛣️ Routing
TypedServer uses a unified routing system powered by `@push.rocks/smartserve`. You can add routes using decorators or the programmatic API.
### Decorator-Based Routing
Create clean, expressive controllers using decorators:
```typescript
import * as smartserve from '@push.rocks/smartserve';
@smartserve.Route('/api/users')
class UserController {
@smartserve.Get('/')
async listUsers(ctx: smartserve.IRequestContext): Promise<Response> {
const users = await getUsersFromDb();
return new Response(JSON.stringify(users), {
headers: { 'Content-Type': 'application/json' },
});
}
@smartserve.Get('/:id')
async getUser(ctx: smartserve.IRequestContext): Promise<Response> {
const userId = ctx.params.id;
const user = await getUserById(userId);
return new Response(JSON.stringify(user), {
headers: { 'Content-Type': 'application/json' },
});
}
@smartserve.Post('/')
async createUser(ctx: smartserve.IRequestContext): Promise<Response> {
const userData = ctx.body;
const newUser = await createUserInDb(userData);
return new Response(JSON.stringify(newUser), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Register the controller
smartserve.ControllerRegistry.registerInstance(new UserController());
```
### Programmatic Routes with `addRoute()`
Add routes dynamically using the `addRoute()` API:
```typescript
import { TypedServer } from '@api.global/typedserver';
const server = new TypedServer({ serveDir: './public', cors: true });
// Simple route
server.addRoute('/api/health', 'GET', async (request) => {
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
});
});
// Route with parameters (Express-style :param syntax)
server.addRoute('/api/items/:id', 'GET', async (request) => {
const itemId = (request as any).params.id;
return new Response(JSON.stringify({ id: itemId }), {
headers: { 'Content-Type': 'application/json' },
});
});
// Wildcard routes
server.addRoute('/files/*path', 'GET', async (request) => {
const filePath = (request as any).params.path;
// Handle file serving logic
return new Response(`Requested: ${filePath}`);
});
await server.start();
```
## 🔌 Type-Safe API Integration
### Adding TypedRequest Handlers
```typescript
import { TypedServer, servertools } from '@api.global/typedserver';
import { TypedServer } from '@api.global/typedserver';
import * as typedrequest from '@api.global/typedrequest';
// Define your typed request interface
interface IGetUser {
interface IGetUser extends typedrequest.implementsTR<IGetUser> {
method: 'getUser';
request: { userId: string };
response: { name: string; email: string };
@@ -95,133 +183,233 @@ interface IGetUser {
const server = new TypedServer({ serveDir: './public', cors: true });
// Create a typed router
const typedRouter = new typedrequest.TypedRouter();
// Add a typed handler
typedRouter.addTypedHandler<IGetUser>(
// Add a typed handler directly to the server's router
server.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
### Real-Time WebSocket Communication
TypedServer automatically sets up TypedSocket for real-time communication:
```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 {
interface IChatMessage extends typedrequest.implementsTR<IChatMessage> {
method: 'sendMessage';
request: { text: string; room: string };
response: { messageId: string; timestamp: number };
}
typedRouter.addTypedHandler<IChatMessage>(
const server = new TypedServer({ serveDir: './public', cors: true });
// Handle real-time messages
server.typedrouter.addTypedHandler<IChatMessage>(
new typedrequest.TypedHandler('sendMessage', async (data) => {
return { messageId: crypto.randomUUID(), timestamp: Date.now() };
})
);
```
## 🛠️ Server Tools
await server.start();
### 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);
// Push messages to connected clients
const connections = await server.typedsocket.findAllTargetConnectionsByTag('allClients');
for (const conn of connections) {
// Push to specific clients via TypedSocket
}
```
## ☁️ Edge Worker (Cloudflare Workers)
Deploy your application to the edge with Cloudflare Workers:
```typescript
import { EdgeWorker, DomainRouter } from '@api.global/typedserver/edgeworker';
const router = new DomainRouter();
const worker = new EdgeWorker();
router.addDomainInstruction({
// Configure domain routing with caching
worker.domainRouter.addDomainInstruction({
domainPattern: '*.example.com',
originUrl: 'https://origin.example.com',
type: 'cache',
cacheConfig: { maxAge: 3600 },
});
const worker = new EdgeWorker(router);
// Pass-through to origin for API routes
worker.domainRouter.addDomainInstruction({
domainPattern: 'api.example.com',
originUrl: 'https://api-origin.example.com',
type: 'origin',
});
// In your Cloudflare Worker entry point
// Cloudflare Worker entry point
export default {
fetch: (request: Request, env: any, ctx: any) => worker.handleRequest(request, env, ctx),
fetch: worker.fetchFunction.bind(worker),
};
```
## 🔧 Service Worker Client
Manage service workers in your frontend application:
```typescript
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
import { getServiceworkerClient } from '@api.global/typedserver/web_serviceworker_client';
const swClient = new ServiceWorkerClient();
// Initialize and register service worker
const swClient = await getServiceworkerClient({
pollInterval: 30000, // Poll for updates every 30s
});
// Register and manage service worker
await swClient.register('/serviceworker.bundle.js');
// The service worker handles:
// - Cache invalidation from server
// - Offline support
// - Background sync
// - Version updates
```
// Listen for updates
swClient.onUpdate(() => {
console.log('New version available!');
## 🛡️ Security Headers
Configure comprehensive security headers including CSP, HSTS, and more:
```typescript
import { TypedServer } from '@api.global/typedserver';
const server = new TypedServer({
serveDir: './dist',
cors: true,
securityHeaders: {
// Content Security Policy
csp: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.example.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'wss:', 'https://api.example.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
frameAncestors: ["'none'"],
upgradeInsecureRequests: true,
},
// HSTS (HTTP Strict Transport Security)
hstsMaxAge: 31536000, // 1 year
hstsIncludeSubDomains: true,
hstsPreload: true,
// Other security headers
xFrameOptions: 'DENY',
xContentTypeOptions: true,
xXssProtection: true,
referrerPolicy: 'strict-origin-when-cross-origin',
// Cross-Origin policies
crossOriginOpenerPolicy: 'same-origin',
crossOriginEmbedderPolicy: 'require-corp',
crossOriginResourcePolicy: 'same-origin',
// Permissions Policy
permissionsPolicy: {
camera: [],
microphone: [],
geolocation: ['self'],
},
},
});
await server.start();
```
### Security Headers Reference
| Header | Option | Description |
|--------|--------|-------------|
| `Content-Security-Policy` | `csp` | Controls resources the browser can load |
| `Strict-Transport-Security` | `hstsMaxAge`, `hstsIncludeSubDomains`, `hstsPreload` | Forces HTTPS connections |
| `X-Frame-Options` | `xFrameOptions` | Prevents clickjacking attacks |
| `X-Content-Type-Options` | `xContentTypeOptions` | Prevents MIME-sniffing |
| `X-XSS-Protection` | `xXssProtection` | Legacy XSS filter |
| `Referrer-Policy` | `referrerPolicy` | Controls referrer information |
| `Permissions-Policy` | `permissionsPolicy` | Controls browser features |
| `Cross-Origin-Opener-Policy` | `crossOriginOpenerPolicy` | Isolates browsing context |
| `Cross-Origin-Embedder-Policy` | `crossOriginEmbedderPolicy` | Controls cross-origin embedding |
| `Cross-Origin-Resource-Policy` | `crossOriginResourcePolicy` | Controls cross-origin resource sharing |
## 🗜️ Compression
TypedServer supports automatic response compression using Brotli and Gzip. Compression is powered by smartserve and enabled by default.
### Global Configuration
```typescript
import { TypedServer } from '@api.global/typedserver';
const server = new TypedServer({
serveDir: './dist',
cors: true,
// Enable with defaults (brotli + gzip, threshold: 1024 bytes)
compression: true,
// Or disable completely
compression: false,
// Or configure in detail
compression: {
enabled: true,
algorithms: ['br', 'gzip'], // Preferred order
threshold: 1024, // Min size to compress (bytes)
level: 4, // Compression level (1-11 for brotli, 1-9 for gzip)
exclude: ['/api/stream/*'], // Skip these paths
},
});
```
### Per-Route Control with Decorators
Use `@Compress` and `@NoCompress` decorators for fine-grained control:
```typescript
import * as smartserve from '@push.rocks/smartserve';
@smartserve.Route('/api')
class ApiController {
// Force maximum compression for this endpoint
@smartserve.Get('/large-data')
@smartserve.Compress({ level: 11 })
async getLargeData(ctx: smartserve.IRequestContext): Promise<Response> {
return new Response(JSON.stringify(largeDataset));
}
// Disable compression for streaming endpoint
@smartserve.Get('/events')
@smartserve.NoCompress()
async streamEvents(ctx: smartserve.IRequestContext): Promise<Response> {
// Server-Sent Events shouldn't be compressed
return new Response(eventStream, {
headers: { 'Content-Type': 'text/event-stream' },
});
}
}
```
### Compression Options Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | `boolean` | `true` | Enable/disable compression |
| `algorithms` | `string[]` | `['br', 'gzip']` | Preferred algorithms in order |
| `threshold` | `number` | `1024` | Minimum response size (bytes) to compress |
| `level` | `number` | `4` | Compression level (1-11 for brotli, 1-9 for gzip) |
| `compressibleTypes` | `string[]` | auto | MIME types to compress |
| `exclude` | `string[]` | `[]` | Path patterns to skip |
## 📋 Configuration Reference
### IServerOptions
@@ -233,9 +421,8 @@ swClient.onUpdate(() => {
| `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 |
| `spaFallback` | `boolean` | `false` | Serve index.html for non-file routes |
| `sitemap` | `boolean` | `false` | Generate sitemap at `/sitemap` |
| `feed` | `boolean` | `false` | Generate RSS feed at `/feed` |
| `robots` | `boolean` | `false` | Serve robots.txt |
@@ -244,16 +431,131 @@ swClient.onUpdate(() => {
| `manifest` | `object` | - | Web App Manifest configuration |
| `publicKey` | `string` | - | SSL certificate |
| `privateKey` | `string` | - | SSL private key |
| `defaultAnswer` | `function` | - | Custom default response handler |
| `feedMetadata` | `object` | - | RSS feed metadata options |
| `blockWaybackMachine` | `boolean` | `false` | Block Wayback Machine archiving |
| `securityHeaders` | `ISecurityHeaders` | - | Security headers configuration |
| `compression` | `ICompressionConfig \| boolean` | `true` | Response compression configuration |
## 🏗️ Architecture
## 🏗️ Package Exports
```
@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
├── . - Main server (TypedServer)
├── /backend - Alias for main server
├── /edgeworker - Cloudflare Workers edge computing
├── /web_inject - Live reload script injection
── /web_serviceworker - Service Worker implementation
└── /web_serviceworker_client - Service Worker client utilities
```
## 🔄 Utility Servers
Pre-configured server templates with best practices built-in.
### UtilityWebsiteServer
Optimized for modern web applications with SPA support enabled by default:
```typescript
import { utilityservers } from '@api.global/typedserver';
const websiteServer = new utilityservers.UtilityWebsiteServer({
serveDir: './dist',
domain: 'example.com',
// SPA fallback enabled by default
spaFallback: true, // default: true
// Security headers
securityHeaders: {
csp: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
},
xFrameOptions: 'SAMEORIGIN',
xContentTypeOptions: true,
},
// Compression (enabled by default)
compression: true, // or { level: 6, threshold: 512 }
// Other options
cors: true, // default: true
forceSsl: false, // default: false
appSemVer: '1.0.0',
port: 3000, // default: 3000
// Optional ads.txt entries (only served if configured)
adsTxt: [
'google.com, pub-1234567890, DIRECT, f08c47fec0942fa0',
],
// RSS feed metadata
feedMetadata: {
title: 'My Blog',
description: 'A cool blog',
link: 'https://example.com',
},
// Add custom routes
addCustomRoutes: async (typedserver) => {
typedserver.addRoute('/api/custom', 'GET', async () => {
return new Response('Custom route!');
});
},
});
await websiteServer.start();
```
### UtilityServiceServer
Optimized for API services with auto-generated info page:
```typescript
import { utilityservers } from '@api.global/typedserver';
const serviceServer = new utilityservers.UtilityServiceServer({
serviceName: 'My API',
serviceVersion: '1.0.0',
serviceDomain: 'api.example.com',
port: 8080,
// Add custom routes
addCustomRoutes: async (typedserver) => {
typedserver.addRoute('/api/status', 'GET', async () => {
return new Response(JSON.stringify({ status: 'healthy' }), {
headers: { 'Content-Type': 'application/json' },
});
});
},
});
await serviceServer.start();
```
## 🧩 Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ TypedServer │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ SmartServe │ │ TypedRouter │ │ TypedSocket │ │
│ │ (Routing) │ │ (RPC) │ │ (WebSocket) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Request Handler Pipeline │ │
│ │ 1. Controller Registry (Decorated Routes) │ │
│ │ 2. TypedRequest/TypedSocket handlers │ │
│ │ 3. Static File Serving │ │
│ │ 4. SPA Fallback │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## License and Legal Information

5
readme.todo.md Normal file
View File

@@ -0,0 +1,5 @@
- Wake up the service worker before sending stuff.
Handle reload properly. Make sure service worker is up.
Pill handling of service worker status.

View File

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

View File

@@ -1,10 +1,80 @@
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';
/**
* Content Security Policy configuration
* Each directive can be a string or array of sources
*/
export interface IContentSecurityPolicy {
/** Fallback for other directives */
defaultSrc?: string | string[];
/** Valid sources for scripts */
scriptSrc?: string | string[];
/** Valid sources for stylesheets */
styleSrc?: string | string[];
/** Valid sources for images */
imgSrc?: string | string[];
/** Valid sources for fonts */
fontSrc?: string | string[];
/** Valid sources for AJAX, WebSockets, etc. */
connectSrc?: string | string[];
/** Valid sources for media (audio/video) */
mediaSrc?: string | string[];
/** Valid sources for frames */
frameSrc?: string | string[];
/** Valid sources for <object>, <embed>, <applet> */
objectSrc?: string | string[];
/** Valid sources for web workers */
workerSrc?: string | string[];
/** Valid sources for form actions */
formAction?: string | string[];
/** Controls which URLs can embed the page */
frameAncestors?: string | string[];
/** Restricts URLs for <base> element */
baseUri?: string | string[];
/** Report violations to this URL */
reportUri?: string;
/** Report violations to this endpoint */
reportTo?: string;
/** Upgrade insecure requests to HTTPS */
upgradeInsecureRequests?: boolean;
/** Block all mixed content */
blockAllMixedContent?: boolean;
}
/**
* Security headers configuration
*/
export interface ISecurityHeaders {
/** Content Security Policy */
csp?: IContentSecurityPolicy;
/** X-Frame-Options: DENY, SAMEORIGIN, or ALLOW-FROM uri */
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | string;
/** X-Content-Type-Options: nosniff */
xContentTypeOptions?: boolean;
/** X-XSS-Protection header (legacy, but still useful) */
xXssProtection?: boolean | string;
/** Referrer-Policy header */
referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
/** Strict-Transport-Security (HSTS) max-age in seconds */
hstsMaxAge?: number;
/** Include subdomains in HSTS */
hstsIncludeSubDomains?: boolean;
/** HSTS preload flag */
hstsPreload?: boolean;
/** Permissions-Policy (formerly Feature-Policy) */
permissionsPolicy?: Record<string, string[]>;
/** Cross-Origin-Opener-Policy */
crossOriginOpenerPolicy?: 'unsafe-none' | 'same-origin-allow-popups' | 'same-origin';
/** Cross-Origin-Embedder-Policy */
crossOriginEmbedderPolicy?: 'unsafe-none' | 'require-corp' | 'credentialless';
/** Cross-Origin-Resource-Policy */
crossOriginResourcePolicy?: 'same-site' | 'same-origin' | 'cross-origin';
}
export interface IServerOptions {
/**
* serve a particular directory
@@ -56,6 +126,23 @@ export interface IServerOptions {
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;
/**
* Security headers configuration (CSP, HSTS, X-Frame-Options, etc.)
*/
securityHeaders?: ISecurityHeaders;
/**
* Response compression configuration
* Set to true for defaults (brotli + gzip), false to disable, or provide detailed config
*/
compression?: plugins.smartserve.ICompressionConfig | boolean;
}
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
@@ -64,14 +151,6 @@ 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;
@@ -94,9 +173,6 @@ export class TypedServer {
// 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;
@@ -129,49 +205,18 @@ export class TypedServer {
* @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 '(.*)';
// Delegate to smartserve's ControllerRegistry
plugins.smartserve.ControllerRegistry.addRoute(path, method, async (ctx: plugins.smartserve.IRequestContext) => {
// Convert context to Request for backwards compatibility
const request = new Request(ctx.url.toString(), {
method: ctx.method,
headers: ctx.headers,
});
// Ensure exact match
regexPattern = `^${regexPattern}$`;
this.customRoutes.push({
pattern: path,
regex: new RegExp(regexPattern),
paramNames,
method,
handler,
(request as any).params = ctx.params;
return handler(request);
});
}
/**
* 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
*/
@@ -242,6 +287,7 @@ export class TypedServer {
const smartServeOptions: plugins.smartserve.ISmartServeOptions = {
port,
hostname: '0.0.0.0',
compression: this.options.compression,
tls:
this.options.privateKey && this.options.publicKey
? {
@@ -252,7 +298,7 @@ export class TypedServer {
websocket: {
typedRouter: this.typedrouter,
onConnectionOpen: (peer) => {
peer.tags.add('typedserver_frontend');
peer.tags.add('allClients');
console.log(`WebSocket connected: ${peer.id}`);
},
onConnectionClose: (peer) => {
@@ -382,16 +428,133 @@ export class TypedServer {
}
/**
* Add CORS headers to a response
* Build CSP header string from configuration
*/
private addCorsHeaders(response: Response): Response {
if (!this.options.cors) return response;
private buildCspHeader(csp: IContentSecurityPolicy): string {
const directives: string[] = [];
const addDirective = (name: string, value: string | string[] | undefined) => {
if (value) {
const sources = Array.isArray(value) ? value.join(' ') : value;
directives.push(`${name} ${sources}`);
}
};
addDirective('default-src', csp.defaultSrc);
addDirective('script-src', csp.scriptSrc);
addDirective('style-src', csp.styleSrc);
addDirective('img-src', csp.imgSrc);
addDirective('font-src', csp.fontSrc);
addDirective('connect-src', csp.connectSrc);
addDirective('media-src', csp.mediaSrc);
addDirective('frame-src', csp.frameSrc);
addDirective('object-src', csp.objectSrc);
addDirective('worker-src', csp.workerSrc);
addDirective('form-action', csp.formAction);
addDirective('frame-ancestors', csp.frameAncestors);
addDirective('base-uri', csp.baseUri);
if (csp.reportUri) {
directives.push(`report-uri ${csp.reportUri}`);
}
if (csp.reportTo) {
directives.push(`report-to ${csp.reportTo}`);
}
if (csp.upgradeInsecureRequests) {
directives.push('upgrade-insecure-requests');
}
if (csp.blockAllMixedContent) {
directives.push('block-all-mixed-content');
}
return directives.join('; ');
}
/**
* Apply all configured headers (CORS, security) to a response
*/
private applyResponseHeaders(response: Response): 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');
// CORS headers
if (this.options.cors) {
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');
}
// Security headers
const security = this.options.securityHeaders;
if (security) {
// Content Security Policy
if (security.csp) {
const cspHeader = this.buildCspHeader(security.csp);
if (cspHeader) {
headers.set('Content-Security-Policy', cspHeader);
}
}
// X-Frame-Options
if (security.xFrameOptions) {
headers.set('X-Frame-Options', security.xFrameOptions);
}
// X-Content-Type-Options
if (security.xContentTypeOptions) {
headers.set('X-Content-Type-Options', 'nosniff');
}
// X-XSS-Protection
if (security.xXssProtection) {
const value = typeof security.xXssProtection === 'string'
? security.xXssProtection
: '1; mode=block';
headers.set('X-XSS-Protection', value);
}
// Referrer-Policy
if (security.referrerPolicy) {
headers.set('Referrer-Policy', security.referrerPolicy);
}
// Strict-Transport-Security (HSTS)
if (security.hstsMaxAge !== undefined) {
let hsts = `max-age=${security.hstsMaxAge}`;
if (security.hstsIncludeSubDomains) {
hsts += '; includeSubDomains';
}
if (security.hstsPreload) {
hsts += '; preload';
}
headers.set('Strict-Transport-Security', hsts);
}
// Permissions-Policy
if (security.permissionsPolicy) {
const policies = Object.entries(security.permissionsPolicy)
.map(([feature, allowlist]) => `${feature}=(${allowlist.join(' ')})`)
.join(', ');
if (policies) {
headers.set('Permissions-Policy', policies);
}
}
// Cross-Origin-Opener-Policy
if (security.crossOriginOpenerPolicy) {
headers.set('Cross-Origin-Opener-Policy', security.crossOriginOpenerPolicy);
}
// Cross-Origin-Embedder-Policy
if (security.crossOriginEmbedderPolicy) {
headers.set('Cross-Origin-Embedder-Policy', security.crossOriginEmbedderPolicy);
}
// Cross-Origin-Resource-Policy
if (security.crossOriginResourcePolicy) {
headers.set('Cross-Origin-Resource-Policy', security.crossOriginResourcePolicy);
}
}
return new Response(response.body, {
status: response.status,
@@ -410,12 +573,12 @@ export class TypedServer {
// Handle OPTIONS preflight for CORS
if (method === 'OPTIONS' && this.options.cors) {
return this.addCorsHeaders(new Response(null, { status: 204 }));
return this.applyResponseHeaders(new Response(null, { status: 204 }));
}
// Process the request and wrap response with CORS headers
// Process the request and wrap response with all configured headers
const response = await this.handleRequestInternal(request, url, path, method);
return this.addCorsHeaders(response);
return this.applyResponseHeaders(response);
}
/**
@@ -452,18 +615,6 @@ export class TypedServer {
}
}
// 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);
@@ -491,6 +642,41 @@ export class TypedServer {
});
}
// 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 });
}
@@ -603,6 +789,8 @@ export class TypedServer {
);
pushTime.fire({
time: this.lastReload,
}).catch(err => {
console.warn('Failed to push latest server change time to client:', err);
});
}
} catch (error) {

View File

@@ -124,6 +124,65 @@ export class BuiltInRoutesController {
});
}
@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 {
@@ -144,4 +203,25 @@ export class BuiltInRoutesController {
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 });
}
}
}

View File

@@ -11,7 +11,7 @@ export class TypedRequestController {
this.typedRouter = typedRouter;
}
@plugins.smartserve.Post('/')
@plugins.smartserve.Post('')
async handleTypedRequest(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
try {
const response = await this.typedRouter.routeAndAddResponse(ctx.body as plugins.typedrequestInterfaces.ITypedRequest);
@@ -31,4 +31,15 @@ export class TypedRequestController {
});
}
}
@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',
},
});
}
}

View File

@@ -9,6 +9,7 @@ export const injectBundleDir = plugins.path.join(packageDir, './dist_ts_web_inje
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');

View File

@@ -1,13 +1,32 @@
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
import { type IServerOptions, type ISecurityHeaders, TypedServer } from '../classes.typedserver.js';
import * as plugins from '../plugins.js';
export interface IUtilityWebsiteServerConstructorOptions {
/** Custom route handler to add additional routes */
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
/** Application semantic version */
appSemVer?: string;
/** Domain name for the website */
domain: string;
/** Directory to serve static files from */
serveDir: string;
feedMetadata: IServerOptions['feedMetadata'];
/** RSS feed metadata */
feedMetadata?: IServerOptions['feedMetadata'];
/** Enable/disable CORS (default: true) */
cors?: boolean;
/** Enable/disable SPA fallback (default: true) */
spaFallback?: boolean;
/** Security headers configuration */
securityHeaders?: ISecurityHeaders;
/** Force SSL redirect (default: false) */
forceSsl?: boolean;
/** Port to listen on (default: 3000) */
port?: number;
/** ads.txt entries (only served if configured) */
adsTxt?: string[];
/** Response compression configuration (default: enabled with brotli + gzip) */
compression?: plugins.smartserve.ICompressionConfig | boolean;
}
/**
@@ -29,14 +48,31 @@ export class UtilityWebsiteServer {
/**
* Start the website server
*/
public async start(portArg = 3000) {
public async start(portArg?: number) {
const port = portArg ?? this.options.port ?? 3000;
this.typedserver = new TypedServer({
cors: true,
injectReload: true,
watch: true,
// Core settings
cors: this.options.cors ?? true,
serveDir: this.options.serveDir,
domain: this.options.domain,
forceSsl: false,
port,
// Development features
injectReload: true,
watch: true,
// SPA support (enabled by default for modern web apps)
spaFallback: this.options.spaFallback ?? true,
// Security
forceSsl: this.options.forceSsl ?? false,
securityHeaders: this.options.securityHeaders,
// Compression
compression: this.options.compression,
// PWA manifest
manifest: {
name: this.options.domain,
short_name: this.options.domain,
@@ -46,11 +82,11 @@ export class UtilityWebsiteServer {
background_color: '#000000',
scope: '/',
},
port: portArg,
// features
// SEO features
robots: true,
sitemap: true,
feedMetadata: this.options.feedMetadata,
});
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] = {
@@ -65,15 +101,16 @@ export class UtilityWebsiteServer {
})
);
// 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' },
// ads.txt handler (only if configured)
if (this.options.adsTxt && this.options.adsTxt.length > 0) {
this.typedserver.addRoute('/ads.txt', 'GET', async () => {
const adsTxt = this.options.adsTxt.join('\n') + '\n';
return new Response(adsTxt, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
});
});
}
// Asset broker manifest handler
this.typedserver.addRoute(

View File

@@ -386,6 +386,7 @@ export interface IRequest_Serviceworker_GetEventLog
limit?: number;
type?: TEventType;
since?: number;
before?: number; // For pagination: get events before this timestamp
};
response: {
events: IEventLogEntry[];
@@ -509,4 +510,132 @@ export interface IMessage_Serviceworker_ResourceCached
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>`;

View File

@@ -6,6 +6,9 @@ import { customElement, property, state } from 'lit/decorators.js';
// DeesComms for push communication
import * as deesComms from '@design.estate/dees-comms';
// Dees-catalog for UI components
import { DeesContextmenu } from '@design.estate/dees-catalog';
export {
LitElement,
html,
@@ -14,6 +17,7 @@ export {
property,
state,
deesComms,
DeesContextmenu,
};
export type { CSSResult, TemplateResult };

View File

@@ -13,9 +13,10 @@ 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';
type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events' | 'requests';
interface IResourceData {
resources: ICachedResource[];
@@ -26,6 +27,11 @@ interface IResourceData {
/**
* 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 {
@@ -126,18 +132,32 @@ export class SwDashApp extends LitElement {
`
];
// 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
};
@state() accessor lastRefresh = new Date().toLocaleTimeString();
@state() accessor isConnected = false;
// DeesComms for receiving push updates from service worker
// 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
@@ -146,7 +166,7 @@ export class SwDashApp extends LitElement {
connectedCallback(): void {
super.connectedCallback();
// Initial HTTP seed request to wake up SW and get initial data
// Initial HTTP seed request to wake up SW and get ALL initial data
this.loadInitialData();
// Setup push listeners via DeesComms
this.setupPushListeners();
@@ -162,19 +182,42 @@ export class SwDashApp extends LitElement {
}
/**
* Initial HTTP request to seed data and wake up service worker
* 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 {
// Fetch metrics (wakes up SW)
const metricsResponse = await fetch('/sw-dash/metrics');
this.metrics = await metricsResponse.json();
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;
// Also load resources
const resourcesResponse = await fetch('/sw-dash/resources');
this.resourceData = await resourcesResponse.json();
} catch (err) {
console.error('Failed to load initial data:', err);
this.isConnected = false;
@@ -182,7 +225,8 @@ export class SwDashApp extends LitElement {
}
/**
* Setup DeesComms handlers for receiving push updates
* Setup DeesComms handlers for receiving push updates from SW
* All real-time updates come through here
*/
private setupPushListeners(): void {
this.comms = new deesComms.DeesComms();
@@ -214,54 +258,6 @@ export class SwDashApp extends LitElement {
resourceCount: snapshot.resourceCount,
uptime: snapshot.uptime,
};
} else {
// If no metrics yet, create minimal structure
this.metrics = {
cache: {
hits: snapshot.cache.hits,
misses: snapshot.cache.misses,
errors: snapshot.cache.errors,
bytesServedFromCache: snapshot.cache.bytesServedFromCache,
bytesFetched: snapshot.cache.bytesFetched,
averageResponseTime: 0,
},
network: {
totalRequests: snapshot.network.totalRequests,
successfulRequests: snapshot.network.successfulRequests,
failedRequests: snapshot.network.failedRequests,
timeouts: 0,
averageLatency: 0,
totalBytesTransferred: 0,
},
update: {
totalChecks: 0,
successfulChecks: 0,
failedChecks: 0,
updatesFound: 0,
updatesApplied: 0,
lastCheckTimestamp: 0,
lastUpdateTimestamp: 0,
},
connection: {
connectedClients: 0,
totalConnectionAttempts: 0,
successfulConnections: 0,
failedConnections: 0,
},
speedtest: {
lastDownloadSpeedMbps: 0,
lastUploadSpeedMbps: 0,
lastLatencyMs: 0,
lastTestTimestamp: 0,
testCount: 0,
isOnline: true,
},
startTime: Date.now() - snapshot.uptime,
uptime: snapshot.uptime,
cacheHitRate: snapshot.cacheHitRate,
networkSuccessRate: snapshot.networkSuccessRate,
resourceCount: snapshot.resourceCount,
};
}
this.lastRefresh = new Date().toLocaleTimeString();
this.isConnected = true;
@@ -269,16 +265,18 @@ export class SwDashApp extends LitElement {
}
);
// Handle event log push updates - dispatch to events component
// Handle new event logged - add to our events array
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_EventLogged>(
'serviceworker_eventLogged',
async (entry) => {
// Dispatch custom event for sw-dash-events component
this.dispatchEvent(new CustomEvent('event-logged', {
detail: entry,
bubbles: true,
composed: true,
}));
// 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 {};
}
);
@@ -297,10 +295,52 @@ export class SwDashApp extends LitElement {
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
* Heartbeat to check SW health periodically (HTTP)
* This is the ONLY periodic HTTP request
*/
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(async () => {
@@ -308,8 +348,22 @@ export class SwDashApp extends LitElement {
const response = await fetch('/sw-dash/metrics');
if (response.ok) {
this.isConnected = true;
// Optionally refresh full metrics periodically
this.metrics = await response.json();
// 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;
@@ -321,22 +375,96 @@ export class SwDashApp extends LitElement {
}
/**
* Load resource data on demand (when switching to urls/domains/types view)
* Handle "load more events" request from sw-dash-events component
* Uses DeesComms to request older events from SW
*/
private async loadResourceData(): Promise<void> {
private async handleLoadMoreEvents(e: CustomEvent<{ before: number }>): Promise<void> {
if (!this.comms) return;
try {
const response = await fetch('/sw-dash/resources');
this.resourceData = await response.json();
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 resources:', 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;
if (view !== 'overview') {
this.loadResourceData();
}
// No HTTP fetch on view change - data is already loaded from initial seed
}
private handleSpeedtestComplete(_e: CustomEvent): void {
@@ -389,12 +517,17 @@ export class SwDashApp extends LitElement {
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>
@@ -412,7 +545,23 @@ export class SwDashApp extends LitElement {
</div>
<div class="view ${this.currentView === 'events' ? 'active' : ''}">
<sw-dash-events></sw-dash-events>
<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>

View File

@@ -18,6 +18,10 @@ type TEventFilter = 'all' | 'sw_installed' | 'sw_activated' | 'sw_updated' | 'sw
/**
* 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 {
@@ -189,97 +193,52 @@ export class SwDashEvents extends LitElement {
`
];
// 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 totalCount = 0;
@state() accessor isLoading = true;
@state() accessor page = 1;
private readonly pageSize = 50;
// Bound event handler reference for cleanup
private boundEventHandler: ((e: Event) => void) | null = null;
connectedCallback(): void {
super.connectedCallback();
this.loadEvents();
// Listen for pushed events from parent
this.setupPushEventListener();
}
disconnectedCallback(): void {
super.disconnectedCallback();
// Clean up event listener
if (this.boundEventHandler) {
window.removeEventListener('event-logged', this.boundEventHandler);
}
}
/**
* Sets up listener for pushed events from service worker (via sw-dash-app)
*/
private setupPushEventListener(): void {
this.boundEventHandler = (e: Event) => {
const customEvent = e as CustomEvent<IEventLogEntry>;
const newEvent = customEvent.detail;
// Only add if it matches current filter (or filter is 'all')
if (this.filter === 'all' || newEvent.type === this.filter) {
// Prepend new event to the list
this.events = [newEvent, ...this.events];
this.totalCount++;
}
};
// Listen at window level since events bubble up with composed: true
window.addEventListener('event-logged', this.boundEventHandler);
}
private async loadEvents(): Promise<void> {
this.isLoading = true;
try {
const params = new URLSearchParams();
params.set('limit', String(this.pageSize * this.page));
if (this.filter !== 'all') {
params.set('type', this.filter);
}
const response = await fetch(`/sw-dash/events?${params}`);
const data = await response.json();
this.events = data.events;
this.totalCount = data.totalCount;
} catch (err) {
console.error('Failed to load events:', err);
} finally {
this.isLoading = false;
}
}
@state() accessor isLoadingMore = false;
private handleFilterChange(e: Event): void {
this.filter = (e.target as HTMLSelectElement).value as TEventFilter;
this.page = 1;
this.loadEvents();
// Local filtering - no HTTP request
}
private handleSearch(e: Event): void {
this.searchText = (e.target as HTMLInputElement).value.toLowerCase();
}
private async handleClear(): Promise<void> {
private handleClear(): void {
if (!confirm('Are you sure you want to clear the event log? This cannot be undone.')) {
return;
}
try {
await fetch('/sw-dash/events', { method: 'DELETE' });
this.loadEvents();
} catch (err) {
console.error('Failed to clear events:', err);
}
// Dispatch event to parent to clear via DeesComms
this.dispatchEvent(new CustomEvent('clear-events', {
bubbles: true,
composed: true,
}));
}
private loadMore(): void {
this.page++;
this.loadEvents();
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 {
@@ -300,13 +259,27 @@ export class SwDashEvents extends LitElement {
return type.replace(/_/g, ' ');
}
/**
* Filter events locally based on type and search text
*/
private getFilteredEvents(): IEventLogEntry[] {
if (!this.searchText) return this.events;
return this.events.filter(e =>
e.message.toLowerCase().includes(this.searchText) ||
e.type.toLowerCase().includes(this.searchText) ||
(e.details && JSON.stringify(e.details).toLowerCase().includes(this.searchText))
);
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 {
@@ -352,10 +325,10 @@ export class SwDashEvents extends LitElement {
<button class="btn clear-btn" @click="${this.handleClear}">Clear Log</button>
</div>
${this.isLoading && this.events.length === 0 ? html`
<div class="empty-state">Loading events...</div>
${this.events.length === 0 ? html`
<div class="empty-state">No events recorded</div>
` : filteredEvents.length === 0 ? html`
<div class="empty-state">No events found</div>
<div class="empty-state">No events match filter</div>
` : html`
<div class="events-list">
${filteredEvents.map(event => html`
@@ -374,8 +347,8 @@ export class SwDashEvents extends LitElement {
${this.events.length < this.totalCount ? html`
<div class="pagination">
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoading}">
${this.isLoading ? 'Loading...' : 'Load More'}
<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>

View File

@@ -79,42 +79,15 @@ export class SwDashOverview extends LitElement {
];
@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;
@state() accessor eventCountLastHour = 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 eventCountInterval: number | null = null;
connectedCallback(): void {
super.connectedCallback();
this.fetchEventCount();
// Refresh event count every 30 seconds
this.eventCountInterval = window.setInterval(() => this.fetchEventCount(), 30000);
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.eventCountInterval) {
window.clearInterval(this.eventCountInterval);
this.eventCountInterval = null;
}
}
private async fetchEventCount(): Promise<void> {
try {
const oneHourAgo = Date.now() - 3600000;
const response = await fetch(`/sw-dash/events/count?since=${oneHourAgo}`);
const data = await response.json();
this.eventCountLastHour = data.count;
} catch (err) {
console.error('Failed to fetch event count:', err);
}
}
private async runSpeedtest(): Promise<void> {
if (this.speedtestRunning) return;

View File

@@ -0,0 +1,999 @@
import { LitElement, html, css, property, state, customElement, DeesContextmenu } 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 handleContextMenu(event: MouseEvent, group: IGroupedRequest): void {
// Build full message object for copying
const fullMessage = {
correlationId: group.correlationId,
method: group.method,
timestamp: group.timestamp,
durationMs: group.durationMs,
request: group.request ? {
direction: group.request.direction,
phase: group.request.phase,
timestamp: group.request.timestamp,
payload: group.request.payload,
} : null,
response: group.response ? {
direction: group.response.direction,
phase: group.response.phase,
timestamp: group.response.timestamp,
durationMs: group.response.durationMs,
payload: group.response.payload,
error: group.response.error,
} : null,
};
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Copy Full Message',
iconName: 'copy',
action: async () => {
await navigator.clipboard.writeText(JSON.stringify(fullMessage, null, 2));
},
},
{
name: 'Copy Request Payload',
iconName: 'upload',
disabled: !group.request,
action: async () => {
if (group.request) {
await navigator.clipboard.writeText(JSON.stringify(group.request.payload, null, 2));
}
},
},
{
name: 'Copy Response Payload',
iconName: 'download',
disabled: !group.response,
action: async () => {
if (group.response) {
await navigator.clipboard.writeText(JSON.stringify(group.response.payload, null, 2));
}
},
},
{ divider: true },
{
name: 'Copy Correlation ID',
iconName: 'hash',
action: async () => {
await navigator.clipboard.writeText(group.correlationId);
},
},
{
name: 'Copy Method Name',
iconName: 'tag',
action: async () => {
await navigator.clipboard.writeText(group.method);
},
},
{ divider: true },
{
name: 'Filter by Method',
iconName: 'filter',
action: async () => {
this.setMethodFilter(group.method);
},
},
{
name: 'Show Payload',
iconName: 'eye',
action: async () => {
this.openPayloadModal(group);
},
},
]);
}
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}">&times;</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' : ''}"
@contextmenu="${(e: MouseEvent) => this.handleContextMenu(e, group)}"
>
<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>
` : ''}
`}
`;
}
}

View File

@@ -5,6 +5,9 @@ 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;
@@ -18,6 +21,7 @@ export class ReloadChecker {
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
@@ -240,6 +244,9 @@ export class ReloadChecker {
}
});
logger.log('success', `ReloadChecker connected through typedsocket!`);
// Enable traffic logging for sw-dash
this.enableTrafficLogging();
}
}
@@ -268,6 +275,52 @@ export class ReloadChecker {
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');
}
}

View File

@@ -4,6 +4,7 @@ import { logger } from './logging.js';
import { getMetricsCollector } from './classes.metrics.js';
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
import { getPersistentStore } from './classes.persistentstore.js';
import { getRequestLogStore } from './classes.requestlogstore.js';
// Add type definitions for ServiceWorker APIs
declare global {
@@ -52,6 +53,22 @@ export class ServiceworkerBackend {
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']>;
@@ -103,6 +120,7 @@ export class ServiceworkerBackend {
limit: reqArg.limit,
type: reqArg.type,
since: reqArg.since,
before: reqArg.before,
});
});
@@ -126,6 +144,49 @@ export class ServiceworkerBackend {
return { count };
});
// ================================
// TypedRequest Traffic Monitoring
// ================================
// Handler for receiving TypedRequest logs from clients
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog', async (reqArg) => {
const requestLogStore = getRequestLogStore();
requestLogStore.addEntry(reqArg);
// Broadcast to sw-dash viewers
await this.broadcastTypedRequestLogged(reqArg);
return {};
});
// Handler for getting TypedRequest logs
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs', async (reqArg) => {
const requestLogStore = getRequestLogStore();
const logs = requestLogStore.getEntries({
limit: reqArg.limit,
method: reqArg.method,
since: reqArg.since,
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();
@@ -244,17 +305,24 @@ export class ServiceworkerBackend {
*/
public async broadcastStatusUpdate(status: interfaces.serviceworker.IStatusUpdate): Promise<void> {
try {
await this.deesComms.postMessage({
method: 'serviceworker_statusUpdate',
request: status,
messageId: `sw_status_${Date.now()}`
});
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
*/
@@ -290,11 +358,7 @@ export class ServiceworkerBackend {
// Send update message via DeesComms
// This will be picked up by clients that have registered a handler for 'serviceworker_newVersion'
await this.deesComms.postMessage({
method: 'serviceworker_newVersion',
request: {},
messageId: `sw_update_${Date.now()}`
});
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' });
@@ -360,11 +424,7 @@ export class ServiceworkerBackend {
// Send message to clients who might be able to show an actual alert
try {
await this.deesComms.postMessage({
method: 'serviceworker_alert',
request: { message: alertText },
messageId: `sw_alert_${Date.now()}`
});
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)}`);
@@ -381,11 +441,7 @@ export class ServiceworkerBackend {
*/
public async pushEvent(entry: interfaces.serviceworker.IEventLogEntry): Promise<void> {
try {
await this.deesComms.postMessage({
method: 'serviceworker_eventLogged',
request: entry,
messageId: `sw_event_${entry.id}`
});
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}`);
@@ -446,11 +502,7 @@ export class ServiceworkerBackend {
};
try {
await this.deesComms.postMessage({
method: 'serviceworker_metricsUpdate',
request: snapshot,
messageId: `sw_metrics_${Date.now()}`
});
await this.deesComms.postMessage(this.createMessage('serviceworker_metricsUpdate', snapshot));
} catch (error) {
logger.log('warn', `Failed to push metrics update: ${error}`);
}
@@ -461,11 +513,7 @@ export class ServiceworkerBackend {
*/
public async pushResourceCached(url: string, contentType: string, size: number, cached: boolean): Promise<void> {
try {
await this.deesComms.postMessage({
method: 'serviceworker_resourceCached',
request: { url, contentType, size, cached },
messageId: `sw_resource_${Date.now()}`
});
await this.deesComms.postMessage(this.createMessage('serviceworker_resourceCached', { url, contentType, size, cached }));
} catch (error) {
logger.log('warn', `Failed to push resource cached: ${error}`);
}

View File

@@ -210,42 +210,27 @@ export class CacheManager {
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(Promise.resolve(dashboard.serveMetrics()));
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;
}
if (parsedUrl.pathname === '/sw-dash/events') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(dashboard.serveEventLog(parsedUrl.searchParams));
return;
}
if (parsedUrl.pathname === '/sw-dash/events/count') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(dashboard.serveEventCount(parsedUrl.searchParams));
return;
}
if (parsedUrl.pathname === '/sw-dash/cumulative-metrics') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(dashboard.serveCumulativeMetrics());
return;
}
// DELETE method for clearing events
if (parsedUrl.pathname === '/sw-dash/events' && originalRequest.method === 'DELETE') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(dashboard.clearEventLog());
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 (

View File

@@ -1,6 +1,7 @@
import { getMetricsCollector } from './classes.metrics.js';
import { getServiceWorkerInstance } from './init.js';
import { getPersistentStore } from './classes.persistentstore.js';
import { getRequestLogStore } from './classes.requestlogstore.js';
import * as interfaces from './env.js';
import type { serviceworker } from '../dist_ts_interfaces/index.js';
@@ -24,15 +25,87 @@ export class DashboardGenerator {
}
/**
* Serves the metrics JSON endpoint
* Serves the metrics JSON endpoint with ALL initial data
* This is the single HTTP seed request that provides:
* - Current metrics
* - Initial events (last 50)
* - Initial request logs (last 50)
* - Request stats and methods
* - Resource data
*/
public serveMetrics(): Response {
return new Response(this.generateMetricsJson(), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
public async serveMetrics(): Promise<Response> {
try {
const metrics = getMetricsCollector();
const persistentStore = getPersistentStore();
await persistentStore.init();
const requestLogStore = getRequestLogStore();
// Get event data
const eventResult = await persistentStore.getEventLog({ limit: 50 });
const oneHourAgo = Date.now() - 3600000;
const eventCountLastHour = await persistentStore.getEventCount(oneHourAgo);
// Build comprehensive initial response
const data = {
// Core metrics
...metrics.getMetrics(),
cacheHitRate: metrics.getCacheHitRate(),
networkSuccessRate: metrics.getNetworkSuccessRate(),
resourceCount: metrics.getResourceCount(),
summary: metrics.getSummary(),
// Resources data
resources: metrics.getCachedResources(),
domains: metrics.getDomainStats(),
contentTypes: metrics.getContentTypeStats(),
// Events data (initial 50)
events: eventResult.events,
eventTotalCount: eventResult.totalCount,
eventCountLastHour,
// Request logs data (initial 50)
requestLogs: requestLogStore.getEntries({ limit: 50 }),
requestTotalCount: requestLogStore.getTotalCount(),
requestStats: requestLogStore.getStats(),
requestMethods: requestLogStore.getMethods(),
};
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
} catch (error) {
console.error('[SW Dashboard] serveMetrics error:', error);
// Return error response with valid JSON structure so client doesn't crash
return new Response(JSON.stringify({
error: String(error),
cache: { hits: 0, misses: 0, errors: 0, bytesServedFromCache: 0, bytesFetched: 0, averageResponseTime: 0 },
network: { totalRequests: 0, successfulRequests: 0, failedRequests: 0, timeouts: 0, averageLatency: 0, totalBytesTransferred: 0 },
update: { totalChecks: 0, successfulChecks: 0, failedChecks: 0, updatesFound: 0, updatesApplied: 0, lastCheckTimestamp: 0, lastUpdateTimestamp: 0 },
connection: { connectedClients: 0, totalConnectionAttempts: 0, successfulConnections: 0, failedConnections: 0 },
speedtest: { lastDownloadSpeedMbps: 0, lastUploadSpeedMbps: 0, lastLatencyMs: 0, lastTestTimestamp: 0, testCount: 0, isOnline: false },
startTime: Date.now(),
uptime: 0,
cacheHitRate: 0,
networkSuccessRate: 0,
resourceCount: 0,
events: [],
eventTotalCount: 0,
eventCountLastHour: 0,
requestLogs: [],
requestTotalCount: 0,
requestStats: { totalRequests: 0, totalResponses: 0, methodCounts: {}, errorCount: 0, avgDurationMs: 0 },
requestMethods: [],
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
}
/**
@@ -119,6 +192,76 @@ export class DashboardGenerator {
});
}
// ================================
// TypedRequest Traffic Endpoints
// ================================
/**
* Serves TypedRequest traffic logs
*/
public serveTypedRequestLogs(searchParams: URLSearchParams): Response {
const requestLogStore = getRequestLogStore();
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!, 10) : undefined;
const method = searchParams.get('method') || undefined;
const since = searchParams.get('since') ? parseInt(searchParams.get('since')!, 10) : undefined;
const logs = requestLogStore.getEntries({ limit, method, since });
const totalCount = requestLogStore.getTotalCount({ method, since });
return new Response(JSON.stringify({ logs, totalCount }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves TypedRequest traffic statistics
*/
public serveTypedRequestStats(): Response {
const requestLogStore = getRequestLogStore();
const stats = requestLogStore.getStats();
return new Response(JSON.stringify(stats), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Clears TypedRequest traffic logs
*/
public clearTypedRequestLogs(): Response {
const requestLogStore = getRequestLogStore();
requestLogStore.clear();
return new Response(JSON.stringify({ success: true }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves unique method names from TypedRequest logs
*/
public serveTypedRequestMethods(): Response {
const requestLogStore = getRequestLogStore();
const methods = requestLogStore.getMethods();
return new Response(JSON.stringify({ methods }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
// Speedtest configuration
private static readonly TEST_DURATION_MS = 5000; // 5 seconds per test
private static readonly CHUNK_SIZE_KB = 64; // 64KB chunks
@@ -262,25 +405,7 @@ export class DashboardGenerator {
* Generates a minimal HTML shell that loads the Lit-based dashboard bundle
*/
public generateDashboardHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SW Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
min-height: 100vh;
}
</style>
</head>
<body>
<sw-dash-app></sw-dash-app>
<script type="module" src="/sw-dash/bundle.js"></script>
</body>
</html>`;
return interfaces.serviceworker.SW_DASH_HTML;
}
}

View File

@@ -64,7 +64,7 @@ export class NetworkManager {
}
try {
const response = await fetch('/sw-typedrequest', {
const response = await fetch('/typedrequest', {
method: 'HEAD',
cache: 'no-cache'
});

View File

@@ -316,6 +316,7 @@ export class PersistentStore {
limit?: number;
type?: TEventType;
since?: number;
before?: number;
}): Promise<{ events: IEventLogEntry[]; totalCount: number }> {
try {
let events: IEventLogEntry[] = [];
@@ -336,6 +337,11 @@ export class PersistentStore {
events = events.filter(e => e.timestamp >= options.since);
}
// Filter by before timestamp (for pagination)
if (options?.before) {
events = events.filter(e => e.timestamp < options.before);
}
// Sort by timestamp (newest first)
events.sort((a, b) => b.timestamp - a.timestamp);

View File

@@ -0,0 +1,233 @@
import { logger } from './logging.js';
import * as interfaces from '../dist_ts_interfaces/index.js';
/**
* Store for TypedRequest traffic logs
* Keeps recent request/response logs in memory for dashboard display
*/
export class RequestLogStore {
private logs: interfaces.serviceworker.ITypedRequestLogEntry[] = [];
private maxEntries: number;
// Statistics
private stats: interfaces.serviceworker.ITypedRequestStats = {
totalRequests: 0,
totalResponses: 0,
methodCounts: {},
errorCount: 0,
avgDurationMs: 0,
};
// For calculating rolling average
private totalDuration = 0;
private durationCount = 0;
constructor(maxEntries = 500) {
this.maxEntries = maxEntries;
logger.log('info', `RequestLogStore initialized with max ${maxEntries} entries`);
}
/**
* Add a new log entry
* Rejects entries for serviceworker_* methods to prevent pollution from SW internal messages
*/
public addEntry(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
// Reject serviceworker_* methods - these are internal SW messages, not app traffic
// This prevents infinite loop pollution if hooks bypass somehow
if (entry.method && entry.method.startsWith('serviceworker_')) {
logger.log('note', `Rejecting serviceworker_* entry: ${entry.method}`);
return;
}
// Also reject entries with deeply nested payloads (sign of previous loop corruption)
if (this.hasNestedServiceworkerPayload(entry)) {
logger.log('warn', `Rejecting corrupted entry with nested serviceworker_* payload`);
return;
}
// Add to log
this.logs.push(entry);
// Trim if over max
if (this.logs.length > this.maxEntries) {
this.logs.shift();
}
// Update statistics
this.updateStats(entry);
}
/**
* Check if an entry has nested serviceworker_* methods in its payload (corruption from old loops)
*/
private hasNestedServiceworkerPayload(entry: interfaces.serviceworker.ITypedRequestLogEntry, depth = 0): boolean {
// Limit recursion depth to prevent stack overflow
if (depth > 3) return false;
const payload = entry.payload;
if (!payload || typeof payload !== 'object') return false;
// Check if payload looks like a TypedRequest log entry with serviceworker_* method
if (payload.method && typeof payload.method === 'string' && payload.method.startsWith('serviceworker_')) {
return true;
}
// Check nested payload
if (payload.payload) {
return this.hasNestedServiceworkerPayload({ ...entry, payload: payload.payload }, depth + 1);
}
return false;
}
/**
* Update statistics based on new entry
*/
private updateStats(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
// Count request/response
if (entry.phase === 'request') {
this.stats.totalRequests++;
} else {
this.stats.totalResponses++;
}
// Count errors
if (entry.error) {
this.stats.errorCount++;
}
// Update duration average
if (entry.durationMs !== undefined) {
this.totalDuration += entry.durationMs;
this.durationCount++;
this.stats.avgDurationMs = Math.round(this.totalDuration / this.durationCount);
}
// Update per-method counts
if (!this.stats.methodCounts[entry.method]) {
this.stats.methodCounts[entry.method] = {
requests: 0,
responses: 0,
errors: 0,
avgDurationMs: 0,
};
}
const methodStats = this.stats.methodCounts[entry.method];
if (entry.phase === 'request') {
methodStats.requests++;
} else {
methodStats.responses++;
}
if (entry.error) {
methodStats.errors++;
}
// Per-method duration tracking (simplified - just uses latest duration)
if (entry.durationMs !== undefined) {
// Rolling average for method
const currentAvg = methodStats.avgDurationMs || 0;
const count = methodStats.responses || 1;
methodStats.avgDurationMs = Math.round((currentAvg * (count - 1) + entry.durationMs) / count);
}
}
/**
* Get log entries with optional filters
*/
public getEntries(options?: {
limit?: number;
method?: string;
since?: number;
before?: number;
}): interfaces.serviceworker.ITypedRequestLogEntry[] {
let result = [...this.logs];
// Filter by method
if (options?.method) {
result = result.filter((e) => e.method === options.method);
}
// Filter by timestamp (since)
if (options?.since) {
result = result.filter((e) => e.timestamp >= options.since);
}
// Filter by timestamp (before - for pagination)
if (options?.before) {
result = result.filter((e) => e.timestamp < options.before);
}
// Sort by timestamp descending (newest first)
result.sort((a, b) => b.timestamp - a.timestamp);
// Apply limit
if (options?.limit && options.limit > 0) {
result = result.slice(0, options.limit);
}
return result;
}
/**
* Get total count of entries (for pagination)
*/
public getTotalCount(options?: { method?: string; since?: number }): number {
if (!options?.method && !options?.since) {
return this.logs.length;
}
let count = 0;
for (const entry of this.logs) {
if (options.method && entry.method !== options.method) continue;
if (options.since && entry.timestamp < options.since) continue;
count++;
}
return count;
}
/**
* Get statistics
*/
public getStats(): interfaces.serviceworker.ITypedRequestStats {
return { ...this.stats };
}
/**
* Clear all logs and reset stats
*/
public clear(): void {
this.logs = [];
this.stats = {
totalRequests: 0,
totalResponses: 0,
methodCounts: {},
errorCount: 0,
avgDurationMs: 0,
};
this.totalDuration = 0;
this.durationCount = 0;
logger.log('info', 'RequestLogStore cleared');
}
/**
* Get unique method names
*/
public getMethods(): string[] {
return Object.keys(this.stats.methodCounts);
}
}
// Singleton instance
let requestLogStoreInstance: RequestLogStore | null = null;
/**
* Get the singleton RequestLogStore instance
*/
export function getRequestLogStore(): RequestLogStore {
if (!requestLogStoreInstance) {
requestLogStoreInstance = new RequestLogStore();
}
return requestLogStoreInstance;
}

View File

@@ -31,6 +31,7 @@ export class ServiceWorker {
// TypedSocket connection for server communication
public typedsocket: plugins.typedsocket.TypedSocket;
public typedrouter = new plugins.typedrequest.TypedRouter();
private handlersInitialized = false;
constructor(selfArg: interfaces.ServiceWindow) {
logger.log('info', `Service worker instantiating at ${Date.now()}`);
@@ -110,6 +111,44 @@ export class ServiceWorker {
done.reject(error);
}
});
// Handle wake-up scenario: if already activated, connect immediately
// (install/activate events don't fire on wake-up)
if (selfArg.registration?.active) {
logger.log('info', 'SW woke up (already activated) - connecting to server');
this.connectToServer();
}
}
/**
* Initialize typed handlers (idempotent - safe to call multiple times)
*/
private initHandlers(): void {
if (this.handlersInitialized) return;
this.handlersInitialized = true;
// Register handler for cache invalidation from server
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
new plugins.typedrequest.TypedHandler('serviceworker_cacheInvalidate', async (reqArg) => {
logger.log('info', `Cache invalidation requested from server: ${reqArg.reason}`);
// Log cache invalidation event (survives)
const persistentStore = getPersistentStore();
await persistentStore.init(); // Ensure store is initialized
await persistentStore.logEvent('cache_invalidated', `Cache invalidated: ${reqArg.reason}`, {
reason: reqArg.reason,
timestamp: reqArg.timestamp,
});
// Reset cumulative metrics (they don't survive cache invalidation)
await persistentStore.resetCumulativeMetrics();
await this.cacheManager.cleanCaches(reqArg.reason);
// Notify all clients to reload
await this.leleServiceWorkerBackend.triggerReloadAll();
return { success: true };
})
);
}
/**
@@ -117,28 +156,7 @@ export class ServiceWorker {
*/
private async connectToServer(): Promise<void> {
try {
// Register handler for cache invalidation from server
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
new plugins.typedrequest.TypedHandler('serviceworker_cacheInvalidate', async (reqArg) => {
logger.log('info', `Cache invalidation requested from server: ${reqArg.reason}`);
// Log cache invalidation event (survives)
const persistentStore = getPersistentStore();
await persistentStore.init(); // Ensure store is initialized
await persistentStore.logEvent('cache_invalidated', `Cache invalidated: ${reqArg.reason}`, {
reason: reqArg.reason,
timestamp: reqArg.timestamp,
});
// Reset cumulative metrics (they don't survive cache invalidation)
await persistentStore.resetCumulativeMetrics();
await this.cacheManager.cleanCaches(reqArg.reason);
// Notify all clients to reload
await this.leleServiceWorkerBackend.triggerReloadAll();
return { success: true };
})
);
this.initHandlers();
// Connect to server via TypedSocket
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(

View File

@@ -177,7 +177,7 @@ export class UpdateManager {
try {
const getAppHashRequest = new plugins.typedrequest.TypedRequest<
interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo
>('/sw-typedrequest', 'serviceworker_versionInfo');
>('/typedrequest', 'serviceworker_versionInfo');
// Use networkManager for the request with retries and timeout
const response = await getAppHashRequest.fire({});

View File

@@ -16,5 +16,6 @@ export interface ServiceWindow extends Window {
location: any;
skipWaiting: any;
clients: any;
registration?: ServiceWorkerRegistration;
}
declare var self: Window;

View File

@@ -191,4 +191,89 @@ export class ActionManager {
const response = await tr.fire({});
return response;
}
// ================================
// TypedRequest Traffic Logging
// ================================
/**
* Log a TypedRequest entry to the service worker for dashboard display
*/
public async logTypedRequest(entry: interfaces.serviceworker.ITypedRequestLogEntry): Promise<void> {
try {
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog');
tr.skipHooks = true; // Prevent infinite loops - don't log the logging request
await tr.fire(entry);
} catch (error) {
// Silently ignore logging errors to avoid infinite loops
// (logging the error would trigger another log entry)
}
}
/**
* Get TypedRequest traffic logs from the service worker
*/
public async getTypedRequestLogs(options?: {
limit?: number;
method?: string;
since?: number;
}): Promise<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs['response'] | null> {
try {
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs');
return await tr.fire(options || {});
} catch (error) {
logger.log('warn', `Failed to get TypedRequest logs: ${error}`);
return null;
}
}
/**
* Get TypedRequest traffic statistics from the service worker
*/
public async getTypedRequestStats(): Promise<interfaces.serviceworker.ITypedRequestStats | null> {
try {
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestStats>('serviceworker_getTypedRequestStats');
return await tr.fire({});
} catch (error) {
logger.log('warn', `Failed to get TypedRequest stats: ${error}`);
return null;
}
}
/**
* Clear TypedRequest traffic logs
*/
public async clearTypedRequestLogs(): Promise<boolean> {
try {
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs');
const result = await tr.fire({});
return result.success;
} catch (error) {
logger.log('warn', `Failed to clear TypedRequest logs: ${error}`);
return false;
}
}
/**
* Subscribe to real-time TypedRequest log entries
* @returns Unsubscribe function
*/
public subscribeToTypedRequestLogs(callback: (entry: interfaces.serviceworker.ITypedRequestLogEntry) => void): () => void {
// Create handler for broadcast messages
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLogged>('serviceworker_typedRequestLogged', async (entry) => {
try {
callback(entry);
} catch (error) {
logger.log('warn', `TypedRequest log callback error: ${error}`);
}
return {};
});
logger.log('info', 'Subscribed to TypedRequest log updates');
// Return unsubscribe function (note: DeesComms doesn't support removing handlers)
return () => {
logger.log('info', 'Unsubscribed from TypedRequest log updates (handler remains active)');
};
}
}

View File

@@ -1,5 +1,6 @@
import * as plugins from './plugins.js';
import { ServiceworkerClient } from './classes.serviceworkerclient.js';
import { ActionManager } from './classes.actionmanager.js';
export class GlobalSW {
losslessSw: ServiceworkerClient;
@@ -8,6 +9,13 @@ export class GlobalSW {
globalThis.globalSw = this;
};
/**
* Exposes the action manager for traffic logging and SW communication
*/
public get actionManager(): ActionManager {
return this.losslessSw.actionManager;
}
/**
* purges the cache of the app's serviceworker
* @returns