Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
74
changelog.md
74
changelog.md
@@ -1,5 +1,79 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "7.8.7",
|
||||
"version": "7.11.1",
|
||||
"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.2.0",
|
||||
"@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,11 +83,11 @@
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartserve": "^1.1.2",
|
||||
"@push.rocks/smartserve": "^1.3.0",
|
||||
"@push.rocks/smartsitemap": "^2.0.4",
|
||||
"@push.rocks/smartstream": "^3.2.5",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartwatch": "^5.0.0",
|
||||
"@push.rocks/smartwatch": "^6.0.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/webrequest": "^4.0.1",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
|
||||
975
pnpm-lock.yaml
generated
975
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 = ctx.body;
|
||||
const newUser = await createUserInDb(userData);
|
||||
return new Response(JSON.stringify(newUser), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register the controller
|
||||
smartserve.ControllerRegistry.registerInstance(new UserController());
|
||||
```
|
||||
|
||||
### Programmatic Routes with `addRoute()`
|
||||
|
||||
Add routes dynamically using the `addRoute()` API:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Simple route
|
||||
server.addRoute('/api/health', 'GET', async (request) => {
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// Route with parameters (Express-style :param syntax)
|
||||
server.addRoute('/api/items/:id', 'GET', async (request) => {
|
||||
const itemId = (request as any).params.id;
|
||||
return new Response(JSON.stringify({ id: itemId }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// Wildcard routes
|
||||
server.addRoute('/files/*path', 'GET', async (request) => {
|
||||
const filePath = (request as any).params.path;
|
||||
// Handle file serving logic
|
||||
return new Response(`Requested: ${filePath}`);
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## 🔌 Type-Safe API Integration
|
||||
|
||||
### Adding TypedRequest Handlers
|
||||
|
||||
```typescript
|
||||
import { TypedServer, servertools } from '@api.global/typedserver';
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
// Define your typed request interface
|
||||
interface IGetUser {
|
||||
interface IGetUser extends typedrequest.implementsTR<IGetUser> {
|
||||
method: 'getUser';
|
||||
request: { userId: string };
|
||||
response: { name: string; email: string };
|
||||
@@ -95,133 +183,233 @@ interface IGetUser {
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Create a typed router
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
// Add a typed handler
|
||||
typedRouter.addTypedHandler<IGetUser>(
|
||||
// Add a typed handler directly to the server's router
|
||||
server.typedrouter.addTypedHandler<IGetUser>(
|
||||
new typedrequest.TypedHandler('getUser', async (data) => {
|
||||
// Your logic here
|
||||
return { name: 'John Doe', email: 'john@example.com' };
|
||||
})
|
||||
);
|
||||
|
||||
// Attach the router to the server
|
||||
server.server.addRoute('/api', new servertools.HandlerTypedRouter(typedRouter));
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### WebSocket Communication with TypedSocket
|
||||
### Real-Time WebSocket Communication
|
||||
|
||||
TypedServer automatically sets up TypedSocket for real-time communication:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
await server.start();
|
||||
|
||||
// Create WebSocket server attached to the HTTP server
|
||||
const socketServer = await typedsocket.TypedSocket.createServer(
|
||||
typedRouter,
|
||||
server.server
|
||||
);
|
||||
|
||||
// Handle real-time events
|
||||
interface IChatMessage {
|
||||
interface IChatMessage extends typedrequest.implementsTR<IChatMessage> {
|
||||
method: 'sendMessage';
|
||||
request: { text: string; room: string };
|
||||
response: { messageId: string; timestamp: number };
|
||||
}
|
||||
|
||||
typedRouter.addTypedHandler<IChatMessage>(
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Handle real-time messages
|
||||
server.typedrouter.addTypedHandler<IChatMessage>(
|
||||
new typedrequest.TypedHandler('sendMessage', async (data) => {
|
||||
return { messageId: crypto.randomUUID(), timestamp: Date.now() };
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## 🛠️ Server Tools
|
||||
await server.start();
|
||||
|
||||
### Custom Route Handlers
|
||||
|
||||
```typescript
|
||||
import { servertools } from '@api.global/typedserver';
|
||||
|
||||
const server = new servertools.Server({
|
||||
cors: true,
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
// Add a custom route with handler
|
||||
server.addRoute('/api/hello', new servertools.Handler('GET', async (req, res) => {
|
||||
res.json({ message: 'Hello, World!' });
|
||||
}));
|
||||
|
||||
// Serve static files from a directory
|
||||
server.addRoute('/{*splat}', new servertools.HandlerStatic('./public', {
|
||||
serveIndexHtmlDefault: true,
|
||||
enableCompression: true,
|
||||
}));
|
||||
|
||||
await server.start(3000);
|
||||
```
|
||||
|
||||
### Proxy Handler
|
||||
|
||||
```typescript
|
||||
import { servertools } from '@api.global/typedserver';
|
||||
|
||||
const server = new servertools.Server({ cors: true });
|
||||
|
||||
// Proxy requests to another server
|
||||
server.addRoute('/proxy/{*splat}', new servertools.HandlerProxy({
|
||||
target: 'https://api.example.com',
|
||||
}));
|
||||
|
||||
await server.start(3000);
|
||||
// Push messages to connected clients
|
||||
const connections = await server.typedsocket.findAllTargetConnectionsByTag('allClients');
|
||||
for (const conn of connections) {
|
||||
// Push to specific clients via TypedSocket
|
||||
}
|
||||
```
|
||||
|
||||
## ☁️ Edge Worker (Cloudflare Workers)
|
||||
|
||||
Deploy your application to the edge with Cloudflare Workers:
|
||||
|
||||
```typescript
|
||||
import { EdgeWorker, DomainRouter } from '@api.global/typedserver/edgeworker';
|
||||
|
||||
const router = new DomainRouter();
|
||||
const worker = new EdgeWorker();
|
||||
|
||||
router.addDomainInstruction({
|
||||
// Configure domain routing with caching
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: '*.example.com',
|
||||
originUrl: 'https://origin.example.com',
|
||||
type: 'cache',
|
||||
cacheConfig: { maxAge: 3600 },
|
||||
});
|
||||
|
||||
const worker = new EdgeWorker(router);
|
||||
// Pass-through to origin for API routes
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: 'api.example.com',
|
||||
originUrl: 'https://api-origin.example.com',
|
||||
type: 'origin',
|
||||
});
|
||||
|
||||
// In your Cloudflare Worker entry point
|
||||
// Cloudflare Worker entry point
|
||||
export default {
|
||||
fetch: (request: Request, env: any, ctx: any) => worker.handleRequest(request, env, ctx),
|
||||
fetch: worker.fetchFunction.bind(worker),
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Service Worker Client
|
||||
|
||||
Manage service workers in your frontend application:
|
||||
|
||||
```typescript
|
||||
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
import { getServiceworkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
|
||||
const swClient = new ServiceWorkerClient();
|
||||
// Initialize and register service worker
|
||||
const swClient = await getServiceworkerClient({
|
||||
pollInterval: 30000, // Poll for updates every 30s
|
||||
});
|
||||
|
||||
// Register and manage service worker
|
||||
await swClient.register('/serviceworker.bundle.js');
|
||||
// The service worker handles:
|
||||
// - Cache invalidation from server
|
||||
// - Offline support
|
||||
// - Background sync
|
||||
// - Version updates
|
||||
```
|
||||
|
||||
// Listen for updates
|
||||
swClient.onUpdate(() => {
|
||||
console.log('New version available!');
|
||||
## 🛡️ Security Headers
|
||||
|
||||
Configure comprehensive security headers including CSP, HSTS, and more:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
const server = new TypedServer({
|
||||
serveDir: './dist',
|
||||
cors: true,
|
||||
|
||||
securityHeaders: {
|
||||
// Content Security Policy
|
||||
csp: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.example.com'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", 'wss:', 'https://api.example.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||
frameAncestors: ["'none'"],
|
||||
upgradeInsecureRequests: true,
|
||||
},
|
||||
|
||||
// HSTS (HTTP Strict Transport Security)
|
||||
hstsMaxAge: 31536000, // 1 year
|
||||
hstsIncludeSubDomains: true,
|
||||
hstsPreload: true,
|
||||
|
||||
// Other security headers
|
||||
xFrameOptions: 'DENY',
|
||||
xContentTypeOptions: true,
|
||||
xXssProtection: true,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
|
||||
// Cross-Origin policies
|
||||
crossOriginOpenerPolicy: 'same-origin',
|
||||
crossOriginEmbedderPolicy: 'require-corp',
|
||||
crossOriginResourcePolicy: 'same-origin',
|
||||
|
||||
// Permissions Policy
|
||||
permissionsPolicy: {
|
||||
camera: [],
|
||||
microphone: [],
|
||||
geolocation: ['self'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### Security Headers Reference
|
||||
|
||||
| Header | Option | Description |
|
||||
|--------|--------|-------------|
|
||||
| `Content-Security-Policy` | `csp` | Controls resources the browser can load |
|
||||
| `Strict-Transport-Security` | `hstsMaxAge`, `hstsIncludeSubDomains`, `hstsPreload` | Forces HTTPS connections |
|
||||
| `X-Frame-Options` | `xFrameOptions` | Prevents clickjacking attacks |
|
||||
| `X-Content-Type-Options` | `xContentTypeOptions` | Prevents MIME-sniffing |
|
||||
| `X-XSS-Protection` | `xXssProtection` | Legacy XSS filter |
|
||||
| `Referrer-Policy` | `referrerPolicy` | Controls referrer information |
|
||||
| `Permissions-Policy` | `permissionsPolicy` | Controls browser features |
|
||||
| `Cross-Origin-Opener-Policy` | `crossOriginOpenerPolicy` | Isolates browsing context |
|
||||
| `Cross-Origin-Embedder-Policy` | `crossOriginEmbedderPolicy` | Controls cross-origin embedding |
|
||||
| `Cross-Origin-Resource-Policy` | `crossOriginResourcePolicy` | Controls cross-origin resource sharing |
|
||||
|
||||
## 🗜️ Compression
|
||||
|
||||
TypedServer supports automatic response compression using Brotli and Gzip. Compression is powered by smartserve and enabled by default.
|
||||
|
||||
### Global Configuration
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
const server = new TypedServer({
|
||||
serveDir: './dist',
|
||||
cors: true,
|
||||
|
||||
// Enable with defaults (brotli + gzip, threshold: 1024 bytes)
|
||||
compression: true,
|
||||
|
||||
// Or disable completely
|
||||
compression: false,
|
||||
|
||||
// Or configure in detail
|
||||
compression: {
|
||||
enabled: true,
|
||||
algorithms: ['br', 'gzip'], // Preferred order
|
||||
threshold: 1024, // Min size to compress (bytes)
|
||||
level: 4, // Compression level (1-11 for brotli, 1-9 for gzip)
|
||||
exclude: ['/api/stream/*'], // Skip these paths
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Route Control with Decorators
|
||||
|
||||
Use `@Compress` and `@NoCompress` decorators for fine-grained control:
|
||||
|
||||
```typescript
|
||||
import * as smartserve from '@push.rocks/smartserve';
|
||||
|
||||
@smartserve.Route('/api')
|
||||
class ApiController {
|
||||
// Force maximum compression for this endpoint
|
||||
@smartserve.Get('/large-data')
|
||||
@smartserve.Compress({ level: 11 })
|
||||
async getLargeData(ctx: smartserve.IRequestContext): Promise<Response> {
|
||||
return new Response(JSON.stringify(largeDataset));
|
||||
}
|
||||
|
||||
// Disable compression for streaming endpoint
|
||||
@smartserve.Get('/events')
|
||||
@smartserve.NoCompress()
|
||||
async streamEvents(ctx: smartserve.IRequestContext): Promise<Response> {
|
||||
// Server-Sent Events shouldn't be compressed
|
||||
return new Response(eventStream, {
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Compression Options Reference
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | `boolean` | `true` | Enable/disable compression |
|
||||
| `algorithms` | `string[]` | `['br', 'gzip']` | Preferred algorithms in order |
|
||||
| `threshold` | `number` | `1024` | Minimum response size (bytes) to compress |
|
||||
| `level` | `number` | `4` | Compression level (1-11 for brotli, 1-9 for gzip) |
|
||||
| `compressibleTypes` | `string[]` | auto | MIME types to compress |
|
||||
| `exclude` | `string[]` | `[]` | Path patterns to skip |
|
||||
|
||||
## 📋 Configuration Reference
|
||||
|
||||
### IServerOptions
|
||||
@@ -233,9 +421,8 @@ swClient.onUpdate(() => {
|
||||
| `cors` | `boolean` | `true` | Enable CORS headers |
|
||||
| `watch` | `boolean` | `false` | Watch files for changes |
|
||||
| `injectReload` | `boolean` | `false` | Inject live reload script into HTML |
|
||||
| `enableCompression` | `boolean` | `false` | Enable response compression |
|
||||
| `preferredCompressionMethod` | `'gzip' \| 'deflate' \| 'brotli'` | - | Preferred compression algorithm |
|
||||
| `forceSsl` | `boolean` | `false` | Redirect HTTP to HTTPS |
|
||||
| `spaFallback` | `boolean` | `false` | Serve index.html for non-file routes |
|
||||
| `sitemap` | `boolean` | `false` | Generate sitemap at `/sitemap` |
|
||||
| `feed` | `boolean` | `false` | Generate RSS feed at `/feed` |
|
||||
| `robots` | `boolean` | `false` | Serve robots.txt |
|
||||
@@ -244,16 +431,131 @@ swClient.onUpdate(() => {
|
||||
| `manifest` | `object` | - | Web App Manifest configuration |
|
||||
| `publicKey` | `string` | - | SSL certificate |
|
||||
| `privateKey` | `string` | - | SSL private key |
|
||||
| `defaultAnswer` | `function` | - | Custom default response handler |
|
||||
| `feedMetadata` | `object` | - | RSS feed metadata options |
|
||||
| `blockWaybackMachine` | `boolean` | `false` | Block Wayback Machine archiving |
|
||||
| `securityHeaders` | `ISecurityHeaders` | - | Security headers configuration |
|
||||
| `compression` | `ICompressionConfig \| boolean` | `true` | Response compression configuration |
|
||||
|
||||
## 🏗️ Architecture
|
||||
## 🏗️ Package Exports
|
||||
|
||||
```
|
||||
@api.global/typedserver
|
||||
├── /backend - Main server exports (TypedServer, servertools)
|
||||
├── /edgeworker - Cloudflare Workers edge computing
|
||||
├── /web_inject - Live reload script injection
|
||||
├── /web_serviceworker - Service Worker implementation
|
||||
└── /web_serviceworker_client - Service Worker client utilities
|
||||
├── . - Main server (TypedServer)
|
||||
├── /backend - Alias for main server
|
||||
├── /edgeworker - Cloudflare Workers edge computing
|
||||
├── /web_inject - Live reload script injection
|
||||
├── /web_serviceworker - Service Worker implementation
|
||||
└── /web_serviceworker_client - Service Worker client utilities
|
||||
```
|
||||
|
||||
## 🔄 Utility Servers
|
||||
|
||||
Pre-configured server templates with best practices built-in.
|
||||
|
||||
### UtilityWebsiteServer
|
||||
|
||||
Optimized for modern web applications with SPA support enabled by default:
|
||||
|
||||
```typescript
|
||||
import { utilityservers } from '@api.global/typedserver';
|
||||
|
||||
const websiteServer = new utilityservers.UtilityWebsiteServer({
|
||||
serveDir: './dist',
|
||||
domain: 'example.com',
|
||||
|
||||
// SPA fallback enabled by default
|
||||
spaFallback: true, // default: true
|
||||
|
||||
// Security headers
|
||||
securityHeaders: {
|
||||
csp: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
},
|
||||
xFrameOptions: 'SAMEORIGIN',
|
||||
xContentTypeOptions: true,
|
||||
},
|
||||
|
||||
// Compression (enabled by default)
|
||||
compression: true, // or { level: 6, threshold: 512 }
|
||||
|
||||
// Other options
|
||||
cors: true, // default: true
|
||||
forceSsl: false, // default: false
|
||||
appSemVer: '1.0.0',
|
||||
port: 3000, // default: 3000
|
||||
|
||||
// Optional ads.txt entries (only served if configured)
|
||||
adsTxt: [
|
||||
'google.com, pub-1234567890, DIRECT, f08c47fec0942fa0',
|
||||
],
|
||||
|
||||
// RSS feed metadata
|
||||
feedMetadata: {
|
||||
title: 'My Blog',
|
||||
description: 'A cool blog',
|
||||
link: 'https://example.com',
|
||||
},
|
||||
|
||||
// Add custom routes
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
typedserver.addRoute('/api/custom', 'GET', async () => {
|
||||
return new Response('Custom route!');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await websiteServer.start();
|
||||
```
|
||||
|
||||
### UtilityServiceServer
|
||||
|
||||
Optimized for API services with auto-generated info page:
|
||||
|
||||
```typescript
|
||||
import { utilityservers } from '@api.global/typedserver';
|
||||
|
||||
const serviceServer = new utilityservers.UtilityServiceServer({
|
||||
serviceName: 'My API',
|
||||
serviceVersion: '1.0.0',
|
||||
serviceDomain: 'api.example.com',
|
||||
port: 8080,
|
||||
|
||||
// Add custom routes
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
typedserver.addRoute('/api/status', 'GET', async () => {
|
||||
return new Response(JSON.stringify({ status: 'healthy' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await serviceServer.start();
|
||||
```
|
||||
|
||||
## 🧩 Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TypedServer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ SmartServe │ │ TypedRouter │ │ TypedSocket │ │
|
||||
│ │ (Routing) │ │ (RPC) │ │ (WebSocket) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Request Handler Pipeline │ │
|
||||
│ │ 1. Controller Registry (Decorated Routes) │ │
|
||||
│ │ 2. TypedRequest/TypedSocket handlers │ │
|
||||
│ │ 3. Static File Serving │ │
|
||||
│ │ 4. SPA Fallback │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
5
readme.todo.md
Normal file
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.
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.8.0',
|
||||
version: '7.11.1',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import * as interfaces from '../dist_ts_interfaces/index.js';
|
||||
import { DevToolsController } from './controllers/controller.devtools.js';
|
||||
import { TypedRequestController } from './controllers/controller.typedrequest.js';
|
||||
import { BuiltInRoutesController } from './controllers/controller.builtin.js';
|
||||
|
||||
/**
|
||||
* Content Security Policy configuration
|
||||
* Each directive can be a string or array of sources
|
||||
*/
|
||||
export interface IContentSecurityPolicy {
|
||||
/** Fallback for other directives */
|
||||
defaultSrc?: string | string[];
|
||||
/** Valid sources for scripts */
|
||||
scriptSrc?: string | string[];
|
||||
/** Valid sources for stylesheets */
|
||||
styleSrc?: string | string[];
|
||||
/** Valid sources for images */
|
||||
imgSrc?: string | string[];
|
||||
/** Valid sources for fonts */
|
||||
fontSrc?: string | string[];
|
||||
/** Valid sources for AJAX, WebSockets, etc. */
|
||||
connectSrc?: string | string[];
|
||||
/** Valid sources for media (audio/video) */
|
||||
mediaSrc?: string | string[];
|
||||
/** Valid sources for frames */
|
||||
frameSrc?: string | string[];
|
||||
/** Valid sources for <object>, <embed>, <applet> */
|
||||
objectSrc?: string | string[];
|
||||
/** Valid sources for web workers */
|
||||
workerSrc?: string | string[];
|
||||
/** Valid sources for form actions */
|
||||
formAction?: string | string[];
|
||||
/** Controls which URLs can embed the page */
|
||||
frameAncestors?: string | string[];
|
||||
/** Restricts URLs for <base> element */
|
||||
baseUri?: string | string[];
|
||||
/** Report violations to this URL */
|
||||
reportUri?: string;
|
||||
/** Report violations to this endpoint */
|
||||
reportTo?: string;
|
||||
/** Upgrade insecure requests to HTTPS */
|
||||
upgradeInsecureRequests?: boolean;
|
||||
/** Block all mixed content */
|
||||
blockAllMixedContent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security headers configuration
|
||||
*/
|
||||
export interface ISecurityHeaders {
|
||||
/** Content Security Policy */
|
||||
csp?: IContentSecurityPolicy;
|
||||
/** X-Frame-Options: DENY, SAMEORIGIN, or ALLOW-FROM uri */
|
||||
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | string;
|
||||
/** X-Content-Type-Options: nosniff */
|
||||
xContentTypeOptions?: boolean;
|
||||
/** X-XSS-Protection header (legacy, but still useful) */
|
||||
xXssProtection?: boolean | string;
|
||||
/** Referrer-Policy header */
|
||||
referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
|
||||
/** Strict-Transport-Security (HSTS) max-age in seconds */
|
||||
hstsMaxAge?: number;
|
||||
/** Include subdomains in HSTS */
|
||||
hstsIncludeSubDomains?: boolean;
|
||||
/** HSTS preload flag */
|
||||
hstsPreload?: boolean;
|
||||
/** Permissions-Policy (formerly Feature-Policy) */
|
||||
permissionsPolicy?: Record<string, string[]>;
|
||||
/** Cross-Origin-Opener-Policy */
|
||||
crossOriginOpenerPolicy?: 'unsafe-none' | 'same-origin-allow-popups' | 'same-origin';
|
||||
/** Cross-Origin-Embedder-Policy */
|
||||
crossOriginEmbedderPolicy?: 'unsafe-none' | 'require-corp' | 'credentialless';
|
||||
/** Cross-Origin-Resource-Policy */
|
||||
crossOriginResourcePolicy?: 'same-site' | 'same-origin' | 'cross-origin';
|
||||
}
|
||||
|
||||
export interface IServerOptions {
|
||||
/**
|
||||
* serve a particular directory
|
||||
@@ -62,6 +132,17 @@ 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;
|
||||
}
|
||||
|
||||
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
|
||||
@@ -70,14 +151,6 @@ export interface IRouteHandler {
|
||||
(request: Request): Promise<Response | null>;
|
||||
}
|
||||
|
||||
export interface IRegisteredRoute {
|
||||
pattern: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
method: THttpMethod;
|
||||
handler: IRouteHandler;
|
||||
}
|
||||
|
||||
export class TypedServer {
|
||||
// instance
|
||||
public options: IServerOptions;
|
||||
@@ -100,9 +173,6 @@ export class TypedServer {
|
||||
// File server for static files
|
||||
private fileServer: plugins.smartserve.FileServer;
|
||||
|
||||
// Custom route handlers (for addRoute API)
|
||||
private customRoutes: IRegisteredRoute[] = [];
|
||||
|
||||
public lastReload: number = Date.now();
|
||||
public ended = false;
|
||||
|
||||
@@ -135,49 +205,18 @@ export class TypedServer {
|
||||
* @param handler - Async function that receives Request and returns Response or null
|
||||
*/
|
||||
public addRoute(path: string, method: THttpMethod, handler: IRouteHandler): void {
|
||||
// Convert Express-style path to regex
|
||||
const paramNames: string[] = [];
|
||||
let regexPattern = path
|
||||
// Handle named parameters :param
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
})
|
||||
// Handle wildcard *splat (matches everything including slashes)
|
||||
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '(.*)';
|
||||
// Delegate to smartserve's ControllerRegistry
|
||||
plugins.smartserve.ControllerRegistry.addRoute(path, method, async (ctx: plugins.smartserve.IRequestContext) => {
|
||||
// Convert context to Request for backwards compatibility
|
||||
const request = new Request(ctx.url.toString(), {
|
||||
method: ctx.method,
|
||||
headers: ctx.headers,
|
||||
});
|
||||
|
||||
// Ensure exact match
|
||||
regexPattern = `^${regexPattern}$`;
|
||||
|
||||
this.customRoutes.push({
|
||||
pattern: path,
|
||||
regex: new RegExp(regexPattern),
|
||||
paramNames,
|
||||
method,
|
||||
handler,
|
||||
(request as any).params = ctx.params;
|
||||
return handler(request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse route parameters from a path using a registered route
|
||||
*/
|
||||
private parseRouteParams(
|
||||
route: IRegisteredRoute,
|
||||
pathname: string
|
||||
): Record<string, string> | null {
|
||||
const match = pathname.match(route.regex);
|
||||
if (!match) return null;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, index) => {
|
||||
params[name] = match[index + 1];
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* inits and starts the server
|
||||
*/
|
||||
@@ -248,6 +287,7 @@ export class TypedServer {
|
||||
const smartServeOptions: plugins.smartserve.ISmartServeOptions = {
|
||||
port,
|
||||
hostname: '0.0.0.0',
|
||||
compression: this.options.compression,
|
||||
tls:
|
||||
this.options.privateKey && this.options.publicKey
|
||||
? {
|
||||
@@ -258,7 +298,7 @@ export class TypedServer {
|
||||
websocket: {
|
||||
typedRouter: this.typedrouter,
|
||||
onConnectionOpen: (peer) => {
|
||||
peer.tags.add('typedserver_frontend');
|
||||
peer.tags.add('allClients');
|
||||
console.log(`WebSocket connected: ${peer.id}`);
|
||||
},
|
||||
onConnectionClose: (peer) => {
|
||||
@@ -388,16 +428,133 @@ export class TypedServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers to a response
|
||||
* Build CSP header string from configuration
|
||||
*/
|
||||
private addCorsHeaders(response: Response): Response {
|
||||
if (!this.options.cors) return response;
|
||||
private buildCspHeader(csp: IContentSecurityPolicy): string {
|
||||
const directives: string[] = [];
|
||||
|
||||
const addDirective = (name: string, value: string | string[] | undefined) => {
|
||||
if (value) {
|
||||
const sources = Array.isArray(value) ? value.join(' ') : value;
|
||||
directives.push(`${name} ${sources}`);
|
||||
}
|
||||
};
|
||||
|
||||
addDirective('default-src', csp.defaultSrc);
|
||||
addDirective('script-src', csp.scriptSrc);
|
||||
addDirective('style-src', csp.styleSrc);
|
||||
addDirective('img-src', csp.imgSrc);
|
||||
addDirective('font-src', csp.fontSrc);
|
||||
addDirective('connect-src', csp.connectSrc);
|
||||
addDirective('media-src', csp.mediaSrc);
|
||||
addDirective('frame-src', csp.frameSrc);
|
||||
addDirective('object-src', csp.objectSrc);
|
||||
addDirective('worker-src', csp.workerSrc);
|
||||
addDirective('form-action', csp.formAction);
|
||||
addDirective('frame-ancestors', csp.frameAncestors);
|
||||
addDirective('base-uri', csp.baseUri);
|
||||
|
||||
if (csp.reportUri) {
|
||||
directives.push(`report-uri ${csp.reportUri}`);
|
||||
}
|
||||
if (csp.reportTo) {
|
||||
directives.push(`report-to ${csp.reportTo}`);
|
||||
}
|
||||
if (csp.upgradeInsecureRequests) {
|
||||
directives.push('upgrade-insecure-requests');
|
||||
}
|
||||
if (csp.blockAllMixedContent) {
|
||||
directives.push('block-all-mixed-content');
|
||||
}
|
||||
|
||||
return directives.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all configured headers (CORS, security) to a response
|
||||
*/
|
||||
private applyResponseHeaders(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
|
||||
// CORS headers
|
||||
if (this.options.cors) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
|
||||
// Security headers
|
||||
const security = this.options.securityHeaders;
|
||||
if (security) {
|
||||
// Content Security Policy
|
||||
if (security.csp) {
|
||||
const cspHeader = this.buildCspHeader(security.csp);
|
||||
if (cspHeader) {
|
||||
headers.set('Content-Security-Policy', cspHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// X-Frame-Options
|
||||
if (security.xFrameOptions) {
|
||||
headers.set('X-Frame-Options', security.xFrameOptions);
|
||||
}
|
||||
|
||||
// X-Content-Type-Options
|
||||
if (security.xContentTypeOptions) {
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
// X-XSS-Protection
|
||||
if (security.xXssProtection) {
|
||||
const value = typeof security.xXssProtection === 'string'
|
||||
? security.xXssProtection
|
||||
: '1; mode=block';
|
||||
headers.set('X-XSS-Protection', value);
|
||||
}
|
||||
|
||||
// Referrer-Policy
|
||||
if (security.referrerPolicy) {
|
||||
headers.set('Referrer-Policy', security.referrerPolicy);
|
||||
}
|
||||
|
||||
// Strict-Transport-Security (HSTS)
|
||||
if (security.hstsMaxAge !== undefined) {
|
||||
let hsts = `max-age=${security.hstsMaxAge}`;
|
||||
if (security.hstsIncludeSubDomains) {
|
||||
hsts += '; includeSubDomains';
|
||||
}
|
||||
if (security.hstsPreload) {
|
||||
hsts += '; preload';
|
||||
}
|
||||
headers.set('Strict-Transport-Security', hsts);
|
||||
}
|
||||
|
||||
// Permissions-Policy
|
||||
if (security.permissionsPolicy) {
|
||||
const policies = Object.entries(security.permissionsPolicy)
|
||||
.map(([feature, allowlist]) => `${feature}=(${allowlist.join(' ')})`)
|
||||
.join(', ');
|
||||
if (policies) {
|
||||
headers.set('Permissions-Policy', policies);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-Origin-Opener-Policy
|
||||
if (security.crossOriginOpenerPolicy) {
|
||||
headers.set('Cross-Origin-Opener-Policy', security.crossOriginOpenerPolicy);
|
||||
}
|
||||
|
||||
// Cross-Origin-Embedder-Policy
|
||||
if (security.crossOriginEmbedderPolicy) {
|
||||
headers.set('Cross-Origin-Embedder-Policy', security.crossOriginEmbedderPolicy);
|
||||
}
|
||||
|
||||
// Cross-Origin-Resource-Policy
|
||||
if (security.crossOriginResourcePolicy) {
|
||||
headers.set('Cross-Origin-Resource-Policy', security.crossOriginResourcePolicy);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
@@ -416,12 +573,12 @@ export class TypedServer {
|
||||
|
||||
// Handle OPTIONS preflight for CORS
|
||||
if (method === 'OPTIONS' && this.options.cors) {
|
||||
return this.addCorsHeaders(new Response(null, { status: 204 }));
|
||||
return this.applyResponseHeaders(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
// Process the request and wrap response with CORS headers
|
||||
// Process the request and wrap response with all configured headers
|
||||
const response = await this.handleRequestInternal(request, url, path, method);
|
||||
return this.addCorsHeaders(response);
|
||||
return this.applyResponseHeaders(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,18 +615,6 @@ export class TypedServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom routes (registered via addRoute)
|
||||
for (const route of this.customRoutes) {
|
||||
if (route.method === 'ALL' || route.method === method) {
|
||||
const params = this.parseRouteParams(route, path);
|
||||
if (params !== null) {
|
||||
(request as any).params = params;
|
||||
const response = await route.handler(request);
|
||||
if (response) return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTML injection for reload (if enabled)
|
||||
if (this.options.injectReload && this.options.serveDir) {
|
||||
const response = await this.handleHtmlWithInjection(request);
|
||||
@@ -644,6 +789,8 @@ export class TypedServer {
|
||||
);
|
||||
pushTime.fire({
|
||||
time: this.lastReload,
|
||||
}).catch(err => {
|
||||
console.warn('Failed to push latest server change time to client:', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
|
||||
import { type IServerOptions, type ISecurityHeaders, TypedServer } from '../classes.typedserver.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IUtilityWebsiteServerConstructorOptions {
|
||||
/** Custom route handler to add additional routes */
|
||||
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
|
||||
/** Application semantic version */
|
||||
appSemVer?: string;
|
||||
/** Domain name for the website */
|
||||
domain: string;
|
||||
/** Directory to serve static files from */
|
||||
serveDir: string;
|
||||
feedMetadata: IServerOptions['feedMetadata'];
|
||||
/** RSS feed metadata */
|
||||
feedMetadata?: IServerOptions['feedMetadata'];
|
||||
/** Enable/disable CORS (default: true) */
|
||||
cors?: boolean;
|
||||
/** Enable/disable SPA fallback (default: true) */
|
||||
spaFallback?: boolean;
|
||||
/** Security headers configuration */
|
||||
securityHeaders?: ISecurityHeaders;
|
||||
/** Force SSL redirect (default: false) */
|
||||
forceSsl?: boolean;
|
||||
/** Port to listen on (default: 3000) */
|
||||
port?: number;
|
||||
/** ads.txt entries (only served if configured) */
|
||||
adsTxt?: string[];
|
||||
/** Response compression configuration (default: enabled with brotli + gzip) */
|
||||
compression?: plugins.smartserve.ICompressionConfig | boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,14 +48,31 @@ export class UtilityWebsiteServer {
|
||||
/**
|
||||
* Start the website server
|
||||
*/
|
||||
public async start(portArg = 3000) {
|
||||
public async start(portArg?: number) {
|
||||
const port = portArg ?? this.options.port ?? 3000;
|
||||
|
||||
this.typedserver = new TypedServer({
|
||||
cors: true,
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
// Core settings
|
||||
cors: this.options.cors ?? true,
|
||||
serveDir: this.options.serveDir,
|
||||
domain: this.options.domain,
|
||||
forceSsl: false,
|
||||
port,
|
||||
|
||||
// Development features
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
|
||||
// SPA support (enabled by default for modern web apps)
|
||||
spaFallback: this.options.spaFallback ?? true,
|
||||
|
||||
// Security
|
||||
forceSsl: this.options.forceSsl ?? false,
|
||||
securityHeaders: this.options.securityHeaders,
|
||||
|
||||
// Compression
|
||||
compression: this.options.compression,
|
||||
|
||||
// PWA manifest
|
||||
manifest: {
|
||||
name: this.options.domain,
|
||||
short_name: this.options.domain,
|
||||
@@ -46,11 +82,11 @@ export class UtilityWebsiteServer {
|
||||
background_color: '#000000',
|
||||
scope: '/',
|
||||
},
|
||||
port: portArg,
|
||||
|
||||
// features
|
||||
// SEO features
|
||||
robots: true,
|
||||
sitemap: true,
|
||||
feedMetadata: this.options.feedMetadata,
|
||||
});
|
||||
|
||||
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] = {
|
||||
@@ -65,15 +101,16 @@ export class UtilityWebsiteServer {
|
||||
})
|
||||
);
|
||||
|
||||
// ads.txt handler
|
||||
this.typedserver.addRoute('/ads.txt', 'GET', async () => {
|
||||
const adsTxt =
|
||||
['google.com, pub-4104137977476459, DIRECT, f08c47fec0942fa0'].join('\n') + '\n';
|
||||
return new Response(adsTxt, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
// ads.txt handler (only if configured)
|
||||
if (this.options.adsTxt && this.options.adsTxt.length > 0) {
|
||||
this.typedserver.addRoute('/ads.txt', 'GET', async () => {
|
||||
const adsTxt = this.options.adsTxt.join('\n') + '\n';
|
||||
return new Response(adsTxt, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Asset broker manifest handler
|
||||
this.typedserver.addRoute(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,6 +21,19 @@ 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';
|
||||
|
||||
@@ -159,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);
|
||||
@@ -238,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 {
|
||||
@@ -288,22 +298,188 @@ 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);
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
@@ -318,9 +494,12 @@ export class SwDashRequests extends LitElement {
|
||||
@state() accessor phaseFilter: TPhaseFilter = 'all';
|
||||
@state() accessor methodFilter = '';
|
||||
@state() accessor searchText = '';
|
||||
@state() accessor expandedPayloads: Set<string> = new Set();
|
||||
@state() accessor isLoadingMore = false;
|
||||
|
||||
// Modal state
|
||||
@state() accessor modalOpen = false;
|
||||
@state() accessor selectedGroup: IGroupedRequest | null = null;
|
||||
|
||||
private handleDirectionFilterChange(e: Event): void {
|
||||
this.directionFilter = (e.target as HTMLSelectElement).value as TRequestFilter;
|
||||
// Local filtering - no HTTP request
|
||||
@@ -336,6 +515,11 @@ export class SwDashRequests extends LitElement {
|
||||
// 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();
|
||||
}
|
||||
@@ -373,14 +557,120 @@ export class SwDashRequests extends LitElement {
|
||||
}, 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 {
|
||||
@@ -440,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">
|
||||
@@ -463,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>
|
||||
@@ -474,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>
|
||||
@@ -506,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.methods.map(m => html`<option value="${m}">${m}</option>`)}
|
||||
${this.methods.map(m => html`<option value="${m}" ?selected="${this.methodFilter === m}">${m}</option>`)}
|
||||
</select>
|
||||
|
||||
<input
|
||||
@@ -523,46 +933,54 @@ export class SwDashRequests extends LitElement {
|
||||
<button class="btn clear-btn" @click="${this.handleClear}">Clear Logs</button>
|
||||
</div>
|
||||
|
||||
<!-- Request List -->
|
||||
<!-- 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>
|
||||
` : filteredLogs.length === 0 ? html`
|
||||
` : 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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user