initial
This commit is contained in:
66
.gitea/workflows/default_nottags.yaml
Normal file
66
.gitea/workflows/default_nottags.yaml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Default (not tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
|
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install pnpm and npmci
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
|
||||||
|
- name: Run npm prepare
|
||||||
|
run: npmci npm prepare
|
||||||
|
|
||||||
|
- name: Audit production dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --prod
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Audit development dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --dev
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
test:
|
||||||
|
if: ${{ always() }}
|
||||||
|
needs: security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Test stable
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci npm test
|
||||||
|
|
||||||
|
- name: Test build
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci npm build
|
||||||
124
.gitea/workflows/default_tags.yaml
Normal file
124
.gitea/workflows/default_tags.yaml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
name: Default (tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
|
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Audit production dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --prod
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Audit development dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --dev
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
test:
|
||||||
|
if: ${{ always() }}
|
||||||
|
needs: security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Test stable
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci npm test
|
||||||
|
|
||||||
|
- name: Test build
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci npm build
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm publish
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Code quality
|
||||||
|
run: |
|
||||||
|
npmci command npm install -g typescript
|
||||||
|
npmci npm install
|
||||||
|
|
||||||
|
- name: Trigger
|
||||||
|
run: npmci trigger
|
||||||
|
|
||||||
|
- name: Build docs and upload artifacts
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
pnpm install -g @git.zone/tsdoc
|
||||||
|
npmci command tsdoc
|
||||||
|
continue-on-error: true
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.nogit/
|
||||||
|
|
||||||
|
# artifacts
|
||||||
|
coverage/
|
||||||
|
public/
|
||||||
|
|
||||||
|
# installs
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.yarn/
|
||||||
|
.cache/
|
||||||
|
.rpt2_cache
|
||||||
|
|
||||||
|
# builds
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
|
|
||||||
|
# AI
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|
||||||
|
#------# custom
|
||||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "npm test",
|
||||||
|
"name": "Run npm test",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
.vscode/settings.json
vendored
Normal file
26
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"json.schemas": [
|
||||||
|
{
|
||||||
|
"fileMatch": ["/npmextra.json"],
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"npmci": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "settings for npmci"
|
||||||
|
},
|
||||||
|
"gitzone": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "settings for gitzone",
|
||||||
|
"properties": {
|
||||||
|
"projectType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["website", "element", "service", "npm", "wcc"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
npmextra.json
Normal file
18
npmextra.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"gitzone": {
|
||||||
|
"projectType": "npm",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "push.rocks",
|
||||||
|
"gitrepo": "smartserve",
|
||||||
|
"description": "a cross platform server module for Node, Deno and Bun",
|
||||||
|
"npmPackagename": "@push.rocks/smartserve",
|
||||||
|
"license": "MIT",
|
||||||
|
"projectDomain": "push.rocks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"npmci": {
|
||||||
|
"npmGlobalTools": [],
|
||||||
|
"npmAccessLevel": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
package.json
Normal file
56
package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "@push.rocks/smartserve",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"private": false,
|
||||||
|
"description": "a cross platform server module for Node, Deno and Bun",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist_ts/index.js"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"author": "Task Venture Capital GmbH",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"test": "(tstest test/ --web)",
|
||||||
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
|
"buildDocs": "(tsdoc)"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbuild": "^3.1.2",
|
||||||
|
"@git.zone/tsbundle": "^2.0.5",
|
||||||
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
|
"@git.zone/tstest": "^3.1.3",
|
||||||
|
"@push.rocks/tapbundle": "^6.0.3",
|
||||||
|
"@types/node": "^20.8.7",
|
||||||
|
"@types/ws": "^8.18.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@push.rocks/lik": "^6.2.2",
|
||||||
|
"@push.rocks/smartenv": "^6.0.0",
|
||||||
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
|
"@push.rocks/smartpath": "^6.0.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://code.foss.global/push.rocks/smartserve.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://code.foss.global/push.rocks/smartserve/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://code.foss.global/push.rocks/smartserve#readme",
|
||||||
|
"files": [
|
||||||
|
"ts/**/*",
|
||||||
|
"ts_web/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"dist_*/**/*",
|
||||||
|
"dist_ts/**/*",
|
||||||
|
"dist_ts_web/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"cli.js",
|
||||||
|
"npmextra.json",
|
||||||
|
"readme.md"
|
||||||
|
],
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
10016
pnpm-lock.yaml
generated
Normal file
10016
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
100
readme.hints.md
Normal file
100
readme.hints.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# SmartServe Development Hints
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
SmartServe is a cross-platform HTTP server for Node.js, Deno, and Bun using:
|
||||||
|
- **Web Standards API** (Request/Response) for cross-platform compatibility
|
||||||
|
- **TC39 Stage 3 decorators** (TypeScript 5.0+) for route definition
|
||||||
|
- **Adapter pattern** for runtime-specific implementations
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- `ts/core/smartserve.classes.smartserve.ts` - Main server class
|
||||||
|
- `ts/core/smartserve.interfaces.ts` - All type definitions
|
||||||
|
- `ts/core/smartserve.errors.ts` - HTTP error classes
|
||||||
|
|
||||||
|
### Adapters
|
||||||
|
- `ts/adapters/adapter.factory.ts` - Runtime detection via @push.rocks/smartenv
|
||||||
|
- `ts/adapters/adapter.node.ts` - Node.js with Request/Response conversion
|
||||||
|
- `ts/adapters/adapter.deno.ts` - Deno (zero overhead, native)
|
||||||
|
- `ts/adapters/adapter.bun.ts` - Bun (zero overhead, native)
|
||||||
|
|
||||||
|
### Decorators
|
||||||
|
- `ts/decorators/decorators.route.ts` - @Route class decorator
|
||||||
|
- `ts/decorators/decorators.methods.ts` - @Get, @Post, etc.
|
||||||
|
- `ts/decorators/decorators.interceptors.ts` - @Guard, @Transform, @Intercept
|
||||||
|
- `ts/decorators/decorators.registry.ts` - Controller registration and route matching
|
||||||
|
|
||||||
|
### Static Files
|
||||||
|
- `ts/files/file.server.ts` - Static file serving with streaming, ETags, directory listing
|
||||||
|
- `ts/utils/utils.mime.ts` - MIME type detection
|
||||||
|
- `ts/utils/utils.etag.ts` - ETag generation
|
||||||
|
|
||||||
|
### Protocols
|
||||||
|
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
|
||||||
|
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
|
||||||
|
- `ts/protocols/webdav/webdav.types.ts` - WebDAV type definitions
|
||||||
|
|
||||||
|
## Decorator System
|
||||||
|
|
||||||
|
Uses TC39 decorators (NOT experimental decorators). Metadata stored via Symbol property on classes.
|
||||||
|
|
||||||
|
### Interceptor Execution Order (Onion Model)
|
||||||
|
```
|
||||||
|
Request → Class Guards → Method Guards → Handler → Method Transforms → Class Transforms → Response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guard vs Transform vs Intercept
|
||||||
|
- `@Guard(fn)` = `@Intercept({ request: fn })` - returns boolean (true=allow, false=403)
|
||||||
|
- `@Transform(fn)` = `@Intercept({ response: fn })` - modifies response
|
||||||
|
- `@Intercept({ request?, response? })` - full control
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `@push.rocks/smartenv` - Runtime detection
|
||||||
|
- `@push.rocks/smartpath` - Path utilities
|
||||||
|
- `@push.rocks/smartlog` - Logging
|
||||||
|
- `@push.rocks/lik` - Collections
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- `ws` - WebSocket support for Node.js (Deno/Bun have native)
|
||||||
|
|
||||||
|
## WebDAV Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SmartServe } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 8080,
|
||||||
|
webdav: {
|
||||||
|
root: '/path/to/files',
|
||||||
|
auth: (ctx) => {
|
||||||
|
// Optional auth - return true to allow, false to reject
|
||||||
|
const auth = ctx.headers.get('Authorization');
|
||||||
|
return auth === 'Basic dXNlcjpwYXNz';
|
||||||
|
},
|
||||||
|
locking: true, // Enable file locking (RFC 4918)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
// Mount at http://localhost:8080 as network drive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported WebDAV Methods
|
||||||
|
- OPTIONS - Capability discovery
|
||||||
|
- PROPFIND - Directory listing and file metadata
|
||||||
|
- PROPPATCH - Property modification (returns 403)
|
||||||
|
- MKCOL - Create directory
|
||||||
|
- COPY - Copy files/directories
|
||||||
|
- MOVE - Move/rename files/directories
|
||||||
|
- LOCK/UNLOCK - Exclusive write locking
|
||||||
|
- GET/HEAD/PUT/DELETE - Standard file operations
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
|
||||||
|
- [ ] HTTP/2 support investigation
|
||||||
|
- [ ] Performance benchmarks
|
||||||
5
readme.md
Normal file
5
readme.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# @push.rocks/smartserve
|
||||||
|
a cross platform server module for Node, Deno and Bun
|
||||||
|
|
||||||
|
## How to create the docs
|
||||||
|
To create docs run gitzone aidoc.
|
||||||
208
test/test.ts
Normal file
208
test/test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import {
|
||||||
|
SmartServe,
|
||||||
|
Route,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Guard,
|
||||||
|
Transform,
|
||||||
|
Intercept,
|
||||||
|
HttpError,
|
||||||
|
type IRequestContext,
|
||||||
|
} from '../ts/index.js';
|
||||||
|
|
||||||
|
// Test controller
|
||||||
|
@Route('/api')
|
||||||
|
class TestController {
|
||||||
|
@Get('/hello')
|
||||||
|
hello() {
|
||||||
|
return { message: 'Hello World' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/users/:id')
|
||||||
|
getUser(ctx: IRequestContext) {
|
||||||
|
return { id: ctx.params.id, name: 'Test User' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/echo')
|
||||||
|
echo(ctx: IRequestContext<{ text: string }>) {
|
||||||
|
return { echo: ctx.body?.text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller with guards
|
||||||
|
const isAuthenticated = (ctx: IRequestContext) => {
|
||||||
|
return ctx.headers.has('Authorization');
|
||||||
|
};
|
||||||
|
|
||||||
|
@Route('/protected')
|
||||||
|
@Guard(isAuthenticated)
|
||||||
|
class ProtectedController {
|
||||||
|
@Get('/data')
|
||||||
|
getData() {
|
||||||
|
return { secret: 'protected data' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller with transforms
|
||||||
|
const wrapResponse = <T>(data: T) => ({ success: true, data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
@Route('/wrapped')
|
||||||
|
@Transform(wrapResponse)
|
||||||
|
class WrappedController {
|
||||||
|
@Get('/info')
|
||||||
|
getInfo() {
|
||||||
|
return { version: '1.0.0' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('SmartServe should create server instance', async () => {
|
||||||
|
const server = new SmartServe({ port: 3456 });
|
||||||
|
expect(server).toBeInstanceOf(SmartServe);
|
||||||
|
expect(server.isRunning()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should register controllers', async () => {
|
||||||
|
const server = new SmartServe({ port: 3457 });
|
||||||
|
server.register(TestController);
|
||||||
|
server.register(ProtectedController);
|
||||||
|
server.register(WrappedController);
|
||||||
|
expect(server).toBeInstanceOf(SmartServe);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should start and stop', async () => {
|
||||||
|
const server = new SmartServe({ port: 3458 });
|
||||||
|
server.register(TestController);
|
||||||
|
|
||||||
|
const instance = await server.start();
|
||||||
|
expect(instance.port).toEqual(3458);
|
||||||
|
expect(instance.runtime).toEqual('node');
|
||||||
|
expect(server.isRunning()).toBeTrue();
|
||||||
|
|
||||||
|
await server.stop();
|
||||||
|
expect(server.isRunning()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should handle GET request', async () => {
|
||||||
|
const server = new SmartServe({ port: 3459 });
|
||||||
|
server.register(TestController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3459/api/hello');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(data.message).toEqual('Hello World');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should handle path parameters', async () => {
|
||||||
|
const server = new SmartServe({ port: 3460 });
|
||||||
|
server.register(TestController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3460/api/users/123');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(data.id).toEqual('123');
|
||||||
|
expect(data.name).toEqual('Test User');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should handle POST with body', async () => {
|
||||||
|
const server = new SmartServe({ port: 3461 });
|
||||||
|
server.register(TestController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3461/api/echo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: 'Hello!' }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(data.echo).toEqual('Hello!');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should enforce guards', async () => {
|
||||||
|
const server = new SmartServe({ port: 3462 });
|
||||||
|
server.register(ProtectedController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Without auth header - should be forbidden
|
||||||
|
const response1 = await fetch('http://localhost:3462/protected/data');
|
||||||
|
expect(response1.status).toEqual(403);
|
||||||
|
|
||||||
|
// With auth header - should succeed
|
||||||
|
const response2 = await fetch('http://localhost:3462/protected/data', {
|
||||||
|
headers: { Authorization: 'Bearer token123' },
|
||||||
|
});
|
||||||
|
const data = await response2.json();
|
||||||
|
|
||||||
|
expect(response2.status).toEqual(200);
|
||||||
|
expect(data.secret).toEqual('protected data');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should apply transforms', async () => {
|
||||||
|
const server = new SmartServe({ port: 3463 });
|
||||||
|
server.register(WrappedController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3463/wrapped/info');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(data.success).toBeTrue();
|
||||||
|
expect(data.data.version).toEqual('1.0.0');
|
||||||
|
expect(data.timestamp).toBeTypeofNumber();
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SmartServe should return 404 for unknown routes', async () => {
|
||||||
|
const server = new SmartServe({ port: 3464 });
|
||||||
|
server.register(TestController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3464/unknown/route');
|
||||||
|
expect(response.status).toEqual(404);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('HttpError should create proper responses', async () => {
|
||||||
|
const error = HttpError.notFound('Resource not found', { id: '123' });
|
||||||
|
|
||||||
|
expect(error.status).toEqual(404);
|
||||||
|
expect(error.message).toEqual('Resource not found');
|
||||||
|
expect(error.details).toEqual({ id: '123' });
|
||||||
|
|
||||||
|
const response = error.toResponse();
|
||||||
|
expect(response.status).toEqual(404);
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toEqual('Resource not found');
|
||||||
|
expect(body.details.id).toEqual('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
132
ts/adapters/adapter.base.ts
Normal file
132
ts/adapters/adapter.base.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type {
|
||||||
|
ISmartServeOptions,
|
||||||
|
ISmartServeInstance,
|
||||||
|
IRequestContext,
|
||||||
|
IConnectionInfo,
|
||||||
|
IServerStats,
|
||||||
|
TRuntime,
|
||||||
|
THttpMethod,
|
||||||
|
} from '../core/smartserve.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter characteristics - what each runtime supports
|
||||||
|
*/
|
||||||
|
export interface IAdapterCharacteristics {
|
||||||
|
/** Zero-copy streaming support */
|
||||||
|
zeroCopyStreaming: boolean;
|
||||||
|
/** HTTP/2 support */
|
||||||
|
http2Support: boolean;
|
||||||
|
/** Maximum concurrent connections */
|
||||||
|
maxConnections: number | 'unlimited';
|
||||||
|
/** Native WebSocket upgrade support */
|
||||||
|
nativeWebSocket: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler function that receives web standard Request and returns Response
|
||||||
|
*/
|
||||||
|
export type TRequestHandler = (
|
||||||
|
request: Request,
|
||||||
|
info: IConnectionInfo
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base adapter for all runtime implementations
|
||||||
|
*/
|
||||||
|
export abstract class BaseAdapter {
|
||||||
|
protected options: ISmartServeOptions;
|
||||||
|
protected handler: TRequestHandler | null = null;
|
||||||
|
protected stats: IServerStats = {
|
||||||
|
uptime: 0,
|
||||||
|
requestsTotal: 0,
|
||||||
|
requestsActive: 0,
|
||||||
|
connectionsTotal: 0,
|
||||||
|
connectionsActive: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
bytesSent: 0,
|
||||||
|
};
|
||||||
|
protected startTime: number = 0;
|
||||||
|
|
||||||
|
constructor(options: ISmartServeOptions) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime name
|
||||||
|
*/
|
||||||
|
abstract get name(): TRuntime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter characteristics
|
||||||
|
*/
|
||||||
|
abstract get characteristics(): IAdapterCharacteristics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this adapter is supported in current runtime
|
||||||
|
*/
|
||||||
|
abstract isSupported(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server
|
||||||
|
*/
|
||||||
|
abstract start(handler: TRequestHandler): Promise<ISmartServeInstance>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server
|
||||||
|
*/
|
||||||
|
abstract stop(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current server statistics
|
||||||
|
*/
|
||||||
|
getStats(): IServerStats {
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
uptime: this.startTime > 0 ? Date.now() - this.startTime : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ISmartServeInstance from adapter
|
||||||
|
*/
|
||||||
|
protected createInstance(): ISmartServeInstance {
|
||||||
|
return {
|
||||||
|
port: this.options.port,
|
||||||
|
hostname: this.options.hostname ?? '0.0.0.0',
|
||||||
|
secure: !!this.options.tls,
|
||||||
|
runtime: this.name,
|
||||||
|
stop: () => this.stop(),
|
||||||
|
stats: () => this.getStats(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse URL from request for cross-platform compatibility
|
||||||
|
*/
|
||||||
|
protected parseUrl(request: Request): URL {
|
||||||
|
return new URL(request.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse query parameters from URL
|
||||||
|
*/
|
||||||
|
protected parseQuery(url: URL): Record<string, string> {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
query[key] = value;
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HTTP method from request
|
||||||
|
*/
|
||||||
|
protected parseMethod(request: Request): THttpMethod {
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
if (['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
|
return method as THttpMethod;
|
||||||
|
}
|
||||||
|
return 'GET';
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ts/adapters/adapter.bun.ts
Normal file
161
ts/adapters/adapter.bun.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js';
|
||||||
|
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
|
||||||
|
|
||||||
|
// Bun types (for type checking without requiring Bun runtime)
|
||||||
|
declare const Bun: {
|
||||||
|
serve(options: any): { stop(): void; port: number; hostname: string };
|
||||||
|
file(path: string): any;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bun adapter - zero overhead, native Request/Response
|
||||||
|
*/
|
||||||
|
export class BunAdapter extends BaseAdapter {
|
||||||
|
private server: { stop(): void; port: number; hostname: string } | null = null;
|
||||||
|
|
||||||
|
get name(): 'bun' {
|
||||||
|
return 'bun';
|
||||||
|
}
|
||||||
|
|
||||||
|
get characteristics(): IAdapterCharacteristics {
|
||||||
|
return {
|
||||||
|
zeroCopyStreaming: true,
|
||||||
|
http2Support: false, // Bun currently HTTP/1.1 only
|
||||||
|
maxConnections: 'unlimited',
|
||||||
|
nativeWebSocket: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isSupported(): boolean {
|
||||||
|
return typeof (globalThis as any).Bun !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(handler: TRequestHandler): Promise<ISmartServeInstance> {
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
throw new Error('Bun runtime is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handler = handler;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
const serveOptions: any = {
|
||||||
|
port: this.options.port,
|
||||||
|
hostname: this.options.hostname ?? '0.0.0.0',
|
||||||
|
fetch: async (request: Request, server: any) => {
|
||||||
|
this.stats.requestsTotal++;
|
||||||
|
this.stats.requestsActive++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle WebSocket upgrade
|
||||||
|
if (this.options.websocket && request.headers.get('upgrade') === 'websocket') {
|
||||||
|
const upgraded = server.upgrade(request);
|
||||||
|
if (upgraded) {
|
||||||
|
return undefined; // Bun handles the upgrade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connection info
|
||||||
|
const connectionInfo: IConnectionInfo = {
|
||||||
|
remoteAddr: server.requestIP(request)?.address ?? 'unknown',
|
||||||
|
remotePort: 0,
|
||||||
|
localAddr: this.options.hostname ?? '0.0.0.0',
|
||||||
|
localPort: this.options.port,
|
||||||
|
encrypted: !!this.options.tls,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await handler(request, connectionInfo);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.options.onError) {
|
||||||
|
return this.options.onError(error as Error, request);
|
||||||
|
}
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
} finally {
|
||||||
|
this.stats.requestsActive--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add TLS if configured
|
||||||
|
if (this.options.tls) {
|
||||||
|
serveOptions.tls = {
|
||||||
|
cert: typeof this.options.tls.cert === 'string'
|
||||||
|
? Bun.file(this.options.tls.cert)
|
||||||
|
: this.options.tls.cert,
|
||||||
|
key: typeof this.options.tls.key === 'string'
|
||||||
|
? Bun.file(this.options.tls.key)
|
||||||
|
: this.options.tls.key,
|
||||||
|
passphrase: this.options.tls.passphrase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add WebSocket handlers if configured
|
||||||
|
if (this.options.websocket) {
|
||||||
|
serveOptions.websocket = this.createWebSocketHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server = Bun.serve(serveOptions);
|
||||||
|
return this.createInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.stop();
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWebSocketHandler(): any {
|
||||||
|
const hooks = this.options.websocket;
|
||||||
|
if (!hooks) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: (ws: any) => {
|
||||||
|
const peer = this.wrapBunWebSocket(ws);
|
||||||
|
hooks.onOpen?.(peer);
|
||||||
|
},
|
||||||
|
message: (ws: any, message: string | ArrayBuffer) => {
|
||||||
|
const peer = this.wrapBunWebSocket(ws);
|
||||||
|
const msg = {
|
||||||
|
type: typeof message === 'string' ? 'text' as const : 'binary' as const,
|
||||||
|
text: typeof message === 'string' ? message : undefined,
|
||||||
|
data: message instanceof ArrayBuffer ? new Uint8Array(message) : undefined,
|
||||||
|
size: typeof message === 'string' ? message.length : (message as ArrayBuffer).byteLength,
|
||||||
|
};
|
||||||
|
hooks.onMessage?.(peer, msg);
|
||||||
|
},
|
||||||
|
close: (ws: any, code: number, reason: string) => {
|
||||||
|
const peer = this.wrapBunWebSocket(ws);
|
||||||
|
hooks.onClose?.(peer, code, reason);
|
||||||
|
},
|
||||||
|
error: (ws: any, error: Error) => {
|
||||||
|
const peer = this.wrapBunWebSocket(ws);
|
||||||
|
hooks.onError?.(peer, error);
|
||||||
|
},
|
||||||
|
ping: (ws: any, data: ArrayBuffer) => {
|
||||||
|
const peer = this.wrapBunWebSocket(ws);
|
||||||
|
hooks.onPing?.(peer, new Uint8Array(data));
|
||||||
|
},
|
||||||
|
pong: (ws: any, data: ArrayBuffer) => {
|
||||||
|
const peer = this.wrapBunWebSocket(ws);
|
||||||
|
hooks.onPong?.(peer, new Uint8Array(data));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapBunWebSocket(ws: any): any {
|
||||||
|
return {
|
||||||
|
id: ws.data?.id ?? crypto.randomUUID(),
|
||||||
|
url: ws.data?.url ?? '',
|
||||||
|
get readyState() { return ws.readyState; },
|
||||||
|
protocol: ws.protocol ?? '',
|
||||||
|
extensions: ws.extensions ?? '',
|
||||||
|
send: (data: string) => ws.send(data),
|
||||||
|
sendBinary: (data: Uint8Array | ArrayBuffer) => ws.send(data),
|
||||||
|
close: (code?: number, reason?: string) => ws.close(code, reason),
|
||||||
|
ping: (data?: Uint8Array) => ws.ping(data),
|
||||||
|
terminate: () => ws.terminate(),
|
||||||
|
context: {} as any,
|
||||||
|
data: ws.data?.customData ?? new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
141
ts/adapters/adapter.deno.ts
Normal file
141
ts/adapters/adapter.deno.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js';
|
||||||
|
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
|
||||||
|
|
||||||
|
// Deno types (for type checking without requiring Deno runtime)
|
||||||
|
declare const Deno: {
|
||||||
|
serve(options: any, handler?: any): { shutdown(): Promise<void>; finished: Promise<void> };
|
||||||
|
upgradeWebSocket(request: Request): { socket: WebSocket; response: Response };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deno adapter - zero overhead, native Request/Response
|
||||||
|
*/
|
||||||
|
export class DenoAdapter extends BaseAdapter {
|
||||||
|
private server: { shutdown(): Promise<void>; finished: Promise<void> } | null = null;
|
||||||
|
|
||||||
|
get name(): 'deno' {
|
||||||
|
return 'deno';
|
||||||
|
}
|
||||||
|
|
||||||
|
get characteristics(): IAdapterCharacteristics {
|
||||||
|
return {
|
||||||
|
zeroCopyStreaming: true,
|
||||||
|
http2Support: true,
|
||||||
|
maxConnections: 'unlimited',
|
||||||
|
nativeWebSocket: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isSupported(): boolean {
|
||||||
|
return typeof (globalThis as any).Deno !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(handler: TRequestHandler): Promise<ISmartServeInstance> {
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
throw new Error('Deno runtime is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handler = handler;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
const serveOptions: any = {
|
||||||
|
port: this.options.port,
|
||||||
|
hostname: this.options.hostname ?? '0.0.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add TLS if configured
|
||||||
|
if (this.options.tls) {
|
||||||
|
serveOptions.cert = this.options.tls.cert;
|
||||||
|
serveOptions.key = this.options.tls.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server = Deno.serve(serveOptions, async (request: Request, info: any) => {
|
||||||
|
this.stats.requestsTotal++;
|
||||||
|
this.stats.requestsActive++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle WebSocket upgrade
|
||||||
|
if (this.options.websocket && request.headers.get('upgrade') === 'websocket') {
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
this.attachWebSocketHooks(socket, request);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connection info
|
||||||
|
const connectionInfo: IConnectionInfo = {
|
||||||
|
remoteAddr: info?.remoteAddr?.hostname ?? 'unknown',
|
||||||
|
remotePort: info?.remoteAddr?.port ?? 0,
|
||||||
|
localAddr: this.options.hostname ?? '0.0.0.0',
|
||||||
|
localPort: this.options.port,
|
||||||
|
encrypted: !!this.options.tls,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await handler(request, connectionInfo);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.options.onError) {
|
||||||
|
return this.options.onError(error as Error, request);
|
||||||
|
}
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
} finally {
|
||||||
|
this.stats.requestsActive--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.createInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.shutdown();
|
||||||
|
await this.server.finished;
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachWebSocketHooks(socket: WebSocket, request: Request): void {
|
||||||
|
const hooks = this.options.websocket;
|
||||||
|
if (!hooks) return;
|
||||||
|
|
||||||
|
const peer = this.createWebSocketPeer(socket, request);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
hooks.onOpen?.(peer);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const message = {
|
||||||
|
type: typeof event.data === 'string' ? 'text' as const : 'binary' as const,
|
||||||
|
text: typeof event.data === 'string' ? event.data : undefined,
|
||||||
|
data: event.data instanceof Uint8Array ? event.data : undefined,
|
||||||
|
size: typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength,
|
||||||
|
};
|
||||||
|
hooks.onMessage?.(peer, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
hooks.onClose?.(peer, event.code, event.reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (event) => {
|
||||||
|
hooks.onError?.(peer, new Error('WebSocket error'));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWebSocketPeer(socket: WebSocket, request: Request): any {
|
||||||
|
const url = this.parseUrl(request);
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
url: url.pathname,
|
||||||
|
get readyState() { return socket.readyState; },
|
||||||
|
protocol: socket.protocol,
|
||||||
|
extensions: socket.extensions,
|
||||||
|
send: (data: string) => socket.send(data),
|
||||||
|
sendBinary: (data: Uint8Array | ArrayBuffer) => socket.send(data),
|
||||||
|
close: (code?: number, reason?: string) => socket.close(code, reason),
|
||||||
|
ping: () => { /* Deno handles ping/pong automatically */ },
|
||||||
|
terminate: () => socket.close(),
|
||||||
|
context: {} as any, // Will be populated with IRequestContext
|
||||||
|
data: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ts/adapters/adapter.factory.ts
Normal file
77
ts/adapters/adapter.factory.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { ISmartServeOptions, TRuntime } from '../core/smartserve.interfaces.js';
|
||||||
|
import { UnsupportedRuntimeError } from '../core/smartserve.errors.js';
|
||||||
|
import type { BaseAdapter } from './adapter.base.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for creating runtime-specific adapters
|
||||||
|
* Uses @push.rocks/smartenv for runtime detection
|
||||||
|
*/
|
||||||
|
export class AdapterFactory {
|
||||||
|
private static smartenv = new plugins.smartenv.Smartenv();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect current runtime
|
||||||
|
*/
|
||||||
|
static detectRuntime(): TRuntime {
|
||||||
|
if (this.smartenv.isBrowser) {
|
||||||
|
throw new UnsupportedRuntimeError('browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Deno
|
||||||
|
if (typeof (globalThis as any).Deno !== 'undefined') {
|
||||||
|
return 'deno';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Bun
|
||||||
|
if (typeof (globalThis as any).Bun !== 'undefined') {
|
||||||
|
return 'bun';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Node.js
|
||||||
|
return 'node';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an adapter for the current runtime
|
||||||
|
*/
|
||||||
|
static async createAdapter(options: ISmartServeOptions): Promise<BaseAdapter> {
|
||||||
|
const runtime = this.detectRuntime();
|
||||||
|
|
||||||
|
switch (runtime) {
|
||||||
|
case 'deno': {
|
||||||
|
const { DenoAdapter } = await import('./adapter.deno.js');
|
||||||
|
return new DenoAdapter(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bun': {
|
||||||
|
const { BunAdapter } = await import('./adapter.bun.js');
|
||||||
|
return new BunAdapter(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'node': {
|
||||||
|
const { NodeAdapter } = await import('./adapter.node.js');
|
||||||
|
return new NodeAdapter(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new UnsupportedRuntimeError(runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific runtime is available
|
||||||
|
*/
|
||||||
|
static isRuntimeAvailable(runtime: TRuntime): boolean {
|
||||||
|
switch (runtime) {
|
||||||
|
case 'deno':
|
||||||
|
return typeof (globalThis as any).Deno !== 'undefined';
|
||||||
|
case 'bun':
|
||||||
|
return typeof (globalThis as any).Bun !== 'undefined';
|
||||||
|
case 'node':
|
||||||
|
return typeof process !== 'undefined' && !!process.versions?.node;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
ts/adapters/adapter.node.ts
Normal file
317
ts/adapters/adapter.node.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js';
|
||||||
|
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node.js adapter - converts IncomingMessage/ServerResponse to web standards
|
||||||
|
*/
|
||||||
|
export class NodeAdapter extends BaseAdapter {
|
||||||
|
private server: plugins.http.Server | plugins.https.Server | null = null;
|
||||||
|
|
||||||
|
get name(): 'node' {
|
||||||
|
return 'node';
|
||||||
|
}
|
||||||
|
|
||||||
|
get characteristics(): IAdapterCharacteristics {
|
||||||
|
return {
|
||||||
|
zeroCopyStreaming: false, // Requires conversion
|
||||||
|
http2Support: true,
|
||||||
|
maxConnections: 16384,
|
||||||
|
nativeWebSocket: false, // Requires ws library
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isSupported(): boolean {
|
||||||
|
return typeof process !== 'undefined' && !!process.versions?.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(handler: TRequestHandler): Promise<ISmartServeInstance> {
|
||||||
|
this.handler = handler;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
const requestListener = this.createRequestListener(handler);
|
||||||
|
|
||||||
|
if (this.options.tls) {
|
||||||
|
// Convert Uint8Array to Buffer if needed
|
||||||
|
const toBuffer = (data: string | Uint8Array | undefined): string | Buffer | undefined => {
|
||||||
|
if (data === undefined) return undefined;
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
return Buffer.from(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlsOptions: plugins.https.ServerOptions = {
|
||||||
|
cert: toBuffer(this.options.tls.cert),
|
||||||
|
key: toBuffer(this.options.tls.key),
|
||||||
|
ca: toBuffer(this.options.tls.ca),
|
||||||
|
passphrase: this.options.tls.passphrase,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.options.tls.alpnProtocols) {
|
||||||
|
tlsOptions.ALPNProtocols = this.options.tls.alpnProtocols;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.tls.minVersion) {
|
||||||
|
tlsOptions.minVersion = this.options.tls.minVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server = plugins.https.createServer(tlsOptions, requestListener);
|
||||||
|
} else {
|
||||||
|
this.server = plugins.http.createServer(requestListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure keep-alive
|
||||||
|
if (this.options.keepAlive?.enabled) {
|
||||||
|
this.server.keepAliveTimeout = this.options.keepAlive.timeout ?? 5000;
|
||||||
|
(this.server as any).maxRequestsPerSocket = this.options.keepAlive.maxRequests ?? 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up connection tracking
|
||||||
|
this.server.on('connection', (socket) => {
|
||||||
|
this.stats.connectionsTotal++;
|
||||||
|
this.stats.connectionsActive++;
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.stats.connectionsActive--;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket upgrade handling (if ws library available)
|
||||||
|
if (this.options.websocket) {
|
||||||
|
await this.setupWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.server!.listen(
|
||||||
|
this.options.port,
|
||||||
|
this.options.hostname ?? '0.0.0.0',
|
||||||
|
() => {
|
||||||
|
resolve(this.createInstance());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.server!.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
this.server = null;
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Node.js request listener that converts to web standards
|
||||||
|
*/
|
||||||
|
private createRequestListener(handler: TRequestHandler) {
|
||||||
|
return async (
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse
|
||||||
|
) => {
|
||||||
|
this.stats.requestsTotal++;
|
||||||
|
this.stats.requestsActive++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert to web standard Request
|
||||||
|
const request = this.toWebRequest(req);
|
||||||
|
|
||||||
|
// Create connection info
|
||||||
|
const connectionInfo: IConnectionInfo = {
|
||||||
|
remoteAddr: req.socket.remoteAddress ?? 'unknown',
|
||||||
|
remotePort: req.socket.remotePort ?? 0,
|
||||||
|
localAddr: req.socket.localAddress ?? '0.0.0.0',
|
||||||
|
localPort: req.socket.localPort ?? this.options.port,
|
||||||
|
encrypted: !!(req.socket as any).encrypted,
|
||||||
|
tlsVersion: (req.socket as any).getCipher?.()?.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call handler and send response
|
||||||
|
const response = await handler(request, connectionInfo);
|
||||||
|
await this.sendResponse(res, response);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.options.onError) {
|
||||||
|
try {
|
||||||
|
const errorResponse = await this.options.onError(error as Error);
|
||||||
|
await this.sendResponse(res, errorResponse);
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.stats.requestsActive--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Node.js IncomingMessage to Web Standard Request
|
||||||
|
*/
|
||||||
|
private toWebRequest(req: plugins.http.IncomingMessage): Request {
|
||||||
|
const protocol = (req.socket as any).encrypted ? 'https' : 'http';
|
||||||
|
const host = req.headers.host ?? 'localhost';
|
||||||
|
const url = new URL(req.url ?? '/', `${protocol}://${host}`);
|
||||||
|
|
||||||
|
// Convert headers
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => headers.append(key, v));
|
||||||
|
} else {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create body stream for non-GET/HEAD requests
|
||||||
|
let body: ReadableStream<Uint8Array> | null = null;
|
||||||
|
const method = req.method?.toUpperCase() ?? 'GET';
|
||||||
|
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') {
|
||||||
|
body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
req.on('data', (chunk: Buffer) => {
|
||||||
|
controller.enqueue(new Uint8Array(chunk));
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
req.on('error', (err) => {
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
req.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use proper init object for Request
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
init.body = body;
|
||||||
|
// @ts-ignore - duplex is needed for streaming body in Node.js
|
||||||
|
init.duplex = 'half';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Request(url.toString(), init);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Web Standard Response via Node.js ServerResponse
|
||||||
|
*/
|
||||||
|
private async sendResponse(
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
response: Response
|
||||||
|
): Promise<void> {
|
||||||
|
res.statusCode = response.status;
|
||||||
|
res.statusMessage = response.statusText;
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
res.setHeader(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream body
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
res.write(value);
|
||||||
|
this.stats.bytesSent += value.byteLength;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up WebSocket support using ws library
|
||||||
|
*/
|
||||||
|
private async setupWebSocket(): Promise<void> {
|
||||||
|
const hooks = this.options.websocket;
|
||||||
|
if (!hooks || !this.server) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import of ws library
|
||||||
|
const { WebSocketServer } = await import('ws');
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
this.server.on('upgrade', (request, socket, head) => {
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', (ws: any, request: any) => {
|
||||||
|
const peer = this.wrapNodeWebSocket(ws, request);
|
||||||
|
|
||||||
|
hooks.onOpen?.(peer);
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer | string) => {
|
||||||
|
const message = {
|
||||||
|
type: typeof data === 'string' ? 'text' as const : 'binary' as const,
|
||||||
|
text: typeof data === 'string' ? data : undefined,
|
||||||
|
data: Buffer.isBuffer(data) ? new Uint8Array(data) : undefined,
|
||||||
|
size: typeof data === 'string' ? data.length : data.length,
|
||||||
|
};
|
||||||
|
hooks.onMessage?.(peer, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code: number, reason: Buffer) => {
|
||||||
|
hooks.onClose?.(peer, code, reason.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error: Error) => {
|
||||||
|
hooks.onError?.(peer, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('ping', (data: Buffer) => {
|
||||||
|
hooks.onPing?.(peer, new Uint8Array(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('pong', (data: Buffer) => {
|
||||||
|
hooks.onPong?.(peer, new Uint8Array(data));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.warn('WebSocket support requires the "ws" package. Install with: pnpm add ws');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapNodeWebSocket(ws: any, request: any): any {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
url: request.url ?? '',
|
||||||
|
get readyState() { return ws.readyState; },
|
||||||
|
protocol: ws.protocol ?? '',
|
||||||
|
extensions: ws.extensions ?? '',
|
||||||
|
send: (data: string) => ws.send(data),
|
||||||
|
sendBinary: (data: Uint8Array | ArrayBuffer) => ws.send(data),
|
||||||
|
close: (code?: number, reason?: string) => ws.close(code, reason),
|
||||||
|
ping: (data?: Uint8Array) => ws.ping(data),
|
||||||
|
terminate: () => ws.terminate(),
|
||||||
|
context: {} as any,
|
||||||
|
data: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ts/adapters/index.ts
Normal file
3
ts/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { BaseAdapter } from './adapter.base.js';
|
||||||
|
export type { IAdapterCharacteristics, TRequestHandler } from './adapter.base.js';
|
||||||
|
export { AdapterFactory } from './adapter.factory.js';
|
||||||
45
ts/core/index.ts
Normal file
45
ts/core/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Main server class
|
||||||
|
export { SmartServe } from './smartserve.classes.smartserve.js';
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export type {
|
||||||
|
// HTTP types
|
||||||
|
THttpMethod,
|
||||||
|
TRuntime,
|
||||||
|
// Request/Response
|
||||||
|
IRequestContext,
|
||||||
|
TRouteHandler,
|
||||||
|
IMethodOptions,
|
||||||
|
IRouteOptions,
|
||||||
|
// Interceptors
|
||||||
|
TRequestInterceptor,
|
||||||
|
TResponseInterceptor,
|
||||||
|
TGuardFunction,
|
||||||
|
IInterceptOptions,
|
||||||
|
IGuardOptions,
|
||||||
|
// WebSocket
|
||||||
|
IWebSocketMessage,
|
||||||
|
IWebSocketPeer,
|
||||||
|
IWebSocketHooks,
|
||||||
|
// Server config
|
||||||
|
ITLSConfig,
|
||||||
|
IKeepAliveConfig,
|
||||||
|
IStaticOptions,
|
||||||
|
IDirectoryListingOptions,
|
||||||
|
IFileEntry,
|
||||||
|
IWebDAVConfig,
|
||||||
|
ISmartServeOptions,
|
||||||
|
// Server instance
|
||||||
|
IServerStats,
|
||||||
|
ISmartServeInstance,
|
||||||
|
IConnectionInfo,
|
||||||
|
} from './smartserve.interfaces.js';
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
export {
|
||||||
|
HttpError,
|
||||||
|
RouteNotFoundError,
|
||||||
|
UnsupportedRuntimeError,
|
||||||
|
ServerAlreadyRunningError,
|
||||||
|
ServerNotRunningError,
|
||||||
|
} from './smartserve.errors.js';
|
||||||
379
ts/core/smartserve.classes.smartserve.ts
Normal file
379
ts/core/smartserve.classes.smartserve.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* Main SmartServe server class
|
||||||
|
* Cross-platform HTTP server with decorator-based routing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type {
|
||||||
|
ISmartServeOptions,
|
||||||
|
ISmartServeInstance,
|
||||||
|
IRequestContext,
|
||||||
|
IConnectionInfo,
|
||||||
|
THttpMethod,
|
||||||
|
IInterceptOptions,
|
||||||
|
TRequestInterceptor,
|
||||||
|
TResponseInterceptor,
|
||||||
|
} from './smartserve.interfaces.js';
|
||||||
|
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError } from './smartserve.errors.js';
|
||||||
|
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
|
||||||
|
import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
|
||||||
|
import { FileServer } from '../files/index.js';
|
||||||
|
import { WebDAVHandler } from '../protocols/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmartServe - Cross-platform HTTP server
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const server = new SmartServe({ port: 3000 });
|
||||||
|
*
|
||||||
|
* // Register decorated controllers
|
||||||
|
* server.register(UserController);
|
||||||
|
* server.register(ProductController);
|
||||||
|
*
|
||||||
|
* await server.start();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class SmartServe {
|
||||||
|
private options: ISmartServeOptions;
|
||||||
|
private adapter: BaseAdapter | null = null;
|
||||||
|
private instance: ISmartServeInstance | null = null;
|
||||||
|
private customHandler: TRequestHandler | null = null;
|
||||||
|
private fileServer: FileServer | null = null;
|
||||||
|
private webdavHandler: WebDAVHandler | null = null;
|
||||||
|
|
||||||
|
constructor(options: ISmartServeOptions) {
|
||||||
|
this.options = {
|
||||||
|
hostname: '0.0.0.0',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize file server if static options provided
|
||||||
|
if (this.options.static) {
|
||||||
|
this.fileServer = new FileServer(this.options.static);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebDAV handler if configured
|
||||||
|
if (this.options.webdav) {
|
||||||
|
this.webdavHandler = new WebDAVHandler(this.options.webdav);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a controller class or instance
|
||||||
|
*/
|
||||||
|
register(controllerOrInstance: Function | object): this {
|
||||||
|
if (typeof controllerOrInstance === 'function') {
|
||||||
|
// It's a class constructor
|
||||||
|
const instance = new (controllerOrInstance as new () => any)();
|
||||||
|
ControllerRegistry.registerInstance(instance);
|
||||||
|
} else {
|
||||||
|
// It's an instance
|
||||||
|
ControllerRegistry.registerInstance(controllerOrInstance);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom request handler (bypasses decorator routing)
|
||||||
|
*/
|
||||||
|
setHandler(handler: TRequestHandler): this {
|
||||||
|
this.customHandler = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server
|
||||||
|
*/
|
||||||
|
async start(): Promise<ISmartServeInstance> {
|
||||||
|
if (this.instance) {
|
||||||
|
throw new ServerAlreadyRunningError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adapter for current runtime
|
||||||
|
this.adapter = await AdapterFactory.createAdapter(this.options);
|
||||||
|
|
||||||
|
// Create request handler
|
||||||
|
const handler = this.createRequestHandler();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
this.instance = await this.adapter.start(handler);
|
||||||
|
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.adapter) {
|
||||||
|
await this.adapter.stop();
|
||||||
|
this.adapter = null;
|
||||||
|
this.instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server instance (if running)
|
||||||
|
*/
|
||||||
|
getInstance(): ISmartServeInstance | null {
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if server is running
|
||||||
|
*/
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.instance !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the main request handler
|
||||||
|
*/
|
||||||
|
private createRequestHandler(): TRequestHandler {
|
||||||
|
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
|
||||||
|
// Use custom handler if set
|
||||||
|
if (this.customHandler) {
|
||||||
|
return this.customHandler(request, connectionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL and method
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const method = request.method.toUpperCase() as THttpMethod;
|
||||||
|
|
||||||
|
// Handle WebDAV requests first if handler is configured
|
||||||
|
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
|
||||||
|
try {
|
||||||
|
return await this.webdavHandler.handle(request);
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleError(error as Error, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match route first
|
||||||
|
const match = ControllerRegistry.matchRoute(url.pathname, method);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
|
||||||
|
if (this.webdavHandler) {
|
||||||
|
try {
|
||||||
|
return await this.webdavHandler.handle(request);
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleError(error as Error, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try static files
|
||||||
|
if (this.fileServer && (method === 'GET' || method === 'HEAD')) {
|
||||||
|
try {
|
||||||
|
const staticResponse = await this.fileServer.serve(request);
|
||||||
|
if (staticResponse) {
|
||||||
|
return staticResponse;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleError(error as Error, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still no match, return 404
|
||||||
|
const error = new RouteNotFoundError(url.pathname, method);
|
||||||
|
if (this.options.onError) {
|
||||||
|
return this.options.onError(error, request);
|
||||||
|
}
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { route, params } = match;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create request context
|
||||||
|
const context = await this.createContext(request, url, params, connectionInfo);
|
||||||
|
|
||||||
|
// Run interceptors and handler
|
||||||
|
return await this.executeRoute(route, context);
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleError(error as Error, request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create request context from Request object
|
||||||
|
*/
|
||||||
|
private async createContext(
|
||||||
|
request: Request,
|
||||||
|
url: URL,
|
||||||
|
params: Record<string, string>,
|
||||||
|
connectionInfo: IConnectionInfo
|
||||||
|
): Promise<IRequestContext> {
|
||||||
|
// Parse query params
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
query[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse body (lazy)
|
||||||
|
let body: any = undefined;
|
||||||
|
const contentType = request.headers.get('content-type');
|
||||||
|
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
||||||
|
try {
|
||||||
|
const text = await request.text();
|
||||||
|
body = Object.fromEntries(new URLSearchParams(text));
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
} else if (contentType?.includes('text/')) {
|
||||||
|
try {
|
||||||
|
body = await request.text();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
body,
|
||||||
|
params,
|
||||||
|
query,
|
||||||
|
headers: request.headers,
|
||||||
|
path: url.pathname,
|
||||||
|
method: request.method.toUpperCase() as THttpMethod,
|
||||||
|
url,
|
||||||
|
runtime: this.adapter?.name ?? 'node',
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute route with interceptor chain
|
||||||
|
*/
|
||||||
|
private async executeRoute(
|
||||||
|
route: ICompiledRoute,
|
||||||
|
context: IRequestContext
|
||||||
|
): Promise<Response> {
|
||||||
|
// Collect all request interceptors
|
||||||
|
const requestInterceptors: TRequestInterceptor[] = [];
|
||||||
|
const responseInterceptors: TResponseInterceptor[] = [];
|
||||||
|
|
||||||
|
for (const interceptor of route.interceptors) {
|
||||||
|
if (interceptor.request) {
|
||||||
|
const reqs = Array.isArray(interceptor.request)
|
||||||
|
? interceptor.request
|
||||||
|
: [interceptor.request];
|
||||||
|
requestInterceptors.push(...reqs);
|
||||||
|
}
|
||||||
|
if (interceptor.response) {
|
||||||
|
const ress = Array.isArray(interceptor.response)
|
||||||
|
? interceptor.response
|
||||||
|
: [interceptor.response];
|
||||||
|
responseInterceptors.push(...ress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run request interceptors
|
||||||
|
let currentContext = context;
|
||||||
|
for (const interceptor of requestInterceptors) {
|
||||||
|
const result = await interceptor(currentContext);
|
||||||
|
|
||||||
|
if (result instanceof Response) {
|
||||||
|
// Short-circuit with response
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && typeof result === 'object' && 'request' in result) {
|
||||||
|
// Updated context
|
||||||
|
currentContext = result as IRequestContext;
|
||||||
|
}
|
||||||
|
// undefined means continue with current context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute handler
|
||||||
|
let handlerResult = await route.handler(currentContext);
|
||||||
|
|
||||||
|
// Run response interceptors (in reverse order for onion model)
|
||||||
|
for (let i = responseInterceptors.length - 1; i >= 0; i--) {
|
||||||
|
const interceptor = responseInterceptors[i];
|
||||||
|
const result = await interceptor(handlerResult, currentContext);
|
||||||
|
|
||||||
|
if (result instanceof Response) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert result to Response
|
||||||
|
return this.resultToResponse(handlerResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert handler result to Response
|
||||||
|
*/
|
||||||
|
private resultToResponse(result: any): Response {
|
||||||
|
// Already a Response
|
||||||
|
if (result instanceof Response) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null/undefined
|
||||||
|
if (result === null || result === undefined) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// String
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
return new Response(result, {
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object/Array - serialize as JSON
|
||||||
|
return new Response(JSON.stringify(result), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle errors
|
||||||
|
*/
|
||||||
|
private handleError(error: Error, request: Request): Response {
|
||||||
|
// Custom error handler
|
||||||
|
if (this.options.onError) {
|
||||||
|
try {
|
||||||
|
const result = this.options.onError(error, request);
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
// Can't await here, return 500
|
||||||
|
console.error('Error in error handler:', error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// Error in error handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpError
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown error
|
||||||
|
console.error('Unhandled error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Internal Server Error' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
ts/core/smartserve.errors.ts
Normal file
129
ts/core/smartserve.errors.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Custom error classes for @push.rocks/smartserve
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP error with status code
|
||||||
|
* Thrown from handlers to return specific HTTP responses
|
||||||
|
*/
|
||||||
|
export class HttpError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HttpError';
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to HTTP Response
|
||||||
|
*/
|
||||||
|
toResponse(): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: this.message,
|
||||||
|
status: this.status,
|
||||||
|
...(this.details ? { details: this.details } : {}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: this.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common HTTP errors as static factory methods
|
||||||
|
static badRequest(message = 'Bad Request', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(400, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unauthorized(message = 'Unauthorized', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(401, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static forbidden(message = 'Forbidden', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(403, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static notFound(message = 'Not Found', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(404, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static methodNotAllowed(message = 'Method Not Allowed', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(405, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static conflict(message = 'Conflict', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(409, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unprocessableEntity(message = 'Unprocessable Entity', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(422, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static tooManyRequests(message = 'Too Many Requests', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(429, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static internalServerError(message = 'Internal Server Error', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(500, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static notImplemented(message = 'Not Implemented', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(501, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static badGateway(message = 'Bad Gateway', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(502, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
static serviceUnavailable(message = 'Service Unavailable', details?: Record<string, unknown>): HttpError {
|
||||||
|
return new HttpError(503, message, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when route is not found
|
||||||
|
*/
|
||||||
|
export class RouteNotFoundError extends HttpError {
|
||||||
|
constructor(path: string, method: string) {
|
||||||
|
super(404, `Route not found: ${method} ${path}`, { path, method });
|
||||||
|
this.name = 'RouteNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when adapter is not supported
|
||||||
|
*/
|
||||||
|
export class UnsupportedRuntimeError extends Error {
|
||||||
|
constructor(runtime: string) {
|
||||||
|
super(`Unsupported runtime: ${runtime}`);
|
||||||
|
this.name = 'UnsupportedRuntimeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when server is already running
|
||||||
|
*/
|
||||||
|
export class ServerAlreadyRunningError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Server is already running');
|
||||||
|
this.name = 'ServerAlreadyRunningError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when server is not running
|
||||||
|
*/
|
||||||
|
export class ServerNotRunningError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Server is not running');
|
||||||
|
this.name = 'ServerNotRunningError';
|
||||||
|
}
|
||||||
|
}
|
||||||
348
ts/core/smartserve.interfaces.ts
Normal file
348
ts/core/smartserve.interfaces.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Core interfaces for @push.rocks/smartserve
|
||||||
|
* Uses Web Standards API (Request/Response) for cross-platform compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HTTP Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
|
||||||
|
|
||||||
|
export type TRuntime = 'node' | 'deno' | 'bun';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Request Context
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request context passed to handlers and interceptors
|
||||||
|
* Wraps Web Standard Request with additional utilities
|
||||||
|
*/
|
||||||
|
export interface IRequestContext<TBody = unknown> {
|
||||||
|
/** Original Web Standards Request */
|
||||||
|
readonly request: Request;
|
||||||
|
/** Parsed request body (typed) */
|
||||||
|
readonly body: TBody;
|
||||||
|
/** URL path parameters extracted from route */
|
||||||
|
readonly params: Record<string, string>;
|
||||||
|
/** URL query parameters */
|
||||||
|
readonly query: Record<string, string>;
|
||||||
|
/** Request headers accessor */
|
||||||
|
readonly headers: Headers;
|
||||||
|
/** Matched route path pattern */
|
||||||
|
readonly path: string;
|
||||||
|
/** HTTP method */
|
||||||
|
readonly method: THttpMethod;
|
||||||
|
/** Full URL object */
|
||||||
|
readonly url: URL;
|
||||||
|
/** Runtime environment */
|
||||||
|
readonly runtime: TRuntime;
|
||||||
|
/** Route-specific state bag for passing data between interceptors */
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Interceptor Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor - runs BEFORE the handler
|
||||||
|
* Can:
|
||||||
|
* - Return modified context to continue
|
||||||
|
* - Return Response to short-circuit
|
||||||
|
* - Return void/undefined to continue with original context
|
||||||
|
* - Throw to trigger error handling
|
||||||
|
*/
|
||||||
|
export type TRequestInterceptor<TBody = unknown> = (
|
||||||
|
ctx: IRequestContext<TBody>
|
||||||
|
) => Promise<IRequestContext<TBody> | Response | void> | IRequestContext<TBody> | Response | void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor - runs AFTER the handler
|
||||||
|
* Can:
|
||||||
|
* - Return modified response data
|
||||||
|
* - Return a Response object directly
|
||||||
|
*/
|
||||||
|
export type TResponseInterceptor<TRes = unknown> = (
|
||||||
|
response: TRes,
|
||||||
|
ctx: IRequestContext
|
||||||
|
) => Promise<TRes | Response> | TRes | Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard function - simplified boolean check for authorization
|
||||||
|
* Returns true to allow, false to reject with 403
|
||||||
|
*/
|
||||||
|
export type TGuardFunction<TBody = unknown> = (
|
||||||
|
ctx: IRequestContext<TBody>
|
||||||
|
) => Promise<boolean> | boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined interceptor options for @Intercept decorator
|
||||||
|
*/
|
||||||
|
export interface IInterceptOptions<TBody = unknown, TRes = unknown> {
|
||||||
|
/** Request interceptors (run before handler) */
|
||||||
|
request?: TRequestInterceptor<TBody> | TRequestInterceptor<TBody>[];
|
||||||
|
/** Response interceptors (run after handler) */
|
||||||
|
response?: TResponseInterceptor<TRes> | TResponseInterceptor<TRes>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for @Guard decorator
|
||||||
|
*/
|
||||||
|
export interface IGuardOptions {
|
||||||
|
/** Custom response when guard rejects (default: 403 Forbidden) */
|
||||||
|
onReject?: (ctx: IRequestContext) => Response | Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Route Handler Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route handler function signature
|
||||||
|
*/
|
||||||
|
export type TRouteHandler<TReq = unknown, TRes = unknown> = (
|
||||||
|
ctx: IRequestContext<TReq>
|
||||||
|
) => Promise<TRes> | TRes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for method decorators (@Get, @Post, etc.)
|
||||||
|
*/
|
||||||
|
export interface IMethodOptions {
|
||||||
|
/** Path segment (appended to class route) */
|
||||||
|
path?: string;
|
||||||
|
/** Content-Type for response */
|
||||||
|
contentType?: string;
|
||||||
|
/** HTTP status code for successful response */
|
||||||
|
statusCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for @Route class decorator
|
||||||
|
*/
|
||||||
|
export interface IRouteOptions {
|
||||||
|
/** Base path for all routes in this controller */
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// WebSocket Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket message types
|
||||||
|
*/
|
||||||
|
export interface IWebSocketMessage {
|
||||||
|
type: 'text' | 'binary';
|
||||||
|
text?: string;
|
||||||
|
data?: Uint8Array;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket peer connection
|
||||||
|
*/
|
||||||
|
export interface IWebSocketPeer {
|
||||||
|
/** Unique connection ID */
|
||||||
|
id: string;
|
||||||
|
/** Connection URL */
|
||||||
|
url: string;
|
||||||
|
/** WebSocket ready state */
|
||||||
|
readyState: 0 | 1 | 2 | 3;
|
||||||
|
/** Negotiated subprotocol */
|
||||||
|
protocol: string;
|
||||||
|
/** Negotiated extensions */
|
||||||
|
extensions: string;
|
||||||
|
/** Send text message */
|
||||||
|
send(data: string): void;
|
||||||
|
/** Send binary message */
|
||||||
|
sendBinary(data: Uint8Array | ArrayBuffer): void;
|
||||||
|
/** Close connection */
|
||||||
|
close(code?: number, reason?: string): void;
|
||||||
|
/** Send ping */
|
||||||
|
ping(data?: Uint8Array): void;
|
||||||
|
/** Force close without handshake */
|
||||||
|
terminate(): void;
|
||||||
|
/** Request context from upgrade */
|
||||||
|
context: IRequestContext;
|
||||||
|
/** Custom per-peer data storage */
|
||||||
|
data: Map<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket event hooks
|
||||||
|
*/
|
||||||
|
export interface IWebSocketHooks {
|
||||||
|
onOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
|
||||||
|
onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>;
|
||||||
|
onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>;
|
||||||
|
onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>;
|
||||||
|
onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
|
||||||
|
onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Server Configuration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS/SSL configuration
|
||||||
|
*/
|
||||||
|
export interface ITLSConfig {
|
||||||
|
/** Certificate (PEM format) */
|
||||||
|
cert: string | Uint8Array;
|
||||||
|
/** Private key (PEM format) */
|
||||||
|
key: string | Uint8Array;
|
||||||
|
/** CA chain (PEM format) */
|
||||||
|
ca?: string | Uint8Array;
|
||||||
|
/** ALPN protocols */
|
||||||
|
alpnProtocols?: string[];
|
||||||
|
/** Minimum TLS version */
|
||||||
|
minVersion?: 'TLSv1.2' | 'TLSv1.3';
|
||||||
|
/** Passphrase for encrypted key */
|
||||||
|
passphrase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep-alive configuration
|
||||||
|
*/
|
||||||
|
export interface IKeepAliveConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
maxRequests?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static file serving options
|
||||||
|
*/
|
||||||
|
export interface IStaticOptions {
|
||||||
|
/** Root directory path */
|
||||||
|
root: string;
|
||||||
|
/** Index files to look for */
|
||||||
|
index?: string[];
|
||||||
|
/** How to handle dotfiles */
|
||||||
|
dotFiles?: 'allow' | 'deny' | 'ignore';
|
||||||
|
/** Generate ETags */
|
||||||
|
etag?: boolean;
|
||||||
|
/** Add Last-Modified header */
|
||||||
|
lastModified?: boolean;
|
||||||
|
/** Cache-Control header value or function */
|
||||||
|
cacheControl?: string | ((path: string) => string);
|
||||||
|
/** File extensions to try */
|
||||||
|
extensions?: string[];
|
||||||
|
/** Enable directory listing */
|
||||||
|
directoryListing?: boolean | IDirectoryListingOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory listing options
|
||||||
|
*/
|
||||||
|
export interface IDirectoryListingOptions {
|
||||||
|
/** Custom template function */
|
||||||
|
template?: (files: IFileEntry[]) => string | Response;
|
||||||
|
/** Show hidden files */
|
||||||
|
showHidden?: boolean;
|
||||||
|
/** Sort field */
|
||||||
|
sortBy?: 'name' | 'size' | 'modified';
|
||||||
|
/** Sort order */
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File entry for directory listing
|
||||||
|
*/
|
||||||
|
export interface IFileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size: number;
|
||||||
|
modified: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebDAV configuration
|
||||||
|
*/
|
||||||
|
export interface IWebDAVConfig {
|
||||||
|
/** Root directory path */
|
||||||
|
root: string;
|
||||||
|
/** Authentication handler */
|
||||||
|
auth?: (ctx: IRequestContext) => boolean | Promise<boolean>;
|
||||||
|
/** Enable locking */
|
||||||
|
locking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main server configuration
|
||||||
|
*/
|
||||||
|
export interface ISmartServeOptions {
|
||||||
|
/** Port to listen on */
|
||||||
|
port: number;
|
||||||
|
/** Hostname to bind to */
|
||||||
|
hostname?: string;
|
||||||
|
/** TLS configuration for HTTPS */
|
||||||
|
tls?: ITLSConfig;
|
||||||
|
/** WebSocket configuration */
|
||||||
|
websocket?: IWebSocketHooks;
|
||||||
|
/** Static file serving */
|
||||||
|
static?: IStaticOptions | string;
|
||||||
|
/** WebDAV configuration */
|
||||||
|
webdav?: IWebDAVConfig;
|
||||||
|
/** Connection timeout (ms) */
|
||||||
|
connectionTimeout?: number;
|
||||||
|
/** Keep-alive settings */
|
||||||
|
keepAlive?: IKeepAliveConfig;
|
||||||
|
/** Global error handler */
|
||||||
|
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Server Instance
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server statistics
|
||||||
|
*/
|
||||||
|
export interface IServerStats {
|
||||||
|
uptime: number;
|
||||||
|
requestsTotal: number;
|
||||||
|
requestsActive: number;
|
||||||
|
connectionsTotal: number;
|
||||||
|
connectionsActive: number;
|
||||||
|
bytesReceived: number;
|
||||||
|
bytesSent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Running server instance
|
||||||
|
*/
|
||||||
|
export interface ISmartServeInstance {
|
||||||
|
/** Listening port */
|
||||||
|
port: number;
|
||||||
|
/** Bound hostname */
|
||||||
|
hostname: string;
|
||||||
|
/** Is HTTPS enabled */
|
||||||
|
secure: boolean;
|
||||||
|
/** Runtime environment */
|
||||||
|
runtime: TRuntime;
|
||||||
|
/** Stop the server */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/** Get server statistics */
|
||||||
|
stats(): IServerStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Connection Info
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection information
|
||||||
|
*/
|
||||||
|
export interface IConnectionInfo {
|
||||||
|
remoteAddr: string;
|
||||||
|
remotePort: number;
|
||||||
|
localAddr: string;
|
||||||
|
localPort: number;
|
||||||
|
encrypted: boolean;
|
||||||
|
tlsVersion?: string;
|
||||||
|
}
|
||||||
215
ts/decorators/decorators.interceptors.ts
Normal file
215
ts/decorators/decorators.interceptors.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Interceptor decorators (@Guard, @Transform, @Intercept)
|
||||||
|
*
|
||||||
|
* All three decorators have unified semantics:
|
||||||
|
* - @Guard is sugar for @Intercept({ request: guardFn })
|
||||||
|
* - @Transform is sugar for @Intercept({ response: transformFn })
|
||||||
|
* - @Intercept provides full control over both request and response
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IRequestContext,
|
||||||
|
IInterceptOptions,
|
||||||
|
IGuardOptions,
|
||||||
|
TGuardFunction,
|
||||||
|
TRequestInterceptor,
|
||||||
|
TResponseInterceptor,
|
||||||
|
} from '../core/smartserve.interfaces.js';
|
||||||
|
import { addClassInterceptor, addMethodInterceptor } from './decorators.metadata.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a decorator that can be applied to both classes and methods
|
||||||
|
*/
|
||||||
|
function createInterceptDecorator(options: IInterceptOptions) {
|
||||||
|
// Class decorator
|
||||||
|
function classDecorator<TClass extends new (...args: any[]) => any>(
|
||||||
|
target: TClass,
|
||||||
|
context: ClassDecoratorContext<TClass>
|
||||||
|
): TClass {
|
||||||
|
addClassInterceptor(target, options);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method decorator
|
||||||
|
function methodDecorator<This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
addMethodInterceptor(this, context.name, options);
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return overloaded function that works for both
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
||||||
|
) {
|
||||||
|
if (context.kind === 'class') {
|
||||||
|
return classDecorator(target, context as ClassDecoratorContext);
|
||||||
|
} else if (context.kind === 'method') {
|
||||||
|
return methodDecorator(target, context as ClassMethodDecoratorContext);
|
||||||
|
}
|
||||||
|
throw new Error('Interceptor decorators can only be applied to classes or methods');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Guard decorator - validates requests before handler execution
|
||||||
|
*
|
||||||
|
* Guards return boolean: true to allow, false to reject with 403 Forbidden
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Single guard
|
||||||
|
* @Guard(isAuthenticated)
|
||||||
|
*
|
||||||
|
* // Multiple guards (all must pass)
|
||||||
|
* @Guard([isAuthenticated, hasRole('admin')])
|
||||||
|
*
|
||||||
|
* // With custom rejection response
|
||||||
|
* @Guard(isAuthenticated, {
|
||||||
|
* onReject: () => new Response('Unauthorized', { status: 401 })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Guard<TBody = unknown>(
|
||||||
|
guardOrGuards: TGuardFunction<TBody> | TGuardFunction<TBody>[],
|
||||||
|
options?: IGuardOptions
|
||||||
|
) {
|
||||||
|
const guards = Array.isArray(guardOrGuards) ? guardOrGuards : [guardOrGuards];
|
||||||
|
|
||||||
|
const interceptor: TRequestInterceptor<TBody> = async (ctx) => {
|
||||||
|
for (const guard of guards) {
|
||||||
|
const allowed = await guard(ctx);
|
||||||
|
if (!allowed) {
|
||||||
|
if (options?.onReject) {
|
||||||
|
return options.onReject(ctx);
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ error: 'Forbidden' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return undefined to continue with original context
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createInterceptDecorator({ request: interceptor });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Transform decorator - modifies response after handler execution
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Single transform
|
||||||
|
* @Transform(data => ({ success: true, data }))
|
||||||
|
*
|
||||||
|
* // Multiple transforms (applied in order)
|
||||||
|
* @Transform([addTimestamp, wrapResponse])
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Transform<TRes = unknown>(
|
||||||
|
transformOrTransforms: TResponseInterceptor<TRes> | TResponseInterceptor<TRes>[]
|
||||||
|
) {
|
||||||
|
const transforms = Array.isArray(transformOrTransforms)
|
||||||
|
? transformOrTransforms
|
||||||
|
: [transformOrTransforms];
|
||||||
|
|
||||||
|
return createInterceptDecorator({ response: transforms });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Intercept decorator - full control over request and response interception
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Intercept({
|
||||||
|
* request: async (ctx) => {
|
||||||
|
* ctx.state.startTime = Date.now();
|
||||||
|
* return ctx;
|
||||||
|
* },
|
||||||
|
* response: (res, ctx) => ({
|
||||||
|
* ...res,
|
||||||
|
* duration: Date.now() - ctx.state.startTime
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Intercept<TBody = unknown, TRes = unknown>(
|
||||||
|
options: IInterceptOptions<TBody, TRes>
|
||||||
|
) {
|
||||||
|
return createInterceptDecorator(options as IInterceptOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Common Guard Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a guard that checks for a specific header
|
||||||
|
*/
|
||||||
|
export function hasHeader(headerName: string, expectedValue?: string): TGuardFunction {
|
||||||
|
return (ctx) => {
|
||||||
|
const value = ctx.headers.get(headerName);
|
||||||
|
if (!value) return false;
|
||||||
|
if (expectedValue !== undefined) return value === expectedValue;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a guard that checks for Bearer token
|
||||||
|
*/
|
||||||
|
export function hasBearerToken(): TGuardFunction {
|
||||||
|
return (ctx) => {
|
||||||
|
const auth = ctx.headers.get('Authorization');
|
||||||
|
return auth?.startsWith('Bearer ') ?? false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a rate limiting guard
|
||||||
|
*/
|
||||||
|
export function rateLimit(
|
||||||
|
maxRequests: number,
|
||||||
|
windowMs: number
|
||||||
|
): TGuardFunction {
|
||||||
|
const requests = new Map<string, number[]>();
|
||||||
|
|
||||||
|
return (ctx) => {
|
||||||
|
const ip = ctx.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - windowMs;
|
||||||
|
|
||||||
|
const timestamps = requests.get(ip)?.filter((t) => t > windowStart) ?? [];
|
||||||
|
if (timestamps.length >= maxRequests) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.push(now);
|
||||||
|
requests.set(ip, timestamps);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Common Transform Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap response in a success envelope
|
||||||
|
*/
|
||||||
|
export function wrapSuccess<T>(data: T): { success: true; data: T } {
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add timestamp to response
|
||||||
|
*/
|
||||||
|
export function addTimestamp<T extends object>(data: T): T & { timestamp: number } {
|
||||||
|
return { ...data, timestamp: Date.now() };
|
||||||
|
}
|
||||||
141
ts/decorators/decorators.metadata.ts
Normal file
141
ts/decorators/decorators.metadata.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Metadata storage for decorators using Symbol.metadata (TC39 Stage 3)
|
||||||
|
* Falls back to WeakMap for environments without Symbol.metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IControllerMetadata, IRouteMetadata } from './decorators.types.js';
|
||||||
|
import type { IInterceptOptions } from '../core/smartserve.interfaces.js';
|
||||||
|
|
||||||
|
// Symbol for storing metadata when Symbol.metadata is not available
|
||||||
|
const CONTROLLER_METADATA = Symbol('smartserve:controller');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create controller metadata for a class
|
||||||
|
* Uses symbol property on the class itself for metadata storage
|
||||||
|
*/
|
||||||
|
export function getControllerMetadata(target: any): IControllerMetadata {
|
||||||
|
// Store metadata on the class itself using symbol
|
||||||
|
if (!target[CONTROLLER_METADATA]) {
|
||||||
|
target[CONTROLLER_METADATA] = createEmptyMetadata();
|
||||||
|
}
|
||||||
|
return target[CONTROLLER_METADATA];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get controller metadata from prototype (for instance lookup)
|
||||||
|
*/
|
||||||
|
export function getMetadataFromInstance(instance: any): IControllerMetadata | undefined {
|
||||||
|
const constructor = instance.constructor;
|
||||||
|
return getControllerMetadata(constructor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set base path for a controller
|
||||||
|
*/
|
||||||
|
export function setBasePath(target: any, path: string): void {
|
||||||
|
const metadata = getControllerMetadata(target);
|
||||||
|
metadata.basePath = normalizePath(path);
|
||||||
|
metadata.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a route to a controller
|
||||||
|
*/
|
||||||
|
export function addRoute(
|
||||||
|
target: any,
|
||||||
|
methodName: string | symbol,
|
||||||
|
route: Omit<IRouteMetadata, 'methodName' | 'interceptors'>
|
||||||
|
): void {
|
||||||
|
const metadata = getControllerMetadata(target.constructor);
|
||||||
|
|
||||||
|
// Get existing route or create new one
|
||||||
|
let existingRoute = metadata.routes.get(methodName);
|
||||||
|
if (!existingRoute) {
|
||||||
|
existingRoute = {
|
||||||
|
...route,
|
||||||
|
methodName,
|
||||||
|
interceptors: [],
|
||||||
|
};
|
||||||
|
metadata.routes.set(methodName, existingRoute);
|
||||||
|
} else {
|
||||||
|
// Update existing route
|
||||||
|
existingRoute.method = route.method;
|
||||||
|
existingRoute.path = route.path;
|
||||||
|
existingRoute.options = { ...existingRoute.options, ...route.options };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add class-level interceptor
|
||||||
|
*/
|
||||||
|
export function addClassInterceptor(target: any, interceptor: IInterceptOptions): void {
|
||||||
|
const metadata = getControllerMetadata(target);
|
||||||
|
metadata.classInterceptors.push(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add method-level interceptor
|
||||||
|
*/
|
||||||
|
export function addMethodInterceptor(
|
||||||
|
target: any,
|
||||||
|
methodName: string | symbol,
|
||||||
|
interceptor: IInterceptOptions
|
||||||
|
): void {
|
||||||
|
const metadata = getControllerMetadata(target.constructor);
|
||||||
|
|
||||||
|
let route = metadata.routes.get(methodName);
|
||||||
|
if (!route) {
|
||||||
|
// Create placeholder route (will be completed by @Get/@Post/etc.)
|
||||||
|
route = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '',
|
||||||
|
methodName,
|
||||||
|
interceptors: [],
|
||||||
|
options: {},
|
||||||
|
};
|
||||||
|
metadata.routes.set(methodName, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
route.interceptors.push(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty metadata object
|
||||||
|
*/
|
||||||
|
function createEmptyMetadata(): IControllerMetadata {
|
||||||
|
return {
|
||||||
|
basePath: '',
|
||||||
|
classInterceptors: [],
|
||||||
|
routes: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize path to ensure consistent format
|
||||||
|
*/
|
||||||
|
export function normalizePath(path: string): string {
|
||||||
|
if (!path) return '';
|
||||||
|
|
||||||
|
// Ensure leading slash
|
||||||
|
let normalized = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
|
||||||
|
// Remove trailing slash (unless it's just '/')
|
||||||
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine base path and route path
|
||||||
|
*/
|
||||||
|
export function combinePaths(basePath: string, routePath: string): string {
|
||||||
|
const base = normalizePath(basePath);
|
||||||
|
const route = normalizePath(routePath);
|
||||||
|
|
||||||
|
if (!base) return route || '/';
|
||||||
|
if (!route) return base;
|
||||||
|
|
||||||
|
return `${base}${route}`;
|
||||||
|
}
|
||||||
117
ts/decorators/decorators.methods.ts
Normal file
117
ts/decorators/decorators.methods.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* HTTP method decorators (@Get, @Post, @Put, @Delete, @Patch, @All)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { THttpMethod, IMethodOptions } from '../core/smartserve.interfaces.js';
|
||||||
|
import { addRoute, normalizePath } from './decorators.metadata.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for creating HTTP method decorators
|
||||||
|
*/
|
||||||
|
function createMethodDecorator(httpMethod: THttpMethod) {
|
||||||
|
return function (pathOrOptions?: string | IMethodOptions) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
if (context.kind !== 'method') {
|
||||||
|
throw new Error(`@${httpMethod} can only decorate methods`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: IMethodOptions = typeof pathOrOptions === 'string'
|
||||||
|
? { path: pathOrOptions }
|
||||||
|
: pathOrOptions ?? {};
|
||||||
|
|
||||||
|
// Use addInitializer to ensure we have access to the class prototype
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
addRoute(this, context.name, {
|
||||||
|
method: httpMethod,
|
||||||
|
path: normalizePath(options.path ?? ''),
|
||||||
|
options,
|
||||||
|
handler: target as unknown as Function,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Get decorator - handles GET requests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get('/users')
|
||||||
|
* listUsers(ctx: IRequestContext) { ... }
|
||||||
|
*
|
||||||
|
* @Get('/:id')
|
||||||
|
* getUser(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Get = createMethodDecorator('GET');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Post decorator - handles POST requests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Post('/users')
|
||||||
|
* createUser(ctx: IRequestContext<ICreateUserBody>) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Post = createMethodDecorator('POST');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Put decorator - handles PUT requests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Put('/users/:id')
|
||||||
|
* updateUser(ctx: IRequestContext<IUpdateUserBody>) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Put = createMethodDecorator('PUT');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Delete decorator - handles DELETE requests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Delete('/users/:id')
|
||||||
|
* deleteUser(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Delete = createMethodDecorator('DELETE');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Patch decorator - handles PATCH requests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Patch('/users/:id')
|
||||||
|
* patchUser(ctx: IRequestContext<IPartialUser>) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Patch = createMethodDecorator('PATCH');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Head decorator - handles HEAD requests
|
||||||
|
*/
|
||||||
|
export const Head = createMethodDecorator('HEAD');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Options decorator - handles OPTIONS requests
|
||||||
|
*/
|
||||||
|
export const Options = createMethodDecorator('OPTIONS');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @All decorator - handles all HTTP methods
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @All('/proxy/*')
|
||||||
|
* proxyRequest(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const All = createMethodDecorator('ALL');
|
||||||
198
ts/decorators/decorators.registry.ts
Normal file
198
ts/decorators/decorators.registry.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Controller registry - stores all registered controllers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js';
|
||||||
|
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
|
||||||
|
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global registry of all controllers
|
||||||
|
*/
|
||||||
|
export class ControllerRegistry {
|
||||||
|
private static controllers: Map<Function, IControllerMetadata> = new Map();
|
||||||
|
private static instances: Map<Function, any> = new Map();
|
||||||
|
private static compiledRoutes: ICompiledRoute[] = [];
|
||||||
|
private static routesCompiled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a controller class
|
||||||
|
*/
|
||||||
|
static registerClass(target: Function): void {
|
||||||
|
const metadata = getControllerMetadata(target);
|
||||||
|
metadata.target = target as new (...args: any[]) => any;
|
||||||
|
this.controllers.set(target, metadata);
|
||||||
|
this.routesCompiled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a controller instance
|
||||||
|
*/
|
||||||
|
static registerInstance(instance: any): void {
|
||||||
|
const constructor = instance.constructor;
|
||||||
|
const metadata = getControllerMetadata(constructor);
|
||||||
|
|
||||||
|
// Store instance
|
||||||
|
this.instances.set(constructor, instance);
|
||||||
|
|
||||||
|
// Register class if not already registered
|
||||||
|
if (!this.controllers.has(constructor)) {
|
||||||
|
this.registerClass(constructor);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.routesCompiled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered controllers
|
||||||
|
*/
|
||||||
|
static getControllers(): IRegisteredController[] {
|
||||||
|
const result: IRegisteredController[] = [];
|
||||||
|
|
||||||
|
for (const [constructor, metadata] of this.controllers) {
|
||||||
|
// Get or create instance
|
||||||
|
let instance = this.instances.get(constructor);
|
||||||
|
if (!instance && metadata.target) {
|
||||||
|
instance = new metadata.target();
|
||||||
|
this.instances.set(constructor, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance) {
|
||||||
|
result.push({ instance, metadata });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile all routes for fast matching
|
||||||
|
*/
|
||||||
|
static compileRoutes(): ICompiledRoute[] {
|
||||||
|
if (this.routesCompiled) {
|
||||||
|
return this.compiledRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.compiledRoutes = [];
|
||||||
|
|
||||||
|
for (const { instance, metadata } of this.getControllers()) {
|
||||||
|
for (const [methodName, route] of metadata.routes) {
|
||||||
|
const fullPath = combinePaths(metadata.basePath, route.path);
|
||||||
|
const { regex, paramNames } = this.pathToRegex(fullPath);
|
||||||
|
|
||||||
|
// Combine class and method interceptors
|
||||||
|
const interceptors: IInterceptOptions[] = [
|
||||||
|
...metadata.classInterceptors,
|
||||||
|
...route.interceptors,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create bound handler
|
||||||
|
const handler = async (ctx: IRequestContext): Promise<any> => {
|
||||||
|
const method = instance[methodName];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
throw new Error(`Method ${String(methodName)} not found on controller`);
|
||||||
|
}
|
||||||
|
return method.call(instance, ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.compiledRoutes.push({
|
||||||
|
pattern: fullPath,
|
||||||
|
regex,
|
||||||
|
paramNames,
|
||||||
|
method: route.method,
|
||||||
|
handler,
|
||||||
|
interceptors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort routes by specificity (more specific paths first)
|
||||||
|
this.compiledRoutes.sort((a, b) => {
|
||||||
|
// Routes without wildcards come first
|
||||||
|
const aHasWildcard = a.pattern.includes('*');
|
||||||
|
const bHasWildcard = b.pattern.includes('*');
|
||||||
|
if (aHasWildcard !== bHasWildcard) return aHasWildcard ? 1 : -1;
|
||||||
|
|
||||||
|
// Routes with more segments come first
|
||||||
|
const aSegments = a.pattern.split('/').length;
|
||||||
|
const bSegments = b.pattern.split('/').length;
|
||||||
|
if (aSegments !== bSegments) return bSegments - aSegments;
|
||||||
|
|
||||||
|
// Routes without params come first
|
||||||
|
const aParams = a.paramNames.length;
|
||||||
|
const bParams = b.paramNames.length;
|
||||||
|
return aParams - bParams;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.routesCompiled = true;
|
||||||
|
return this.compiledRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a request to a compiled route
|
||||||
|
*/
|
||||||
|
static matchRoute(path: string, method: THttpMethod): {
|
||||||
|
route: ICompiledRoute;
|
||||||
|
params: Record<string, string>;
|
||||||
|
} | null {
|
||||||
|
const routes = this.compileRoutes();
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
// Check method match
|
||||||
|
if (route.method !== 'ALL' && route.method !== method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check path match
|
||||||
|
const match = route.regex.exec(path);
|
||||||
|
if (match) {
|
||||||
|
// Extract params
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
route.paramNames.forEach((name, index) => {
|
||||||
|
params[name] = match[index + 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { route, params };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert path pattern to regex
|
||||||
|
* Supports :param and * wildcard
|
||||||
|
*/
|
||||||
|
private static pathToRegex(path: string): { regex: RegExp; paramNames: string[] } {
|
||||||
|
const paramNames: string[] = [];
|
||||||
|
|
||||||
|
let regexStr = path
|
||||||
|
// Escape special regex chars (except : and *)
|
||||||
|
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
// Convert :param to capture group
|
||||||
|
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
||||||
|
paramNames.push(name);
|
||||||
|
return '([^/]+)';
|
||||||
|
})
|
||||||
|
// Convert * to wildcard
|
||||||
|
.replace(/\*/g, '(.*)');
|
||||||
|
|
||||||
|
// Anchor the regex
|
||||||
|
regexStr = `^${regexStr}$`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
regex: new RegExp(regexStr),
|
||||||
|
paramNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all registered controllers (useful for testing)
|
||||||
|
*/
|
||||||
|
static clear(): void {
|
||||||
|
this.controllers.clear();
|
||||||
|
this.instances.clear();
|
||||||
|
this.compiledRoutes = [];
|
||||||
|
this.routesCompiled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ts/decorators/decorators.route.ts
Normal file
45
ts/decorators/decorators.route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @Route class decorator
|
||||||
|
* Marks a class as a controller with a base path
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouteOptions } from '../core/smartserve.interfaces.js';
|
||||||
|
import { setBasePath, normalizePath } from './decorators.metadata.js';
|
||||||
|
import { ControllerRegistry } from './decorators.registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route decorator - marks a class as a route controller
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Route('/api/users')
|
||||||
|
* class UserController {
|
||||||
|
* @Get('/:id')
|
||||||
|
* getUser(ctx: IRequestContext) { ... }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Route(pathOrOptions?: string | IRouteOptions) {
|
||||||
|
return function <TClass extends new (...args: any[]) => any>(
|
||||||
|
target: TClass,
|
||||||
|
context: ClassDecoratorContext<TClass>
|
||||||
|
): TClass {
|
||||||
|
if (context.kind !== 'class') {
|
||||||
|
throw new Error('@Route can only decorate classes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = typeof pathOrOptions === 'string'
|
||||||
|
? pathOrOptions
|
||||||
|
: pathOrOptions?.path ?? '';
|
||||||
|
|
||||||
|
// Store base path in metadata
|
||||||
|
setBasePath(target, path);
|
||||||
|
|
||||||
|
// Register controller after class initialization
|
||||||
|
context.addInitializer(function (this: TClass) {
|
||||||
|
ControllerRegistry.registerClass(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
76
ts/decorators/decorators.types.ts
Normal file
76
ts/decorators/decorators.types.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Decorator type definitions for @push.rocks/smartserve
|
||||||
|
* Uses TC39 Stage 3 decorators (TypeScript 5.2+)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
THttpMethod,
|
||||||
|
IRequestContext,
|
||||||
|
IInterceptOptions,
|
||||||
|
IMethodOptions,
|
||||||
|
IRouteOptions,
|
||||||
|
} from '../core/smartserve.interfaces.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Metadata Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata stored on controller classes
|
||||||
|
*/
|
||||||
|
export interface IControllerMetadata {
|
||||||
|
/** Base path for all routes in this controller */
|
||||||
|
basePath: string;
|
||||||
|
/** Class-level interceptors (apply to all methods) */
|
||||||
|
classInterceptors: IInterceptOptions[];
|
||||||
|
/** Route definitions by method name */
|
||||||
|
routes: Map<string | symbol, IRouteMetadata>;
|
||||||
|
/** Controller class reference */
|
||||||
|
target?: new (...args: any[]) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for individual route methods
|
||||||
|
*/
|
||||||
|
export interface IRouteMetadata {
|
||||||
|
/** HTTP method */
|
||||||
|
method: THttpMethod;
|
||||||
|
/** Path segment (combined with class basePath) */
|
||||||
|
path: string;
|
||||||
|
/** Method-level interceptors */
|
||||||
|
interceptors: IInterceptOptions[];
|
||||||
|
/** Response options */
|
||||||
|
options: IMethodOptions;
|
||||||
|
/** Method name */
|
||||||
|
methodName: string | symbol;
|
||||||
|
/** Handler function reference */
|
||||||
|
handler?: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered controller with metadata
|
||||||
|
*/
|
||||||
|
export interface IRegisteredController {
|
||||||
|
/** Controller instance */
|
||||||
|
instance: any;
|
||||||
|
/** Controller metadata */
|
||||||
|
metadata: IControllerMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiled route for fast matching
|
||||||
|
*/
|
||||||
|
export interface ICompiledRoute {
|
||||||
|
/** Full path pattern */
|
||||||
|
pattern: string;
|
||||||
|
/** Regex for matching */
|
||||||
|
regex: RegExp;
|
||||||
|
/** Parameter names */
|
||||||
|
paramNames: string[];
|
||||||
|
/** HTTP method */
|
||||||
|
method: THttpMethod;
|
||||||
|
/** Handler function */
|
||||||
|
handler: (ctx: IRequestContext) => Promise<any>;
|
||||||
|
/** Combined interceptors (class + method) */
|
||||||
|
interceptors: IInterceptOptions[];
|
||||||
|
}
|
||||||
47
ts/decorators/index.ts
Normal file
47
ts/decorators/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Type exports
|
||||||
|
export type {
|
||||||
|
IControllerMetadata,
|
||||||
|
IRouteMetadata,
|
||||||
|
IRegisteredController,
|
||||||
|
ICompiledRoute,
|
||||||
|
} from './decorators.types.js';
|
||||||
|
|
||||||
|
// Route decorator
|
||||||
|
export { Route } from './decorators.route.js';
|
||||||
|
|
||||||
|
// HTTP method decorators
|
||||||
|
export {
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Patch,
|
||||||
|
Head,
|
||||||
|
Options,
|
||||||
|
All,
|
||||||
|
} from './decorators.methods.js';
|
||||||
|
|
||||||
|
// Interceptor decorators
|
||||||
|
export {
|
||||||
|
Guard,
|
||||||
|
Transform,
|
||||||
|
Intercept,
|
||||||
|
// Utility guards
|
||||||
|
hasHeader,
|
||||||
|
hasBearerToken,
|
||||||
|
rateLimit,
|
||||||
|
// Utility transforms
|
||||||
|
wrapSuccess,
|
||||||
|
addTimestamp,
|
||||||
|
} from './decorators.interceptors.js';
|
||||||
|
|
||||||
|
// Registry
|
||||||
|
export { ControllerRegistry } from './decorators.registry.js';
|
||||||
|
|
||||||
|
// Metadata utilities
|
||||||
|
export {
|
||||||
|
getControllerMetadata,
|
||||||
|
getMetadataFromInstance,
|
||||||
|
normalizePath,
|
||||||
|
combinePaths,
|
||||||
|
} from './decorators.metadata.js';
|
||||||
385
ts/files/file.server.ts
Normal file
385
ts/files/file.server.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Static file server with streaming, ETags, and directory listing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type {
|
||||||
|
IStaticOptions,
|
||||||
|
IDirectoryListingOptions,
|
||||||
|
IFileEntry,
|
||||||
|
} from '../core/smartserve.interfaces.js';
|
||||||
|
import { getMimeType } from '../utils/utils.mime.js';
|
||||||
|
import { generateETag } from '../utils/utils.etag.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static file server
|
||||||
|
*/
|
||||||
|
export class FileServer {
|
||||||
|
private options: IStaticOptions;
|
||||||
|
|
||||||
|
constructor(options: IStaticOptions | string) {
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
this.options = { root: options };
|
||||||
|
} else {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
this.options.index = this.options.index ?? ['index.html', 'index.htm'];
|
||||||
|
this.options.dotFiles = this.options.dotFiles ?? 'ignore';
|
||||||
|
this.options.etag = this.options.etag ?? true;
|
||||||
|
this.options.lastModified = this.options.lastModified ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a request for static files
|
||||||
|
*/
|
||||||
|
async serve(request: Request): Promise<Response | null> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
let pathname = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if (pathname.includes('..')) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve file path
|
||||||
|
const filePath = plugins.path.join(this.options.root, pathname);
|
||||||
|
|
||||||
|
// Check if path is within root
|
||||||
|
const realRoot = plugins.path.resolve(this.options.root);
|
||||||
|
const realPath = plugins.path.resolve(filePath);
|
||||||
|
if (!realPath.startsWith(realRoot)) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(realPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// Try index files
|
||||||
|
for (const indexFile of this.options.index!) {
|
||||||
|
const indexPath = plugins.path.join(realPath, indexFile);
|
||||||
|
try {
|
||||||
|
const indexStat = await plugins.fs.promises.stat(indexPath);
|
||||||
|
if (indexStat.isFile()) {
|
||||||
|
return this.serveFile(request, indexPath, indexStat);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Index file doesn't exist, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory listing
|
||||||
|
if (this.options.directoryListing) {
|
||||||
|
return this.serveDirectory(request, realPath, pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
// Check dotfile policy
|
||||||
|
const basename = plugins.path.basename(realPath);
|
||||||
|
if (basename.startsWith('.')) {
|
||||||
|
if (this.options.dotFiles === 'deny') {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
if (this.options.dotFiles === 'ignore') {
|
||||||
|
return null; // Let other handlers try
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.serveFile(request, realPath, stat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
return null; // File not found, let other handlers try
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a single file with proper headers
|
||||||
|
*/
|
||||||
|
private async serveFile(
|
||||||
|
request: Request,
|
||||||
|
filePath: string,
|
||||||
|
stat: plugins.fs.Stats
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
// Content-Type
|
||||||
|
const mimeType = getMimeType(filePath);
|
||||||
|
headers.set('Content-Type', mimeType);
|
||||||
|
|
||||||
|
// Content-Length
|
||||||
|
headers.set('Content-Length', stat.size.toString());
|
||||||
|
|
||||||
|
// Last-Modified
|
||||||
|
if (this.options.lastModified) {
|
||||||
|
headers.set('Last-Modified', stat.mtime.toUTCString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETag
|
||||||
|
let etag: string | undefined;
|
||||||
|
if (this.options.etag) {
|
||||||
|
etag = generateETag(stat);
|
||||||
|
headers.set('ETag', etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-Control
|
||||||
|
if (this.options.cacheControl) {
|
||||||
|
const cacheControl = typeof this.options.cacheControl === 'function'
|
||||||
|
? this.options.cacheControl(filePath)
|
||||||
|
: this.options.cacheControl;
|
||||||
|
headers.set('Cache-Control', cacheControl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check conditional requests
|
||||||
|
const ifNoneMatch = request.headers.get('If-None-Match');
|
||||||
|
if (etag && ifNoneMatch === etag) {
|
||||||
|
return new Response(null, { status: 304, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ifModifiedSince = request.headers.get('If-Modified-Since');
|
||||||
|
if (ifModifiedSince) {
|
||||||
|
const clientDate = new Date(ifModifiedSince);
|
||||||
|
if (stat.mtime <= clientDate) {
|
||||||
|
return new Response(null, { status: 304, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Range requests
|
||||||
|
const rangeHeader = request.headers.get('Range');
|
||||||
|
if (rangeHeader) {
|
||||||
|
return this.servePartial(filePath, stat, rangeHeader, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD request
|
||||||
|
if (request.method === 'HEAD') {
|
||||||
|
return new Response(null, { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the file
|
||||||
|
const stream = plugins.fs.createReadStream(filePath);
|
||||||
|
const readableStream = this.nodeStreamToWebStream(stream);
|
||||||
|
|
||||||
|
return new Response(readableStream, { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve partial content (Range request)
|
||||||
|
*/
|
||||||
|
private async servePartial(
|
||||||
|
filePath: string,
|
||||||
|
stat: plugins.fs.Stats,
|
||||||
|
rangeHeader: string,
|
||||||
|
headers: Headers
|
||||||
|
): Promise<Response> {
|
||||||
|
const size = stat.size;
|
||||||
|
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return new Response('Invalid Range', { status: 416 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = match[1] ? parseInt(match[1], 10) : 0;
|
||||||
|
let end = match[2] ? parseInt(match[2], 10) : size - 1;
|
||||||
|
|
||||||
|
// Validate range
|
||||||
|
if (start >= size || end >= size || start > end) {
|
||||||
|
headers.set('Content-Range', `bytes */${size}`);
|
||||||
|
return new Response('Range Not Satisfiable', { status: 416, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set partial content headers
|
||||||
|
headers.set('Content-Range', `bytes ${start}-${end}/${size}`);
|
||||||
|
headers.set('Content-Length', (end - start + 1).toString());
|
||||||
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
const stream = plugins.fs.createReadStream(filePath, { start, end });
|
||||||
|
const readableStream = this.nodeStreamToWebStream(stream);
|
||||||
|
|
||||||
|
return new Response(readableStream, { status: 206, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve directory listing
|
||||||
|
*/
|
||||||
|
private async serveDirectory(
|
||||||
|
request: Request,
|
||||||
|
dirPath: string,
|
||||||
|
urlPath: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const entries = await plugins.fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||||
|
const files: IFileEntry[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Skip hidden files unless configured
|
||||||
|
const listingOptions = typeof this.options.directoryListing === 'object'
|
||||||
|
? this.options.directoryListing
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (entry.name.startsWith('.') && !listingOptions.showHidden) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPath = plugins.path.join(dirPath, entry.name);
|
||||||
|
const stat = await plugins.fs.promises.stat(entryPath);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: plugins.path.join(urlPath, entry.name),
|
||||||
|
isDirectory: entry.isDirectory(),
|
||||||
|
size: stat.size,
|
||||||
|
modified: stat.mtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort files
|
||||||
|
const listingOptions = typeof this.options.directoryListing === 'object'
|
||||||
|
? this.options.directoryListing
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const sortBy = listingOptions.sortBy ?? 'name';
|
||||||
|
const sortOrder = listingOptions.sortOrder ?? 'asc';
|
||||||
|
|
||||||
|
files.sort((a, b) => {
|
||||||
|
// Directories first
|
||||||
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
return a.isDirectory ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let comparison = 0;
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'size':
|
||||||
|
comparison = a.size - b.size;
|
||||||
|
break;
|
||||||
|
case 'modified':
|
||||||
|
comparison = a.modified.getTime() - b.modified.getTime();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
comparison = a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === 'desc' ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom template
|
||||||
|
if (listingOptions.template) {
|
||||||
|
const result = listingOptions.template(files);
|
||||||
|
if (result instanceof Response) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return new Response(result, {
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default HTML listing
|
||||||
|
const html = this.generateDirectoryHtml(urlPath, files);
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate default directory listing HTML
|
||||||
|
*/
|
||||||
|
private generateDirectoryHtml(urlPath: string, files: IFileEntry[]): string {
|
||||||
|
const formatSize = (size: number): string => {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return date.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (str: string): string => {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = files.map(file => {
|
||||||
|
const icon = file.isDirectory ? '📁' : '📄';
|
||||||
|
const href = encodeURIComponent(file.name) + (file.isDirectory ? '/' : '');
|
||||||
|
const size = file.isDirectory ? '-' : formatSize(file.size);
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td>${icon} <a href="${href}">${escapeHtml(file.name)}</a></td>
|
||||||
|
<td>${size}</td>
|
||||||
|
<td>${formatDate(file.modified)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// Add parent directory link if not at root
|
||||||
|
const parentLink = urlPath !== '/' ? `<tr>
|
||||||
|
<td>📁 <a href="../">..</a></td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>-</td>
|
||||||
|
</tr>` : '';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Index of ${escapeHtml(urlPath)}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 2em; }
|
||||||
|
h1 { font-size: 1.5em; margin-bottom: 1em; }
|
||||||
|
table { border-collapse: collapse; width: 100%; max-width: 800px; }
|
||||||
|
th, td { text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #eee; }
|
||||||
|
th { background: #f5f5f5; }
|
||||||
|
a { color: #0066cc; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
td:nth-child(2), td:nth-child(3) { white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Index of ${escapeHtml(urlPath)}</h1>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${parentLink}
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Node.js stream to Web ReadableStream
|
||||||
|
*/
|
||||||
|
private nodeStreamToWebStream(nodeStream: plugins.fs.ReadStream): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
nodeStream.on('data', (chunk: Buffer) => {
|
||||||
|
controller.enqueue(new Uint8Array(chunk));
|
||||||
|
});
|
||||||
|
nodeStream.on('end', () => {
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
nodeStream.on('error', (err) => {
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
nodeStream.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/files/index.ts
Normal file
1
ts/files/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FileServer } from './file.server.js';
|
||||||
19
ts/index.ts
Normal file
19
ts/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
// Core exports
|
||||||
|
export * from './core/index.js';
|
||||||
|
|
||||||
|
// Decorator exports
|
||||||
|
export * from './decorators/index.js';
|
||||||
|
|
||||||
|
// File server exports
|
||||||
|
export * from './files/index.js';
|
||||||
|
|
||||||
|
// Protocol exports (WebDAV, etc.)
|
||||||
|
export * from './protocols/index.js';
|
||||||
|
|
||||||
|
// Utility exports
|
||||||
|
export * from './utils/index.js';
|
||||||
|
|
||||||
|
// Re-export plugins for advanced usage
|
||||||
|
export { plugins };
|
||||||
5
ts/paths.ts
Normal file
5
ts/paths.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
export const packageDir = plugins.path.join(
|
||||||
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
|
'../'
|
||||||
|
);
|
||||||
15
ts/plugins.ts
Normal file
15
ts/plugins.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// native scope
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
export { path, http, https, fs };
|
||||||
|
|
||||||
|
// @push.rocks scope
|
||||||
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
import * as smartenv from '@push.rocks/smartenv';
|
||||||
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
|
import * as lik from '@push.rocks/lik';
|
||||||
|
|
||||||
|
export { smartpath, smartenv, smartlog, lik };
|
||||||
1
ts/protocols/index.ts
Normal file
1
ts/protocols/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './webdav/index.js';
|
||||||
15
ts/protocols/webdav/index.ts
Normal file
15
ts/protocols/webdav/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export { WebDAVHandler } from './webdav.handler.js';
|
||||||
|
export type {
|
||||||
|
TWebDAVMethod,
|
||||||
|
TWebDAVDepth,
|
||||||
|
IWebDAVProperty,
|
||||||
|
IWebDAVResource,
|
||||||
|
IWebDAVLock,
|
||||||
|
IWebDAVContext,
|
||||||
|
} from './webdav.types.js';
|
||||||
|
export {
|
||||||
|
generateMultistatus,
|
||||||
|
generateLockResponse,
|
||||||
|
generateError,
|
||||||
|
parsePropfindRequest,
|
||||||
|
} from './webdav.xml.js';
|
||||||
659
ts/protocols/webdav/webdav.handler.ts
Normal file
659
ts/protocols/webdav/webdav.handler.ts
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
/**
|
||||||
|
* WebDAV protocol handler
|
||||||
|
* Implements RFC 4918 for network drive mounting
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IWebDAVConfig, IRequestContext } from '../../core/smartserve.interfaces.js';
|
||||||
|
import type {
|
||||||
|
TWebDAVMethod,
|
||||||
|
TWebDAVDepth,
|
||||||
|
IWebDAVResource,
|
||||||
|
IWebDAVLock,
|
||||||
|
IWebDAVContext,
|
||||||
|
} from './webdav.types.js';
|
||||||
|
import {
|
||||||
|
generateMultistatus,
|
||||||
|
generateLockResponse,
|
||||||
|
generateError,
|
||||||
|
parsePropfindRequest,
|
||||||
|
} from './webdav.xml.js';
|
||||||
|
import { getMimeType } from '../../utils/utils.mime.js';
|
||||||
|
import { generateETag } from '../../utils/utils.etag.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebDAV handler for serving files with WebDAV protocol
|
||||||
|
*/
|
||||||
|
export class WebDAVHandler {
|
||||||
|
private config: IWebDAVConfig;
|
||||||
|
private locks: Map<string, IWebDAVLock> = new Map();
|
||||||
|
|
||||||
|
constructor(config: IWebDAVConfig) {
|
||||||
|
this.config = {
|
||||||
|
locking: true,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request is a WebDAV request
|
||||||
|
*/
|
||||||
|
isWebDAVRequest(request: Request): boolean {
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
const webdavMethods = ['PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK', 'UNLOCK'];
|
||||||
|
return webdavMethods.includes(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WebDAV request
|
||||||
|
*/
|
||||||
|
async handle(request: Request): Promise<Response> {
|
||||||
|
const method = request.method.toUpperCase() as TWebDAVMethod;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if (path.includes('..')) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse WebDAV context
|
||||||
|
const context = this.parseContext(request);
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
if (this.config.auth) {
|
||||||
|
const mockCtx = { headers: request.headers } as IRequestContext;
|
||||||
|
const authenticated = await this.config.auth(mockCtx);
|
||||||
|
if (!authenticated) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'WWW-Authenticate': 'Basic realm="WebDAV"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (method) {
|
||||||
|
case 'OPTIONS':
|
||||||
|
return this.handleOptions();
|
||||||
|
case 'PROPFIND':
|
||||||
|
return await this.handlePropfind(request, path, context);
|
||||||
|
case 'PROPPATCH':
|
||||||
|
return await this.handleProppatch(request, path);
|
||||||
|
case 'MKCOL':
|
||||||
|
return await this.handleMkcol(path);
|
||||||
|
case 'COPY':
|
||||||
|
return await this.handleCopy(path, context);
|
||||||
|
case 'MOVE':
|
||||||
|
return await this.handleMove(path, context);
|
||||||
|
case 'LOCK':
|
||||||
|
return await this.handleLock(request, path, context);
|
||||||
|
case 'UNLOCK':
|
||||||
|
return await this.handleUnlock(path, context);
|
||||||
|
case 'GET':
|
||||||
|
case 'HEAD':
|
||||||
|
return await this.handleGet(request, path, method === 'HEAD');
|
||||||
|
case 'PUT':
|
||||||
|
return await this.handlePut(request, path, context);
|
||||||
|
case 'DELETE':
|
||||||
|
return await this.handleDelete(path, context);
|
||||||
|
default:
|
||||||
|
return new Response('Method Not Allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('WebDAV error:', error);
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse WebDAV-specific headers into context
|
||||||
|
*/
|
||||||
|
private parseContext(request: Request): IWebDAVContext {
|
||||||
|
const depth = (request.headers.get('Depth') ?? '1') as TWebDAVDepth;
|
||||||
|
const destination = request.headers.get('Destination') ?? undefined;
|
||||||
|
const overwrite = request.headers.get('Overwrite') !== 'F';
|
||||||
|
const lockToken = request.headers.get('Lock-Token')?.replace(/[<>]/g, '');
|
||||||
|
const timeout = this.parseTimeout(request.headers.get('Timeout'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: request.method.toUpperCase() as TWebDAVMethod,
|
||||||
|
depth: depth === 'infinity' ? 'infinity' : depth === '0' ? '0' : '1',
|
||||||
|
destination,
|
||||||
|
overwrite,
|
||||||
|
lockToken,
|
||||||
|
timeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse timeout header
|
||||||
|
*/
|
||||||
|
private parseTimeout(header: string | null): number {
|
||||||
|
if (!header) return 3600; // 1 hour default
|
||||||
|
const match = header.match(/Second-(\d+)/i);
|
||||||
|
if (match) return parseInt(match[1], 10);
|
||||||
|
if (header.toLowerCase() === 'infinite') return 0;
|
||||||
|
return 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve file path
|
||||||
|
*/
|
||||||
|
private resolvePath(path: string): string {
|
||||||
|
return plugins.path.join(this.config.root, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OPTIONS request
|
||||||
|
*/
|
||||||
|
private handleOptions(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Allow': 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK',
|
||||||
|
'DAV': '1, 2',
|
||||||
|
'MS-Author-Via': 'DAV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PROPFIND request
|
||||||
|
*/
|
||||||
|
private async handlePropfind(
|
||||||
|
request: Request,
|
||||||
|
path: string,
|
||||||
|
context: IWebDAVContext
|
||||||
|
): Promise<Response> {
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.text();
|
||||||
|
const { allprop } = parsePropfindRequest(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(filePath);
|
||||||
|
const resources: IWebDAVResource[] = [];
|
||||||
|
|
||||||
|
// Add the requested resource
|
||||||
|
resources.push(await this.statToResource(path, stat));
|
||||||
|
|
||||||
|
// If directory and depth > 0, list children
|
||||||
|
if (stat.isDirectory() && context.depth !== '0') {
|
||||||
|
const entries = await plugins.fs.promises.readdir(filePath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childPath = plugins.path.join(path, entry.name);
|
||||||
|
const childFilePath = plugins.path.join(filePath, entry.name);
|
||||||
|
const childStat = await plugins.fs.promises.stat(childFilePath);
|
||||||
|
resources.push(await this.statToResource(childPath, childStat));
|
||||||
|
|
||||||
|
// Handle infinite depth (recursive)
|
||||||
|
if (context.depth === 'infinity' && entry.isDirectory()) {
|
||||||
|
await this.collectResources(childPath, childFilePath, resources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = generateMultistatus(resources);
|
||||||
|
return new Response(xml, {
|
||||||
|
status: 207, // Multi-Status
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect resources for infinite depth
|
||||||
|
*/
|
||||||
|
private async collectResources(
|
||||||
|
basePath: string,
|
||||||
|
filePath: string,
|
||||||
|
resources: IWebDAVResource[]
|
||||||
|
): Promise<void> {
|
||||||
|
const entries = await plugins.fs.promises.readdir(filePath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childPath = plugins.path.join(basePath, entry.name);
|
||||||
|
const childFilePath = plugins.path.join(filePath, entry.name);
|
||||||
|
const childStat = await plugins.fs.promises.stat(childFilePath);
|
||||||
|
resources.push(await this.statToResource(childPath, childStat));
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await this.collectResources(childPath, childFilePath, resources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert fs.Stats to WebDAV resource
|
||||||
|
*/
|
||||||
|
private async statToResource(path: string, stat: plugins.fs.Stats): Promise<IWebDAVResource> {
|
||||||
|
const isCollection = stat.isDirectory();
|
||||||
|
const displayName = plugins.path.basename(path) || '/';
|
||||||
|
|
||||||
|
return {
|
||||||
|
href: encodeURI(path),
|
||||||
|
isCollection,
|
||||||
|
displayName,
|
||||||
|
contentType: isCollection ? 'httpd/unix-directory' : getMimeType(path),
|
||||||
|
contentLength: isCollection ? undefined : stat.size,
|
||||||
|
lastModified: stat.mtime,
|
||||||
|
creationDate: stat.birthtime,
|
||||||
|
etag: generateETag(stat),
|
||||||
|
properties: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PROPPATCH request (property modification)
|
||||||
|
*/
|
||||||
|
private async handleProppatch(request: Request, path: string): Promise<Response> {
|
||||||
|
// For now, we don't support modifying properties
|
||||||
|
// Return 403 Forbidden for property modification attempts
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.access(filePath);
|
||||||
|
return new Response(generateError(403, 'cannot-modify-protected-property'), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle MKCOL request (create directory)
|
||||||
|
*/
|
||||||
|
private async handleMkcol(path: string): Promise<Response> {
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if parent exists
|
||||||
|
const parent = plugins.path.dirname(filePath);
|
||||||
|
await plugins.fs.promises.access(parent);
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.access(filePath);
|
||||||
|
return new Response('Method Not Allowed', { status: 405 }); // Already exists
|
||||||
|
} catch {
|
||||||
|
// Good, doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.fs.promises.mkdir(filePath);
|
||||||
|
return new Response(null, { status: 201 }); // Created
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Conflict', { status: 409 }); // Parent doesn't exist
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle COPY request
|
||||||
|
*/
|
||||||
|
private async handleCopy(path: string, context: IWebDAVContext): Promise<Response> {
|
||||||
|
if (!context.destination) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = this.resolvePath(path);
|
||||||
|
const destUrl = new URL(context.destination);
|
||||||
|
const destPath = this.resolvePath(decodeURIComponent(destUrl.pathname));
|
||||||
|
|
||||||
|
// Check if destination exists
|
||||||
|
let destExists = false;
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.access(destPath);
|
||||||
|
destExists = true;
|
||||||
|
if (!context.overwrite) {
|
||||||
|
return new Response('Precondition Failed', { status: 412 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Destination doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(sourcePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await this.copyDirectory(sourcePath, destPath);
|
||||||
|
} else {
|
||||||
|
await plugins.fs.promises.copyFile(sourcePath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: destExists ? 204 : 201 });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copy directory
|
||||||
|
*/
|
||||||
|
private async copyDirectory(src: string, dest: string): Promise<void> {
|
||||||
|
await plugins.fs.promises.mkdir(dest, { recursive: true });
|
||||||
|
const entries = await plugins.fs.promises.readdir(src, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = plugins.path.join(src, entry.name);
|
||||||
|
const destPath = plugins.path.join(dest, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await this.copyDirectory(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
await plugins.fs.promises.copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle MOVE request
|
||||||
|
*/
|
||||||
|
private async handleMove(path: string, context: IWebDAVContext): Promise<Response> {
|
||||||
|
if (!context.destination) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = this.resolvePath(path);
|
||||||
|
const destUrl = new URL(context.destination);
|
||||||
|
const destPath = this.resolvePath(decodeURIComponent(destUrl.pathname));
|
||||||
|
|
||||||
|
// Check lock
|
||||||
|
if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
|
||||||
|
return new Response('Locked', { status: 423 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination exists
|
||||||
|
let destExists = false;
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.access(destPath);
|
||||||
|
destExists = true;
|
||||||
|
if (!context.overwrite) {
|
||||||
|
return new Response('Precondition Failed', { status: 412 });
|
||||||
|
}
|
||||||
|
// Remove existing destination
|
||||||
|
await plugins.fs.promises.rm(destPath, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Destination doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.rename(sourcePath, destPath);
|
||||||
|
|
||||||
|
// Move lock if exists
|
||||||
|
const lock = this.locks.get(path);
|
||||||
|
if (lock) {
|
||||||
|
this.locks.delete(path);
|
||||||
|
lock.path = decodeURIComponent(destUrl.pathname);
|
||||||
|
this.locks.set(lock.path, lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: destExists ? 204 : 201 });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle LOCK request
|
||||||
|
*/
|
||||||
|
private async handleLock(
|
||||||
|
request: Request,
|
||||||
|
path: string,
|
||||||
|
context: IWebDAVContext
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!this.config.locking) {
|
||||||
|
return new Response('Method Not Allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
// Check if resource exists
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.access(filePath);
|
||||||
|
} catch {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already locked by someone else
|
||||||
|
if (this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
|
||||||
|
return new Response('Locked', { status: 423 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lock
|
||||||
|
const lock: IWebDAVLock = {
|
||||||
|
token: `opaquelocktoken:${crypto.randomUUID()}`,
|
||||||
|
owner: 'anonymous', // Could parse from request body
|
||||||
|
depth: context.depth,
|
||||||
|
timeout: context.timeout ?? 3600,
|
||||||
|
scope: 'exclusive',
|
||||||
|
type: 'write',
|
||||||
|
path,
|
||||||
|
created: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.locks.set(path, lock);
|
||||||
|
|
||||||
|
// Set timeout to remove lock
|
||||||
|
if (lock.timeout > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.locks.delete(path);
|
||||||
|
}, lock.timeout * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = generateLockResponse(lock);
|
||||||
|
return new Response(xml, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Lock-Token': `<${lock.token}>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle UNLOCK request
|
||||||
|
*/
|
||||||
|
private async handleUnlock(path: string, context: IWebDAVContext): Promise<Response> {
|
||||||
|
if (!this.config.locking) {
|
||||||
|
return new Response('Method Not Allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.lockToken) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = this.locks.get(path);
|
||||||
|
if (!lock) {
|
||||||
|
return new Response('Conflict', { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lock.token !== context.lockToken) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.locks.delete(path);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET/HEAD request
|
||||||
|
*/
|
||||||
|
private async handleGet(request: Request, path: string, headOnly: boolean): Promise<Response> {
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// Return directory listing as HTML (fallback)
|
||||||
|
return new Response('This is a WebDAV directory', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
'Content-Type': getMimeType(filePath),
|
||||||
|
'Content-Length': stat.size.toString(),
|
||||||
|
'Last-Modified': stat.mtime.toUTCString(),
|
||||||
|
'ETag': `"${generateETag(stat)}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (headOnly) {
|
||||||
|
return new Response(null, { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = plugins.fs.createReadStream(filePath);
|
||||||
|
const webStream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
||||||
|
stream.on('end', () => controller.close());
|
||||||
|
stream.on('error', (err) => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(webStream, { status: 200, headers });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PUT request (file upload)
|
||||||
|
*/
|
||||||
|
private async handlePut(
|
||||||
|
request: Request,
|
||||||
|
path: string,
|
||||||
|
context: IWebDAVContext
|
||||||
|
): Promise<Response> {
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
// Check lock
|
||||||
|
if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
|
||||||
|
return new Response('Locked', { status: 423 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.access(filePath);
|
||||||
|
exists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, will create
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const parent = plugins.path.dirname(filePath);
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.mkdir(parent, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Parent might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
const body = request.body;
|
||||||
|
if (body) {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const writeStream = plugins.fs.createWriteStream(filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
writeStream.write(value);
|
||||||
|
}
|
||||||
|
writeStream.end();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
writeStream.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty file
|
||||||
|
await plugins.fs.promises.writeFile(filePath, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: exists ? 204 : 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DELETE request
|
||||||
|
*/
|
||||||
|
private async handleDelete(path: string, context: IWebDAVContext): Promise<Response> {
|
||||||
|
const filePath = this.resolvePath(path);
|
||||||
|
|
||||||
|
// Check lock
|
||||||
|
if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
|
||||||
|
return new Response('Locked', { status: 423 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await plugins.fs.promises.rm(filePath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
await plugins.fs.promises.unlink(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove lock if exists
|
||||||
|
this.locks.delete(path);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is locked
|
||||||
|
*/
|
||||||
|
private isLocked(path: string): boolean {
|
||||||
|
return this.locks.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token matches lock
|
||||||
|
*/
|
||||||
|
private hasValidLock(path: string, token?: string): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const lock = this.locks.get(path);
|
||||||
|
return lock?.token === token;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
ts/protocols/webdav/webdav.types.ts
Normal file
74
ts/protocols/webdav/webdav.types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* WebDAV type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TWebDAVMethod =
|
||||||
|
| 'OPTIONS'
|
||||||
|
| 'GET'
|
||||||
|
| 'HEAD'
|
||||||
|
| 'PUT'
|
||||||
|
| 'DELETE'
|
||||||
|
| 'PROPFIND'
|
||||||
|
| 'PROPPATCH'
|
||||||
|
| 'MKCOL'
|
||||||
|
| 'COPY'
|
||||||
|
| 'MOVE'
|
||||||
|
| 'LOCK'
|
||||||
|
| 'UNLOCK';
|
||||||
|
|
||||||
|
export type TWebDAVDepth = '0' | '1' | 'infinity';
|
||||||
|
|
||||||
|
export interface IWebDAVProperty {
|
||||||
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWebDAVResource {
|
||||||
|
href: string;
|
||||||
|
isCollection: boolean;
|
||||||
|
properties: IWebDAVProperty[];
|
||||||
|
displayName?: string;
|
||||||
|
contentType?: string;
|
||||||
|
contentLength?: number;
|
||||||
|
lastModified?: Date;
|
||||||
|
creationDate?: Date;
|
||||||
|
etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWebDAVLock {
|
||||||
|
token: string;
|
||||||
|
owner: string;
|
||||||
|
depth: TWebDAVDepth;
|
||||||
|
timeout: number;
|
||||||
|
scope: 'exclusive' | 'shared';
|
||||||
|
type: 'write';
|
||||||
|
path: string;
|
||||||
|
created: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWebDAVContext {
|
||||||
|
method: TWebDAVMethod;
|
||||||
|
depth: TWebDAVDepth;
|
||||||
|
lockToken?: string;
|
||||||
|
destination?: string;
|
||||||
|
overwrite: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard WebDAV properties (DAV: namespace)
|
||||||
|
export const DAV_NAMESPACE = 'DAV:';
|
||||||
|
|
||||||
|
export const DAV_PROPERTIES = {
|
||||||
|
// Required properties
|
||||||
|
creationdate: 'creationdate',
|
||||||
|
displayname: 'displayname',
|
||||||
|
getcontentlanguage: 'getcontentlanguage',
|
||||||
|
getcontentlength: 'getcontentlength',
|
||||||
|
getcontenttype: 'getcontenttype',
|
||||||
|
getetag: 'getetag',
|
||||||
|
getlastmodified: 'getlastmodified',
|
||||||
|
lockdiscovery: 'lockdiscovery',
|
||||||
|
resourcetype: 'resourcetype',
|
||||||
|
supportedlock: 'supportedlock',
|
||||||
|
} as const;
|
||||||
184
ts/protocols/webdav/webdav.xml.ts
Normal file
184
ts/protocols/webdav/webdav.xml.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* WebDAV XML generation utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IWebDAVResource, IWebDAVProperty, IWebDAVLock } from './webdav.types.js';
|
||||||
|
import { DAV_NAMESPACE } from './webdav.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape XML special characters
|
||||||
|
*/
|
||||||
|
function escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for WebDAV (RFC 1123)
|
||||||
|
*/
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for creationdate (ISO 8601)
|
||||||
|
*/
|
||||||
|
function formatCreationDate(date: Date): string {
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multistatus response for PROPFIND
|
||||||
|
*/
|
||||||
|
export function generateMultistatus(resources: IWebDAVResource[]): string {
|
||||||
|
const responses = resources.map(resource => generateResponse(resource)).join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="${DAV_NAMESPACE}">
|
||||||
|
${responses}
|
||||||
|
</D:multistatus>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate single response element
|
||||||
|
*/
|
||||||
|
function generateResponse(resource: IWebDAVResource): string {
|
||||||
|
const props: string[] = [];
|
||||||
|
|
||||||
|
// Resource type
|
||||||
|
if (resource.isCollection) {
|
||||||
|
props.push('<D:resourcetype><D:collection/></D:resourcetype>');
|
||||||
|
} else {
|
||||||
|
props.push('<D:resourcetype/>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name
|
||||||
|
if (resource.displayName) {
|
||||||
|
props.push(`<D:displayname>${escapeXml(resource.displayName)}</D:displayname>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content type
|
||||||
|
if (resource.contentType) {
|
||||||
|
props.push(`<D:getcontenttype>${escapeXml(resource.contentType)}</D:getcontenttype>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content length
|
||||||
|
if (resource.contentLength !== undefined) {
|
||||||
|
props.push(`<D:getcontentlength>${resource.contentLength}</D:getcontentlength>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last modified
|
||||||
|
if (resource.lastModified) {
|
||||||
|
props.push(`<D:getlastmodified>${formatDate(resource.lastModified)}</D:getlastmodified>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creation date
|
||||||
|
if (resource.creationDate) {
|
||||||
|
props.push(`<D:creationdate>${formatCreationDate(resource.creationDate)}</D:creationdate>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETag
|
||||||
|
if (resource.etag) {
|
||||||
|
props.push(`<D:getetag>"${escapeXml(resource.etag)}"</D:getetag>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supported lock
|
||||||
|
props.push(`<D:supportedlock>
|
||||||
|
<D:lockentry>
|
||||||
|
<D:lockscope><D:exclusive/></D:lockscope>
|
||||||
|
<D:locktype><D:write/></D:locktype>
|
||||||
|
</D:lockentry>
|
||||||
|
</D:supportedlock>`);
|
||||||
|
|
||||||
|
// Custom properties
|
||||||
|
for (const prop of resource.properties) {
|
||||||
|
if (prop.value) {
|
||||||
|
props.push(`<${prop.name} xmlns="${prop.namespace}">${escapeXml(prop.value)}</${prop.name}>`);
|
||||||
|
} else {
|
||||||
|
props.push(`<${prop.name} xmlns="${prop.namespace}"/>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ` <D:response>
|
||||||
|
<D:href>${escapeXml(resource.href)}</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
${props.join('\n ')}
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate lock discovery response
|
||||||
|
*/
|
||||||
|
export function generateLockDiscovery(locks: IWebDAVLock[]): string {
|
||||||
|
if (locks.length === 0) {
|
||||||
|
return '<D:lockdiscovery/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockEntries = locks.map(lock => `
|
||||||
|
<D:activelock>
|
||||||
|
<D:locktype><D:write/></D:locktype>
|
||||||
|
<D:lockscope><D:${lock.scope}/></D:lockscope>
|
||||||
|
<D:depth>${lock.depth}</D:depth>
|
||||||
|
<D:owner>${escapeXml(lock.owner)}</D:owner>
|
||||||
|
<D:timeout>Second-${lock.timeout}</D:timeout>
|
||||||
|
<D:locktoken><D:href>${escapeXml(lock.token)}</D:href></D:locktoken>
|
||||||
|
</D:activelock>`).join('');
|
||||||
|
|
||||||
|
return `<D:lockdiscovery>${lockEntries}
|
||||||
|
</D:lockdiscovery>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate lock response
|
||||||
|
*/
|
||||||
|
export function generateLockResponse(lock: IWebDAVLock): string {
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:prop xmlns:D="${DAV_NAMESPACE}">
|
||||||
|
${generateLockDiscovery([lock])}
|
||||||
|
</D:prop>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate error response
|
||||||
|
*/
|
||||||
|
export function generateError(status: number, message: string): string {
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:error xmlns:D="${DAV_NAMESPACE}">
|
||||||
|
<D:${message.toLowerCase().replace(/\s+/g, '-')}/>
|
||||||
|
</D:error>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse PROPFIND request body to extract requested properties
|
||||||
|
*/
|
||||||
|
export function parsePropfindRequest(body: string): { allprop: boolean; propnames: string[] } {
|
||||||
|
// Simple XML parsing for PROPFIND
|
||||||
|
if (!body || body.includes('<allprop') || body.includes('<D:allprop')) {
|
||||||
|
return { allprop: true, propnames: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.includes('<propname') || body.includes('<D:propname')) {
|
||||||
|
return { allprop: false, propnames: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract property names
|
||||||
|
const propnames: string[] = [];
|
||||||
|
const propMatch = body.match(/<(?:D:)?prop[^>]*>([\s\S]*?)<\/(?:D:)?prop>/i);
|
||||||
|
if (propMatch) {
|
||||||
|
const propContent = propMatch[1];
|
||||||
|
const tagMatches = propContent.matchAll(/<(?:D:)?(\w+)[^>]*\/?>/gi);
|
||||||
|
for (const match of tagMatches) {
|
||||||
|
propnames.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allprop: propnames.length === 0, propnames };
|
||||||
|
}
|
||||||
2
ts/utils/index.ts
Normal file
2
ts/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
||||||
|
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
||||||
45
ts/utils/utils.etag.ts
Normal file
45
ts/utils/utils.etag.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* ETag generation utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate ETag from file stats
|
||||||
|
* Uses weak ETag format: W/"size-mtime"
|
||||||
|
*/
|
||||||
|
export function generateETag(stat: { size: number; mtime: Date }): string {
|
||||||
|
const mtime = stat.mtime.getTime().toString(16);
|
||||||
|
const size = stat.size.toString(16);
|
||||||
|
return `W/"${size}-${mtime}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate strong ETag from content
|
||||||
|
* Uses hash of content
|
||||||
|
*/
|
||||||
|
export async function generateStrongETag(content: Uint8Array): Promise<string> {
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', content as unknown as ArrayBuffer);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `"${hashHex.slice(0, 32)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ETag matches
|
||||||
|
*/
|
||||||
|
export function matchesETag(etag: string, ifNoneMatch: string | null): boolean {
|
||||||
|
if (!ifNoneMatch) return false;
|
||||||
|
|
||||||
|
// Handle multiple ETags
|
||||||
|
const etags = ifNoneMatch.split(',').map(e => e.trim());
|
||||||
|
|
||||||
|
// Wildcard match
|
||||||
|
if (etags.includes('*')) return true;
|
||||||
|
|
||||||
|
// Weak comparison (ignore W/ prefix)
|
||||||
|
const normalizeETag = (e: string) => e.replace(/^W\//, '');
|
||||||
|
const normalizedETag = normalizeETag(etag);
|
||||||
|
|
||||||
|
return etags.some(e => normalizeETag(e) === normalizedETag);
|
||||||
|
}
|
||||||
101
ts/utils/utils.mime.ts
Normal file
101
ts/utils/utils.mime.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* MIME type detection based on file extension
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
// Text
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.htm': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.xml': 'application/xml; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.md': 'text/markdown; charset=utf-8',
|
||||||
|
'.csv': 'text/csv; charset=utf-8',
|
||||||
|
'.yaml': 'text/yaml; charset=utf-8',
|
||||||
|
'.yml': 'text/yaml; charset=utf-8',
|
||||||
|
|
||||||
|
// Images
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
|
||||||
|
// Fonts
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.otf': 'font/otf',
|
||||||
|
'.eot': 'application/vnd.ms-fontobject',
|
||||||
|
|
||||||
|
// Audio
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
'.flac': 'audio/flac',
|
||||||
|
'.aac': 'audio/aac',
|
||||||
|
|
||||||
|
// Video
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.ogv': 'video/ogg',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'.xls': 'application/vnd.ms-excel',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.ppt': 'application/vnd.ms-powerpoint',
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
|
||||||
|
// Archives
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.tar': 'application/x-tar',
|
||||||
|
'.gz': 'application/gzip',
|
||||||
|
'.rar': 'application/vnd.rar',
|
||||||
|
'.7z': 'application/x-7z-compressed',
|
||||||
|
|
||||||
|
// Source maps
|
||||||
|
'.map': 'application/json',
|
||||||
|
|
||||||
|
// TypeScript
|
||||||
|
'.ts': 'text/typescript; charset=utf-8',
|
||||||
|
'.tsx': 'text/typescript; charset=utf-8',
|
||||||
|
'.d.ts': 'text/typescript; charset=utf-8',
|
||||||
|
|
||||||
|
// WebAssembly
|
||||||
|
'.wasm': 'application/wasm',
|
||||||
|
|
||||||
|
// Manifest
|
||||||
|
'.webmanifest': 'application/manifest+json',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type for a file path
|
||||||
|
*/
|
||||||
|
export function getMimeType(filePath: string): string {
|
||||||
|
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? '';
|
||||||
|
return MIME_TYPES[ext] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if MIME type is text-based
|
||||||
|
*/
|
||||||
|
export function isTextMimeType(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith('text/') ||
|
||||||
|
mimeType.includes('json') ||
|
||||||
|
mimeType.includes('xml') ||
|
||||||
|
mimeType.includes('javascript');
|
||||||
|
}
|
||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"dist_*/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user