Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 980ccfe949 | |||
| 4a76c8f738 | |||
| 05b1f0a395 | |||
| d060d99146 | |||
| 94c6e47e6e | |||
| ffb00cdb71 | |||
| 2f064c7ea8 | |||
| 76b5cb5142 |
38
changelog.md
38
changelog.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "7.8.17",
|
||||
"version": "7.10.1",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
300
readme.md
300
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 TypeScript-first web server framework for building modern full-stack applications. Features static file serving, live reload, type-safe API integration, service worker support, and edge computing capabilities. Part of the `@api.global` ecosystem.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -8,15 +8,15 @@ 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`
|
||||
- 🛡️ **Security Headers** - Built-in CSP, HSTS, X-Frame-Options, and more
|
||||
- ⚡ **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 (default in UtilityWebsiteServer)
|
||||
- 📱 **PWA Ready** - Web App Manifest generation for progressive web apps
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -30,7 +30,7 @@ npm install @api.global/typedserver
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Basic Static File Server
|
||||
### Basic Server
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
@@ -43,10 +43,10 @@ const server = new TypedServer({
|
||||
});
|
||||
|
||||
await server.start();
|
||||
console.log('Server running on port 3000');
|
||||
console.log('Server running!');
|
||||
```
|
||||
|
||||
### Server with All Options
|
||||
### Full Configuration
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
@@ -55,15 +55,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',
|
||||
@@ -83,11 +91,11 @@ await server.start();
|
||||
### 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,94 +103,46 @@ 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('typedserver_frontend');
|
||||
for (const conn of connections) {
|
||||
// Push to specific clients
|
||||
}
|
||||
```
|
||||
|
||||
## ☁️ Edge Worker (Cloudflare Workers)
|
||||
@@ -190,38 +150,113 @@ await server.start(3000);
|
||||
```typescript
|
||||
import { EdgeWorker, DomainRouter } from '@api.global/typedserver/edgeworker';
|
||||
|
||||
const router = new DomainRouter();
|
||||
const worker = new EdgeWorker();
|
||||
|
||||
router.addDomainInstruction({
|
||||
// Configure domain routing
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: '*.example.com',
|
||||
originUrl: 'https://origin.example.com',
|
||||
type: 'cache',
|
||||
cacheConfig: { maxAge: 3600 },
|
||||
});
|
||||
|
||||
const worker = new EdgeWorker(router);
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: 'api.example.com',
|
||||
originUrl: 'https://api-origin.example.com',
|
||||
type: 'origin', // Pass through to origin
|
||||
});
|
||||
|
||||
// In your Cloudflare Worker entry point
|
||||
// Cloudflare Worker entry point
|
||||
export default {
|
||||
fetch: (request: Request, env: any, ctx: any) => worker.handleRequest(request, env, ctx),
|
||||
fetch: worker.fetchFunction.bind(worker),
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Service Worker Client
|
||||
|
||||
Manage service workers in your frontend application:
|
||||
|
||||
```typescript
|
||||
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
import { getServiceworkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
|
||||
const swClient = new ServiceWorkerClient();
|
||||
|
||||
// Register and manage service worker
|
||||
await swClient.register('/serviceworker.bundle.js');
|
||||
|
||||
// Listen for updates
|
||||
swClient.onUpdate(() => {
|
||||
console.log('New version available!');
|
||||
// Initialize and register service worker
|
||||
const swClient = await getServiceworkerClient({
|
||||
pollInterval: 30000, // Poll for updates every 30s
|
||||
});
|
||||
|
||||
// The service worker handles:
|
||||
// - Cache invalidation from server
|
||||
// - Offline support
|
||||
// - Background sync
|
||||
```
|
||||
|
||||
## 🛡️ 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 (still useful) |
|
||||
| `Referrer-Policy` | `referrerPolicy` | Controls referrer information |
|
||||
| `Permissions-Policy` | `permissionsPolicy` | Controls browser features |
|
||||
| `Cross-Origin-Opener-Policy` | `crossOriginOpenerPolicy` | Isolates browsing context |
|
||||
| `Cross-Origin-Embedder-Policy` | `crossOriginEmbedderPolicy` | Controls cross-origin embedding |
|
||||
| `Cross-Origin-Resource-Policy` | `crossOriginResourcePolicy` | Controls cross-origin resource sharing |
|
||||
|
||||
## 📋 Configuration Reference
|
||||
|
||||
### IServerOptions
|
||||
@@ -233,9 +268,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 +278,76 @@ 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 (CSP, HSTS, etc.) |
|
||||
|
||||
## 🏗️ 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 (serves index.html for client routes)
|
||||
spaFallback: true, // default: true
|
||||
|
||||
// Optional security headers
|
||||
securityHeaders: {
|
||||
csp: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
},
|
||||
xFrameOptions: 'SAMEORIGIN',
|
||||
xContentTypeOptions: true,
|
||||
},
|
||||
|
||||
// Other options
|
||||
cors: true, // default: true
|
||||
forceSsl: false, // default: false
|
||||
appSemVer: '1.0.0',
|
||||
});
|
||||
|
||||
await websiteServer.start(); // Default port 3000
|
||||
```
|
||||
|
||||
### UtilityServiceServer
|
||||
|
||||
Optimized for API services:
|
||||
|
||||
```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,
|
||||
});
|
||||
|
||||
await serviceServer.start();
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.8.11',
|
||||
version: '7.10.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,11 @@ export interface IServerOptions {
|
||||
* Useful for single-page applications with client-side routing
|
||||
*/
|
||||
spaFallback?: boolean;
|
||||
|
||||
/**
|
||||
* Security headers configuration (CSP, HSTS, X-Frame-Options, etc.)
|
||||
*/
|
||||
securityHeaders?: ISecurityHeaders;
|
||||
}
|
||||
|
||||
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
|
||||
@@ -70,14 +145,6 @@ export interface IRouteHandler {
|
||||
(request: Request): Promise<Response | null>;
|
||||
}
|
||||
|
||||
export interface IRegisteredRoute {
|
||||
pattern: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
method: THttpMethod;
|
||||
handler: IRouteHandler;
|
||||
}
|
||||
|
||||
export class TypedServer {
|
||||
// instance
|
||||
public options: IServerOptions;
|
||||
@@ -100,9 +167,6 @@ export class TypedServer {
|
||||
// File server for static files
|
||||
private fileServer: plugins.smartserve.FileServer;
|
||||
|
||||
// Custom route handlers (for addRoute API)
|
||||
private customRoutes: IRegisteredRoute[] = [];
|
||||
|
||||
public lastReload: number = Date.now();
|
||||
public ended = false;
|
||||
|
||||
@@ -135,49 +199,18 @@ export class TypedServer {
|
||||
* @param handler - Async function that receives Request and returns Response or null
|
||||
*/
|
||||
public addRoute(path: string, method: THttpMethod, handler: IRouteHandler): void {
|
||||
// Convert Express-style path to regex
|
||||
const paramNames: string[] = [];
|
||||
let regexPattern = path
|
||||
// Handle named parameters :param
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
})
|
||||
// Handle wildcard *splat (matches everything including slashes)
|
||||
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '(.*)';
|
||||
// Delegate to smartserve's ControllerRegistry
|
||||
plugins.smartserve.ControllerRegistry.addRoute(path, method, async (ctx: plugins.smartserve.IRequestContext) => {
|
||||
// Convert context to Request for backwards compatibility
|
||||
const request = new Request(ctx.url.toString(), {
|
||||
method: ctx.method,
|
||||
headers: ctx.headers,
|
||||
});
|
||||
|
||||
// Ensure exact match
|
||||
regexPattern = `^${regexPattern}$`;
|
||||
|
||||
this.customRoutes.push({
|
||||
pattern: path,
|
||||
regex: new RegExp(regexPattern),
|
||||
paramNames,
|
||||
method,
|
||||
handler,
|
||||
(request as any).params = ctx.params;
|
||||
return handler(request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse route parameters from a path using a registered route
|
||||
*/
|
||||
private parseRouteParams(
|
||||
route: IRegisteredRoute,
|
||||
pathname: string
|
||||
): Record<string, string> | null {
|
||||
const match = pathname.match(route.regex);
|
||||
if (!match) return null;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, index) => {
|
||||
params[name] = match[index + 1];
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* inits and starts the server
|
||||
*/
|
||||
@@ -388,16 +421,133 @@ export class TypedServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers to a response
|
||||
* Build CSP header string from configuration
|
||||
*/
|
||||
private addCorsHeaders(response: Response): Response {
|
||||
if (!this.options.cors) return response;
|
||||
private buildCspHeader(csp: IContentSecurityPolicy): string {
|
||||
const directives: string[] = [];
|
||||
|
||||
const addDirective = (name: string, value: string | string[] | undefined) => {
|
||||
if (value) {
|
||||
const sources = Array.isArray(value) ? value.join(' ') : value;
|
||||
directives.push(`${name} ${sources}`);
|
||||
}
|
||||
};
|
||||
|
||||
addDirective('default-src', csp.defaultSrc);
|
||||
addDirective('script-src', csp.scriptSrc);
|
||||
addDirective('style-src', csp.styleSrc);
|
||||
addDirective('img-src', csp.imgSrc);
|
||||
addDirective('font-src', csp.fontSrc);
|
||||
addDirective('connect-src', csp.connectSrc);
|
||||
addDirective('media-src', csp.mediaSrc);
|
||||
addDirective('frame-src', csp.frameSrc);
|
||||
addDirective('object-src', csp.objectSrc);
|
||||
addDirective('worker-src', csp.workerSrc);
|
||||
addDirective('form-action', csp.formAction);
|
||||
addDirective('frame-ancestors', csp.frameAncestors);
|
||||
addDirective('base-uri', csp.baseUri);
|
||||
|
||||
if (csp.reportUri) {
|
||||
directives.push(`report-uri ${csp.reportUri}`);
|
||||
}
|
||||
if (csp.reportTo) {
|
||||
directives.push(`report-to ${csp.reportTo}`);
|
||||
}
|
||||
if (csp.upgradeInsecureRequests) {
|
||||
directives.push('upgrade-insecure-requests');
|
||||
}
|
||||
if (csp.blockAllMixedContent) {
|
||||
directives.push('block-all-mixed-content');
|
||||
}
|
||||
|
||||
return directives.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all configured headers (CORS, security) to a response
|
||||
*/
|
||||
private applyResponseHeaders(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
|
||||
// CORS headers
|
||||
if (this.options.cors) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
|
||||
// Security headers
|
||||
const security = this.options.securityHeaders;
|
||||
if (security) {
|
||||
// Content Security Policy
|
||||
if (security.csp) {
|
||||
const cspHeader = this.buildCspHeader(security.csp);
|
||||
if (cspHeader) {
|
||||
headers.set('Content-Security-Policy', cspHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// X-Frame-Options
|
||||
if (security.xFrameOptions) {
|
||||
headers.set('X-Frame-Options', security.xFrameOptions);
|
||||
}
|
||||
|
||||
// X-Content-Type-Options
|
||||
if (security.xContentTypeOptions) {
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
// X-XSS-Protection
|
||||
if (security.xXssProtection) {
|
||||
const value = typeof security.xXssProtection === 'string'
|
||||
? security.xXssProtection
|
||||
: '1; mode=block';
|
||||
headers.set('X-XSS-Protection', value);
|
||||
}
|
||||
|
||||
// Referrer-Policy
|
||||
if (security.referrerPolicy) {
|
||||
headers.set('Referrer-Policy', security.referrerPolicy);
|
||||
}
|
||||
|
||||
// Strict-Transport-Security (HSTS)
|
||||
if (security.hstsMaxAge !== undefined) {
|
||||
let hsts = `max-age=${security.hstsMaxAge}`;
|
||||
if (security.hstsIncludeSubDomains) {
|
||||
hsts += '; includeSubDomains';
|
||||
}
|
||||
if (security.hstsPreload) {
|
||||
hsts += '; preload';
|
||||
}
|
||||
headers.set('Strict-Transport-Security', hsts);
|
||||
}
|
||||
|
||||
// Permissions-Policy
|
||||
if (security.permissionsPolicy) {
|
||||
const policies = Object.entries(security.permissionsPolicy)
|
||||
.map(([feature, allowlist]) => `${feature}=(${allowlist.join(' ')})`)
|
||||
.join(', ');
|
||||
if (policies) {
|
||||
headers.set('Permissions-Policy', policies);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-Origin-Opener-Policy
|
||||
if (security.crossOriginOpenerPolicy) {
|
||||
headers.set('Cross-Origin-Opener-Policy', security.crossOriginOpenerPolicy);
|
||||
}
|
||||
|
||||
// Cross-Origin-Embedder-Policy
|
||||
if (security.crossOriginEmbedderPolicy) {
|
||||
headers.set('Cross-Origin-Embedder-Policy', security.crossOriginEmbedderPolicy);
|
||||
}
|
||||
|
||||
// Cross-Origin-Resource-Policy
|
||||
if (security.crossOriginResourcePolicy) {
|
||||
headers.set('Cross-Origin-Resource-Policy', security.crossOriginResourcePolicy);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
@@ -416,12 +566,12 @@ export class TypedServer {
|
||||
|
||||
// Handle OPTIONS preflight for CORS
|
||||
if (method === 'OPTIONS' && this.options.cors) {
|
||||
return this.addCorsHeaders(new Response(null, { status: 204 }));
|
||||
return this.applyResponseHeaders(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
// Process the request and wrap response with CORS headers
|
||||
// Process the request and wrap response with all configured headers
|
||||
const response = await this.handleRequestInternal(request, url, path, method);
|
||||
return this.addCorsHeaders(response);
|
||||
return this.applyResponseHeaders(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,18 +608,6 @@ export class TypedServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom routes (registered via addRoute)
|
||||
for (const route of this.customRoutes) {
|
||||
if (route.method === 'ALL' || route.method === method) {
|
||||
const params = this.parseRouteParams(route, path);
|
||||
if (params !== null) {
|
||||
(request as any).params = params;
|
||||
const response = await route.handler(request);
|
||||
if (response) return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTML injection for reload (if enabled)
|
||||
if (this.options.injectReload && this.options.serveDir) {
|
||||
const response = await this.handleHtmlWithInjection(request);
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
|
||||
import { type IServerOptions, type ISecurityHeaders, TypedServer } from '../classes.typedserver.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IUtilityWebsiteServerConstructorOptions {
|
||||
/** Custom route handler to add additional routes */
|
||||
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
|
||||
/** Application semantic version */
|
||||
appSemVer?: string;
|
||||
/** Domain name for the website */
|
||||
domain: string;
|
||||
/** Directory to serve static files from */
|
||||
serveDir: string;
|
||||
feedMetadata: IServerOptions['feedMetadata'];
|
||||
/** RSS feed metadata */
|
||||
feedMetadata?: IServerOptions['feedMetadata'];
|
||||
/** Enable/disable CORS (default: true) */
|
||||
cors?: boolean;
|
||||
/** Enable/disable SPA fallback (default: true) */
|
||||
spaFallback?: boolean;
|
||||
/** Security headers configuration */
|
||||
securityHeaders?: ISecurityHeaders;
|
||||
/** Force SSL redirect (default: false) */
|
||||
forceSsl?: boolean;
|
||||
/** Port to listen on (default: 3000) */
|
||||
port?: number;
|
||||
/** ads.txt entries (only served if configured) */
|
||||
adsTxt?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,14 +46,28 @@ export class UtilityWebsiteServer {
|
||||
/**
|
||||
* Start the website server
|
||||
*/
|
||||
public async start(portArg = 3000) {
|
||||
public async start(portArg?: number) {
|
||||
const port = portArg ?? this.options.port ?? 3000;
|
||||
|
||||
this.typedserver = new TypedServer({
|
||||
cors: true,
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
// Core settings
|
||||
cors: this.options.cors ?? true,
|
||||
serveDir: this.options.serveDir,
|
||||
domain: this.options.domain,
|
||||
forceSsl: false,
|
||||
port,
|
||||
|
||||
// Development features
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
|
||||
// SPA support (enabled by default for modern web apps)
|
||||
spaFallback: this.options.spaFallback ?? true,
|
||||
|
||||
// Security
|
||||
forceSsl: this.options.forceSsl ?? false,
|
||||
securityHeaders: this.options.securityHeaders,
|
||||
|
||||
// PWA manifest
|
||||
manifest: {
|
||||
name: this.options.domain,
|
||||
short_name: this.options.domain,
|
||||
@@ -46,11 +77,11 @@ export class UtilityWebsiteServer {
|
||||
background_color: '#000000',
|
||||
scope: '/',
|
||||
},
|
||||
port: portArg,
|
||||
|
||||
// features
|
||||
// SEO features
|
||||
robots: true,
|
||||
sitemap: true,
|
||||
feedMetadata: this.options.feedMetadata,
|
||||
});
|
||||
|
||||
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] = {
|
||||
@@ -65,15 +96,16 @@ export class UtilityWebsiteServer {
|
||||
})
|
||||
);
|
||||
|
||||
// ads.txt handler
|
||||
this.typedserver.addRoute('/ads.txt', 'GET', async () => {
|
||||
const adsTxt =
|
||||
['google.com, pub-4104137977476459, DIRECT, f08c47fec0942fa0'].join('\n') + '\n';
|
||||
return new Response(adsTxt, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
// ads.txt handler (only if configured)
|
||||
if (this.options.adsTxt && this.options.adsTxt.length > 0) {
|
||||
this.typedserver.addRoute('/ads.txt', 'GET', async () => {
|
||||
const adsTxt = this.options.adsTxt.join('\n') + '\n';
|
||||
return new Response(adsTxt, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Asset broker manifest handler
|
||||
this.typedserver.addRoute(
|
||||
|
||||
Reference in New Issue
Block a user