Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5c45f668f | |||
| aa748e0d82 | |||
| 14c8d83ab5 | |||
| d66b7648a8 | |||
| 657bdfb403 | |||
| e1b2a13395 | |||
| c753206456 | |||
| 4cc1efb7cc | |||
| 9641a00174 | |||
| 75b4570742 | |||
| fac6384807 | |||
| 48995e6dfd | |||
| 64f8f400c2 | |||
| d5800f58b4 | |||
| 49949b6776 | |||
| 623e40c5b7 | |||
| 94532c3c68 | |||
| e8e4f81747 | |||
| f8b4c355d5 | |||
| 980ccfe949 | |||
| 4a76c8f738 | |||
| 05b1f0a395 | |||
| d060d99146 | |||
| 94c6e47e6e | |||
| ffb00cdb71 | |||
| 2f064c7ea8 | |||
| 76b5cb5142 | |||
| 790b468188 | |||
| 24d6d6d2e7 | |||
| a86fd6c1f3 | |||
| d04179ccbe | |||
| d6eacf5fcc | |||
| 0f974701d4 | |||
| 2ad38dece3 | |||
| 32cb5bb423 | |||
| 5fa97322fb | |||
| af16473495 | |||
| 748a60ef74 | |||
| 3f71643e81 | |||
| 9f107b6876 | |||
| 4a8cd4b4b7 | |||
| 54d2cd1eb7 | |||
| 94eb289081 | |||
| e022ffc2ba | |||
| 25e92f4351 | |||
| b508cbe927 | |||
| 4cbc37c888 | |||
| 16f759c2b9 | |||
| f8fee04751 | |||
| 9406cfa0e2 | |||
| 1f310ef8f1 | |||
| 9cd10118e3 | |||
| 6308e0126d | |||
| e1310269fe | |||
| 1aadc2da21 | |||
| 37426f0708 | |||
| c124a06bc6 | |||
| 849e7f4407 | |||
| 3baf171394 |
122
changelog.md
122
changelog.md
@@ -1,5 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-24 - 8.4.0 - feat(utilityservers)
|
||||
add injectReload and noCache options and enable dev features by default
|
||||
|
||||
- Adds optional configuration properties 'injectReload' and 'noCache' to the website server options interface.
|
||||
- Dev features (injectReload and noCache) are no longer only enabled when serveDir is set; they now default to true when not explicitly provided.
|
||||
- This changes default runtime behavior: live-reload injection and disabled browser caching may be enabled for servers that previously did not have them — consumers should set options explicitly to preserve previous behavior.
|
||||
|
||||
## 2026-02-24 - 8.3.1 - fix(typedserver)
|
||||
no changes detected — no version bump needed
|
||||
|
||||
- No files changed in the diff
|
||||
- Current package version: 8.3.0
|
||||
|
||||
## 2026-01-23 - 8.3.0 - feat(typedserver)
|
||||
add noCache option to disable client-side caching and set no-cache headers on responses
|
||||
|
||||
- Introduces an optional noCache?: boolean in the server options interface
|
||||
- applyResponseHeaders now sets Cache-Control, Pragma, and Expires headers when noCache is true
|
||||
- Existing CORS and security header behavior unchanged when noCache is not set or false
|
||||
|
||||
## 2026-01-23 - 8.2.0 - feat(typedserver)
|
||||
serve bundled in-memory content with caching and reload injection
|
||||
|
||||
- Add IBundledContentItem and IServerOptions.bundledContent to accept tsbundle output (path + contentBase64).
|
||||
- Initialize in-memory bundled content map with MIME type detection, SHA-256-based ETag, size and combined app hash.
|
||||
- Serve bundled files with proper Content-Type, ETag, Cache-Control, support HEAD and conditional 304 responses.
|
||||
- Inject live-reload script into bundled HTML responses when injectReload is enabled; modify SPA fallback to prefer bundled index.html.
|
||||
- Require serveDir or bundledContent when injectReload is enabled; prefer in-memory bundled content over filesystem requests.
|
||||
- Add helper methods: initializeBundledContent, getMimeType, serveBundledContent and serveBundledHtmlWithInjection; log initialization.
|
||||
|
||||
## 2025-12-22 - 8.1.0 - feat(types)
|
||||
export IRequestContext type from @push.rocks/smartserve for consumers to use in route handlers
|
||||
|
||||
- Adds a type export: IRequestContext from @push.rocks/smartserve
|
||||
- Type-only change — no runtime or behavioral changes
|
||||
|
||||
## 2025-12-20 - 8.0.0 - BREAKING CHANGE(typedserver)
|
||||
migrate route handlers to use IRequestContext and lazy body parsers
|
||||
|
||||
- Route handlers now receive plugins.smartserve.IRequestContext instead of Request (breaking API change). addRoute no longer wraps handlers to convert context → Request.
|
||||
- createContext() is now synchronous and provides lazy body accessors: ctx.json(), ctx.text(), ctx.arrayBuffer(), ctx.formData(); ctx.body property was removed.
|
||||
- DevToolsController constructor now accepts optional options and supplies no-op defaults so controllers can be auto-instantiated without args.
|
||||
- TypedRequest controller now reads the request via await ctx.json() and forwards typed requests accordingly.
|
||||
- Utility website server handlers and other internal callsites updated to use ctx.params and the new context API.
|
||||
- Tests updated to the new TypedServer API, improved assertions, changed test port and reduced delays, and switched tap runner export to default.
|
||||
- Bumped dependency @push.rocks/smartserve to ^2.0.1 to match API changes.
|
||||
- npmextra.json reorganized git.zone/tsdoc entries and added release registries and @ship.zone/szci metadata.
|
||||
|
||||
## 2025-12-08 - 7.11.1 - fix(dependencies)
|
||||
Upgrade dependencies: bump @design.estate/dees-catalog to v3.1.1 and @push.rocks/smartwatch to v6.0.0; update migration notes in readme.hints.md
|
||||
|
||||
- package.json: @design.estate/dees-catalog updated from ^2.0.3 to ^3.1.1 (includes new icons, components and DeesIcon unified icon property; legacy iconFA deprecated)
|
||||
- package.json: @push.rocks/smartwatch updated from ^5.0.0 to ^6.0.0 (cross-runtime support, native fs.watch, API compatibility maintained: new Smartwatch class methods and events documented)
|
||||
- readme.hints.md: added migration notes for smartwatch v6 and dees-catalog v3, plus other dependency update summaries
|
||||
|
||||
## 2025-12-08 - 7.11.0 - feat(typedserver)
|
||||
Add configurable response compression (Brotli + Gzip) with defaults enabled and documentation
|
||||
|
||||
- Expose a new compression option on IServerOptions (plugins.smartserve.ICompressionConfig | boolean).
|
||||
- Pass the compression setting through to SmartServe (smartServeOptions.compression = this.options.compression).
|
||||
- Add compression option to UtilityWebsiteServer and forward it when creating SmartServe options.
|
||||
- Update README: new Compression section with global config examples, per-route decorator usage, and options reference.
|
||||
- Add a small readme.todo.md with service worker wake/reload TODO notes.
|
||||
|
||||
## 2025-12-05 - 7.10.2 - fix(docs)
|
||||
Update README with routing examples and utility server config; bump @cloudflare/workers-types and @push.rocks/smartserve versions
|
||||
|
||||
- Bumped dependency @cloudflare/workers-types to ^4.20251205.0
|
||||
- Bumped dependency @push.rocks/smartserve to ^1.3.0
|
||||
- Expanded README: added decorator-based routing examples (Route/Get/Post) using smartserve
|
||||
- Added programmatic routing examples (addRoute) and SPA/wildcard route samples
|
||||
- Enhanced UtilityWebsiteServer and UtilityServiceServer docs: default port, ads.txt, feedMetadata, addCustomRoutes example and other config options
|
||||
- Clarified security headers descriptions and configuration reference
|
||||
- Updated Quick Start console message to show running port ("Server running on port 3000!")
|
||||
- Documented EdgeWorker/domain routing caching example and noted service worker version update behavior
|
||||
- Adjusted TypedSocket example tag to use 'allClients' in README
|
||||
|
||||
## 2025-12-05 - 7.10.1 - fix(typedserver)
|
||||
Use smartserve ControllerRegistry for custom routes and remove custom route parsing
|
||||
|
||||
- addRoute now delegates to plugins.smartserve.ControllerRegistry instead of building its own regex-based matcher
|
||||
- Backwards compatibility: incoming smartserve IRequestContext is converted to a Request and ctx.params is attached to request.params before invoking the handler
|
||||
- Removed internal IRegisteredRoute, customRoutes storage, and parseRouteParams helper
|
||||
- Request handling now uses ControllerRegistry.matchRoute and registered controllers are compiled via ControllerRegistry.compileRoutes()
|
||||
|
||||
## 2025-12-05 - 7.10.0 - feat(website-server)
|
||||
Add configurable ads.txt support to website server
|
||||
|
||||
- Introduce adsTxt?: string[] option to the server options to allow configuring ads.txt entries.
|
||||
- Serve /ads.txt only when adsTxt is provided; the route is not registered if no entries are configured.
|
||||
- Replace previous hard-coded Google ads.txt entry with values joined from the provided adsTxt array and served as text/plain.
|
||||
- Preserves existing behavior when adsTxt is not set (no /ads.txt endpoint will be exposed).
|
||||
|
||||
## 2025-12-05 - 7.9.0 - feat(typedserver)
|
||||
Add configurable security headers and default SPA behavior
|
||||
|
||||
Introduce structured security headers support (CSP, HSTS, X-Frame-Options, COOP/COEP/CORP, Permissions-Policy, Referrer-Policy, X-XSS-Protection, etc.) and apply them to responses and OPTIONS preflight. Expose configuration via the server API and document usage. Also update UtilityWebsiteServer defaults (SPA fallback enabled by default) and related docs.
|
||||
|
||||
- Add ISecurityHeaders and IContentSecurityPolicy TypeScript interfaces to configure CSP, HSTS and other security-related headers.
|
||||
- Implement buildCspHeader to serialize CSP config and applyResponseHeaders to add CORS and all configured security headers to outgoing responses.
|
||||
- Apply security headers to OPTIONS preflight responses and all other responses by default when securityHeaders option is provided.
|
||||
- Add securityHeaders option to IServerOptions and wire it through TypedServer and UtilityWebsiteServer constructors.
|
||||
- Update UtilityWebsiteServer: renamed template to UtilityWebsiteServer, enable SPA fallback by default, expose options (cors, spaFallback, securityHeaders, forceSsl, port, feedMetadata, etc.) and forward them into the TypedServer instance.
|
||||
- Documentation: add Security Headers section and example usage to readme.md; document the UtilityWebsiteServer defaults and example.
|
||||
- Ensure CORS headers are only added when cors option is enabled.
|
||||
|
||||
## 2025-12-05 - 7.8.18 - fix(readme)
|
||||
Update README to reflect new features and updated examples (SPA/PWA/Edge/ServiceWorker) and clarify API usage
|
||||
|
||||
- Rewrite project introduction and features list to highlight Service Worker, Edge Workers, SPA support, and PWA readiness
|
||||
- Replace and expand example sections: Basic Server, Full Configuration, TypedRequest handlers, WebSocket usage, Edge Worker entrypoint, and Service Worker client usage
|
||||
- Update configuration reference: remove legacy compression flags, add spaFallback, defaultAnswer, feedMetadata, and blockWaybackMachine options
|
||||
- Document package exports and add examples for utility servers (WebsiteServer, ServiceServer)
|
||||
- Clarify TypedRequest/TypedSocket usage by showing server.typedrouter and service worker client initializer (getServiceworkerClient)
|
||||
|
||||
## 2025-12-04 - 7.8.11 - fix(web_inject)
|
||||
Improve logging in web injection (TypedRequest) and update dees-comms dependency
|
||||
|
||||
- Add debug logging to ts_web_inject to explicitly filter serviceworker_* methods and avoid infinite loops
|
||||
- Log incoming TypedRequest methods for better visibility during debugging
|
||||
- Bump dependency @design.estate/dees-comms from ^1.0.27 to ^1.0.28
|
||||
|
||||
## 2025-12-04 - 7.8.0 - feat(serviceworker)
|
||||
Add TypedRequest traffic monitoring and SW dashboard 'Requests' panel
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -27,9 +24,17 @@
|
||||
"robots.txt",
|
||||
"compression (gzip, deflate, brotli)"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "7.8.0",
|
||||
"version": "8.4.0",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -58,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": "^3.1.1",
|
||||
"@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": "^2.0.1",
|
||||
"@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/smartwatch": "^6.0.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/webrequest": "^4.0.1",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
|
||||
997
pnpm-lock.yaml
generated
997
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,9 @@
|
||||
## Recent Changes (December 2025)
|
||||
|
||||
### Dependency Updates
|
||||
- `@push.rocks/smartchok` replaced with `@push.rocks/smartwatch` (renamed package, same API)
|
||||
- `@push.rocks/smartwatch` upgraded to v6.0.0 (cross-runtime, native fs.watch)
|
||||
- `@design.estate/dees-catalog` upgraded to v3.1.1 (new icons, components)
|
||||
- `@push.rocks/smartchok` replaced with `@push.rocks/smartwatch` (renamed package)
|
||||
- `@push.rocks/smartfile` upgraded from v11 to v13 (major API change - `fs` module removed)
|
||||
- `@push.rocks/smartfs` added for filesystem operations (v1.2.0+)
|
||||
- `@push.rocks/smartenv` upgraded to v6.0.0
|
||||
@@ -14,6 +16,26 @@
|
||||
|
||||
### Code Migration Notes
|
||||
|
||||
#### smartwatch v6.0.0
|
||||
- Cross-runtime support: Node.js 20+, Deno, Bun
|
||||
- Uses native `fs.watch({ recursive: true })` for performance
|
||||
- Minimal dependencies (no chokidar, no FSEvents bindings)
|
||||
- API unchanged: `new Smartwatch([patterns])`, `.start()`, `.stop()`, `.getObservableFor(event)`
|
||||
- Events: `add`, `addDir`, `change`, `unlink`, `unlinkDir`, `error`, `ready`
|
||||
- Dynamic watching: `.add(patterns)`, `.remove(pattern)`
|
||||
- Status property: `'idle' | 'starting' | 'watching'`
|
||||
|
||||
#### dees-catalog v3.0.0+ Migration
|
||||
- **DeesIcon**: New unified `icon` property with library prefixes:
|
||||
- FontAwesome: `icon="fa:check"` (prefix `fa:`)
|
||||
- Lucide: `icon="lucide:menu"` (prefix `lucide:`)
|
||||
- Legacy `iconFA` property deprecated but still supported
|
||||
- **DeesToast**: New convenience methods and positioning:
|
||||
- `DeesToast.info()`, `.success()`, `.warning()`, `.error()`
|
||||
- Position options: `top-right`, `top-left`, `bottom-right`, `bottom-left`, `top-center`, `bottom-center`
|
||||
- New components: DeesInputTags, DeesInputDatepicker, DeesStatsGrid, DeesPagination, DeesAppuiBase
|
||||
- DeesAppuiAppbar: Hierarchical menus with keyboard navigation
|
||||
|
||||
#### smartfile v13 Migration
|
||||
- Old: `plugins.smartfile.fs.toStringSync(path)` / `plugins.smartfile.fs.toBufferSync(path)`
|
||||
- New: Use `plugins.fsInstance` (SmartFs instance with Node provider)
|
||||
@@ -24,10 +46,6 @@
|
||||
- Old: `plugins.smartfile.fs.fileTreeToHash(dir, pattern)`
|
||||
- New: `await plugins.fsInstance.directory(dir).recursive().treeHash()`
|
||||
|
||||
#### smartwatch (formerly smartchok)
|
||||
- Class renamed: `Smartchok` → `Smartwatch`
|
||||
- API remains the same: `new Smartwatch([paths])`, `.start()`, `.stop()`, `.getObservableFor(event)`
|
||||
|
||||
#### webrequest v4
|
||||
- Class renamed: `WebRequest` → `WebrequestClient`
|
||||
|
||||
|
||||
504
readme.md
504
readme.md
@@ -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 = await ctx.json();
|
||||
const newUser = await createUserInDb(userData);
|
||||
return new Response(JSON.stringify(newUser), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register the controller
|
||||
smartserve.ControllerRegistry.registerInstance(new UserController());
|
||||
```
|
||||
|
||||
### Programmatic Routes with `addRoute()`
|
||||
|
||||
Add routes dynamically using the `addRoute()` API:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Simple route
|
||||
server.addRoute('/api/health', 'GET', async (request) => {
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// Route with parameters (Express-style :param syntax)
|
||||
server.addRoute('/api/items/:id', 'GET', async (request) => {
|
||||
const itemId = (request as any).params.id;
|
||||
return new Response(JSON.stringify({ id: itemId }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// Wildcard routes
|
||||
server.addRoute('/files/*path', 'GET', async (request) => {
|
||||
const filePath = (request as any).params.path;
|
||||
// Handle file serving logic
|
||||
return new Response(`Requested: ${filePath}`);
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## 🔌 Type-Safe API Integration
|
||||
|
||||
### Adding TypedRequest Handlers
|
||||
|
||||
```typescript
|
||||
import { TypedServer, servertools } from '@api.global/typedserver';
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
// Define your typed request interface
|
||||
interface IGetUser {
|
||||
interface IGetUser extends typedrequest.implementsTR<IGetUser> {
|
||||
method: 'getUser';
|
||||
request: { userId: string };
|
||||
response: { name: string; email: string };
|
||||
@@ -95,133 +183,233 @@ interface IGetUser {
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Create a typed router
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
// Add a typed handler
|
||||
typedRouter.addTypedHandler<IGetUser>(
|
||||
// Add a typed handler directly to the server's router
|
||||
server.typedrouter.addTypedHandler<IGetUser>(
|
||||
new typedrequest.TypedHandler('getUser', async (data) => {
|
||||
// Your logic here
|
||||
return { name: 'John Doe', email: 'john@example.com' };
|
||||
})
|
||||
);
|
||||
|
||||
// Attach the router to the server
|
||||
server.server.addRoute('/api', new servertools.HandlerTypedRouter(typedRouter));
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### WebSocket Communication with TypedSocket
|
||||
### Real-Time WebSocket Communication
|
||||
|
||||
TypedServer automatically sets up TypedSocket for real-time communication:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
await server.start();
|
||||
|
||||
// Create WebSocket server attached to the HTTP server
|
||||
const socketServer = await typedsocket.TypedSocket.createServer(
|
||||
typedRouter,
|
||||
server.server
|
||||
);
|
||||
|
||||
// Handle real-time events
|
||||
interface IChatMessage {
|
||||
interface IChatMessage extends typedrequest.implementsTR<IChatMessage> {
|
||||
method: 'sendMessage';
|
||||
request: { text: string; room: string };
|
||||
response: { messageId: string; timestamp: number };
|
||||
}
|
||||
|
||||
typedRouter.addTypedHandler<IChatMessage>(
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Handle real-time messages
|
||||
server.typedrouter.addTypedHandler<IChatMessage>(
|
||||
new typedrequest.TypedHandler('sendMessage', async (data) => {
|
||||
return { messageId: crypto.randomUUID(), timestamp: Date.now() };
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## 🛠️ Server Tools
|
||||
await server.start();
|
||||
|
||||
### Custom Route Handlers
|
||||
|
||||
```typescript
|
||||
import { servertools } from '@api.global/typedserver';
|
||||
|
||||
const server = new servertools.Server({
|
||||
cors: true,
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
// Add a custom route with handler
|
||||
server.addRoute('/api/hello', new servertools.Handler('GET', async (req, res) => {
|
||||
res.json({ message: 'Hello, World!' });
|
||||
}));
|
||||
|
||||
// Serve static files from a directory
|
||||
server.addRoute('/{*splat}', new servertools.HandlerStatic('./public', {
|
||||
serveIndexHtmlDefault: true,
|
||||
enableCompression: true,
|
||||
}));
|
||||
|
||||
await server.start(3000);
|
||||
```
|
||||
|
||||
### Proxy Handler
|
||||
|
||||
```typescript
|
||||
import { servertools } from '@api.global/typedserver';
|
||||
|
||||
const server = new servertools.Server({ cors: true });
|
||||
|
||||
// Proxy requests to another server
|
||||
server.addRoute('/proxy/{*splat}', new servertools.HandlerProxy({
|
||||
target: 'https://api.example.com',
|
||||
}));
|
||||
|
||||
await server.start(3000);
|
||||
// Push messages to connected clients
|
||||
const connections = await server.typedsocket.findAllTargetConnectionsByTag('allClients');
|
||||
for (const conn of connections) {
|
||||
// Push to specific clients via TypedSocket
|
||||
}
|
||||
```
|
||||
|
||||
## ☁️ Edge Worker (Cloudflare Workers)
|
||||
|
||||
Deploy your application to the edge with Cloudflare Workers:
|
||||
|
||||
```typescript
|
||||
import { EdgeWorker, DomainRouter } from '@api.global/typedserver/edgeworker';
|
||||
|
||||
const router = new DomainRouter();
|
||||
const worker = new EdgeWorker();
|
||||
|
||||
router.addDomainInstruction({
|
||||
// Configure domain routing with caching
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: '*.example.com',
|
||||
originUrl: 'https://origin.example.com',
|
||||
type: 'cache',
|
||||
cacheConfig: { maxAge: 3600 },
|
||||
});
|
||||
|
||||
const worker = new EdgeWorker(router);
|
||||
// Pass-through to origin for API routes
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: 'api.example.com',
|
||||
originUrl: 'https://api-origin.example.com',
|
||||
type: 'origin',
|
||||
});
|
||||
|
||||
// In your Cloudflare Worker entry point
|
||||
// Cloudflare Worker entry point
|
||||
export default {
|
||||
fetch: (request: Request, env: any, ctx: any) => worker.handleRequest(request, env, ctx),
|
||||
fetch: worker.fetchFunction.bind(worker),
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Service Worker Client
|
||||
|
||||
Manage service workers in your frontend application:
|
||||
|
||||
```typescript
|
||||
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
import { getServiceworkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
|
||||
const swClient = new ServiceWorkerClient();
|
||||
// Initialize and register service worker
|
||||
const swClient = await getServiceworkerClient({
|
||||
pollInterval: 30000, // Poll for updates every 30s
|
||||
});
|
||||
|
||||
// Register and manage service worker
|
||||
await swClient.register('/serviceworker.bundle.js');
|
||||
// The service worker handles:
|
||||
// - Cache invalidation from server
|
||||
// - Offline support
|
||||
// - Background sync
|
||||
// - Version updates
|
||||
```
|
||||
|
||||
// Listen for updates
|
||||
swClient.onUpdate(() => {
|
||||
console.log('New version available!');
|
||||
## 🛡️ Security Headers
|
||||
|
||||
Configure comprehensive security headers including CSP, HSTS, and more:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
const server = new TypedServer({
|
||||
serveDir: './dist',
|
||||
cors: true,
|
||||
|
||||
securityHeaders: {
|
||||
// Content Security Policy
|
||||
csp: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.example.com'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", 'wss:', 'https://api.example.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||
frameAncestors: ["'none'"],
|
||||
upgradeInsecureRequests: true,
|
||||
},
|
||||
|
||||
// HSTS (HTTP Strict Transport Security)
|
||||
hstsMaxAge: 31536000, // 1 year
|
||||
hstsIncludeSubDomains: true,
|
||||
hstsPreload: true,
|
||||
|
||||
// Other security headers
|
||||
xFrameOptions: 'DENY',
|
||||
xContentTypeOptions: true,
|
||||
xXssProtection: true,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
|
||||
// Cross-Origin policies
|
||||
crossOriginOpenerPolicy: 'same-origin',
|
||||
crossOriginEmbedderPolicy: 'require-corp',
|
||||
crossOriginResourcePolicy: 'same-origin',
|
||||
|
||||
// Permissions Policy
|
||||
permissionsPolicy: {
|
||||
camera: [],
|
||||
microphone: [],
|
||||
geolocation: ['self'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### Security Headers Reference
|
||||
|
||||
| Header | Option | Description |
|
||||
|--------|--------|-------------|
|
||||
| `Content-Security-Policy` | `csp` | Controls resources the browser can load |
|
||||
| `Strict-Transport-Security` | `hstsMaxAge`, `hstsIncludeSubDomains`, `hstsPreload` | Forces HTTPS connections |
|
||||
| `X-Frame-Options` | `xFrameOptions` | Prevents clickjacking attacks |
|
||||
| `X-Content-Type-Options` | `xContentTypeOptions` | Prevents MIME-sniffing |
|
||||
| `X-XSS-Protection` | `xXssProtection` | Legacy XSS filter |
|
||||
| `Referrer-Policy` | `referrerPolicy` | Controls referrer information |
|
||||
| `Permissions-Policy` | `permissionsPolicy` | Controls browser features |
|
||||
| `Cross-Origin-Opener-Policy` | `crossOriginOpenerPolicy` | Isolates browsing context |
|
||||
| `Cross-Origin-Embedder-Policy` | `crossOriginEmbedderPolicy` | Controls cross-origin embedding |
|
||||
| `Cross-Origin-Resource-Policy` | `crossOriginResourcePolicy` | Controls cross-origin resource sharing |
|
||||
|
||||
## 🗜️ Compression
|
||||
|
||||
TypedServer supports automatic response compression using Brotli and Gzip. Compression is powered by smartserve and enabled by default.
|
||||
|
||||
### Global Configuration
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
const server = new TypedServer({
|
||||
serveDir: './dist',
|
||||
cors: true,
|
||||
|
||||
// Enable with defaults (brotli + gzip, threshold: 1024 bytes)
|
||||
compression: true,
|
||||
|
||||
// Or disable completely
|
||||
compression: false,
|
||||
|
||||
// Or configure in detail
|
||||
compression: {
|
||||
enabled: true,
|
||||
algorithms: ['br', 'gzip'], // Preferred order
|
||||
threshold: 1024, // Min size to compress (bytes)
|
||||
level: 4, // Compression level (1-11 for brotli, 1-9 for gzip)
|
||||
exclude: ['/api/stream/*'], // Skip these paths
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Route Control with Decorators
|
||||
|
||||
Use `@Compress` and `@NoCompress` decorators for fine-grained control:
|
||||
|
||||
```typescript
|
||||
import * as smartserve from '@push.rocks/smartserve';
|
||||
|
||||
@smartserve.Route('/api')
|
||||
class ApiController {
|
||||
// Force maximum compression for this endpoint
|
||||
@smartserve.Get('/large-data')
|
||||
@smartserve.Compress({ level: 11 })
|
||||
async getLargeData(ctx: smartserve.IRequestContext): Promise<Response> {
|
||||
return new Response(JSON.stringify(largeDataset));
|
||||
}
|
||||
|
||||
// Disable compression for streaming endpoint
|
||||
@smartserve.Get('/events')
|
||||
@smartserve.NoCompress()
|
||||
async streamEvents(ctx: smartserve.IRequestContext): Promise<Response> {
|
||||
// Server-Sent Events shouldn't be compressed
|
||||
return new Response(eventStream, {
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Compression Options Reference
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | `boolean` | `true` | Enable/disable compression |
|
||||
| `algorithms` | `string[]` | `['br', 'gzip']` | Preferred algorithms in order |
|
||||
| `threshold` | `number` | `1024` | Minimum response size (bytes) to compress |
|
||||
| `level` | `number` | `4` | Compression level (1-11 for brotli, 1-9 for gzip) |
|
||||
| `compressibleTypes` | `string[]` | auto | MIME types to compress |
|
||||
| `exclude` | `string[]` | `[]` | Path patterns to skip |
|
||||
|
||||
## 📋 Configuration Reference
|
||||
|
||||
### IServerOptions
|
||||
@@ -233,9 +421,8 @@ swClient.onUpdate(() => {
|
||||
| `cors` | `boolean` | `true` | Enable CORS headers |
|
||||
| `watch` | `boolean` | `false` | Watch files for changes |
|
||||
| `injectReload` | `boolean` | `false` | Inject live reload script into HTML |
|
||||
| `enableCompression` | `boolean` | `false` | Enable response compression |
|
||||
| `preferredCompressionMethod` | `'gzip' \| 'deflate' \| 'brotli'` | - | Preferred compression algorithm |
|
||||
| `forceSsl` | `boolean` | `false` | Redirect HTTP to HTTPS |
|
||||
| `spaFallback` | `boolean` | `false` | Serve index.html for non-file routes |
|
||||
| `sitemap` | `boolean` | `false` | Generate sitemap at `/sitemap` |
|
||||
| `feed` | `boolean` | `false` | Generate RSS feed at `/feed` |
|
||||
| `robots` | `boolean` | `false` | Serve robots.txt |
|
||||
@@ -244,16 +431,131 @@ swClient.onUpdate(() => {
|
||||
| `manifest` | `object` | - | Web App Manifest configuration |
|
||||
| `publicKey` | `string` | - | SSL certificate |
|
||||
| `privateKey` | `string` | - | SSL private key |
|
||||
| `defaultAnswer` | `function` | - | Custom default response handler |
|
||||
| `feedMetadata` | `object` | - | RSS feed metadata options |
|
||||
| `blockWaybackMachine` | `boolean` | `false` | Block Wayback Machine archiving |
|
||||
| `securityHeaders` | `ISecurityHeaders` | - | Security headers configuration |
|
||||
| `compression` | `ICompressionConfig \| boolean` | `true` | Response compression configuration |
|
||||
|
||||
## 🏗️ Architecture
|
||||
## 🏗️ Package Exports
|
||||
|
||||
```
|
||||
@api.global/typedserver
|
||||
├── /backend - Main server exports (TypedServer, servertools)
|
||||
├── /edgeworker - Cloudflare Workers edge computing
|
||||
├── /web_inject - Live reload script injection
|
||||
├── /web_serviceworker - Service Worker implementation
|
||||
└── /web_serviceworker_client - Service Worker client utilities
|
||||
├── . - Main server (TypedServer)
|
||||
├── /backend - Alias for main server
|
||||
├── /edgeworker - Cloudflare Workers edge computing
|
||||
├── /web_inject - Live reload script injection
|
||||
├── /web_serviceworker - Service Worker implementation
|
||||
└── /web_serviceworker_client - Service Worker client utilities
|
||||
```
|
||||
|
||||
## 🔄 Utility Servers
|
||||
|
||||
Pre-configured server templates with best practices built-in.
|
||||
|
||||
### UtilityWebsiteServer
|
||||
|
||||
Optimized for modern web applications with SPA support enabled by default:
|
||||
|
||||
```typescript
|
||||
import { utilityservers } from '@api.global/typedserver';
|
||||
|
||||
const websiteServer = new utilityservers.UtilityWebsiteServer({
|
||||
serveDir: './dist',
|
||||
domain: 'example.com',
|
||||
|
||||
// SPA fallback enabled by default
|
||||
spaFallback: true, // default: true
|
||||
|
||||
// Security headers
|
||||
securityHeaders: {
|
||||
csp: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
},
|
||||
xFrameOptions: 'SAMEORIGIN',
|
||||
xContentTypeOptions: true,
|
||||
},
|
||||
|
||||
// Compression (enabled by default)
|
||||
compression: true, // or { level: 6, threshold: 512 }
|
||||
|
||||
// Other options
|
||||
cors: true, // default: true
|
||||
forceSsl: false, // default: false
|
||||
appSemVer: '1.0.0',
|
||||
port: 3000, // default: 3000
|
||||
|
||||
// Optional ads.txt entries (only served if configured)
|
||||
adsTxt: [
|
||||
'google.com, pub-1234567890, DIRECT, f08c47fec0942fa0',
|
||||
],
|
||||
|
||||
// RSS feed metadata
|
||||
feedMetadata: {
|
||||
title: 'My Blog',
|
||||
description: 'A cool blog',
|
||||
link: 'https://example.com',
|
||||
},
|
||||
|
||||
// Add custom routes
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
typedserver.addRoute('/api/custom', 'GET', async () => {
|
||||
return new Response('Custom route!');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await websiteServer.start();
|
||||
```
|
||||
|
||||
### UtilityServiceServer
|
||||
|
||||
Optimized for API services with auto-generated info page:
|
||||
|
||||
```typescript
|
||||
import { utilityservers } from '@api.global/typedserver';
|
||||
|
||||
const serviceServer = new utilityservers.UtilityServiceServer({
|
||||
serviceName: 'My API',
|
||||
serviceVersion: '1.0.0',
|
||||
serviceDomain: 'api.example.com',
|
||||
port: 8080,
|
||||
|
||||
// Add custom routes
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
typedserver.addRoute('/api/status', 'GET', async () => {
|
||||
return new Response(JSON.stringify({ status: 'healthy' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await serviceServer.start();
|
||||
```
|
||||
|
||||
## 🧩 Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TypedServer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ SmartServe │ │ TypedRouter │ │ TypedSocket │ │
|
||||
│ │ (Routing) │ │ (RPC) │ │ (WebSocket) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Request Handler Pipeline │ │
|
||||
│ │ 1. Controller Registry (Decorated Routes) │ │
|
||||
│ │ 2. TypedRequest/TypedSocket handlers │ │
|
||||
│ │ 3. Static File Serving │ │
|
||||
│ │ 4. SPA Fallback │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
5
readme.todo.md
Normal file
5
readme.todo.md
Normal file
@@ -0,0 +1,5 @@
|
||||
- Wake up the service worker before sending stuff.
|
||||
|
||||
Handle reload properly. Make sure service worker is up.
|
||||
|
||||
Pill handling of service worker status.
|
||||
@@ -7,7 +7,7 @@ let testTypedServer: TypedServer;
|
||||
tap.test('should create a valid instance of TypedServer', async () => {
|
||||
testTypedServer = new TypedServer({
|
||||
injectReload: true,
|
||||
port: 3000,
|
||||
port: 3001,
|
||||
serveDir: smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
watch: true,
|
||||
cors: true,
|
||||
@@ -17,15 +17,15 @@ tap.test('should create a valid instance of TypedServer', async () => {
|
||||
|
||||
tap.test('should start to serve files', async (tools) => {
|
||||
await testTypedServer.start();
|
||||
await tools.delayFor(5000);
|
||||
await tools.delayFor(1000);
|
||||
await testTypedServer.reload();
|
||||
await tools.delayFor(5000);
|
||||
await tools.delayFor(1000);
|
||||
await testTypedServer.reload();
|
||||
});
|
||||
|
||||
tap.test('should stop to serve files ', async (tools) => {
|
||||
await tools.delayFor(5000);
|
||||
tap.test('should stop to serve files', async (tools) => {
|
||||
await tools.delayFor(1000);
|
||||
await testTypedServer.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
// tslint:disable-next-line:no-implicit-dependencies
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
// helper dependencies
|
||||
// tslint:disable-next-line:no-implicit-dependencies
|
||||
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
|
||||
import * as typedserver from '../ts/index.js';
|
||||
|
||||
let testServer: typedserver.servertools.Server;
|
||||
let testRoute: typedserver.servertools.Route;
|
||||
let testRoute2: typedserver.servertools.Route;
|
||||
let testHandler: typedserver.servertools.Handler;
|
||||
let testServer: typedserver.TypedServer;
|
||||
|
||||
// =================
|
||||
// Test class Server
|
||||
// Test TypedServer
|
||||
// =================
|
||||
|
||||
tap.test('should create a valid Server', async () => {
|
||||
testServer = new typedserver.servertools.Server({
|
||||
tap.test('should create a valid TypedServer', async () => {
|
||||
testServer = new typedserver.TypedServer({
|
||||
cors: true,
|
||||
domain: 'testing.git.zone',
|
||||
forceSsl: false,
|
||||
port: 3000,
|
||||
appVersion: 'v3.2.1',
|
||||
manifest: {
|
||||
name: 'Test App',
|
||||
@@ -38,101 +31,137 @@ tap.test('should create a valid Server', async () => {
|
||||
feed: true,
|
||||
sitemap: true,
|
||||
robots: true,
|
||||
serveDir: smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
});
|
||||
expect(testServer).toBeInstanceOf(typedserver.servertools.Server);
|
||||
expect(testServer).toBeInstanceOf(typedserver.TypedServer);
|
||||
});
|
||||
|
||||
// ================
|
||||
// Test class Route
|
||||
// Test addRoute
|
||||
// ================
|
||||
|
||||
tap.test('should create a valid Route', async () => {
|
||||
testRoute = testServer.addRoute('/someroute');
|
||||
testRoute2 = testServer.addRoute('/someroute/*splat');
|
||||
expect(testRoute).toBeInstanceOf(typedserver.servertools.Route);
|
||||
});
|
||||
|
||||
// ==================
|
||||
// Test class Handler
|
||||
// ==================
|
||||
|
||||
tap.test('should produce a valid handler', async () => {
|
||||
testHandler = new typedserver.servertools.Handler('POST', (request, response) => {
|
||||
tap.test('should add a POST route', async () => {
|
||||
testServer.addRoute('/someroute', 'POST', async (ctx) => {
|
||||
const body = await ctx.json();
|
||||
console.log('request body is:');
|
||||
console.log(request.body);
|
||||
response.send('hi');
|
||||
console.log(body);
|
||||
return new Response(JSON.stringify({ message: 'hi', received: body }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
expect(testHandler).toBeInstanceOf(typedserver.servertools.Handler);
|
||||
});
|
||||
|
||||
tap.test('should add handler to route', async () => {
|
||||
testRoute.addHandler(testHandler);
|
||||
});
|
||||
|
||||
tap.test('should create a valid StaticHandler', async () => {
|
||||
testRoute2.addHandler(
|
||||
new typedserver.servertools.HandlerStatic(
|
||||
smartpath.get.dirnameFromImportMetaUrl(import.meta.url)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
tap.test('should add typedrequest and typedsocket', async () => {
|
||||
const typedrequest = await import('@api.global/typedrequest');
|
||||
|
||||
const typedrouter = new typedrequest.TypedRouter();
|
||||
testServer.addTypedRequest(typedrouter);
|
||||
testServer.addTypedSocket(typedrouter);
|
||||
tap.test('should add a GET route with params', async () => {
|
||||
testServer.addRoute('/users/:id', 'GET', async (ctx) => {
|
||||
const userId = ctx.params.id;
|
||||
return new Response(JSON.stringify({ userId }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =====================
|
||||
// start the server and test the configuration
|
||||
// Test typedrouter integration
|
||||
// =====================
|
||||
|
||||
tap.test('should start the server allright', async () => {
|
||||
await testServer.start(3000);
|
||||
tap.test('should have a typedrouter', async () => {
|
||||
expect(testServer.typedrouter).toBeDefined();
|
||||
});
|
||||
|
||||
// see if a demo request holds up
|
||||
tap.test('should issue a request', async (tools) => {
|
||||
// =====================
|
||||
// Start the server and test
|
||||
// =====================
|
||||
|
||||
tap.test('should start the server', async () => {
|
||||
await testServer.start();
|
||||
});
|
||||
|
||||
// Test POST route
|
||||
tap.test('should handle a POST request', async () => {
|
||||
const smartRequestInstance = smartrequest.SmartRequest.create();
|
||||
const response = await smartRequestInstance
|
||||
.url('http://127.0.0.1:3000/someroute')
|
||||
.headers({
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
.json({
|
||||
someprop: 'hi',
|
||||
someprop: 'hello world',
|
||||
})
|
||||
.post();
|
||||
const responseBody = await response.text();
|
||||
console.log(responseBody);
|
||||
const responseBody = await response.json();
|
||||
console.log('POST response:', responseBody);
|
||||
expect(responseBody.message).toEqual('hi');
|
||||
expect(responseBody.received.someprop).toEqual('hello world');
|
||||
});
|
||||
|
||||
tap.test('should get a file from disk', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/someroute/testresponse.js');
|
||||
console.log(response.status);
|
||||
console.log(response.headers);
|
||||
// Test GET route with params
|
||||
tap.test('should handle a GET request with params', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/users/123');
|
||||
const body = await response.json();
|
||||
console.log('GET response:', body);
|
||||
expect(body.userId).toEqual('123');
|
||||
});
|
||||
|
||||
// Test static file serving
|
||||
tap.test('should serve a static file', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/test.server.ts');
|
||||
console.log('Static file status:', response.status);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
||||
// Test CORS preflight
|
||||
tap.test('should answer a preflight request', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/some/randompath/', {
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
console.log(response.headers);
|
||||
console.log('Preflight headers:', Object.fromEntries(response.headers.entries()));
|
||||
// CORS should return appropriate headers
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeDefined();
|
||||
});
|
||||
|
||||
// Test sitemap endpoint
|
||||
tap.test('should expose a sitemap', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/sitemap');
|
||||
console.log(await response.text());
|
||||
const text = await response.text();
|
||||
console.log('Sitemap:', text);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
||||
// Test robots.txt endpoint
|
||||
tap.test('should expose robots.txt', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/robots.txt');
|
||||
const text = await response.text();
|
||||
console.log('Robots.txt:', text);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
||||
// Test manifest endpoint
|
||||
tap.test('should expose manifest.json', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/manifest.json');
|
||||
const json = await response.json();
|
||||
console.log('Manifest:', json);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(json.name).toEqual('Test App');
|
||||
});
|
||||
|
||||
// Test appversion endpoint
|
||||
tap.test('should expose appversion', async () => {
|
||||
const response = await fetch('http://127.0.0.1:3000/appversion');
|
||||
const text = await response.text();
|
||||
console.log('App version:', text);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(text).toEqual('v3.2.1');
|
||||
});
|
||||
|
||||
// ========
|
||||
// clean up
|
||||
// Clean up
|
||||
// ========
|
||||
|
||||
tap.test('should stop the server', async () => {
|
||||
await testServer.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.8.0',
|
||||
version: '8.4.0',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -1,16 +1,100 @@
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled content item - matches tsbundle output format
|
||||
*/
|
||||
export interface IBundledContentItem {
|
||||
path: string;
|
||||
contentBase64: string;
|
||||
}
|
||||
|
||||
export interface IServerOptions {
|
||||
/**
|
||||
* serve a particular directory
|
||||
*/
|
||||
serveDir?: string;
|
||||
|
||||
/**
|
||||
* Bundled content to serve from memory (higher priority than filesystem)
|
||||
* Accepts array format from tsbundle: { path: string; contentBase64: string }[]
|
||||
*/
|
||||
bundledContent?: IBundledContentItem[];
|
||||
|
||||
/**
|
||||
* inject a reload script that takes care of live reloading
|
||||
*/
|
||||
@@ -62,20 +146,29 @@ 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;
|
||||
|
||||
/**
|
||||
* Response compression configuration
|
||||
* Set to true for defaults (brotli + gzip), false to disable, or provide detailed config
|
||||
*/
|
||||
compression?: plugins.smartserve.ICompressionConfig | boolean;
|
||||
|
||||
/**
|
||||
* Disable all client-side caching by setting appropriate headers
|
||||
* Useful for development or when content changes frequently
|
||||
*/
|
||||
noCache?: boolean;
|
||||
}
|
||||
|
||||
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
|
||||
|
||||
export interface IRouteHandler {
|
||||
(request: Request): Promise<Response | null>;
|
||||
}
|
||||
|
||||
export interface IRegisteredRoute {
|
||||
pattern: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
method: THttpMethod;
|
||||
handler: IRouteHandler;
|
||||
(ctx: plugins.smartserve.IRequestContext): Promise<Response | null>;
|
||||
}
|
||||
|
||||
export class TypedServer {
|
||||
@@ -100,8 +193,14 @@ export class TypedServer {
|
||||
// File server for static files
|
||||
private fileServer: plugins.smartserve.FileServer;
|
||||
|
||||
// Custom route handlers (for addRoute API)
|
||||
private customRoutes: IRegisteredRoute[] = [];
|
||||
// Bundled content map for O(1) lookup
|
||||
private bundledContentMap: Map<string, {
|
||||
content: Uint8Array;
|
||||
etag: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}> = new Map();
|
||||
private bundledContentHash: string = '';
|
||||
|
||||
public lastReload: number = Date.now();
|
||||
public ended = false;
|
||||
@@ -132,50 +231,136 @@ export class TypedServer {
|
||||
* Supports Express-style path patterns like '/path/:param' and '/path/*splat'
|
||||
* @param path - The route path pattern
|
||||
* @param method - HTTP method (GET, POST, PUT, DELETE, PATCH, ALL)
|
||||
* @param handler - Async function that receives Request and returns Response or null
|
||||
* @param handler - Async function that receives IRequestContext and returns Response or null
|
||||
*/
|
||||
public addRoute(path: string, method: THttpMethod, handler: IRouteHandler): void {
|
||||
// Convert Express-style path to regex
|
||||
const paramNames: string[] = [];
|
||||
let regexPattern = path
|
||||
// Handle named parameters :param
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
})
|
||||
// Handle wildcard *splat (matches everything including slashes)
|
||||
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '(.*)';
|
||||
});
|
||||
|
||||
// Ensure exact match
|
||||
regexPattern = `^${regexPattern}$`;
|
||||
|
||||
this.customRoutes.push({
|
||||
pattern: path,
|
||||
regex: new RegExp(regexPattern),
|
||||
paramNames,
|
||||
method,
|
||||
handler,
|
||||
});
|
||||
// Delegate to smartserve's ControllerRegistry
|
||||
plugins.smartserve.ControllerRegistry.addRoute(path, method, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse route parameters from a path using a registered route
|
||||
* Initialize bundled content from base64-encoded files
|
||||
*/
|
||||
private parseRouteParams(
|
||||
route: IRegisteredRoute,
|
||||
pathname: string
|
||||
): Record<string, string> | null {
|
||||
const match = pathname.match(route.regex);
|
||||
if (!match) return null;
|
||||
private async initializeBundledContent(): Promise<void> {
|
||||
if (!this.options.bundledContent?.length) return;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, index) => {
|
||||
params[name] = match[index + 1];
|
||||
const hashParts: string[] = [];
|
||||
|
||||
for (const item of this.options.bundledContent) {
|
||||
let path = item.path.replace(/^\/+/, '') || 'index.html';
|
||||
|
||||
// Decode base64 to Uint8Array
|
||||
const binary = atob(item.contentBase64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Generate ETag from content hash
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const etag = '"' + hashArray.slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join('') + '"';
|
||||
|
||||
// Get MIME type
|
||||
const mimeType = this.getMimeType(path);
|
||||
|
||||
this.bundledContentMap.set(path, { content: bytes, etag, mimeType, size: bytes.length });
|
||||
hashParts.push(etag);
|
||||
}
|
||||
|
||||
// Combined hash for cache busting
|
||||
this.bundledContentHash = hashParts.join('').slice(0, 12);
|
||||
if (!this.options.serveDir) this.serveHash = this.bundledContentHash;
|
||||
|
||||
console.log(`Initialized ${this.bundledContentMap.size} bundled files (hash: ${this.bundledContentHash})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for a file path
|
||||
*/
|
||||
private getMimeType(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'html': 'text/html; charset=utf-8',
|
||||
'js': 'application/javascript; charset=utf-8',
|
||||
'mjs': 'application/javascript; charset=utf-8',
|
||||
'css': 'text/css; charset=utf-8',
|
||||
'json': 'application/json; charset=utf-8',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'ico': 'image/x-icon',
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
'ttf': 'font/ttf',
|
||||
'eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve bundled content from memory
|
||||
*/
|
||||
private async serveBundledContent(request: Request, pathname: string): Promise<Response | null> {
|
||||
if (this.bundledContentMap.size === 0) return null;
|
||||
|
||||
let path = pathname.replace(/^\/+/, '');
|
||||
if (!path || path.endsWith('/')) path = (path || '') + 'index.html';
|
||||
|
||||
const entry = this.bundledContentMap.get(path);
|
||||
if (!entry) return null;
|
||||
|
||||
const headers = new Headers({
|
||||
'Content-Type': entry.mimeType,
|
||||
'ETag': entry.etag,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
});
|
||||
return params;
|
||||
if (this.bundledContentHash) headers.set('appHash', this.bundledContentHash);
|
||||
|
||||
// Conditional request
|
||||
const ifNoneMatch = request.headers.get('If-None-Match');
|
||||
if (ifNoneMatch === entry.etag) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
if (request.method === 'HEAD') {
|
||||
headers.set('Content-Length', entry.size.toString());
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
// HTML injection for reload
|
||||
if (this.options.injectReload && entry.mimeType.includes('text/html')) {
|
||||
return this.serveBundledHtmlWithInjection(entry, headers);
|
||||
}
|
||||
|
||||
headers.set('Content-Length', entry.size.toString());
|
||||
return new Response(Buffer.from(entry.content), { status: 200, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve bundled HTML with reload script injection
|
||||
*/
|
||||
private serveBundledHtmlWithInjection(
|
||||
entry: { content: Uint8Array; mimeType: string },
|
||||
headers: Headers
|
||||
): Response {
|
||||
let html = new TextDecoder().decode(entry.content);
|
||||
|
||||
if (html.includes('<head>')) {
|
||||
html = html.replace('<head>', `<head>
|
||||
<!-- injected by @apiglobal/typedserver -->
|
||||
<script async defer type="module" src="/typedserver/devtools"></script>
|
||||
<script>globalThis.typedserver = { lastReload: ${this.lastReload} }</script>`);
|
||||
}
|
||||
|
||||
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
headers.delete('ETag');
|
||||
|
||||
const content = new TextEncoder().encode(html);
|
||||
headers.set('Content-Length', content.length.toString());
|
||||
return new Response(content, { status: 200, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,9 +368,9 @@ export class TypedServer {
|
||||
*/
|
||||
public async start() {
|
||||
// Validate essential configuration before starting
|
||||
if (this.options.injectReload && !this.options.serveDir) {
|
||||
if (this.options.injectReload && !this.options.serveDir && !this.options.bundledContent) {
|
||||
throw new Error(
|
||||
'You set to inject the reload script without a serve dir. This is not supported at the moment.'
|
||||
'injectReload requires serveDir or bundledContent'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,6 +396,9 @@ export class TypedServer {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize bundled content from memory
|
||||
await this.initializeBundledContent();
|
||||
|
||||
// Initialize decorated controllers
|
||||
if (this.options.injectReload) {
|
||||
this.devToolsController = new DevToolsController({
|
||||
@@ -235,6 +423,9 @@ export class TypedServer {
|
||||
});
|
||||
|
||||
// Register controllers with SmartServe's ControllerRegistry
|
||||
// Note: @Route decorators auto-register classes at import time.
|
||||
// Controllers with constructor args (like DevToolsController) use default no-op
|
||||
// constructors to handle auto-instantiation gracefully.
|
||||
if (this.options.injectReload) {
|
||||
plugins.smartserve.ControllerRegistry.registerInstance(this.devToolsController);
|
||||
}
|
||||
@@ -248,6 +439,7 @@ export class TypedServer {
|
||||
const smartServeOptions: plugins.smartserve.ISmartServeOptions = {
|
||||
port,
|
||||
hostname: '0.0.0.0',
|
||||
compression: this.options.compression,
|
||||
tls:
|
||||
this.options.privateKey && this.options.publicKey
|
||||
? {
|
||||
@@ -258,7 +450,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) => {
|
||||
@@ -349,10 +541,10 @@ export class TypedServer {
|
||||
/**
|
||||
* Create an IRequestContext from a Request
|
||||
*/
|
||||
private async createContext(
|
||||
private createContext(
|
||||
request: Request,
|
||||
params: Record<string, string>
|
||||
): Promise<plugins.smartserve.IRequestContext> {
|
||||
): plugins.smartserve.IRequestContext {
|
||||
const url = new URL(request.url);
|
||||
const method = request.method.toUpperCase() as THttpMethod;
|
||||
|
||||
@@ -362,20 +554,14 @@ export class TypedServer {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
// Parse body
|
||||
let body: unknown = undefined;
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
body = await request.clone().json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
}
|
||||
// Cached body parsers (lazy evaluation)
|
||||
let jsonCache: unknown;
|
||||
let textCache: string;
|
||||
let arrayBufferCache: ArrayBuffer;
|
||||
let formDataCache: FormData;
|
||||
|
||||
return {
|
||||
request,
|
||||
body,
|
||||
params,
|
||||
query,
|
||||
headers: request.headers,
|
||||
@@ -384,20 +570,168 @@ export class TypedServer {
|
||||
url,
|
||||
runtime: 'node' as const,
|
||||
state: {},
|
||||
async json<T = unknown>(): Promise<T> {
|
||||
if (jsonCache === undefined) {
|
||||
jsonCache = await request.clone().json();
|
||||
}
|
||||
return jsonCache as T;
|
||||
},
|
||||
async text(): Promise<string> {
|
||||
if (textCache === undefined) {
|
||||
textCache = await request.clone().text();
|
||||
}
|
||||
return textCache;
|
||||
},
|
||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
if (arrayBufferCache === undefined) {
|
||||
arrayBufferCache = await request.clone().arrayBuffer();
|
||||
}
|
||||
return arrayBufferCache;
|
||||
},
|
||||
async formData(): Promise<FormData> {
|
||||
if (formDataCache === undefined) {
|
||||
formDataCache = await request.clone().formData();
|
||||
}
|
||||
return formDataCache;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, cache control) 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');
|
||||
|
||||
// No-cache headers
|
||||
if (this.options.noCache) {
|
||||
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
||||
headers.set('Pragma', 'no-cache');
|
||||
headers.set('Expires', '0');
|
||||
}
|
||||
|
||||
// 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 +750,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
|
||||
const response = await this.handleRequestInternal(request, url, path, method);
|
||||
return this.addCorsHeaders(response);
|
||||
// Process the request and wrap response with all configured headers
|
||||
const response = await this.handleRequestInternal(request, path, method);
|
||||
return this.applyResponseHeaders(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,7 +763,6 @@ export class TypedServer {
|
||||
*/
|
||||
private async handleRequestInternal(
|
||||
request: Request,
|
||||
url: URL,
|
||||
path: string,
|
||||
method: THttpMethod
|
||||
): Promise<Response> {
|
||||
@@ -437,7 +770,7 @@ export class TypedServer {
|
||||
const match = plugins.smartserve.ControllerRegistry.matchRoute(path, method);
|
||||
if (match) {
|
||||
try {
|
||||
const context = await this.createContext(request, match.params);
|
||||
const context = this.createContext(request, match.params);
|
||||
const result = await match.route.handler(context);
|
||||
|
||||
// Handle Response or convert to Response
|
||||
@@ -458,16 +791,10 @@ 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;
|
||||
}
|
||||
}
|
||||
// Try bundled content first (in-memory, faster)
|
||||
if (this.bundledContentMap.size > 0 && (method === 'GET' || method === 'HEAD')) {
|
||||
const bundledResponse = await this.serveBundledContent(request, path);
|
||||
if (bundledResponse) return bundledResponse;
|
||||
}
|
||||
|
||||
// HTML injection for reload (if enabled)
|
||||
@@ -498,14 +825,22 @@ export class TypedServer {
|
||||
}
|
||||
|
||||
// SPA fallback - serve index.html for non-file routes
|
||||
if (this.options.spaFallback && this.options.serveDir && method === 'GET' && !path.includes('.')) {
|
||||
try {
|
||||
const indexPath = plugins.path.join(this.options.serveDir, 'index.html');
|
||||
let html = await plugins.fsInstance.file(indexPath).encoding('utf8').read() as string;
|
||||
if (this.options.spaFallback && method === 'GET' && !path.includes('.')) {
|
||||
// Try bundled index.html first
|
||||
if (this.bundledContentMap.has('index.html')) {
|
||||
const response = await this.serveBundledContent(request, '/index.html');
|
||||
if (response) return response;
|
||||
}
|
||||
|
||||
// Inject reload script if enabled
|
||||
if (this.options.injectReload && html.includes('<head>')) {
|
||||
const injection = `<head>
|
||||
// Fall back to filesystem
|
||||
if (this.options.serveDir) {
|
||||
try {
|
||||
const indexPath = plugins.path.join(this.options.serveDir, 'index.html');
|
||||
let html = await plugins.fsInstance.file(indexPath).encoding('utf8').read() as string;
|
||||
|
||||
// Inject reload script if enabled
|
||||
if (this.options.injectReload && html.includes('<head>')) {
|
||||
const injection = `<head>
|
||||
<!-- injected by @apiglobal/typedserver start -->
|
||||
<script async defer type="module" src="/typedserver/devtools"></script>
|
||||
<script>
|
||||
@@ -516,19 +851,20 @@ export class TypedServer {
|
||||
</script>
|
||||
<!-- injected by @apiglobal/typedserver stop -->
|
||||
`;
|
||||
html = html.replace('<head>', injection);
|
||||
}
|
||||
html = html.replace('<head>', injection);
|
||||
}
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
appHash: this.serveHash,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fall through to 404
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
appHash: this.serveHash,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fall through to 404
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +980,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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ export class DevToolsController {
|
||||
private getLastReload: () => number;
|
||||
private getEnded: () => boolean;
|
||||
|
||||
constructor(options: { getLastReload: () => number; getEnded: () => boolean }) {
|
||||
this.getLastReload = options.getLastReload;
|
||||
this.getEnded = options.getEnded;
|
||||
constructor(options?: { getLastReload: () => number; getEnded: () => boolean }) {
|
||||
// Default no-op functions for when controller is auto-instantiated without options
|
||||
this.getLastReload = options?.getLastReload ?? (() => 0);
|
||||
this.getEnded = options?.getEnded ?? (() => false);
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/devtools')
|
||||
|
||||
@@ -11,10 +11,11 @@ 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);
|
||||
const body = await ctx.json() as plugins.typedrequestInterfaces.ITypedRequest;
|
||||
const response = await this.typedRouter.routeAndAddResponse(body);
|
||||
|
||||
return new Response(plugins.smartjson.stringify(response), {
|
||||
status: 200,
|
||||
@@ -31,4 +32,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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,6 @@ export * from './classes.typedserver.js';
|
||||
// lets export utilityservers
|
||||
import * as utilityservers from './utilityservers/index.js';
|
||||
export { utilityservers };
|
||||
|
||||
// Export IRequestContext for consumers to use in route handlers
|
||||
export type { IRequestContext } from '@push.rocks/smartserve';
|
||||
|
||||
@@ -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');
|
||||
@@ -1,13 +1,38 @@
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
|
||||
import { type IServerOptions, type ISecurityHeaders, type IBundledContentItem, 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;
|
||||
serveDir: string;
|
||||
feedMetadata: IServerOptions['feedMetadata'];
|
||||
/** Directory to serve static files from */
|
||||
serveDir?: string;
|
||||
/** Bundled content to serve from memory (base64-encoded files from tsbundle) */
|
||||
bundledContent?: IBundledContentItem[];
|
||||
/** RSS feed metadata */
|
||||
feedMetadata?: IServerOptions['feedMetadata'];
|
||||
/** Enable/disable CORS (default: true) */
|
||||
cors?: boolean;
|
||||
/** Enable/disable SPA fallback (default: true) */
|
||||
spaFallback?: boolean;
|
||||
/** Security headers configuration */
|
||||
securityHeaders?: ISecurityHeaders;
|
||||
/** Force SSL redirect (default: false) */
|
||||
forceSsl?: boolean;
|
||||
/** Port to listen on (default: 3000) */
|
||||
port?: number;
|
||||
/** ads.txt entries (only served if configured) */
|
||||
adsTxt?: string[];
|
||||
/** Response compression configuration (default: enabled with brotli + gzip) */
|
||||
compression?: plugins.smartserve.ICompressionConfig | boolean;
|
||||
/** Disable browser caching (default: true when serveDir is set) */
|
||||
noCache?: boolean;
|
||||
/** Inject live-reload devtools script into HTML (default: true when serveDir is set) */
|
||||
injectReload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,14 +54,33 @@ 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,
|
||||
bundledContent: this.options.bundledContent,
|
||||
domain: this.options.domain,
|
||||
forceSsl: false,
|
||||
port,
|
||||
|
||||
// Development features
|
||||
injectReload: this.options.injectReload ?? true,
|
||||
watch: !!this.options.serveDir,
|
||||
noCache: this.options.noCache ?? true,
|
||||
|
||||
// SPA support (enabled by default for modern web apps)
|
||||
spaFallback: this.options.spaFallback ?? true,
|
||||
|
||||
// Security
|
||||
forceSsl: this.options.forceSsl ?? false,
|
||||
securityHeaders: this.options.securityHeaders,
|
||||
|
||||
// Compression
|
||||
compression: this.options.compression,
|
||||
|
||||
// PWA manifest
|
||||
manifest: {
|
||||
name: this.options.domain,
|
||||
short_name: this.options.domain,
|
||||
@@ -46,11 +90,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,22 +109,23 @@ 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(
|
||||
'/assetbroker/manifest/:manifestAsset',
|
||||
'GET',
|
||||
async (request: Request) => {
|
||||
let manifestAssetName = (request as any).params?.manifestAsset;
|
||||
async (ctx) => {
|
||||
let manifestAssetName = ctx.params?.manifestAsset;
|
||||
if (manifestAssetName === 'favicon.png') {
|
||||
manifestAssetName = `favicon_${this.options.domain
|
||||
.replace('.', '')
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}">×</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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,7 @@ export class NetworkManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/sw-typedrequest', {
|
||||
const response = await fetch('/typedrequest', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user