Compare commits

..

42 Commits

Author SHA1 Message Date
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
27 changed files with 2600 additions and 719 deletions

View File

@@ -1,5 +1,63 @@
# Changelog
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@api.global/typedserver",
"version": "7.8.0",
"version": "7.10.2",
"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

435
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,164 @@ 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();
// Register and manage service worker
await swClient.register('/serviceworker.bundle.js');
// Listen for updates
swClient.onUpdate(() => {
console.log('New version available!');
// Initialize and register service worker
const swClient = await getServiceworkerClient({
pollInterval: 30000, // Poll for updates every 30s
});
// The service worker handles:
// - Cache invalidation from server
// - Offline support
// - Background sync
// - Version updates
```
## 🛡️ 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 |
## 📋 Configuration Reference
### IServerOptions
@@ -233,9 +352,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 +362,127 @@ 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 |
## 🏗️ 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,
},
// 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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedserver',
version: '7.8.0',
version: '7.10.2',
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
@@ -62,6 +132,11 @@ export interface IServerOptions {
* Useful for single-page applications with client-side routing
*/
spaFallback?: boolean;
/**
* Security headers configuration (CSP, HSTS, X-Frame-Options, etc.)
*/
securityHeaders?: ISecurityHeaders;
}
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
@@ -70,14 +145,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;
@@ -100,9 +167,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;
@@ -135,49 +199,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
*/
@@ -258,7 +291,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) => {
@@ -388,16 +421,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,
@@ -416,12 +566,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);
}
/**
@@ -458,18 +608,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);
@@ -644,6 +782,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

@@ -203,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,30 @@
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[];
}
/**
@@ -29,14 +46,28 @@ 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,
// PWA manifest
manifest: {
name: this.options.domain,
short_name: this.options.domain,
@@ -46,11 +77,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 +96,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[];
@@ -579,6 +580,7 @@ export interface IRequest_Serviceworker_GetTypedRequestLogs
limit?: number;
method?: string;
since?: number;
before?: number; // For pagination: get logs before this timestamp
};
response: {
logs: ITypedRequestLogEntry[];

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

@@ -27,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 {
@@ -127,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
@@ -147,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();
@@ -163,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;
@@ -183,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();
@@ -215,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;
@@ -270,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 {};
}
);
@@ -299,23 +296,51 @@ export class SwDashApp extends LitElement {
}
);
// Handle TypedRequest logged push updates - dispatch to requests component
// Handle new TypedRequest logged - add to our logs array
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_TypedRequestLogged>(
'serviceworker_typedRequestLogged',
async (entry) => {
// Dispatch custom event for sw-dash-requests component
this.dispatchEvent(new CustomEvent('typedrequest-logged', {
detail: entry,
bubbles: true,
composed: true,
}));
// 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 () => {
@@ -323,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;
@@ -336,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 {
@@ -414,6 +527,7 @@ export class SwDashApp extends LitElement {
<div class="view ${this.currentView === 'overview' ? 'active' : ''}">
<sw-dash-overview
.metrics="${this.metrics}"
.eventCountLastHour="${this.eventCountLastHour}"
@speedtest-complete="${this.handleSpeedtestComplete}"
></sw-dash-overview>
</div>
@@ -431,11 +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></sw-dash-requests>
<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

@@ -1,4 +1,4 @@
import { LitElement, html, css, property, state, customElement } from './plugins.js';
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';
@@ -21,11 +21,28 @@ export interface ITypedRequestStats {
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 {
@@ -155,20 +172,6 @@ export class SwDashRequests extends LitElement {
color: var(--accent-error);
}
.request-payload {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: var(--space-2);
border-radius: var(--radius-sm);
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
white-space: pre-wrap;
word-break: break-all;
max-height: 150px;
overflow-y: auto;
margin-top: var(--space-2);
}
.request-error {
font-size: 12px;
color: var(--accent-error);
@@ -234,6 +237,17 @@ export class SwDashRequests extends LitElement {
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 {
@@ -284,166 +298,379 @@ export class SwDashRequests extends LitElement {
color: var(--text-tertiary);
}
.toggle-payload {
font-size: 11px;
color: var(--accent-primary);
cursor: pointer;
margin-top: var(--space-1);
}
.toggle-payload:hover {
text-decoration: underline;
}
.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[] = [];
@state() accessor stats: ITypedRequestStats | null = null;
@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 totalCount = 0;
@state() accessor isLoading = true;
@state() accessor page = 1;
@state() accessor expandedPayloads: Set<string> = new Set();
@state() accessor availableMethods: string[] = [];
private readonly pageSize = 50;
@state() accessor isLoadingMore = false;
// Bound event handler reference for cleanup
private boundLogHandler: ((e: Event) => void) | null = null;
connectedCallback(): void {
super.connectedCallback();
this.loadLogs();
this.loadStats();
this.loadMethods();
this.setupPushListener();
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.boundLogHandler) {
window.removeEventListener('typedrequest-logged', this.boundLogHandler);
}
}
private setupPushListener(): void {
this.boundLogHandler = (e: Event) => {
const customEvent = e as CustomEvent<ITypedRequestLogEntry>;
const newLog = customEvent.detail;
// Apply filters
if (this.directionFilter !== 'all' && newLog.direction !== this.directionFilter) return;
if (this.phaseFilter !== 'all' && newLog.phase !== this.phaseFilter) return;
if (this.methodFilter && newLog.method !== this.methodFilter) return;
// Prepend new log
this.logs = [newLog, ...this.logs];
this.totalCount++;
// Update available methods if new
if (!this.availableMethods.includes(newLog.method)) {
this.availableMethods = [...this.availableMethods, newLog.method];
}
};
window.addEventListener('typedrequest-logged', this.boundLogHandler);
}
private async loadLogs(): Promise<void> {
this.isLoading = true;
try {
const params = new URLSearchParams();
params.set('limit', String(this.pageSize * this.page));
if (this.methodFilter) {
params.set('method', this.methodFilter);
}
const response = await fetch(`/sw-dash/requests?${params}`);
const data = await response.json();
this.logs = data.logs;
this.totalCount = data.totalCount;
} catch (err) {
console.error('Failed to load request logs:', err);
} finally {
this.isLoading = false;
}
}
private async loadStats(): Promise<void> {
try {
const response = await fetch('/sw-dash/requests/stats');
this.stats = await response.json();
} catch (err) {
console.error('Failed to load request stats:', err);
}
}
private async loadMethods(): Promise<void> {
try {
const response = await fetch('/sw-dash/requests/methods');
const data = await response.json();
this.availableMethods = data.methods;
} catch (err) {
console.error('Failed to load methods:', err);
}
}
// 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;
this.page = 1;
this.loadLogs();
// Local filtering - no HTTP request
}
private handlePhaseFilterChange(e: Event): void {
this.phaseFilter = (e.target as HTMLSelectElement).value as TPhaseFilter;
this.page = 1;
this.loadLogs();
// Local filtering - no HTTP request
}
private handleMethodFilterChange(e: Event): void {
this.methodFilter = (e.target as HTMLSelectElement).value;
this.page = 1;
this.loadLogs();
// 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 async handleClear(): Promise<void> {
private handleClear(): void {
if (!confirm('Are you sure you want to clear the request logs? This cannot be undone.')) {
return;
}
try {
await fetch('/sw-dash/requests', { method: 'DELETE' });
this.loadLogs();
this.loadStats();
} catch (err) {
console.error('Failed to clear request logs:', err);
}
// Dispatch event to parent to clear via DeesComms
this.dispatchEvent(new CustomEvent('clear-requests', {
bubbles: true,
composed: true,
}));
}
private loadMore(): void {
this.page++;
this.loadLogs();
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 togglePayload(correlationId: string): void {
const newSet = new Set(this.expandedPayloads);
if (newSet.has(correlationId)) {
newSet.delete(correlationId);
} else {
newSet.add(correlationId);
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();
}
this.expandedPayloads = newSet;
}
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 {
@@ -469,6 +696,9 @@ export class SwDashRequests extends LitElement {
return `${(durationMs / 1000).toFixed(2)}s`;
}
/**
* Filter logs locally based on direction, phase, method, and search text
*/
private getFilteredLogs(): ITypedRequestLogEntry[] {
let result = this.logs;
@@ -482,6 +712,11 @@ export class SwDashRequests extends LitElement {
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 =>
@@ -495,10 +730,127 @@ export class SwDashRequests extends LitElement {
return result;
}
public render(): TemplateResult {
const filteredLogs = this.getFilteredLogs();
/**
* 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">
@@ -518,7 +870,7 @@ export class SwDashRequests extends LitElement {
<span class="stat-label">Avg Duration</span>
</div>
<div class="stat-item">
<span class="stat-value">${filteredLogs.length}</span>
<span class="stat-value">${groupedLogs.length}</span>
<span class="stat-label">Showing</span>
</div>
</div>
@@ -529,7 +881,10 @@ export class SwDashRequests extends LitElement {
<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">
<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>
@@ -561,9 +916,9 @@ export class SwDashRequests extends LitElement {
</select>
<span class="filter-label">Method:</span>
<select class="filter-select" @change="${this.handleMethodFilterChange}">
<select class="filter-select" .value="${this.methodFilter}" @change="${this.handleMethodFilterChange}">
<option value="">All Methods</option>
${this.availableMethods.map(m => html`<option value="${m}">${m}</option>`)}
${this.methods.map(m => html`<option value="${m}" ?selected="${this.methodFilter === m}">${m}</option>`)}
</select>
<input
@@ -578,54 +933,62 @@ export class SwDashRequests extends LitElement {
<button class="btn clear-btn" @click="${this.handleClear}">Clear Logs</button>
</div>
<!-- Request List -->
${this.isLoading && this.logs.length === 0 ? html`
<div class="empty-state">Loading request logs...</div>
` : filteredLogs.length === 0 ? html`
<!-- 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">
${filteredLogs.map(log => html`
<div class="request-card ${log.error ? 'has-error' : ''}">
${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">
<span class="badge direction-${log.direction}">${log.direction}</span>
<span class="badge phase-${log.phase}">${log.phase}</span>
${log.error ? html`<span class="badge error">error</span>` : ''}
${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 class="method-name">${log.method}</div>
<div class="correlation-id">${log.correlationId}</div>
</div>
<div class="request-meta">
<span class="request-time">${this.formatTimestamp(log.timestamp)}</span>
${log.durationMs !== undefined ? html`
<span class="request-duration ${this.getDurationClass(log.durationMs)}">
${this.formatDuration(log.durationMs)}
<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>
${log.error ? html`
<div class="request-error">${log.error}</div>
${group.response?.error ? html`
<div class="request-error">${group.response.error}</div>
` : ''}
<div class="toggle-payload" @click="${() => this.togglePayload(log.correlationId)}">
${this.expandedPayloads.has(log.correlationId) ? 'Hide payload' : 'Show payload'}
</div>
${this.expandedPayloads.has(log.correlationId) ? html`
<div class="request-payload">${JSON.stringify(log.payload, null, 2)}</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.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.logs.length} of ${this.totalCount} logs</span>
</div>

View File

@@ -303,8 +303,9 @@ export class ReloadChecker {
// Helper function to log entries
const logEntry = (entry: ITypedRequestLogEntry) => {
// Skip logging our own logging requests to avoid infinite loops
if (entry.method === 'serviceworker_typedRequestLog') {
// 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);

View File

@@ -120,6 +120,7 @@ export class ServiceworkerBackend {
limit: reqArg.limit,
type: reqArg.type,
since: reqArg.since,
before: reqArg.before,
});
});
@@ -164,6 +165,7 @@ export class ServiceworkerBackend {
limit: reqArg.limit,
method: reqArg.method,
since: reqArg.since,
before: reqArg.before,
});
const totalCount = requestLogStore.getTotalCount({
method: reqArg.method,

View File

@@ -210,65 +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;
}
// TypedRequest traffic monitoring endpoints
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'GET') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestLogs(parsedUrl.searchParams)));
return;
}
if (parsedUrl.pathname === '/sw-dash/requests/stats') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestStats()));
return;
}
if (parsedUrl.pathname === '/sw-dash/requests/methods') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestMethods()));
return;
}
// DELETE method for clearing TypedRequest logs
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'DELETE') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.clearTypedRequestLogs()));
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

@@ -25,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',
},
});
}
}
/**

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

@@ -29,8 +29,22 @@ export class RequestLogStore {
/**
* 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);
@@ -43,6 +57,29 @@ export class RequestLogStore {
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
*/
@@ -103,6 +140,7 @@ export class RequestLogStore {
limit?: number;
method?: string;
since?: number;
before?: number;
}): interfaces.serviceworker.ITypedRequestLogEntry[] {
let result = [...this.logs];
@@ -111,11 +149,16 @@ export class RequestLogStore {
result = result.filter((e) => e.method === options.method);
}
// Filter by timestamp
// 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);

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()}`);
@@ -119,33 +120,43 @@ export class ServiceWorker {
}
}
/**
* 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 };
})
);
}
/**
* Connect to TypedServer via TypedSocket for cache invalidation
*/
private async connectToServer(): Promise<void> {
try {
// Register handler for cache invalidation from server
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
new plugins.typedrequest.TypedHandler('serviceworker_cacheInvalidate', async (reqArg) => {
logger.log('info', `Cache invalidation requested from server: ${reqArg.reason}`);
// 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

@@ -202,6 +202,7 @@ export class ActionManager {
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

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