Compare commits
78 Commits
af0a09e744
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf165811f | |||
|
410b17e494
|
|||
| cdeeebea90 | |||
|
bf4aa3f393
|
|||
|
8850e5e478
|
|||
|
6d7127a52f
|
|||
| 3a4a056760 | |||
|
f177746760
|
|||
| e98244325d | |||
|
5686940fe2
|
|||
| 4a06bc67ea | |||
|
68cec85380
|
|||
| 86895f41f1 | |||
|
de6d3ee389
|
|||
|
54cfa1888e
|
|||
|
c207dc7392
|
|||
|
b14e9acc5f
|
|||
|
731bba22d8
|
|||
|
aea3fb45e7
|
|||
|
35d83c073e
|
|||
|
67ebb4307a
|
|||
|
3da34e2684
|
|||
| 26198ccdcd | |||
|
1b447f5190
|
|||
|
38c00b035b
|
|||
|
6e6e61693f
|
|||
| d8283ea83a | |||
|
ec1697df94
|
|||
| e416af9754 | |||
|
b83aa330b3
|
|||
|
c28eb7f28d
|
|||
| d7460c4b1d | |||
|
6a0f1c774b
|
|||
| 7327ed5c1b | |||
|
b44bb2ddaf
|
|||
| dd60c2cdc7 | |||
|
6399113e12
|
|||
| 813c734ae6 | |||
|
3707242d27
|
|||
| adc1a4276e | |||
|
71ea4247b3
|
|||
| b624415320 | |||
|
a88b4d112f
|
|||
|
4f2b65049f
|
|||
| c9de4669c7 | |||
|
a1ce30627c
|
|||
|
5118a19aea
|
|||
| 0a09c8c324 | |||
| b9d25f23fc | |||
| 92f09e6e60 | |||
|
03115464e0
|
|||
| 11a2273240 | |||
|
1233a0b720
|
|||
| 195619ca99 | |||
|
9d5db4f414
|
|||
|
16c0053964
|
|||
|
04029f87a3
|
|||
| 594eccbc8b | |||
|
3afe6bcbc3
|
|||
|
bbf78cff17
|
|||
|
8235680904
|
|||
|
56633cd95b
|
|||
|
5c03cdfb03
|
|||
|
661f83d1fd
|
|||
|
7b6eb2b574
|
|||
|
f0838567b4
|
|||
|
b009b5763d
|
|||
|
b0c6901d7d
|
|||
|
6c4420d32f
|
|||
|
b7410b44dd
|
|||
|
9059bdda62
|
|||
|
0990cacb22
|
|||
|
fd1c7f4170
|
|||
|
ba7aa79f56
|
|||
|
a236fa7c97
|
|||
|
82a6877485
|
|||
|
94525fce52
|
|||
|
cc734fa7b1
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
CHANGELOG.md merge=ours
|
||||
198
.gitea/HOWTO_RELEASE.md
Normal file
198
.gitea/HOWTO_RELEASE.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# 📦 HOWTO: Release erstellen mit Auto-Changelog-Workflow
|
||||
|
||||
Dieses Repository nutzt einen automatisierten CI/CD-Workflow zur **Versionsverwaltung, Changelog-Generierung und Release-Erstellung**.
|
||||
Der gesamte Prozess ist deklarativ und läuft automatisch – ausgelöst durch Änderungen an einer Datei: `VERSION`.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Was passiert automatisch?
|
||||
|
||||
Sobald Änderungen in `main` landen, prüft der Workflow:
|
||||
|
||||
- 🔍 **Hat sich die Datei `VERSION` geändert?**
|
||||
- ❌ **Nein** → es wird nur das `CHANGELOG.md` aktualisiert (unreleased Abschnitt)
|
||||
- ✅ **Ja** → es wird:
|
||||
- ein vollständiger Changelog für diese Version erzeugt
|
||||
- ein Git-Tag `vX.Y.Z` erstellt
|
||||
- ein Release in Gitea veröffentlicht (inkl. Beschreibung aus dem Changelog)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Wie erzeuge ich ein Release?
|
||||
|
||||
### 1. Erhöhe die Version in der Datei `VERSION`
|
||||
|
||||
Beispiel:
|
||||
|
||||
```txt
|
||||
1.2.3
|
||||
```
|
||||
|
||||
> Diese Datei muss **als eigene Commit-Änderung** erfolgen – idealerweise als letzter Commit in einem PR.
|
||||
> Die Commit-Nachricht sollte mit `chore(version)` beginnen, damit dieser nicht im Changelog auftaucht.
|
||||
|
||||
---
|
||||
|
||||
### 2. Mergen in `main`
|
||||
|
||||
Sobald `main` den Commit mit neuer `VERSION` enthält, wird automatisch:
|
||||
|
||||
- das `CHANGELOG.md` regeneriert und committed
|
||||
- der neue Git-Tag erstellt (`v1.2.3`)
|
||||
- ein Gitea Release mit genau diesem Changelog erzeugt
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Hinweis zu Tokens & Webhooks
|
||||
|
||||
Damit das Release auch korrekt weitere Workflows auslösen kann (z. B. über `on: release`), ist **ein Personal Access Token notwendig**.
|
||||
|
||||
### 🔐 Secret: `RELEASE_PUBLISH_TOKEN`
|
||||
|
||||
> Lege ein Repository-Secret mit diesem Namen an.
|
||||
> Es sollte ein **Gitea Personal Access Token** mit folgenden Berechtigungen sein:
|
||||
|
||||
- `write:repo`
|
||||
- `write:release`
|
||||
- idealerweise: keine Ablaufzeit
|
||||
|
||||
Wird dieser Token **nicht** gesetzt, fällt der Workflow auf `ACTIONS_RUNTIME_TOKEN` zurück, aber:
|
||||
- Release wird trotzdem erstellt
|
||||
- **⚠️ andere Workflows, die auf `release.published` reagieren, könnten nicht getriggert werden**
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Debugging-Tipps
|
||||
|
||||
- Stelle sicher, dass `VERSION` exakt **eine gültige neue semver-Version** enthält
|
||||
- Achte auf den Commit-Log: Changelog-Commits sind mit `chore(changelog):` gekennzeichnet
|
||||
- Verwende nur `main` als Trigger-Zweig
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Erweiterung
|
||||
|
||||
In `upload-assets.yml` kannst du beliebige Build-Artefakte automatisch an das Release anhängen, sobald es veröffentlicht ist.
|
||||
|
||||
Dafür:
|
||||
- liegt das Script `.gitea/scripts/get-release-id.sh`
|
||||
- sowie `.gitea/scripts/upload-asset.sh` bereit
|
||||
|
||||
Mehr dazu in der Datei: `.gitea/workflows/upload-assets.yml`
|
||||
|
||||
---
|
||||
|
||||
## 🧘 Best Practice
|
||||
|
||||
- Changelog-Generierung nie manuell ausführen
|
||||
- Nur `VERSION` ändern, um ein neues Release auszulösen
|
||||
- Auf `CHANGELOG.md` nie direkt committen
|
||||
- Release-Daten niemals per Hand in Gitea pflegen
|
||||
|
||||
📎 Alles wird versioniert, automatisiert und reproduzierbar erzeugt.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Commit-Gruppierung & Changelog-Erzeugung
|
||||
|
||||
Der Changelog wird auf Basis definierter **Commit-Gruppen** erzeugt.
|
||||
Diese Regeln sind in `cliff.toml` unter `commit_parsers` konfiguriert.
|
||||
|
||||
| Prefix / Muster | Gruppe | Beschreibung |
|
||||
|-------------------------------|---------------------------|--------------------------------------------------|
|
||||
| `feat:` | 🚀 Features | Neue Funktionalität |
|
||||
| `fix:` | 🐛 Bug Fixes | Fehlerbehebungen |
|
||||
| `doc:` | 📚 Documentation | Änderungen an Doku, Readmes etc. |
|
||||
| `perf:` | ⚡ Performance | Leistungsverbesserungen |
|
||||
| `refactor:` | 🚜 Refactor | Reorganisation ohne Verhaltensänderung |
|
||||
| `style:` | 🎨 Styling | Formatierung, Whitespaces, Code-Style |
|
||||
| `test:` | 🧪 Testing | Neue oder angepasste Tests |
|
||||
| `ci:` oder `chore:` (ohne Spezifizierung) | ⚙️ Miscellaneous Tasks | CI-Änderungen, Aufgaben, Wartung etc. |
|
||||
| `chore(changelog)`, `chore(version)`, `chore(release): prepare for`, `chore(deps...)`, `chore(pr)`, `chore(pull)` | *(ignoriert)* | Diese Commits werden im Changelog **ausgelassen** |
|
||||
| Commit-Body enthält `security` | 🛡️ Security | Sicherheitsrelevante Änderungen |
|
||||
| `revert:` | ◀️ Revert | Rückgängig gemachte Commits |
|
||||
| alles andere | 💼 Other | Fallback für nicht erkannte Formate |
|
||||
|
||||
### ✍️ Beispiel:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add login endpoint"
|
||||
git commit -m "fix: prevent crash on null input"
|
||||
git commit -m "chore(version): bump to 1.2.3"
|
||||
```
|
||||
|
||||
> Nur die ersten beiden erscheinen im Changelog – der dritte wird **automatisch übersprungen**.
|
||||
|
||||
---
|
||||
|
||||
## 🧾 Umgang mit `CHANGELOG.md` beim Mergen und Releasen
|
||||
|
||||
Wenn du automatisiert einen Changelog mit `git-cliff` erzeugst, ist `CHANGELOG.md` ein **generiertes Artefakt** – und kein handgepflegter Quelltext.
|
||||
|
||||
Beim Mergen von Feature-Branches in `main` kann es deshalb zu **unnötigen Konflikten** in dieser Datei kommen, obwohl der Inhalt später sowieso neu erzeugt wird.
|
||||
|
||||
---
|
||||
|
||||
## 🧼 Umgang mit `CHANGELOG.md` in Feature-Branches
|
||||
|
||||
Wenn du mit **Feature-Branches** arbeitest, wird `CHANGELOG.md` dort oft automatisch erzeugt.
|
||||
Das kann beim späteren Merge in `main` zu **unnötigen Merge-Konflikten** führen.
|
||||
|
||||
### ✅ Empfohlene Vorgehensweise
|
||||
|
||||
**Bevor du den Branch mit `main` zusammenführst** (Merge oder Cherry-Pick):
|
||||
|
||||
```bash
|
||||
git rm CHANGELOG.md
|
||||
git commit -m "chore(changelog): remove generated CHANGELOG.md before merge"
|
||||
git push
|
||||
```
|
||||
|
||||
Dadurch:
|
||||
|
||||
* verhinderst du Merge-Konflikte mit `CHANGELOG.md`
|
||||
* wird die Datei bei Feature-Branches nicht mehr automatisch erzeugt
|
||||
* bleibt deine Historie sauber und konfliktfrei
|
||||
|
||||
> 💡 Der Workflow erzeugt `CHANGELOG.md` automatisch **nur**, wenn:
|
||||
>
|
||||
> * die Datei schon vorhanden ist **oder**
|
||||
> * der Branch `main` heißt
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Merge-Konflikte verhindern mit `.gitattributes`
|
||||
|
||||
Damit Git bei Konflikten in `CHANGELOG.md` **automatisch deine Version bevorzugt**, kannst du folgende Zeile in die Datei `.gitattributes` aufnehmen:
|
||||
|
||||
```gitattributes
|
||||
CHANGELOG.md merge=ours
|
||||
```
|
||||
|
||||
Das bedeutet:
|
||||
|
||||
* Beim Merge wird die Version aus dem aktuellen Branch (`ours`) behalten
|
||||
* Änderungen aus dem Ziel-Branch (`theirs`) werden verworfen
|
||||
|
||||
### ✅ So verwendest du es richtig:
|
||||
|
||||
1. **Füge die Regel in `main` hinzu**:
|
||||
|
||||
```bash
|
||||
echo "CHANGELOG.md merge=ours" >> .gitattributes
|
||||
git add .gitattributes
|
||||
git commit -m "chore(git): prevent merge conflicts in CHANGELOG.md"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
2. **Hole sie in deinen Feature-Branch**:
|
||||
|
||||
```bash
|
||||
git checkout feature/xyz
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
3. **Ab sofort werden Konflikte in `CHANGELOG.md` automatisch aufgelöst** – lokal.
|
||||
|
||||
> ⚠️ Hinweis: Plattformen wie **Gitea, GitHub oder GitLab ignorieren `.gitattributes` beim Merge über die Web-Oberfläche**.
|
||||
> Führe Merges daher **lokal** durch, wenn du Konflikte verhindern willst.
|
||||
21
.gitea/scripts/get-release-id.sh
Executable file
21
.gitea/scripts/get-release-id.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Eingaben
|
||||
TAG="$1"
|
||||
TOKEN="${ACTIONS_RUNTIME_TOKEN:-<fallback_token>}"
|
||||
REPO="${GITHUB_REPOSITORY:-owner/example}"
|
||||
API="${GITHUB_API_URL:-https://gitea.example.tld/api/v1}"
|
||||
|
||||
OWNER=$(echo "$REPO" | cut -d/ -f1)
|
||||
NAME=$(echo "$REPO" | cut -d/ -f2)
|
||||
|
||||
RESPONSE=$(curl -sf \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$API/repos/$OWNER/$NAME/releases/tags/$TAG")
|
||||
|
||||
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
||||
echo "Release-ID für $TAG ist: $RELEASE_ID"
|
||||
|
||||
# Für GitHub Actions als Umgebungsvariable
|
||||
echo "GT_RELEASE_ID=$RELEASE_ID" >> "$GITHUB_ENV"
|
||||
40
.gitea/scripts/upload-asset.sh
Executable file
40
.gitea/scripts/upload-asset.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Eingabeparameter
|
||||
FILE_PATH="$1" # z. B. ./dist/build.zip
|
||||
CUSTOM_NAME="${2:-}" # optional: anderer Name unter dem das Asset gespeichert werden soll
|
||||
RELEASE_ID="${GT_RELEASE_ID:-}" # aus Umgebung
|
||||
|
||||
# Validierung
|
||||
if [[ -z "$RELEASE_ID" ]]; then
|
||||
echo "❌ RELEASE_ID ist nicht gesetzt. Abbruch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FILE_PATH" ]]; then
|
||||
echo "❌ Datei '$FILE_PATH' existiert nicht. Abbruch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Default-Konfiguration
|
||||
TOKEN="${ACTIONS_RUNTIME_TOKEN:-<fallback_token>}"
|
||||
REPO="${GITHUB_REPOSITORY:-owner/example}"
|
||||
API="${GITHUB_API_URL:-https://gitea.example.tld/api/v1}"
|
||||
|
||||
# Owner/Repo auflösen
|
||||
OWNER=$(echo "$REPO" | cut -d/ -f1)
|
||||
NAME=$(echo "$REPO" | cut -d/ -f2)
|
||||
|
||||
# Dateiname setzen
|
||||
FILENAME="${CUSTOM_NAME:-$(basename "$FILE_PATH")}"
|
||||
|
||||
echo "🔼 Uploading '$FILE_PATH' as '$FILENAME' to release ID $RELEASE_ID"
|
||||
|
||||
# Upload
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$FILE_PATH" \
|
||||
"$API/repos/$OWNER/$NAME/releases/$RELEASE_ID/assets?name=$FILENAME"
|
||||
|
||||
echo "✅ Upload abgeschlossen: $FILENAME"
|
||||
52
.gitea/workflows/ci.yml
Normal file
52
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Format
|
||||
id: format
|
||||
continue-on-error: true
|
||||
run: deno task fmt
|
||||
|
||||
- name: Lint
|
||||
id: lint
|
||||
continue-on-error: true
|
||||
run: deno task lint
|
||||
|
||||
- name: Test
|
||||
id: test
|
||||
continue-on-error: true
|
||||
run: deno task test
|
||||
|
||||
- name: Benchmark
|
||||
id: benchmark
|
||||
continue-on-error: true
|
||||
run: deno task benchmark
|
||||
|
||||
- name: Fail if any step failed
|
||||
if: |
|
||||
steps.format.outcome != 'success' ||
|
||||
steps.lint.outcome != 'success' ||
|
||||
steps.test.outcome != 'success' ||
|
||||
steps.benchmark.outcome != 'success'
|
||||
run: |
|
||||
echo "::error::One or more steps failed"
|
||||
exit 1
|
||||
126
.gitea/workflows/release-on-pages.yml
Normal file
126
.gitea/workflows/release-on-pages.yml
Normal file
@@ -0,0 +1,126 @@
|
||||
name: Build Tags Export
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-tags:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch tags list
|
||||
run: |
|
||||
git tag > tags.txt
|
||||
|
||||
- name: Prepare target directory
|
||||
run: mkdir -p TARGET
|
||||
|
||||
- name: Checkout all tags and export
|
||||
run: |
|
||||
while read TAG; do
|
||||
echo "Processing tag: $TAG"
|
||||
mkdir -p "TARGET/$TAG"
|
||||
|
||||
# Sauber: Clonen aus dem lokalen Checkout
|
||||
git clone . "workdir" --branch "$TAG" --depth 1
|
||||
|
||||
cp -a workdir/* "TARGET/$TAG/"
|
||||
rm -rf workdir
|
||||
done < tags.txt
|
||||
|
||||
- name: Upload as artifact (optional)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: all-tags
|
||||
path: TARGET/
|
||||
|
||||
publish-pages:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-tags
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository (for push setup)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: all-tags
|
||||
path: TAGS_EXPORT
|
||||
|
||||
- name: Git Configure
|
||||
run: |
|
||||
git config --global user.name "$CI_COMMIT_AUTHOR_NAME"
|
||||
git config --global user.email "$CI_COMMIT_AUTHOR_EMAIL"
|
||||
|
||||
- name: Check pages branch
|
||||
id: check
|
||||
run: |
|
||||
if git ls-remote --exit-code origin pages > /dev/null; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Clone pages branch into repo/
|
||||
run: |
|
||||
REPO_URL_BASE="$(echo $GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git | sed 's/^https\?:\/\///')"
|
||||
AUTH_URL="https://${{ secrets.PACKAGE_USER }}:${{ secrets.PACKAGE_TOKEN }}@$REPO_URL_BASE"
|
||||
|
||||
echo "::add-mask::$AUTH_URL"
|
||||
echo "📡 Klone Repository von: $AUTH_URL"
|
||||
|
||||
if [ "${{ steps.check.outputs.exists }}" = "true" ]; then
|
||||
git clone --depth 1 --branch pages "$AUTH_URL" repo
|
||||
else
|
||||
# Branch existiert nicht → ohne Branch klonen, später orphan erzeugen
|
||||
git clone --depth 1 "$AUTH_URL" repo
|
||||
fi
|
||||
|
||||
- name: Prepare pages branch
|
||||
working-directory: repo
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.exists }}" = "true" ]; then
|
||||
echo "📥 Checkout existing pages"
|
||||
git checkout pages
|
||||
else
|
||||
echo "🌱 Create orphan"
|
||||
git checkout --orphan pages
|
||||
git reset --hard
|
||||
fi
|
||||
|
||||
# Alles löschen für frischen Inhalt
|
||||
rm -rf *
|
||||
|
||||
- name: Copy tag export
|
||||
run: cp -a TAGS_EXPORT/* repo/
|
||||
|
||||
- name: Commit
|
||||
working-directory: repo
|
||||
run: |
|
||||
git add -A
|
||||
git commit --allow-empty -m "CI: Update Pages ($(date -u +"%Y-%m-%d %H:%M:%S"))" || echo "⚠️ Nothing to commit"
|
||||
|
||||
- name: Set new remote (auth)
|
||||
working-directory: repo
|
||||
run: |
|
||||
REPO_URL_BASE="$(echo $GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git | sed 's/^https\?:\/\///')"
|
||||
AUTH_URL="https://${{ secrets.PACKAGE_USER }}:${{ secrets.PACKAGE_TOKEN }}@$REPO_URL_BASE"
|
||||
git remote set-url origin "$AUTH_URL"
|
||||
|
||||
- name: Push pages branch
|
||||
working-directory: repo
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.exists }}" = "true" ]; then
|
||||
echo "🚀 Normal push"
|
||||
git push origin pages
|
||||
else
|
||||
echo "⛔ Force push orphan"
|
||||
git push origin pages --force
|
||||
fi
|
||||
18
.gitea/workflows/release.yml
Normal file
18
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Auto Changelog & (Release)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Release
|
||||
uses: https://git.0xmax42.io/actions/auto-changelog-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PUBLISH_TOKEN }}
|
||||
27
.gitea/workflows/sync-release-to-github.yml
Normal file
27
.gitea/workflows/sync-release-to-github.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Run Releases Sync Action
|
||||
uses: https://git.0xmax42.io/actions/releases-sync@main
|
||||
with:
|
||||
gitea_token: $ACTIONS_RUNTIME_TOKEN
|
||||
gitea_url: https://git.0xmax42.io
|
||||
gitea_owner: maxp
|
||||
gitea_repo: http-kernel
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
github_token: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
github_owner: 0xMax42
|
||||
github_repo: http-kernel
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
||||
coverage/
|
||||
logs/
|
||||
.locale/
|
||||
.local/
|
||||
cache/
|
||||
out.py
|
||||
output.txt
|
||||
git_log_diff.txt
|
||||
.gitea/COMMIT_GPT.md
|
||||
|
||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@@ -10,4 +10,21 @@
|
||||
"editor.defaultFormatter": "denoland.vscode-deno",
|
||||
"editor.detectIndentation": false,
|
||||
"editor.indentSize": "tabSize",
|
||||
"exportall.config.folderListener": [
|
||||
"/src/Interfaces",
|
||||
"/src/Utils",
|
||||
"/src/Types",
|
||||
"/src/Errors"
|
||||
],
|
||||
"exportall.config.barrelName": "mod.ts",
|
||||
"exportall.config.message": "deno-coverage-ignore-file",
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#9ed8bc",
|
||||
"activityBar.background": "#9ed8bc",
|
||||
"activityBar.foreground": "#15202b",
|
||||
"activityBar.inactiveForeground": "#15202b99",
|
||||
"activityBarBadge.background": "#a177c8",
|
||||
"activityBarBadge.foreground": "#15202b"
|
||||
},
|
||||
"peacock.color": "#7ac9a3",
|
||||
}
|
||||
113
CHANGELOG.md
Normal file
113
CHANGELOG.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [unreleased]
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(ci)* Add workflow to export and publish tags to pages branch - ([410b17e](https://git.0xmax42.io/maxp/http-kernel/commit/410b17e494e84354478abd505c2d5e24328f3cde))
|
||||
|
||||
## [0.2.1](https://git.0xmax42.io/maxp/http-kernel/compare/v0.2.0..v0.2.1) - 2025-11-12
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Export errors, interfaces, types, and utils from main module - ([6d7127a](https://git.0xmax42.io/maxp/http-kernel/commit/6d7127a52f4aecfd178523c8a873ab0b558550f1))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(workflows)* Remove redundant tag fallback in sync job - ([5686940](https://git.0xmax42.io/maxp/http-kernel/commit/5686940fe26b699bffa62af7fb0efc42cc85a6b3))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(workflows)* Update release workflow for consistency - ([f177746](https://git.0xmax42.io/maxp/http-kernel/commit/f1777467607874f6bc83e1d7e37433298e25607c))
|
||||
|
||||
## [0.2.0](https://git.0xmax42.io/maxp/http-kernel/compare/v0.1.0..v0.2.0) - 2025-05-27
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(workflows)* Add GitHub release sync workflow - ([de6d3ee](https://git.0xmax42.io/maxp/http-kernel/commit/de6d3ee389b0d92c5056e47be85da1d0c41f62af))
|
||||
- *(ci)* Enhance CI workflow with granular steps and error handling - ([54cfa18](https://git.0xmax42.io/maxp/http-kernel/commit/54cfa1888e13d0872b5411e83d3d45925f2687ee))
|
||||
- *(route-builder)* Add middleware chain compilation - ([35d83c0](https://git.0xmax42.io/maxp/http-kernel/commit/35d83c073ef8644d657195c332b463d18e856e18))
|
||||
- *(interfaces)* Add runRoute method to IInternalRoute - ([67ebb43](https://git.0xmax42.io/maxp/http-kernel/commit/67ebb4307a2a1c588b78f8f0c498d1a4276ad09b))
|
||||
- *(workflows)* Conditionally generate changelog - ([b44bb2d](https://git.0xmax42.io/maxp/http-kernel/commit/b44bb2ddafe99c85b25229d2c4a0dfeacf750052))
|
||||
- *(workflows)* Add CI for Deno project tests - ([9d5db4f](https://git.0xmax42.io/maxp/http-kernel/commit/9d5db4f414cf961248f2b879f2b132b81a32cb92))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(workflows)* Ensure version detection output is always set - ([3707242](https://git.0xmax42.io/maxp/http-kernel/commit/3707242d278e15c55a41056bb64810f6824d24b3))
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(kernel)* Simplify middleware and handler execution - ([aea3fb4](https://git.0xmax42.io/maxp/http-kernel/commit/aea3fb45e7c099a38440c85783747e80fca54ba6))
|
||||
- *(imports)* Use explicit type-only imports across codebase - ([b83aa33](https://git.0xmax42.io/maxp/http-kernel/commit/b83aa330b34523e5102ab98ee61dedbbd62d4656))
|
||||
- *(workflows)* Rename changelog file for consistency - ([b9d25f2](https://git.0xmax42.io/maxp/http-kernel/commit/b9d25f23fc6ad7696deee319024aa5b1af4d98c0))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- *(release)* Update guidelines for handling changelog - ([6a0f1c7](https://git.0xmax42.io/maxp/http-kernel/commit/6a0f1c774bc01ab976090612bbc361576feb3942))
|
||||
- Add README for HttpKernel project - ([a1ce306](https://git.0xmax42.io/maxp/http-kernel/commit/a1ce30627c68a3f869eb6a104308322af8596dc1))
|
||||
- Add MIT license file - ([5118a19](https://git.0xmax42.io/maxp/http-kernel/commit/5118a19aeaa1102591aa7fe093fdec1aa19dc7f5))
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- *(routebuilder)* Add validation tests for handler and middleware - ([b14e9ac](https://git.0xmax42.io/maxp/http-kernel/commit/b14e9acc5f9617a01886e7734b2ae717b86de03e))
|
||||
- *(httpkernel)* Enforce compile-time validation for signatures - ([731bba2](https://git.0xmax42.io/maxp/http-kernel/commit/731bba22d88df077b0a39293ddd1a3eec3bf96e8))
|
||||
- *(bench)* Add parallel benchmarks for HTTP kernel - ([3da34e2](https://git.0xmax42.io/maxp/http-kernel/commit/3da34e268426b92510c7f9b730a2fa297dca6fbf))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(config)* Add CI task for local checks - ([c207dc7](https://git.0xmax42.io/maxp/http-kernel/commit/c207dc7392d9f40e7b7c736eadf6c9c7bbf9b7d4))
|
||||
- *(gitignore)* Add .local directory to ignored files - ([1b447f5](https://git.0xmax42.io/maxp/http-kernel/commit/1b447f51900b3a1a7f1be9d5192fd5aba37bdbc4))
|
||||
- *(ci)* Update deno tasks in CI workflow - ([38c00b0](https://git.0xmax42.io/maxp/http-kernel/commit/38c00b035bfd05c83d5898c97c9423a653db0840))
|
||||
- *(tasks)* Add benchmark, format, and lint commands - ([6e6e616](https://git.0xmax42.io/maxp/http-kernel/commit/6e6e61693fef3b11a81ce260d80bc93edae1e718))
|
||||
- *(workflows)* Consolidate and update CI configuration - ([ec1697d](https://git.0xmax42.io/maxp/http-kernel/commit/ec1697df94b5378f1766663e278a41d403a64336))
|
||||
- *(config)* Add exports field to module metadata - ([c28eb7f](https://git.0xmax42.io/maxp/http-kernel/commit/c28eb7f28dfaa8d3fdc540c4bcc306a3a8b9d6f8))
|
||||
- *(git)* Ignore merge conflicts for CHANGELOG.md - ([6399113](https://git.0xmax42.io/maxp/http-kernel/commit/6399113e122e1207ebf4113aebd250358e31f461))
|
||||
- *(workflows)* Refine branch handling in release process - ([71ea424](https://git.0xmax42.io/maxp/http-kernel/commit/71ea4247b35dc4afe5090d3c6502bfa936b5a947))
|
||||
- *(workflows)* Update changelog file extension to .md and revert b9d25f23fc - ([a88b4d1](https://git.0xmax42.io/maxp/http-kernel/commit/a88b4d112f5c07664d41f6e9d03246307551f25d))
|
||||
- Rename changelog and readme files to use .md extension - ([4f2b650](https://git.0xmax42.io/maxp/http-kernel/commit/4f2b65049f461ef377e7231905fd066cbc3c7fe0))
|
||||
- *(workflows)* Update test workflow for http-kernel project - ([0311546](https://git.0xmax42.io/maxp/http-kernel/commit/03115464e0fb01b8ca00a2fdabde013d004ae8a2))
|
||||
- *(workflows)* Update Deno setup action to v2 - ([1233a0b](https://git.0xmax42.io/maxp/http-kernel/commit/1233a0b7204d12a60f4b7bd1199242a4cb7c4579))
|
||||
- *(workflows)* Remove unused workflow_dispatch trigger - ([16c0053](https://git.0xmax42.io/maxp/http-kernel/commit/16c0053964c72d01e5f555ec8f33c9eead160e69))
|
||||
- *(tasks)* Remove commented-out start and watch scripts - ([04029f8](https://git.0xmax42.io/maxp/http-kernel/commit/04029f87a3b9dd24e8792b852ead9097e18d23c7))
|
||||
|
||||
## [0.1.0] - 2025-05-08
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(workflows)* Add automated changelog and release workflow - ([bbf78cf](https://git.0xmax42.io/maxp/http-kernel/commit/bbf78cff17be0cae651b8abf3e239103b26354bf))
|
||||
- *(vscode)* Customize activity bar and peacock colors - ([56633cd](https://git.0xmax42.io/maxp/http-kernel/commit/56633cd95b37a8b2cfd8eb95982d07cd1f9b5126))
|
||||
- *(workflows)* Add upload assets template for releases - ([7b6eb2b](https://git.0xmax42.io/maxp/http-kernel/commit/7b6eb2b57470198684a1dfa8b668351b8b9a91ae))
|
||||
- *(config)* Add project metadata and test watch task - ([b009b57](https://git.0xmax42.io/maxp/http-kernel/commit/b009b5763d1824fc94fdc1e3d919fe2597158f84))
|
||||
- *(http)* Add error handling for invalid HTTP methods - ([ba7aa79](https://git.0xmax42.io/maxp/http-kernel/commit/ba7aa79f56772213bf73b62bc6bf8810f3871127))
|
||||
- *(http)* Enhance type safety and extend route context - ([a236fa7](https://git.0xmax42.io/maxp/http-kernel/commit/a236fa7c97ae49e6baf560d4ca92c6e83702b3ec))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(params)* Enforce non-undefined route parameter values - ([b0c6901](https://git.0xmax42.io/maxp/http-kernel/commit/b0c6901d7d272ec98b3d00ef2dd2848482892a25))
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(types)* Unify handler and middleware definitions - ([8235680](https://git.0xmax42.io/maxp/http-kernel/commit/8235680904c7f30f25b98b835d48376431108e91))
|
||||
- *(core)* [**breaking**] Enhance HttpKernel pipeline and matcher system with full context and error handling - ([b7410b4](https://git.0xmax42.io/maxp/http-kernel/commit/b7410b44dd8720e46ee2871aa1727ce5039ebad4))
|
||||
- *(httpkernel)* Introduce configuration object for flexibility - ([9059bdd](https://git.0xmax42.io/maxp/http-kernel/commit/9059bdda62081c8e775087cabe4c3406e42065a5))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- *(gitea)* Add release automation guide and scripts - ([5c03cdf](https://git.0xmax42.io/maxp/http-kernel/commit/5c03cdfb031adeb6ee5d0de0889477d6d1efafef))
|
||||
- *(httpkernel)* Enhance class and interface documentation - ([6c4420d](https://git.0xmax42.io/maxp/http-kernel/commit/6c4420d32f8e7fe317f7c1b0b45de2dcf8565ef5))
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- *(utils)* Rename and update import paths in test file - ([82a6877](https://git.0xmax42.io/maxp/http-kernel/commit/82a687748558f15c2023861a0cc3a33095c86731))
|
||||
- *(utils)* Add unit tests for parseQuery function - ([94525fc](https://git.0xmax42.io/maxp/http-kernel/commit/94525fce5299f3417801f0152a475892e1edac30))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(config)* Add default git-cliff configuration - ([661f83d](https://git.0xmax42.io/maxp/http-kernel/commit/661f83d1fd0101aa0d5d06b60f6eeb68efac6ceb))
|
||||
- *(gitignore)* Add .gitea/COMMIT_GPT.md to ignored files - ([f083856](https://git.0xmax42.io/maxp/http-kernel/commit/f0838567b46822327fe739d8de099722e405dfa3))
|
||||
- *(settings)* Add exportall configuration for barrel name and message - ([0990cac](https://git.0xmax42.io/maxp/http-kernel/commit/0990cacb225e1cbbbbb2a288501df7de9641294f))
|
||||
- *(.gitignore)* Add git_log_diff.txt to ignore list - ([fd1c7f4](https://git.0xmax42.io/maxp/http-kernel/commit/fd1c7f4170ffffd55ab276090f8b90ee82b853fc))
|
||||
|
||||
|
||||
18
LICENSE
Normal file
18
LICENSE
Normal file
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 0xMax42
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# HttpKernel – A Type-Safe Router & Middleware Kernel for Deno
|
||||
|
||||
> Fluent routing • Zero-dependency core • 100 % TypeScript
|
||||
|
||||
HttpKernel is a small but powerful dispatching engine that turns an ordinary
|
||||
`Deno.serve()` loop into a structured, middleware-driven HTTP server.
|
||||
It focuses on **type safety**, **immutability**, and an **expressive builder API**
|
||||
while staying framework-agnostic and dependency‑free.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
* **Fluent Route Builder** – chain middleware and handlers without side effects
|
||||
* **Static *and* Dynamic Matching** – use URL patterns *or* custom matcher functions
|
||||
* **First-Class Generics** – strongly‑typed `ctx.params`, `ctx.query`, and `ctx.state`
|
||||
* **Pluggable Error Handling** – override 404/500 (and any other status) per kernel
|
||||
* **Response Decorators** – inject CORS headers, security headers, logging, … in one place
|
||||
* **100 % Test Coverage** – built‑in unit tests ensure every edge case is covered
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```ts
|
||||
// Import directly from your repo or deno.land/x
|
||||
import { HttpKernel } from "https://deno.land/x/httpkernel/mod.ts";
|
||||
|
||||
// 1) Create a kernel (optionally pass overrides)
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
// 2) Register a route with fluent chaining
|
||||
kernel
|
||||
.route({ method: "GET", path: "/hello/:name" })
|
||||
.middleware(async (ctx, next) => {
|
||||
console.log("Incoming request for", ctx.params.name);
|
||||
return await next(); // continue pipeline
|
||||
})
|
||||
.handle(async (ctx) =>
|
||||
new Response(`Hello ${ctx.params.name}!`, { status: 200 })
|
||||
);
|
||||
|
||||
// 3) Let Deno serve the kernel
|
||||
Deno.serve(kernel.handle);
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
deno run --allow-net main.ts
|
||||
# → GET http://localhost:8000/hello/Isaac
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 API Overview
|
||||
|
||||
| Method / Type | Purpose | Hints |
|
||||
| --------------------- | ---------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `kernel.route(def)` | Begin defining a new route. Returns `RouteBuilder`. | `def` can be `{ method, path }` **or** `{ method, matcher }`. |
|
||||
| `.middleware(fn)` | Add a middleware to the current builder. | Each call returns a *new* builder (immutability). |
|
||||
| `.handle(fn)` | Finalise the route and register the handler. | Must be called exactly once per route. |
|
||||
| `kernel.handle(req)` | Kernel entry point you pass to `Deno.serve()`. | Resolves to a `Response`. |
|
||||
|
||||
### Context Shape
|
||||
|
||||
```ts
|
||||
interface Context<S = Record<string, unknown>> {
|
||||
req: Request; // original request
|
||||
params: Record<string>; // route params e.g. { id: "42" }
|
||||
query: Record<string | string[]>; // parsed query string
|
||||
state: S; // per‑request mutable storage
|
||||
}
|
||||
```
|
||||
|
||||
Generics let you supply your own param / query / state types for full IntelliSense.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Configuration
|
||||
|
||||
```ts
|
||||
new HttpKernel({
|
||||
decorateResponse: (res, ctx) => {
|
||||
// add CORS header globally
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("Access-Control-Allow-Origin", "*");
|
||||
return new Response(res.body, { ...res, headers });
|
||||
},
|
||||
httpErrorHandlers: {
|
||||
404: () => new Response("Nothing here ☹️", { status: 404 }),
|
||||
500: (_ctx, err) => {
|
||||
console.error(err);
|
||||
return new Response("Custom 500", { status: 500 });
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Everything is optional – omit what you do not override.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
All logic is covered by unit tests using `std@0.204.0/testing`.
|
||||
Run them with:
|
||||
|
||||
```bash
|
||||
deno test -A
|
||||
```
|
||||
|
||||
The CI suite checks:
|
||||
|
||||
* Route guards (`isStaticRouteDefinition`, `isDynamicRouteDefinition`)
|
||||
* Builder immutability & middleware order
|
||||
* 404 / 500 fall-backs and error propagation
|
||||
* Middleware mis-use (double `next()`, wrong signatures, …)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Roadmap
|
||||
|
||||
* 🔌 Adapter helpers for Oak / Fresh / any framework that can delegate to `kernel.handle`
|
||||
* 🔍 Built‑in logger & timing middleware
|
||||
* 🔒 CSRF & auth middleware presets
|
||||
* 📝 OpenAPI route generator
|
||||
|
||||
Contributions & ideas are welcome – feel free to open an issue or PR.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](LICENSE)
|
||||
104
cliff.toml
Normal file
104
cliff.toml
Normal file
@@ -0,0 +1,104 @@
|
||||
# CLIFF_VERSION=2.8.0
|
||||
# git-cliff ~ default configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
#
|
||||
# Lines starting with "#" are comments.
|
||||
# Configuration options are organized into tables and keys.
|
||||
# See documentation for more information on available options.
|
||||
[remote.gitea]
|
||||
owner = "maxp"
|
||||
repo = "http-kernel"
|
||||
|
||||
[changelog]
|
||||
# postprocessors
|
||||
postprocessors = [
|
||||
{ pattern = '<GITEA_URL>', replace = "https://git.0xmax42.io" }, # replace gitea url
|
||||
]
|
||||
|
||||
# template for the changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
<GITEA_URL>/{{ remote.gitea.owner }}/{{ remote.gitea.repo }}
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version %}\
|
||||
{% if previous.version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}]\
|
||||
({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% endif %}\
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
|
||||
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||
{{ commit.message | upper_first }} - \
|
||||
([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# template for the changelog footer
|
||||
footer = """
|
||||
|
||||
"""
|
||||
# remove the leading and trailing s
|
||||
trim = true
|
||||
|
||||
# render body even when there are no releases to process
|
||||
# render_always = true
|
||||
# output file path
|
||||
# output = "test.md"
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# Replace issue numbers
|
||||
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
|
||||
# Check spelling of the commit with https://github.com/crate-ci/typos
|
||||
# If the spelling is incorrect, it will be automatically fixed.
|
||||
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
|
||||
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
|
||||
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
|
||||
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
|
||||
{ message = "^chore\\(changelog\\)", skip = true },
|
||||
{ message = "^chore\\(version\\)", skip = true },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
||||
{ message = ".*", group = "<!-- 10 -->💼 Other" },
|
||||
]
|
||||
# Regex to select git tags that represent releases.
|
||||
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+"
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "newest"
|
||||
17
deno.jsonc
17
deno.jsonc
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"name": "@0xmax42/http-kernel",
|
||||
"description": "A simple HTTP kernel for Deno",
|
||||
"exports": {
|
||||
"./mod.ts": "./src/mod.ts"
|
||||
},
|
||||
"tasks": {
|
||||
// "start": "deno run --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose",
|
||||
// "watch": "deno run --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose",
|
||||
"test": "deno test --allow-net --allow-env --unstable-kv --allow-read --allow-write --coverage **/__tests__/*.test.ts"
|
||||
"test": "deno test --allow-net --allow-env --unstable-kv --allow-read --allow-write --coverage **/__tests__/*.test.ts",
|
||||
"test:watch": "deno test --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write **/__tests__/*.test.ts",
|
||||
"benchmark": "deno bench --allow-net --allow-env --unstable-kv --allow-read --allow-write **/__bench__/*.bench.ts",
|
||||
"fmt": "deno fmt --check",
|
||||
"lint": "deno lint",
|
||||
"ci": "deno task fmt && deno task lint && deno task test && deno task benchmark" // For local CI checks
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
@@ -24,6 +32,5 @@
|
||||
"src/",
|
||||
"main.ts"
|
||||
]
|
||||
},
|
||||
//"importMap": "./import_map.json"
|
||||
}
|
||||
}
|
||||
25
src/Errors/InvalidHttpMethodError.ts
Normal file
25
src/Errors/InvalidHttpMethodError.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Represents an error thrown when an incoming HTTP method
|
||||
* is not among the recognized set of valid HTTP methods.
|
||||
*
|
||||
* This is typically used in routers or request dispatchers
|
||||
* to enforce allowed methods and produce 405-like behavior.
|
||||
*/
|
||||
export class InvalidHttpMethodError extends Error {
|
||||
/**
|
||||
* The invalid method that triggered this error.
|
||||
*/
|
||||
public readonly method: unknown;
|
||||
|
||||
/**
|
||||
* A fixed HTTP status code representing "Method Not Allowed".
|
||||
*/
|
||||
public readonly status: number = 405;
|
||||
|
||||
constructor(method: unknown) {
|
||||
const label = typeof method === 'string' ? method : '[non-string]';
|
||||
super(`Unsupported HTTP method: ${label}`);
|
||||
this.name = 'InvalidHttpMethodError';
|
||||
this.method = method;
|
||||
}
|
||||
}
|
||||
3
src/Errors/mod.ts
Normal file
3
src/Errors/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// deno-coverage-ignore-file
|
||||
|
||||
export { InvalidHttpMethodError } from './InvalidHttpMethodError.ts';
|
||||
@@ -1,56 +1,96 @@
|
||||
import {
|
||||
import type {
|
||||
IContext,
|
||||
IHandler,
|
||||
IHttpKernel,
|
||||
IHttpKernelConfig,
|
||||
IInternalRoute,
|
||||
IMiddleware,
|
||||
IRouteBuilder,
|
||||
IRouteBuilderFactory,
|
||||
IRouteDefinition,
|
||||
ResponseDecorator,
|
||||
} from './Interfaces/mod.ts';
|
||||
import {
|
||||
type DeepPartial,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
HttpStatusTextMap,
|
||||
} from './Types/mod.ts';
|
||||
import { RouteBuilder } from './RouteBuilder.ts';
|
||||
import { createEmptyContext, normalizeError } from './Utils/mod.ts';
|
||||
|
||||
/**
|
||||
* The central HTTP kernel responsible for managing route definitions,
|
||||
* executing middleware chains, and dispatching HTTP requests to their handlers.
|
||||
* The `HttpKernel` is the central routing engine that manages the full HTTP request lifecycle.
|
||||
*
|
||||
* This class supports a fluent API for route registration and allows the injection
|
||||
* of custom response decorators and route builder factories for maximum flexibility and testability.
|
||||
* It enables:
|
||||
* - Dynamic and static route registration via a fluent API
|
||||
* - Execution of typed middleware chains and final route handlers
|
||||
* - Injection of response decorators and factory overrides
|
||||
* - Fine-grained error handling via typed status-code-based handlers
|
||||
*
|
||||
* The kernel is designed with generics for flexible context typing, strong type safety,
|
||||
* and a clear extension point for advanced routing, DI, or tracing logic.
|
||||
*
|
||||
* @typeParam TContext - The global context type used for all requests handled by this kernel.
|
||||
*/
|
||||
export class HttpKernel implements IHttpKernel {
|
||||
/**
|
||||
* The list of internally registered routes, each with method, matcher, middleware, and handler.
|
||||
*/
|
||||
private routes: IInternalRoute[] = [];
|
||||
export class HttpKernel<TContext extends IContext = IContext>
|
||||
implements IHttpKernel<TContext> {
|
||||
private cfg: IHttpKernelConfig<TContext>;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the `HttpKernel`.
|
||||
* The list of registered route definitions, including method, matcher,
|
||||
* middleware pipeline, and final handler.
|
||||
*/
|
||||
private routes: IInternalRoute<TContext>[] = [];
|
||||
|
||||
/**
|
||||
* Initializes the `HttpKernel` with optional configuration overrides.
|
||||
*
|
||||
* @param decorateResponse - An optional response decorator function that is applied to all responses
|
||||
* after the middleware/handler pipeline. Defaults to identity (no modification).
|
||||
* @param routeBuilderFactory - Optional factory for creating route builders. Defaults to using `RouteBuilder`.
|
||||
* Default components such as the route builder factory, response decorator,
|
||||
* and 404/500 error handlers can be replaced by injecting a partial config.
|
||||
* Any omitted values fall back to sensible defaults.
|
||||
*
|
||||
* @param config - Partial kernel configuration. Missing fields are filled with defaults.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly decorateResponse: ResponseDecorator = (res) => res,
|
||||
private readonly routeBuilderFactory: IRouteBuilderFactory =
|
||||
RouteBuilder,
|
||||
) {}
|
||||
config?: DeepPartial<IHttpKernelConfig<TContext>>,
|
||||
) {
|
||||
this.cfg = {
|
||||
decorateResponse: (res) => res,
|
||||
routeBuilderFactory: RouteBuilder,
|
||||
httpErrorHandlers: {
|
||||
[HTTP_404_NOT_FOUND]: () =>
|
||||
new Response(HttpStatusTextMap[HTTP_404_NOT_FOUND], {
|
||||
status: HTTP_404_NOT_FOUND,
|
||||
}),
|
||||
[HTTP_500_INTERNAL_SERVER_ERROR]: () =>
|
||||
new Response(
|
||||
HttpStatusTextMap[HTTP_500_INTERNAL_SERVER_ERROR],
|
||||
{
|
||||
status: HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
),
|
||||
...(config?.httpErrorHandlers ?? {}),
|
||||
},
|
||||
...config,
|
||||
} as IHttpKernelConfig<TContext>;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public route(definition: IRouteDefinition): IRouteBuilder {
|
||||
return new this.routeBuilderFactory(
|
||||
this.registerRoute.bind(this),
|
||||
definition,
|
||||
);
|
||||
this.handle = this.handle.bind(this);
|
||||
this.registerRoute = this.registerRoute.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public handle = async (request: Request): Promise<Response> => {
|
||||
public route<_TContext extends IContext = TContext>(
|
||||
definition: IRouteDefinition,
|
||||
): IRouteBuilder<_TContext> {
|
||||
return new this.cfg.routeBuilderFactory(
|
||||
this.registerRoute,
|
||||
definition,
|
||||
) as IRouteBuilder<_TContext>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
@@ -58,64 +98,47 @@ export class HttpKernel implements IHttpKernel {
|
||||
if (route.method !== method) continue;
|
||||
const match = route.matcher(url, request);
|
||||
if (match) {
|
||||
const ctx: IContext = {
|
||||
const ctx: TContext = {
|
||||
req: request,
|
||||
params: match.params,
|
||||
query: match.query,
|
||||
state: {},
|
||||
};
|
||||
return await this.executePipeline(
|
||||
ctx,
|
||||
route.middlewares,
|
||||
route.handler,
|
||||
);
|
||||
} as TContext;
|
||||
try {
|
||||
const response = await route.runRoute(ctx);
|
||||
return this.cfg.decorateResponse(response, ctx);
|
||||
} catch (e) {
|
||||
return await this.handleInternalError(ctx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
return this.cfg.httpErrorHandlers[HTTP_404_NOT_FOUND](
|
||||
createEmptyContext<TContext>(request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes and registers a route within the kernel.
|
||||
*
|
||||
* This method is invoked internally by the route builder once
|
||||
* `.handle()` is called. It appends the route to the internal list.
|
||||
*
|
||||
* @param route - A fully constructed internal route object.
|
||||
*/
|
||||
private registerRoute<_TContext extends IContext = TContext>(
|
||||
route: IInternalRoute<_TContext>,
|
||||
): void {
|
||||
this.routes.push(route as unknown as IInternalRoute<TContext>);
|
||||
}
|
||||
|
||||
private handleInternalError = (
|
||||
ctx: TContext,
|
||||
err?: unknown,
|
||||
): Response | Promise<Response> => {
|
||||
return this.cfg.httpErrorHandlers[HTTP_500_INTERNAL_SERVER_ERROR](
|
||||
ctx,
|
||||
normalizeError(err),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a finalized route by pushing it into the internal route list.
|
||||
*
|
||||
* This method is typically called by the route builder after `.handle()` is invoked.
|
||||
*
|
||||
* @param route - The fully constructed route including matcher, middlewares, and handler.
|
||||
*/
|
||||
private registerRoute(route: IInternalRoute): void {
|
||||
this.routes.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the middleware pipeline and final handler for a given request context.
|
||||
*
|
||||
* This function recursively invokes middleware in the order they were registered,
|
||||
* ending with the route's final handler. If a middleware returns a response directly
|
||||
* without calling `next()`, the pipeline is short-circuited.
|
||||
*
|
||||
* The final response is passed through the `decorateResponse` function before being returned.
|
||||
*
|
||||
* @param ctx - The request context containing the request, parameters, and shared state.
|
||||
* @param middleware - The ordered list of middleware to apply before the handler.
|
||||
* @param handler - The final request handler to invoke at the end of the pipeline.
|
||||
* @returns The final HTTP response after middleware and decoration.
|
||||
*/
|
||||
private async executePipeline(
|
||||
ctx: IContext,
|
||||
middleware: IMiddleware[],
|
||||
handler: IHandler,
|
||||
): Promise<Response> {
|
||||
let i = -1;
|
||||
const dispatch = async (index: number): Promise<Response> => {
|
||||
if (index <= i) throw new Error('next() called multiple times');
|
||||
i = index;
|
||||
const fn: IMiddleware | IHandler = index < middleware.length
|
||||
? middleware[index]
|
||||
: handler;
|
||||
if (!fn) return new Response('Internal error', { status: 500 });
|
||||
return index < middleware.length
|
||||
? await fn(ctx, () => dispatch(index + 1))
|
||||
: await (fn as IHandler)(ctx);
|
||||
};
|
||||
return this.decorateResponse(await dispatch(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { Params, Query, State } from '../Types/mod.ts';
|
||||
|
||||
/**
|
||||
* Represents the per-request context passed through the middleware pipeline and to the final handler.
|
||||
* Represents the complete context for a single HTTP request,
|
||||
* passed through the middleware pipeline and to the final route handler.
|
||||
*
|
||||
* This context object encapsulates the original HTTP request,
|
||||
* the path parameters extracted from the matched route,
|
||||
* and a mutable state object for sharing information across middlewares and handlers.
|
||||
* This context object encapsulates all relevant runtime data for a request,
|
||||
* including the original request, path parameters, query parameters,
|
||||
* and a shared, mutable application state.
|
||||
*
|
||||
* @template TState Structured per-request state shared across middlewares and handlers.
|
||||
* @template TParams Parsed URL path parameters, typically derived from route templates.
|
||||
* @template TQuery Parsed query string parameters, preserving multi-value semantics.
|
||||
*/
|
||||
export interface IContext {
|
||||
export interface IContext<
|
||||
TState extends State = State,
|
||||
TParams extends Params = Params,
|
||||
TQuery extends Query = Query,
|
||||
> {
|
||||
/**
|
||||
* The original HTTP request object as received by Deno.
|
||||
* Contains all standard fields like headers, method, body, etc.
|
||||
@@ -18,14 +29,25 @@ export interface IContext {
|
||||
*
|
||||
* These parameters are considered read-only and are set by the router.
|
||||
*/
|
||||
params: Record<string, string>;
|
||||
params: TParams;
|
||||
|
||||
/**
|
||||
* A shared, mutable object used to pass arbitrary data between middlewares and handlers.
|
||||
* Query parameters extracted from the request URL's search string.
|
||||
*
|
||||
* Use this field to attach validated user info, auth state, logging context, etc.
|
||||
* Values may occur multiple times (e.g., `?tag=ts&tag=deno`), and are therefore
|
||||
* represented as either a string or an array of strings, depending on occurrence.
|
||||
*
|
||||
* Each key should be well-named to avoid collisions across layers.
|
||||
* Use this field to access filters, flags, pagination info, or similar modifiers.
|
||||
*/
|
||||
state: Record<string, unknown>;
|
||||
query: TQuery;
|
||||
|
||||
/**
|
||||
* A typed, mutable object used to pass structured data between middlewares and handlers.
|
||||
*
|
||||
* This object is ideal for sharing validated input, user identity, trace information,
|
||||
* or other contextual state throughout the request lifecycle.
|
||||
*
|
||||
* Type-safe access to fields is ensured by the generic `TState` type.
|
||||
*/
|
||||
state: TState;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { IContext } from './IContext.ts';
|
||||
|
||||
/**
|
||||
* Represents a final request handler responsible for generating a response.
|
||||
*
|
||||
* The handler is the last step in the middleware pipeline and must return
|
||||
* a valid HTTP `Response`. It has access to all data injected into the
|
||||
* request context, including path parameters and any state added by middleware.
|
||||
*/
|
||||
export interface IHandler {
|
||||
/**
|
||||
* @param ctx - The complete request context, including parameters and middleware state.
|
||||
* @returns A promise resolving to an HTTP `Response`.
|
||||
*/
|
||||
(ctx: IContext): Promise<Response>;
|
||||
}
|
||||
40
src/Interfaces/IHttpErrorHandlers.ts
Normal file
40
src/Interfaces/IHttpErrorHandlers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IContext } from '../Interfaces/mod.ts';
|
||||
import type { HttpErrorHandler, validHttpErrorCodes } from '../Types/mod.ts';
|
||||
|
||||
/**
|
||||
* A mapping of HTTP status codes to their corresponding error handlers.
|
||||
*
|
||||
* This interface defines required handlers for common critical status codes (404 and 500)
|
||||
* and allows optional handlers for all other known error codes defined in `validHttpErrorCodes`.
|
||||
*
|
||||
* This hybrid approach ensures predictable handling for key failure cases,
|
||||
* while remaining flexible for less common codes.
|
||||
*
|
||||
* @template TContext - The context type used in all error handlers.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const errorHandlers: IHttpErrorHandlers = {
|
||||
* 404: (ctx) => new Response("Not Found", { status: 404 }),
|
||||
* 500: (ctx, err) => {
|
||||
* console.error(err);
|
||||
* return new Response("Internal Server Error", { status: 500 });
|
||||
* },
|
||||
* 429: (ctx) => new Response("Too Many Requests", { status: 429 }),
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface IHttpErrorHandlers<TContext extends IContext = IContext>
|
||||
extends
|
||||
Partial<
|
||||
Record<
|
||||
Exclude<typeof validHttpErrorCodes[number], 404 | 500>,
|
||||
HttpErrorHandler<TContext>
|
||||
>
|
||||
> {
|
||||
/** Required error handler for HTTP 404 (Not Found). */
|
||||
404: HttpErrorHandler<TContext>;
|
||||
|
||||
/** Required error handler for HTTP 500 (Internal Server Error). */
|
||||
500: HttpErrorHandler<TContext>;
|
||||
}
|
||||
@@ -1,33 +1,49 @@
|
||||
import { IRouteBuilder } from './IRouteBuilder.ts';
|
||||
import { IRouteDefinition } from './IRouteDefinition.ts';
|
||||
import type { IContext } from './IContext.ts';
|
||||
import type { IRouteBuilder } from './IRouteBuilder.ts';
|
||||
import type { IRouteDefinition } from './IRouteDefinition.ts';
|
||||
|
||||
/**
|
||||
* Defines the core interface for the HTTP kernel, responsible for route registration,
|
||||
* middleware orchestration, and request dispatching.
|
||||
* The `IHttpKernel` interface defines the public API for a type-safe, middleware-driven HTTP dispatching system.
|
||||
*
|
||||
* Implementations of this interface are responsible for:
|
||||
* - Registering routes with optional per-route context typing
|
||||
* - Handling incoming requests by matching and dispatching to appropriate handlers
|
||||
* - Managing the complete middleware pipeline and final response generation
|
||||
*
|
||||
* The kernel operates on a customizable `IContext` type to support strongly typed request parameters, state,
|
||||
* and query values across the entire routing lifecycle.
|
||||
*
|
||||
* @typeParam TContext - The default context type used for all routes unless overridden per-route.
|
||||
*/
|
||||
export interface IHttpKernel {
|
||||
export interface IHttpKernel<TContext extends IContext = IContext> {
|
||||
/**
|
||||
* Registers a new route with a static path pattern or a dynamic matcher.
|
||||
* Registers a new HTTP route (static or dynamic) and returns a route builder for middleware/handler chaining.
|
||||
*
|
||||
* This method accepts both conventional route definitions (with path templates)
|
||||
* and advanced matcher-based routes for flexible URL structures.
|
||||
* This method supports contextual polymorphism via the `_TContext` type parameter, enabling fine-grained
|
||||
* typing of route-specific `params`, `query`, and `state` values. The route is not registered until
|
||||
* `.handle()` is called on the returned builder.
|
||||
*
|
||||
* Returns a route builder that allows chaining middleware and assigning a handler.
|
||||
* @typeParam _TContext - An optional override for the context type specific to this route.
|
||||
* Falls back to the global `TContext` of the kernel if omitted.
|
||||
*
|
||||
* @param definition - A static or dynamic route definition, including the HTTP method
|
||||
* and either a path pattern or custom matcher function.
|
||||
* @returns A builder interface to attach middleware and define the handler.
|
||||
* @param definition - A route definition specifying the HTTP method and path or custom matcher.
|
||||
* @returns A fluent builder interface to define middleware and attach a final handler.
|
||||
*/
|
||||
route(definition: IRouteDefinition): IRouteBuilder;
|
||||
route<_TContext extends IContext = TContext>(
|
||||
definition: IRouteDefinition,
|
||||
): IRouteBuilder<_TContext>;
|
||||
|
||||
/**
|
||||
* Handles an incoming HTTP request by matching it against registered routes,
|
||||
* executing any associated middleware in order, and invoking the final route handler.
|
||||
* Handles an incoming HTTP request and produces a `Response`.
|
||||
*
|
||||
* This method serves as the main entry point to integrate with `Deno.serve`.
|
||||
* The kernel matches the request against all registered routes by method and matcher,
|
||||
* constructs a typed context, and executes the middleware/handler pipeline.
|
||||
* If no route matches, a 404 error handler is invoked.
|
||||
*
|
||||
* This method is designed to be passed directly to `Deno.serve()` or similar server frameworks.
|
||||
*
|
||||
* @param request - The incoming HTTP request object.
|
||||
* @returns A promise resolving to the final HTTP response.
|
||||
* @returns A `Promise` resolving to a complete HTTP response.
|
||||
*/
|
||||
handle(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
10
src/Interfaces/IHttpKernelConfig.ts
Normal file
10
src/Interfaces/IHttpKernelConfig.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ResponseDecorator } from '../Types/mod.ts';
|
||||
import type { IContext } from './IContext.ts';
|
||||
import type { IHttpErrorHandlers } from './IHttpErrorHandlers.ts';
|
||||
import type { IRouteBuilderFactory } from './IRouteBuilder.ts';
|
||||
|
||||
export interface IHttpKernelConfig<TContext extends IContext = IContext> {
|
||||
decorateResponse: ResponseDecorator<TContext>;
|
||||
routeBuilderFactory: IRouteBuilderFactory;
|
||||
httpErrorHandlers: IHttpErrorHandlers<TContext>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IHandler } from './IHandler.ts';
|
||||
import { IMiddleware } from './IMiddleware.ts';
|
||||
import type { Handler, HttpMethod, Middleware } from '../Types/mod.ts';
|
||||
import type { IContext, IRouteMatcher } from './mod.ts';
|
||||
|
||||
/**
|
||||
* Represents an internally registered route within the HttpKernel.
|
||||
@@ -7,12 +7,12 @@ import { IMiddleware } from './IMiddleware.ts';
|
||||
* Contains all data required to match an incoming request and dispatch it
|
||||
* through the associated middleware chain and final handler.
|
||||
*/
|
||||
export interface IInternalRoute {
|
||||
export interface IInternalRoute<TContext extends IContext = IContext> {
|
||||
/**
|
||||
* The HTTP method (e.g. 'GET', 'POST') that this route responds to.
|
||||
* The method should always be in uppercase.
|
||||
*/
|
||||
method: string;
|
||||
method: HttpMethod;
|
||||
|
||||
/**
|
||||
* A matcher function used to determine whether this route matches a given request.
|
||||
@@ -25,18 +25,40 @@ export interface IInternalRoute {
|
||||
* @param req - The original Request object.
|
||||
* @returns An object with extracted path parameters, or `null` if not matched.
|
||||
*/
|
||||
matcher: (
|
||||
url: URL,
|
||||
req: Request,
|
||||
) => null | { params: Record<string, string> };
|
||||
matcher: IRouteMatcher;
|
||||
|
||||
/**
|
||||
* An ordered list of middleware functions to be executed before the handler.
|
||||
*/
|
||||
middlewares: IMiddleware[];
|
||||
middlewares: Middleware<TContext>[];
|
||||
|
||||
/**
|
||||
* The final handler that generates the HTTP response after all middleware has run.
|
||||
*/
|
||||
handler: IHandler;
|
||||
handler: Handler<TContext>;
|
||||
|
||||
/**
|
||||
* The fully compiled execution pipeline for this route.
|
||||
*
|
||||
* This function is generated at route registration time and encapsulates the
|
||||
* entire middleware chain as well as the final handler. It is called by the
|
||||
* HttpKernel during request dispatch when a route has been matched.
|
||||
*
|
||||
* Internally, `runRoute` ensures that each middleware is invoked in the correct order
|
||||
* and receives a `next()` callback to pass control downstream. The final handler is
|
||||
* invoked once all middleware has completed or short-circuited the pipeline.
|
||||
*
|
||||
* It is guaranteed that:
|
||||
* - The function is statically compiled and does not perform dynamic dispatching.
|
||||
* - Each middleware can only call `next()` once; repeated invocations will throw.
|
||||
* - The return value is either a `Response` or a Promise resolving to one.
|
||||
*
|
||||
* @param ctx - The context object carrying route, request, response and other scoped data.
|
||||
* @returns A `Response` object or a Promise resolving to a `Response`.
|
||||
*
|
||||
* @throws {Error} If a middleware calls `next()` more than once.
|
||||
*/
|
||||
runRoute: (
|
||||
ctx: TContext,
|
||||
) => Promise<Response> | Response;
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { IContext } from './IContext.ts';
|
||||
|
||||
/**
|
||||
* Represents a middleware function in the HTTP request pipeline.
|
||||
*
|
||||
* Middleware can perform tasks such as logging, authentication, validation,
|
||||
* or response transformation. It receives the current request context and
|
||||
* a `next()` function to delegate control to the next middleware or final handler.
|
||||
*
|
||||
* To stop the request pipeline, a middleware can return a `Response` directly
|
||||
* without calling `next()`.
|
||||
*/
|
||||
export interface IMiddleware {
|
||||
/**
|
||||
* @param ctx - The request context, containing the request, path parameters, and shared state.
|
||||
* @param next - A function that continues the middleware pipeline. Returns the final `Response`.
|
||||
* @returns A promise resolving to an HTTP `Response`.
|
||||
*/
|
||||
(ctx: IContext, next: () => Promise<Response>): Promise<Response>;
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import { IHandler } from './IHandler.ts';
|
||||
import { IInternalRoute } from './IInternalRoute.ts';
|
||||
import { IMiddleware } from './IMiddleware.ts';
|
||||
import { IRouteDefinition } from './IRouteDefinition.ts';
|
||||
import type { Handler, Middleware } from '../Types/mod.ts';
|
||||
import type { IInternalRoute } from './IInternalRoute.ts';
|
||||
import type { IRouteDefinition } from './IRouteDefinition.ts';
|
||||
import type { IContext } from './mod.ts';
|
||||
|
||||
export interface IRouteBuilderFactory {
|
||||
export interface IRouteBuilderFactory<TContext extends IContext = IContext> {
|
||||
new (
|
||||
registerRoute: (route: IInternalRoute) => void,
|
||||
registerRoute: (route: IInternalRoute<TContext>) => void,
|
||||
def: IRouteDefinition,
|
||||
mws?: IMiddleware[],
|
||||
): IRouteBuilder;
|
||||
mws?: Middleware<TContext>[],
|
||||
): IRouteBuilder<TContext>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a fluent API to build a single route configuration by chaining
|
||||
* middleware and setting the final request handler.
|
||||
*/
|
||||
export interface IRouteBuilder {
|
||||
export interface IRouteBuilder<TContext extends IContext = IContext> {
|
||||
/**
|
||||
* Adds a middleware to the current route.
|
||||
* Middleware will be executed in the order of registration.
|
||||
@@ -23,7 +23,9 @@ export interface IRouteBuilder {
|
||||
* @param mw - A middleware function.
|
||||
* @returns The route builder for further chaining.
|
||||
*/
|
||||
middleware(mw: IMiddleware): IRouteBuilder;
|
||||
middleware(
|
||||
mw: Middleware<TContext>,
|
||||
): IRouteBuilder<TContext>;
|
||||
|
||||
/**
|
||||
* Sets the final request handler for the route.
|
||||
@@ -31,5 +33,7 @@ export interface IRouteBuilder {
|
||||
*
|
||||
* @param handler - The function to execute when this route is matched.
|
||||
*/
|
||||
handle(handler: IHandler): void;
|
||||
handle(
|
||||
handler: Handler<TContext>,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IRouteMatcher } from './IRouteMatcher.ts';
|
||||
import { type HttpMethod, isHttpMethod } from '../Types/mod.ts';
|
||||
import type { IRouteMatcher } from './IRouteMatcher.ts';
|
||||
|
||||
/**
|
||||
* Defines a static route using a path pattern with optional parameters.
|
||||
@@ -10,7 +11,7 @@ export interface IStaticRouteDefinition {
|
||||
/**
|
||||
* The HTTP method this route should match (e.g. "GET", "POST").
|
||||
*/
|
||||
method: string;
|
||||
method: HttpMethod;
|
||||
|
||||
/**
|
||||
* A static path pattern for the route, which may include named parameters
|
||||
@@ -29,7 +30,7 @@ export interface IDynamicRouteDefinition {
|
||||
/**
|
||||
* The HTTP method this route should match (e.g. "GET", "POST").
|
||||
*/
|
||||
method: string;
|
||||
method: HttpMethod;
|
||||
|
||||
/**
|
||||
* A custom matcher function that receives the parsed URL and raw request.
|
||||
@@ -44,3 +45,47 @@ export interface IDynamicRouteDefinition {
|
||||
* or a dynamic route with a custom matcher function for advanced matching logic.
|
||||
*/
|
||||
export type IRouteDefinition = IStaticRouteDefinition | IDynamicRouteDefinition;
|
||||
|
||||
/**
|
||||
* Type guard to check whether a route definition is a valid static route definition.
|
||||
*
|
||||
* Ensures that the object:
|
||||
* - has a `method` property of type `HttpMethod`
|
||||
* - has a `path` property of type `string`
|
||||
* - does NOT have a `matcher` function (to avoid ambiguous mixed types)
|
||||
*/
|
||||
export function isStaticRouteDefinition(
|
||||
def: IRouteDefinition,
|
||||
): def is IStaticRouteDefinition {
|
||||
return (
|
||||
def &&
|
||||
typeof def === 'object' &&
|
||||
'method' in def &&
|
||||
isHttpMethod(def.method) &&
|
||||
'path' in def &&
|
||||
typeof (def as { path?: unknown }).path === 'string' &&
|
||||
!('matcher' in def)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check whether a route definition is a valid dynamic route definition.
|
||||
*
|
||||
* Ensures that the object:
|
||||
* - has a `method` property of type `HttpMethod`
|
||||
* - has a `matcher` property of type `function`
|
||||
* - does NOT have a `path` property (to avoid ambiguous mixed types)
|
||||
*/
|
||||
export function isDynamicRouteDefinition(
|
||||
def: IRouteDefinition,
|
||||
): def is IDynamicRouteDefinition {
|
||||
return (
|
||||
def &&
|
||||
typeof def === 'object' &&
|
||||
'method' in def &&
|
||||
isHttpMethod(def.method) &&
|
||||
'matcher' in def &&
|
||||
typeof (def as { matcher?: unknown }).matcher === 'function' &&
|
||||
!('path' in def)
|
||||
);
|
||||
}
|
||||
|
||||
6
src/Interfaces/IRouteMatch.ts
Normal file
6
src/Interfaces/IRouteMatch.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Params, Query } from '../Types/mod.ts';
|
||||
|
||||
export interface IRouteMatch {
|
||||
params?: Params;
|
||||
query?: Query;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IRouteDefinition } from './IRouteDefinition.ts';
|
||||
import type { IRouteDefinition } from './IRouteDefinition.ts';
|
||||
import type { IRouteMatch } from './IRouteMatch.ts';
|
||||
|
||||
/**
|
||||
* Defines a route matcher function that evaluates whether a route applies to a given request.
|
||||
@@ -14,7 +15,7 @@ export interface IRouteMatcher {
|
||||
* @param req - The raw Request object (may be used for context or headers).
|
||||
* @returns An object containing path parameters if matched, or `null` if not matched.
|
||||
*/
|
||||
(url: URL, req: Request): null | { params: Record<string, string> };
|
||||
(url: URL, req: Request): null | IRouteMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
43
src/Interfaces/__tests__/routeDefinitionGuards.test.ts
Normal file
43
src/Interfaces/__tests__/routeDefinitionGuards.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
|
||||
import {
|
||||
type IRouteDefinition,
|
||||
isDynamicRouteDefinition,
|
||||
isStaticRouteDefinition,
|
||||
} from '../IRouteDefinition.ts';
|
||||
|
||||
Deno.test('isStaticRouteDefinition returns true for static route', () => {
|
||||
const staticDef: IRouteDefinition = {
|
||||
method: 'GET',
|
||||
path: '/users/:id',
|
||||
};
|
||||
|
||||
assertEquals(isStaticRouteDefinition(staticDef), true);
|
||||
assertEquals(isDynamicRouteDefinition(staticDef), false);
|
||||
});
|
||||
|
||||
Deno.test('isDynamicRouteDefinition returns true for dynamic route', () => {
|
||||
const dynamicDef: IRouteDefinition = {
|
||||
method: 'POST',
|
||||
matcher: (_url, _req) => ({ params: {} }),
|
||||
};
|
||||
|
||||
assertEquals(isDynamicRouteDefinition(dynamicDef), true);
|
||||
assertEquals(isStaticRouteDefinition(dynamicDef), false);
|
||||
});
|
||||
|
||||
Deno.test('isStaticRouteDefinition returns false for invalid object', () => {
|
||||
const invalidDef = {
|
||||
method: 'GET',
|
||||
} as unknown as IRouteDefinition;
|
||||
|
||||
assertEquals(isStaticRouteDefinition(invalidDef), false);
|
||||
});
|
||||
|
||||
Deno.test('isDynamicRouteDefinition returns false for object with no matcher', () => {
|
||||
const def = {
|
||||
method: 'DELETE',
|
||||
path: '/something',
|
||||
};
|
||||
|
||||
assertEquals(isDynamicRouteDefinition(def as IRouteDefinition), false);
|
||||
});
|
||||
@@ -1,13 +1,19 @@
|
||||
// deno-coverage-ignore-file
|
||||
|
||||
export type { IContext } from './IContext.ts';
|
||||
export type { IMiddleware } from './IMiddleware.ts';
|
||||
export type { IHandler } from './IHandler.ts';
|
||||
export type { IHttpErrorHandlers } from './IHttpErrorHandlers.ts';
|
||||
export type { IHttpKernel } from './IHttpKernel.ts';
|
||||
export type { IHttpKernelConfig } from './IHttpKernelConfig.ts';
|
||||
export type { IInternalRoute } from './IInternalRoute.ts';
|
||||
export type { IRouteBuilder, IRouteBuilderFactory } from './IRouteBuilder.ts';
|
||||
export {
|
||||
isDynamicRouteDefinition,
|
||||
isStaticRouteDefinition,
|
||||
} from './IRouteDefinition.ts';
|
||||
export type {
|
||||
IDynamicRouteDefinition,
|
||||
IRouteDefinition,
|
||||
IStaticRouteDefinition,
|
||||
} from './IRouteDefinition.ts';
|
||||
export type { IInternalRoute } from './IInternalRoute.ts';
|
||||
export type { IRouteMatcher } from './IRouteMatcher.ts';
|
||||
export type { ResponseDecorator } from './ResponseDecorator.ts';
|
||||
export type { IRouteMatch } from './IRouteMatch.ts';
|
||||
export type { IRouteMatcher, IRouteMatcherFactory } from './IRouteMatcher.ts';
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { IRouteMatcherFactory } from './Interfaces/IRouteMatcher.ts';
|
||||
import {
|
||||
IHandler,
|
||||
import type { IRouteMatcherFactory } from './Interfaces/IRouteMatcher.ts';
|
||||
import type {
|
||||
IContext,
|
||||
IInternalRoute,
|
||||
IMiddleware,
|
||||
IRouteBuilder,
|
||||
IRouteDefinition,
|
||||
} from './Interfaces/mod.ts';
|
||||
import { createRouteMatcher } from './Utils.ts';
|
||||
import { isHandler } from './Types/Handler.ts';
|
||||
import {
|
||||
type Handler,
|
||||
isMiddleware,
|
||||
type Middleware,
|
||||
type RegisterRoute,
|
||||
} from './Types/mod.ts';
|
||||
import { createRouteMatcher } from './Utils/createRouteMatcher.ts';
|
||||
|
||||
/**
|
||||
* Provides a fluent builder interface for defining a single route,
|
||||
@@ -14,7 +20,8 @@ import { createRouteMatcher } from './Utils.ts';
|
||||
*
|
||||
* This builder is stateless and immutable; each chained call returns a new instance.
|
||||
*/
|
||||
export class RouteBuilder implements IRouteBuilder {
|
||||
export class RouteBuilder<TContext extends IContext = IContext>
|
||||
implements IRouteBuilder<TContext> {
|
||||
/**
|
||||
* Constructs a new instance of the route builder.
|
||||
*
|
||||
@@ -23,9 +30,9 @@ export class RouteBuilder implements IRouteBuilder {
|
||||
* @param mws - The list of middleware functions collected so far (default: empty).
|
||||
*/
|
||||
constructor(
|
||||
private readonly registerRoute: (route: IInternalRoute) => void,
|
||||
private readonly registerRoute: RegisterRoute<TContext>,
|
||||
private readonly def: IRouteDefinition,
|
||||
private readonly mws: IMiddleware[] = [],
|
||||
private readonly mws: Middleware<TContext>[] = [],
|
||||
private readonly matcherFactory: IRouteMatcherFactory =
|
||||
createRouteMatcher,
|
||||
) {}
|
||||
@@ -39,11 +46,14 @@ export class RouteBuilder implements IRouteBuilder {
|
||||
* @param mw - A middleware function to be executed before the handler.
|
||||
* @returns A new `RouteBuilder` instance for continued chaining.
|
||||
*/
|
||||
middleware(mw: IMiddleware): IRouteBuilder {
|
||||
return new RouteBuilder(this.registerRoute, this.def, [
|
||||
...this.mws,
|
||||
mw,
|
||||
]);
|
||||
middleware(
|
||||
mw: Middleware<TContext>,
|
||||
): IRouteBuilder<TContext> {
|
||||
return new RouteBuilder<TContext>(
|
||||
this.registerRoute,
|
||||
this.def,
|
||||
[...this.mws, mw],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,13 +64,85 @@ export class RouteBuilder implements IRouteBuilder {
|
||||
*
|
||||
* @param handler - The final request handler for this route.
|
||||
*/
|
||||
handle(handler: IHandler): void {
|
||||
handle(
|
||||
handler: Handler<TContext>,
|
||||
): void {
|
||||
const matcher = this.matcherFactory(this.def);
|
||||
this.registerRoute({
|
||||
method: this.def.method.toUpperCase(),
|
||||
method: this.def.method,
|
||||
matcher,
|
||||
middlewares: this.mws,
|
||||
handler,
|
||||
handler: handler,
|
||||
runRoute: this.compile({
|
||||
middlewares: this.mws,
|
||||
handler: handler,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the middleware chain and handler into a single executable function.
|
||||
*
|
||||
* This method constructs a statically linked function chain by reducing all middleware
|
||||
* and the final handler into one composed `runRoute` function. Each middleware receives
|
||||
* a `next()` callback that invokes the next function in the chain.
|
||||
*
|
||||
* Additionally, the returned function ensures that `next()` can only be called once
|
||||
* per middleware. If `next()` is invoked multiple times within the same middleware,
|
||||
* a runtime `Error` is thrown, preventing unintended double-processing.
|
||||
*
|
||||
* Type safety is enforced at compile time:
|
||||
* - If the final handler does not match the expected signature, a `TypeError` is thrown.
|
||||
* - If any middleware does not conform to the middleware interface, a `TypeError` is thrown.
|
||||
*
|
||||
* @param route - A partial route object containing middleware and handler,
|
||||
* excluding `matcher`, `method`, and `runRoute`.
|
||||
* @returns A composed route execution function that takes a context object
|
||||
* and returns a `Promise<Response>`.
|
||||
*
|
||||
* @throws {TypeError} If the handler or any middleware function is invalid.
|
||||
* @throws {Error} If a middleware calls `next()` more than once during execution.
|
||||
*/
|
||||
private compile(
|
||||
route: Omit<
|
||||
IInternalRoute<TContext>,
|
||||
'runRoute' | 'matcher' | 'method'
|
||||
>,
|
||||
): (
|
||||
ctx: TContext,
|
||||
) => Promise<Response> {
|
||||
if (!isHandler<TContext>(route.handler)) {
|
||||
throw new TypeError(
|
||||
'Route handler must be a function returning a Promise<Response>.',
|
||||
);
|
||||
}
|
||||
let composed = route.handler;
|
||||
|
||||
for (let i = route.middlewares.length - 1; i >= 0; i--) {
|
||||
if (!isMiddleware<TContext>(route.middlewares[i])) {
|
||||
throw new TypeError(
|
||||
`Middleware at index ${i} is not a valid function.`,
|
||||
);
|
||||
}
|
||||
|
||||
const current = route.middlewares[i];
|
||||
const next = composed;
|
||||
|
||||
composed = async (ctx: TContext): Promise<Response> => {
|
||||
let called = false;
|
||||
|
||||
return await current(ctx, async () => {
|
||||
if (called) {
|
||||
throw new Error(
|
||||
`next() called multiple times in middleware at index ${i}`,
|
||||
);
|
||||
}
|
||||
called = true;
|
||||
return await next(ctx);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return composed;
|
||||
}
|
||||
}
|
||||
|
||||
4
src/Types/DeepPartial.ts
Normal file
4
src/Types/DeepPartial.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
57
src/Types/Handler.ts
Normal file
57
src/Types/Handler.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { IContext } from '../Interfaces/mod.ts';
|
||||
|
||||
/**
|
||||
* Represents a final request handler responsible for producing an HTTP response.
|
||||
*
|
||||
* The handler is the terminal stage of the middleware pipeline and is responsible
|
||||
* for processing the incoming request and generating the final `Response`.
|
||||
*
|
||||
* It receives the fully-typed request context, which includes the original request,
|
||||
* parsed route parameters, query parameters, and any shared state populated by prior middleware.
|
||||
*
|
||||
* @template TContext The specific context type for this handler, including typed `state`, `params`, and `query`.
|
||||
*/
|
||||
type Handler<TContext extends IContext = IContext> = (
|
||||
ctx: TContext,
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Represents a handler function with an associated name.
|
||||
*
|
||||
* This is useful for debugging, logging, or when you need to reference
|
||||
* the handler by name in your application.
|
||||
*
|
||||
* @template TContext The specific context type for this handler, including typed `state`, `params`, and `query`.
|
||||
*/
|
||||
type NamedHandler<TContext extends IContext = IContext> =
|
||||
& Handler<TContext>
|
||||
& { name?: string };
|
||||
|
||||
export type { NamedHandler as Handler };
|
||||
|
||||
/**
|
||||
* Type guard to determine whether a given value is a valid `IHandler` function.
|
||||
*
|
||||
* This function checks whether the input is a function and whether it returns
|
||||
* a `Promise<Response>` when called. Due to TypeScript's structural typing and
|
||||
* the lack of runtime type information, only minimal runtime validation is possible.
|
||||
*
|
||||
* @param value - The value to test.
|
||||
* @returns `true` if the value is a function that appears to conform to `IHandler`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const candidate = async (ctx: IContext) => new Response("ok");
|
||||
* if (isHandler(candidate)) {
|
||||
* // candidate is now typed as IHandler<IContext>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isHandler<TContext extends IContext = IContext>(
|
||||
value: unknown,
|
||||
): value is Handler<TContext> {
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
value.length === 1 // ctx
|
||||
);
|
||||
}
|
||||
28
src/Types/HttpErrorHandler.ts
Normal file
28
src/Types/HttpErrorHandler.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IContext } from '../Interfaces/mod.ts';
|
||||
|
||||
/**
|
||||
* Defines a handler function for errors that occur during the execution
|
||||
* of middleware or route handlers within the HTTP kernel.
|
||||
*
|
||||
* This function receives both the request context and the thrown error,
|
||||
* and is responsible for producing an appropriate HTTP `Response`.
|
||||
*
|
||||
* Typical use cases include:
|
||||
* - Mapping known error types to specific HTTP status codes.
|
||||
* - Generating structured error responses (e.g. JSON error payloads).
|
||||
* - Logging errors centrally with request metadata.
|
||||
*
|
||||
* The handler may return the response synchronously or asynchronously.
|
||||
*
|
||||
* @template TContext - The specific request context type, allowing typed access to route parameters,
|
||||
* query parameters, and per-request state when formatting error responses.
|
||||
*
|
||||
* @param context - The active request context at the time the error occurred.
|
||||
* @param error - The exception or error that was thrown during request processing.
|
||||
*
|
||||
* @returns A `Response` object or a `Promise` resolving to one, to be sent to the client.
|
||||
*/
|
||||
export type HttpErrorHandler<TContext extends IContext = IContext> = (
|
||||
context?: Partial<TContext>,
|
||||
error?: Error,
|
||||
) => Promise<Response> | Response;
|
||||
52
src/Types/HttpMethod.ts
Normal file
52
src/Types/HttpMethod.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* A constant list of all supported HTTP methods according to RFC 7231 and RFC 5789.
|
||||
*
|
||||
* This array serves both as a runtime value list for validation
|
||||
* and as the basis for deriving the `HttpMethod` union type.
|
||||
*
|
||||
* Note: The list is immutable and should not be modified at runtime.
|
||||
*/
|
||||
export const validHttpMethods = [
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A union type representing all valid HTTP methods recognized by this application.
|
||||
*
|
||||
* This type is derived directly from the `validHttpMethods` constant,
|
||||
* ensuring type safety and consistency between type system and runtime checks.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const method: HttpMethod = 'POST'; // ✅ valid
|
||||
* const method: HttpMethod = 'FOO'; // ❌ Type error
|
||||
* ```
|
||||
*/
|
||||
export type HttpMethod = typeof validHttpMethods[number];
|
||||
|
||||
/**
|
||||
* Type guard to verify whether a given value is a valid HTTP method.
|
||||
*
|
||||
* This function checks both the type and content of the value
|
||||
* and is suitable for runtime validation of inputs (e.g., from HTTP requests).
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* if (isHttpMethod(input)) {
|
||||
* // input is now typed as HttpMethod
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param value - The value to test (typically a string from a request).
|
||||
* @returns `true` if the value is a valid `HttpMethod`, otherwise `false`.
|
||||
*/
|
||||
export function isHttpMethod(value: unknown): value is HttpMethod {
|
||||
return typeof value === 'string' &&
|
||||
validHttpMethods.includes(value as HttpMethod);
|
||||
}
|
||||
189
src/Types/HttpStatusCode.ts
Normal file
189
src/Types/HttpStatusCode.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// Informational responses
|
||||
/** Indicates that the request was received and the client can continue. */
|
||||
export const HTTP_100_CONTINUE = 100;
|
||||
/** The server is switching protocols as requested by the client. */
|
||||
export const HTTP_101_SWITCHING_PROTOCOLS = 101;
|
||||
/** The server has received and is processing the request, but no response is available yet. */
|
||||
export const HTTP_102_PROCESSING = 102;
|
||||
|
||||
// Successful responses
|
||||
/** The request has succeeded. */
|
||||
export const HTTP_200_OK = 200;
|
||||
/** The request has succeeded and a new resource has been created as a result. */
|
||||
export const HTTP_201_CREATED = 201;
|
||||
/** The request has been accepted for processing, but the processing is not complete. */
|
||||
export const HTTP_202_ACCEPTED = 202;
|
||||
/** The server has successfully fulfilled the request and there is no content to send. */
|
||||
export const HTTP_204_NO_CONTENT = 204;
|
||||
|
||||
// Redirection messages
|
||||
/** The resource has been moved permanently to a new URI. */
|
||||
export const HTTP_301_MOVED_PERMANENTLY = 301;
|
||||
/** The resource resides temporarily under a different URI. */
|
||||
export const HTTP_302_FOUND = 302;
|
||||
/** Indicates that the resource has not been modified since the last request. */
|
||||
export const HTTP_304_NOT_MODIFIED = 304;
|
||||
|
||||
// Client error responses
|
||||
/** The server could not understand the request due to invalid syntax. */
|
||||
export const HTTP_400_BAD_REQUEST = 400;
|
||||
/** The request requires user authentication. */
|
||||
export const HTTP_401_UNAUTHORIZED = 401;
|
||||
/** The server understood the request but refuses to authorize it. */
|
||||
export const HTTP_403_FORBIDDEN = 403;
|
||||
/** The server cannot find the requested resource. */
|
||||
export const HTTP_404_NOT_FOUND = 404;
|
||||
/** The request method is known by the server but is not supported by the target resource. */
|
||||
export const HTTP_405_METHOD_NOT_ALLOWED = 405;
|
||||
/** The request could not be completed due to a conflict with the current state of the resource. */
|
||||
export const HTTP_409_CONFLICT = 409;
|
||||
/** The server understands the content type but was unable to process the contained instructions. */
|
||||
export const HTTP_422_UNPROCESSABLE_ENTITY = 422;
|
||||
/** The user has sent too many requests in a given amount of time. */
|
||||
export const HTTP_429_TOO_MANY_REQUESTS = 429;
|
||||
|
||||
// Server error responses
|
||||
/** The server encountered an unexpected condition that prevented it from fulfilling the request. */
|
||||
export const HTTP_500_INTERNAL_SERVER_ERROR = 500;
|
||||
/** The server does not support the functionality required to fulfill the request. */
|
||||
export const HTTP_501_NOT_IMPLEMENTED = 501;
|
||||
/** The server, while acting as a gateway or proxy, received an invalid response from the upstream server. */
|
||||
export const HTTP_502_BAD_GATEWAY = 502;
|
||||
/** The server is not ready to handle the request, often due to maintenance or overload. */
|
||||
export const HTTP_503_SERVICE_UNAVAILABLE = 503;
|
||||
/** The server is acting as a gateway and cannot get a response in time. */
|
||||
export const HTTP_504_GATEWAY_TIMEOUT = 504;
|
||||
|
||||
/**
|
||||
* A constant list of supported HTTP status codes used by this application.
|
||||
*
|
||||
* These constants are grouped by category and used to construct the union type `HttpStatusCode`.
|
||||
*/
|
||||
export const validHttpStatusCodes = [
|
||||
// Informational
|
||||
HTTP_100_CONTINUE,
|
||||
HTTP_101_SWITCHING_PROTOCOLS,
|
||||
HTTP_102_PROCESSING,
|
||||
|
||||
// Successful
|
||||
HTTP_200_OK,
|
||||
HTTP_201_CREATED,
|
||||
HTTP_202_ACCEPTED,
|
||||
HTTP_204_NO_CONTENT,
|
||||
|
||||
// Redirection
|
||||
HTTP_301_MOVED_PERMANENTLY,
|
||||
HTTP_302_FOUND,
|
||||
HTTP_304_NOT_MODIFIED,
|
||||
|
||||
// Client Errors
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
HTTP_409_CONFLICT,
|
||||
HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
HTTP_429_TOO_MANY_REQUESTS,
|
||||
|
||||
// Server Errors
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
HTTP_501_NOT_IMPLEMENTED,
|
||||
HTTP_502_BAD_GATEWAY,
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
HTTP_504_GATEWAY_TIMEOUT,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A constant list of HTTP error codes that are commonly used in the application.
|
||||
*/
|
||||
export const validHttpErrorCodes = [
|
||||
// Client Errors
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
HTTP_409_CONFLICT,
|
||||
HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
HTTP_429_TOO_MANY_REQUESTS,
|
||||
|
||||
// Server Errors
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
HTTP_501_NOT_IMPLEMENTED,
|
||||
HTTP_502_BAD_GATEWAY,
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
HTTP_504_GATEWAY_TIMEOUT,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maps each supported HTTP status code to its standard status message.
|
||||
*
|
||||
* Useful for logging, diagnostics, or building custom error responses.
|
||||
*/
|
||||
export const HttpStatusTextMap: Record<
|
||||
typeof validHttpStatusCodes[number],
|
||||
string
|
||||
> = {
|
||||
[HTTP_100_CONTINUE]: 'Continue',
|
||||
[HTTP_101_SWITCHING_PROTOCOLS]: 'Switching Protocols',
|
||||
[HTTP_102_PROCESSING]: 'Processing',
|
||||
|
||||
[HTTP_200_OK]: 'OK',
|
||||
[HTTP_201_CREATED]: 'Created',
|
||||
[HTTP_202_ACCEPTED]: 'Accepted',
|
||||
[HTTP_204_NO_CONTENT]: 'No Content',
|
||||
|
||||
[HTTP_301_MOVED_PERMANENTLY]: 'Moved Permanently',
|
||||
[HTTP_302_FOUND]: 'Found',
|
||||
[HTTP_304_NOT_MODIFIED]: 'Not Modified',
|
||||
|
||||
[HTTP_400_BAD_REQUEST]: 'Bad Request',
|
||||
[HTTP_401_UNAUTHORIZED]: 'Unauthorized',
|
||||
[HTTP_403_FORBIDDEN]: 'Forbidden',
|
||||
[HTTP_404_NOT_FOUND]: 'Not Found',
|
||||
[HTTP_405_METHOD_NOT_ALLOWED]: 'Method Not Allowed',
|
||||
[HTTP_409_CONFLICT]: 'Conflict',
|
||||
[HTTP_422_UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
|
||||
[HTTP_429_TOO_MANY_REQUESTS]: 'Too Many Requests',
|
||||
|
||||
[HTTP_500_INTERNAL_SERVER_ERROR]: 'Internal Server Error',
|
||||
[HTTP_501_NOT_IMPLEMENTED]: 'Not Implemented',
|
||||
[HTTP_502_BAD_GATEWAY]: 'Bad Gateway',
|
||||
[HTTP_503_SERVICE_UNAVAILABLE]: 'Service Unavailable',
|
||||
[HTTP_504_GATEWAY_TIMEOUT]: 'Gateway Timeout',
|
||||
};
|
||||
|
||||
/**
|
||||
* A union type representing commonly used HTTP status codes.
|
||||
*
|
||||
* This type ensures consistency between runtime and type-level status code handling.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const status: HttpStatusCode = 404; // ✅ valid
|
||||
* const status: HttpStatusCode = 418; // ❌ Type error (unless added to list)
|
||||
* ```
|
||||
*/
|
||||
export type HttpStatusCode = typeof validHttpStatusCodes[number];
|
||||
|
||||
/**
|
||||
* Type guard to check whether a given value is a valid HTTP status code.
|
||||
*
|
||||
* This is useful for validating numeric values received from external input,
|
||||
* ensuring they conform to known HTTP semantics.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* if (isHttpStatusCode(value)) {
|
||||
* // value is now typed as HttpStatusCode
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param value - The numeric value to check.
|
||||
* @returns `true` if the value is a recognized HTTP status code, otherwise `false`.
|
||||
*/
|
||||
export function isHttpStatusCode(value: unknown): value is HttpStatusCode {
|
||||
return typeof value === 'number' &&
|
||||
validHttpStatusCodes.includes(value as HttpStatusCode);
|
||||
}
|
||||
51
src/Types/Middleware.ts
Normal file
51
src/Types/Middleware.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { IContext } from '../Interfaces/IContext.ts';
|
||||
|
||||
/**
|
||||
* Represents a middleware function in the HTTP request pipeline.
|
||||
*
|
||||
* Middleware is a core mechanism to intercept, observe, or modify the request lifecycle.
|
||||
* It can be used for tasks such as logging, authentication, input validation,
|
||||
* metrics collection, or response transformation.
|
||||
*
|
||||
* Each middleware receives a fully-typed request context and a `next()` function
|
||||
* to invoke the next stage of the pipeline. Middleware may choose to short-circuit
|
||||
* the pipeline by returning a `Response` early.
|
||||
*
|
||||
* @template TContext The specific context type for this middleware, including state, params, and query information.
|
||||
*/
|
||||
type Middleware<TContext extends IContext = IContext> = (
|
||||
ctx: TContext,
|
||||
next: () => Promise<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Represents a middleware function with an associated name.
|
||||
*
|
||||
* This is useful for debugging, logging, or when you need to reference
|
||||
* the middleware by name in your application.
|
||||
*
|
||||
* @template TContext The specific context type for this middleware, including state, params, and query information.
|
||||
*/
|
||||
type NamedMiddleware<TContext extends IContext = IContext> =
|
||||
& Middleware<TContext>
|
||||
& { name?: string };
|
||||
|
||||
export type { NamedMiddleware as Middleware };
|
||||
|
||||
/**
|
||||
* Type guard to verify whether a given value is a valid `IMiddleware` function.
|
||||
*
|
||||
* This guard checks whether the input is a function that accepts exactly two arguments.
|
||||
* Note: This is a structural check and cannot fully guarantee the semantics of a middleware.
|
||||
*
|
||||
* @param value - The value to test.
|
||||
* @returns `true` if the value is structurally a valid middleware function.
|
||||
*/
|
||||
export function isMiddleware<TContext extends IContext = IContext>(
|
||||
value: unknown,
|
||||
): value is Middleware<TContext> {
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
value.length === 2 // ctx, next
|
||||
);
|
||||
}
|
||||
10
src/Types/Params.ts
Normal file
10
src/Types/Params.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Represents route parameters parsed from dynamic segments in the URL path.
|
||||
*
|
||||
* This type is typically derived from route definitions with placeholders,
|
||||
* such as `/users/:id`, which would yield `{ id: "123" }`.
|
||||
*
|
||||
* All values are strings and should be considered read-only, as they are
|
||||
* extracted by the router and should not be modified by application code.
|
||||
*/
|
||||
export type Params = Record<string, string>;
|
||||
12
src/Types/Query.ts
Normal file
12
src/Types/Query.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Represents the parsed query parameters from the request URL.
|
||||
*
|
||||
* Query parameters originate from the URL search string (e.g. `?filter=active&tags=ts&tags=deno`)
|
||||
* and may contain single or multiple values per key.
|
||||
*
|
||||
* All values are expressed as strings or arrays of strings, depending on how often
|
||||
* the key occurs. This structure preserves the raw semantics of the query.
|
||||
*
|
||||
* For normalized single-value access, prefer custom DTOs or wrapper utilities.
|
||||
*/
|
||||
export type Query = Record<string, string | string[]>;
|
||||
16
src/Types/RegisterRoute.ts
Normal file
16
src/Types/RegisterRoute.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IContext } from '../Interfaces/IContext.ts';
|
||||
import type { IInternalRoute } from '../Interfaces/mod.ts';
|
||||
|
||||
/**
|
||||
* A type alias for the internal route registration function used by the `HttpKernel`.
|
||||
*
|
||||
* This function accepts a fully constructed internal route, including method, matcher,
|
||||
* middleware chain, and final handler, and registers it for dispatching.
|
||||
*
|
||||
* Typically passed into `RouteBuilder` instances to enable fluent API chaining.
|
||||
*
|
||||
* @template TContext The context type associated with the route being registered.
|
||||
*/
|
||||
export type RegisterRoute<TContext extends IContext = IContext> = (
|
||||
route: IInternalRoute<TContext>,
|
||||
) => void;
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IContext } from '../Interfaces/mod.ts';
|
||||
|
||||
/**
|
||||
* A function that modifies or enriches an outgoing HTTP response before it is returned to the client.
|
||||
*
|
||||
@@ -22,4 +24,7 @@
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export type ResponseDecorator = (res: Response) => Response;
|
||||
export type ResponseDecorator<TContext extends IContext = IContext> = (
|
||||
res: Response,
|
||||
ctx: TContext,
|
||||
) => Response;
|
||||
9
src/Types/State.ts
Normal file
9
src/Types/State.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Represents the per-request state object shared across the middleware pipeline.
|
||||
*
|
||||
* This type defines the base structure for custom state definitions,
|
||||
* which can be extended with concrete fields like user data, request metadata, etc.
|
||||
*
|
||||
* Custom `TState` types must extend this base to ensure compatibility.
|
||||
*/
|
||||
export type State = Record<string, unknown>;
|
||||
40
src/Types/__tests__/HttpMethod.test.ts
Normal file
40
src/Types/__tests__/HttpMethod.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
|
||||
import { isHttpMethod, validHttpMethods } from '../HttpMethod.ts';
|
||||
|
||||
Deno.test('isHttpMethod: returns true for all valid methods', () => {
|
||||
for (const method of validHttpMethods) {
|
||||
const result = isHttpMethod(method);
|
||||
assertEquals(result, true, `Expected "${method}" to be valid`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('isHttpMethod: returns false for lowercase or unknown strings', () => {
|
||||
const invalid = [
|
||||
'get',
|
||||
'post',
|
||||
'FETCH',
|
||||
'TRACE',
|
||||
'CONNECT',
|
||||
'INVALID',
|
||||
'',
|
||||
' ',
|
||||
];
|
||||
|
||||
for (const method of invalid) {
|
||||
const result = isHttpMethod(method);
|
||||
assertEquals(result, false, `Expected "${method}" to be invalid`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('isHttpMethod: returns false for non-string inputs', () => {
|
||||
const invalidInputs = [null, undefined, 123, {}, [], true, Symbol('GET')];
|
||||
|
||||
for (const input of invalidInputs) {
|
||||
const result = isHttpMethod(input);
|
||||
assertEquals(
|
||||
result,
|
||||
false,
|
||||
`Expected non-string input to be invalid: ${String(input)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
35
src/Types/__tests__/HttpStatusCode.test.ts
Normal file
35
src/Types/__tests__/HttpStatusCode.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/Types/__tests__/HttpStatusCode.test.ts
|
||||
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
|
||||
import { isHttpStatusCode, validHttpStatusCodes } from '../HttpStatusCode.ts';
|
||||
|
||||
Deno.test('isHttpStatusCode: returns true for all valid status codes', () => {
|
||||
for (const code of validHttpStatusCodes) {
|
||||
assertEquals(
|
||||
isHttpStatusCode(code),
|
||||
true,
|
||||
`Expected ${code} to be valid`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('isHttpStatusCode: returns false for invalid status codes', () => {
|
||||
const invalidInputs = [99, 600, 1234, -1, 0, 999];
|
||||
for (const val of invalidInputs) {
|
||||
assertEquals(
|
||||
isHttpStatusCode(val),
|
||||
false,
|
||||
`Expected ${val} to be invalid`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('isHttpStatusCode: returns false for non-numeric values', () => {
|
||||
const invalid = ['200', null, undefined, {}, [], true];
|
||||
for (const val of invalid) {
|
||||
assertEquals(
|
||||
isHttpStatusCode(val),
|
||||
false,
|
||||
`Expected ${val} to be invalid`,
|
||||
);
|
||||
}
|
||||
});
|
||||
45
src/Types/mod.ts
Normal file
45
src/Types/mod.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// deno-coverage-ignore-file
|
||||
|
||||
export type { DeepPartial } from './DeepPartial.ts';
|
||||
export { isHandler } from './Handler.ts';
|
||||
export type { Handler } from './Handler.ts';
|
||||
export type { HttpErrorHandler } from './HttpErrorHandler.ts';
|
||||
export { isHttpMethod, validHttpMethods } from './HttpMethod.ts';
|
||||
export type { HttpMethod } from './HttpMethod.ts';
|
||||
export {
|
||||
HTTP_100_CONTINUE,
|
||||
HTTP_101_SWITCHING_PROTOCOLS,
|
||||
HTTP_102_PROCESSING,
|
||||
HTTP_200_OK,
|
||||
HTTP_201_CREATED,
|
||||
HTTP_202_ACCEPTED,
|
||||
HTTP_204_NO_CONTENT,
|
||||
HTTP_301_MOVED_PERMANENTLY,
|
||||
HTTP_302_FOUND,
|
||||
HTTP_304_NOT_MODIFIED,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
HTTP_409_CONFLICT,
|
||||
HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
HTTP_429_TOO_MANY_REQUESTS,
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
HTTP_501_NOT_IMPLEMENTED,
|
||||
HTTP_502_BAD_GATEWAY,
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
HTTP_504_GATEWAY_TIMEOUT,
|
||||
HttpStatusTextMap,
|
||||
isHttpStatusCode,
|
||||
validHttpErrorCodes,
|
||||
validHttpStatusCodes,
|
||||
} from './HttpStatusCode.ts';
|
||||
export type { HttpStatusCode } from './HttpStatusCode.ts';
|
||||
export { isMiddleware } from './Middleware.ts';
|
||||
export type { Middleware } from './Middleware.ts';
|
||||
export type { Params } from './Params.ts';
|
||||
export type { Query } from './Query.ts';
|
||||
export type { RegisterRoute } from './RegisterRoute.ts';
|
||||
export type { ResponseDecorator } from './ResponseDecorator.ts';
|
||||
export type { State } from './State.ts';
|
||||
61
src/Utils.ts
61
src/Utils.ts
@@ -1,61 +0,0 @@
|
||||
import { IRouteDefinition, IRouteMatcher } from './Interfaces/mod.ts';
|
||||
|
||||
/**
|
||||
* Creates a matcher function from a given route definition.
|
||||
*
|
||||
* This utility supports both static path-based route definitions (e.g. `/users/:id`)
|
||||
* and custom matcher functions for dynamic routing scenarios.
|
||||
*
|
||||
* ### Static Path Example
|
||||
* For a definition like:
|
||||
* ```ts
|
||||
* { method: "GET", path: "/users/:id" }
|
||||
* ```
|
||||
* the returned matcher function will:
|
||||
* - match requests to `/users/123`
|
||||
* - extract `{ id: "123" }` as `params`
|
||||
*
|
||||
* ### Dynamic Matcher Example
|
||||
* If the `IRouteDefinition` includes a `matcher` function, it will be used as-is.
|
||||
*
|
||||
* @param def - The route definition to convert into a matcher function.
|
||||
* Can be static (`path`) or dynamic (`matcher`).
|
||||
*
|
||||
* @returns A matcher function that receives a `URL` and `Request` and returns:
|
||||
* - `{ params: Record<string, string> }` if the route matches
|
||||
* - `null` if the route does not match the request
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const matcher = createRouteMatcher({ method: "GET", path: "/repo/:owner/:name" });
|
||||
* const result = matcher(new URL("http://localhost/repo/foo/bar"), req);
|
||||
* // result: { params: { owner: "foo", name: "bar" } }
|
||||
* ```
|
||||
*/
|
||||
export function createRouteMatcher(
|
||||
def: IRouteDefinition,
|
||||
): IRouteMatcher {
|
||||
if ('matcher' in def) {
|
||||
return def.matcher;
|
||||
} else {
|
||||
const pattern = def.path;
|
||||
const keys: string[] = [];
|
||||
const regex = new RegExp(
|
||||
'^' +
|
||||
pattern.replace(/:[^\/]+/g, (m) => {
|
||||
keys.push(m.substring(1));
|
||||
return '([^/]+)';
|
||||
}) +
|
||||
'$',
|
||||
);
|
||||
return (url: URL) => {
|
||||
const match = url.pathname.match(regex);
|
||||
if (!match) return null;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
params[keys[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
return { params };
|
||||
};
|
||||
}
|
||||
}
|
||||
28
src/Utils/__tests__/createEmptyContext.test.ts
Normal file
28
src/Utils/__tests__/createEmptyContext.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
|
||||
import { createEmptyContext } from '../createEmptyContext.ts';
|
||||
import type { IContext } from '../../Interfaces/mod.ts';
|
||||
|
||||
Deno.test('createEmptyContext: returns default-initialized context', () => {
|
||||
const request = new Request('http://localhost');
|
||||
const ctx = createEmptyContext(request);
|
||||
|
||||
assertEquals(ctx.req, request);
|
||||
assertEquals(ctx.params, {});
|
||||
assertEquals(ctx.query, {});
|
||||
assertEquals(ctx.state, {});
|
||||
});
|
||||
|
||||
Deno.test('createEmptyContext: preserves generic type compatibility', () => {
|
||||
interface MyContext
|
||||
extends
|
||||
IContext<{ userId: string }, { id: string }, { verbose: string }> {}
|
||||
|
||||
const req = new Request('http://localhost');
|
||||
const ctx = createEmptyContext<MyContext>(req);
|
||||
|
||||
// All properties exist and are empty
|
||||
assertEquals(ctx.params, {} as MyContext['params']);
|
||||
assertEquals(ctx.query, {} as MyContext['query']);
|
||||
assertEquals(ctx.state, {} as MyContext['state']);
|
||||
assertEquals(ctx.req, req);
|
||||
});
|
||||
118
src/Utils/__tests__/createRouteMatcher.test.ts
Normal file
118
src/Utils/__tests__/createRouteMatcher.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertStrictEquals,
|
||||
} from 'https://deno.land/std/assert/mod.ts';
|
||||
import type { IRouteDefinition } from '../../Interfaces/mod.ts';
|
||||
import { createRouteMatcher } from '../../mod.ts';
|
||||
|
||||
// Dummy request
|
||||
const dummyRequest = new Request('http://localhost');
|
||||
|
||||
Deno.test('createRouteMatcher: static route matches and extracts params', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(new URL('http://localhost/users/42'), dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, { id: '42' });
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: static route with multiple params', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/repo/:owner/:name' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(
|
||||
new URL('http://localhost/repo/max/wiki'),
|
||||
dummyRequest,
|
||||
);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, { owner: 'max', name: 'wiki' });
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: static route does not match wrong path', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(new URL('http://localhost/posts/42'), dummyRequest);
|
||||
|
||||
assertStrictEquals(result, null);
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: uses custom matcher if provided', () => {
|
||||
const def: IRouteDefinition = {
|
||||
method: 'GET',
|
||||
matcher: (url) => url.pathname === '/ping' ? { params: {} } : null,
|
||||
};
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(new URL('http://localhost/ping'), dummyRequest);
|
||||
assert(result);
|
||||
assertEquals(result.params, {});
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: extracts single query param', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/search' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const url = new URL('http://localhost/search?q=deno');
|
||||
const result = matcher(url, dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, {}); // no path params
|
||||
assertEquals(result.query, { q: 'deno' }); // single key → string
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: duplicate query keys become array', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/tags' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const url = new URL('http://localhost/tags?tag=js&tag=ts&tag=deno');
|
||||
const result = matcher(url, dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, {});
|
||||
assertEquals(result.query, { tag: ['js', 'ts', 'deno'] }); // multi → string[]
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: mix of single and duplicate keys', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/filter/:type' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const url = new URL('http://localhost/filter/repo?lang=ts&lang=js&page=2');
|
||||
const result = matcher(url, dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, { type: 'repo' });
|
||||
assertEquals(result.query, {
|
||||
lang: ['ts', 'js'], // duplicated
|
||||
page: '2', // single
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: no query parameters returns empty object', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/info' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const url = new URL('http://localhost/info');
|
||||
const result = matcher(url, dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, {});
|
||||
assertEquals(result.query, {}); // empty
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: retains array order of duplicate keys', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/order' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const url = new URL(
|
||||
'http://localhost/order?item=first&item=second&item=third',
|
||||
);
|
||||
const result = matcher(url, dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.query?.item, ['first', 'second', 'third']);
|
||||
});
|
||||
35
src/Utils/__tests__/normalizeError.test.ts
Normal file
35
src/Utils/__tests__/normalizeError.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
assertEquals,
|
||||
assertInstanceOf,
|
||||
} from 'https://deno.land/std/assert/mod.ts';
|
||||
import { normalizeError } from '../normalizeError.ts';
|
||||
|
||||
Deno.test('normalizeError: preserves Error instances', () => {
|
||||
const original = new Error('original');
|
||||
const result = normalizeError(original);
|
||||
|
||||
assertInstanceOf(result, Error);
|
||||
assertEquals(result, original);
|
||||
});
|
||||
|
||||
Deno.test('normalizeError: converts string to Error', () => {
|
||||
const result = normalizeError('something went wrong');
|
||||
|
||||
assertInstanceOf(result, Error);
|
||||
assertEquals(result.message, 'something went wrong');
|
||||
});
|
||||
|
||||
Deno.test('normalizeError: converts number to Error', () => {
|
||||
const result = normalizeError(404);
|
||||
|
||||
assertInstanceOf(result, Error);
|
||||
assertEquals(result.message, '404');
|
||||
});
|
||||
|
||||
Deno.test('normalizeError: converts plain object to Error', () => {
|
||||
const input = { error: true, msg: 'Invalid' };
|
||||
const result = normalizeError(input);
|
||||
|
||||
assertInstanceOf(result, Error);
|
||||
assertEquals(result.message, JSON.stringify(input));
|
||||
});
|
||||
30
src/Utils/createEmptyContext.ts
Normal file
30
src/Utils/createEmptyContext.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { IContext } from '../Interfaces/mod.ts';
|
||||
import type { Params, Query, State } from '../Types/mod.ts';
|
||||
|
||||
/**
|
||||
* Creates an empty request context suitable for fallback handlers (e.g., 404 or 500 errors).
|
||||
*
|
||||
* This function is primarily intended for cases where no route matched, but a context-compatible
|
||||
* object is still needed to invoke a generic error handler. All context fields are initialized
|
||||
* to their default empty values (`{}` for params, query, and state).
|
||||
*
|
||||
* @template TContext - The expected context type, typically extending `IContext`.
|
||||
* @param req - The original HTTP request object from `Deno.serve()`.
|
||||
* @returns A minimal context object compatible with `TContext`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = createEmptyContext<MyContext>(request);
|
||||
* return httpErrorHandlers[404](ctx);
|
||||
* ```
|
||||
*/
|
||||
export function createEmptyContext<TContext extends IContext = IContext>(
|
||||
req: Request,
|
||||
): TContext {
|
||||
return {
|
||||
req,
|
||||
params: {} as Params,
|
||||
query: {} as Query,
|
||||
state: {} as State,
|
||||
} as TContext;
|
||||
}
|
||||
54
src/Utils/createRouteMatcher.ts
Normal file
54
src/Utils/createRouteMatcher.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// createRouteMatcher.ts
|
||||
|
||||
import {
|
||||
type IRouteDefinition,
|
||||
type IRouteMatch,
|
||||
type IRouteMatcher,
|
||||
isDynamicRouteDefinition,
|
||||
} from '../Interfaces/mod.ts';
|
||||
import type { Params, Query } from '../Types/mod.ts';
|
||||
|
||||
/**
|
||||
* Transforms a route definition into a matcher using Deno's URLPattern API.
|
||||
*
|
||||
* @param def - Static path pattern or custom matcher.
|
||||
* @returns IRouteMatcher that returns `{ params, query }` or `null`.
|
||||
*/
|
||||
export function createRouteMatcher(
|
||||
def: IRouteDefinition,
|
||||
): IRouteMatcher {
|
||||
// 1. Allow users to provide their own matcher
|
||||
if (isDynamicRouteDefinition(def)) {
|
||||
return def.matcher;
|
||||
}
|
||||
|
||||
// 2. Build URLPattern; supports :id, *wildcards, regex groups, etc.
|
||||
const pattern = new URLPattern({ pathname: def.path });
|
||||
|
||||
// 3. The actual matcher closure
|
||||
return (url: URL): IRouteMatch | null => {
|
||||
const result = pattern.exec(url);
|
||||
|
||||
// 3a. Path did not match
|
||||
if (!result) return null;
|
||||
|
||||
// 3b. Extract route params
|
||||
const params: Params = {};
|
||||
for (const [key, value] of Object.entries(result.pathname.groups)) {
|
||||
if (value) {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Extract query parameters – keep duplicates as arrays
|
||||
const query: Query = {};
|
||||
for (const key of url.searchParams.keys()) {
|
||||
const values = url.searchParams.getAll(key); // → string[]
|
||||
query[key] = values.length === 1
|
||||
? values[0] // single → "foo"
|
||||
: values; // multi → ["foo","bar"]
|
||||
}
|
||||
|
||||
return { params, query };
|
||||
};
|
||||
}
|
||||
5
src/Utils/mod.ts
Normal file
5
src/Utils/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// deno-coverage-ignore-file
|
||||
|
||||
export { createEmptyContext } from './createEmptyContext.ts';
|
||||
export { createRouteMatcher } from './createRouteMatcher.ts';
|
||||
export { normalizeError } from './normalizeError.ts';
|
||||
30
src/Utils/normalizeError.ts
Normal file
30
src/Utils/normalizeError.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Normalizes any thrown value to a proper `Error` instance.
|
||||
*
|
||||
* This is useful when handling unknown thrown values that may be:
|
||||
* - strings (e.g. `throw "oops"`)
|
||||
* - numbers (e.g. `throw 404`)
|
||||
* - objects that are not instances of `Error`
|
||||
*
|
||||
* Ensures that downstream error handling logic always receives a consistent `Error` object.
|
||||
*
|
||||
* @param unknownError - Any value that might have been thrown.
|
||||
* @returns A valid `Error` instance wrapping the original input.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* try {
|
||||
* throw "something went wrong";
|
||||
* } catch (e) {
|
||||
* const err = normalizeError(e);
|
||||
* console.error(err.message); // "something went wrong"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function normalizeError(unknownError: unknown): Error {
|
||||
return unknownError instanceof Error ? unknownError : new Error(
|
||||
typeof unknownError === 'string'
|
||||
? unknownError
|
||||
: JSON.stringify(unknownError),
|
||||
);
|
||||
}
|
||||
87
src/__bench__/HttpKernel.bench.ts
Normal file
87
src/__bench__/HttpKernel.bench.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { IRouteDefinition } from '../Interfaces/mod.ts';
|
||||
import { HttpKernel } from '../mod.ts';
|
||||
|
||||
const CONCURRENT_REQUESTS = 10000;
|
||||
|
||||
// Deno.bench('Simple request', async (b) => {
|
||||
// const kernel = new HttpKernel();
|
||||
|
||||
// const def: IRouteDefinition = { method: 'GET', path: '/hello' };
|
||||
// kernel.route(def).handle((_ctx) => {
|
||||
// return Promise.resolve(new Response('OK', { status: 200 }));
|
||||
// });
|
||||
// b.start();
|
||||
// await kernel.handle(
|
||||
// new Request('http://localhost/hello', { method: 'GET' }),
|
||||
// );
|
||||
// b.end();
|
||||
// });
|
||||
|
||||
Deno.bench('Simple request (parallel)', async (b) => {
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/hello' };
|
||||
kernel.route(def).handle((_ctx) => {
|
||||
return Promise.resolve(new Response('OK', { status: 200 }));
|
||||
});
|
||||
|
||||
const requests = Array.from(
|
||||
{ length: CONCURRENT_REQUESTS },
|
||||
() =>
|
||||
kernel.handle(
|
||||
new Request('http://localhost/hello', { method: 'GET' }),
|
||||
),
|
||||
);
|
||||
|
||||
b.start();
|
||||
await Promise.all(requests);
|
||||
b.end();
|
||||
});
|
||||
|
||||
// Deno.bench('Complex request', async (b) => {
|
||||
// const kernel = new HttpKernel();
|
||||
|
||||
// kernel.route({ method: 'GET', path: '/test' })
|
||||
// .middleware(async (_ctx, next) => {
|
||||
// return await next();
|
||||
// })
|
||||
// .middleware(async (_ctx, next) => {
|
||||
// return await next();
|
||||
// })
|
||||
// .handle((_ctx) => {
|
||||
// return Promise.resolve(new Response('done'));
|
||||
// });
|
||||
|
||||
// b.start();
|
||||
// await kernel.handle(
|
||||
// new Request('http://localhost/test', { method: 'GET' }),
|
||||
// );
|
||||
// b.end();
|
||||
// });
|
||||
|
||||
Deno.bench('Complex request (parallel)', async (b) => {
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
kernel.route({ method: 'GET', path: '/test' })
|
||||
.middleware(async (_ctx, next) => {
|
||||
return await next();
|
||||
})
|
||||
.middleware(async (_ctx, next) => {
|
||||
return await next();
|
||||
})
|
||||
.handle((_ctx) => {
|
||||
return Promise.resolve(new Response('done'));
|
||||
});
|
||||
|
||||
const requests = Array.from(
|
||||
{ length: CONCURRENT_REQUESTS },
|
||||
() =>
|
||||
kernel.handle(
|
||||
new Request('http://localhost/test', { method: 'GET' }),
|
||||
),
|
||||
);
|
||||
|
||||
b.start();
|
||||
await Promise.all(requests);
|
||||
b.end();
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
assertEquals,
|
||||
assertRejects,
|
||||
assertThrows,
|
||||
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
|
||||
import { HttpKernel } from '../HttpKernel.ts';
|
||||
import { IRouteDefinition } from '../Interfaces/mod.ts';
|
||||
import type { IRouteDefinition } from '../Interfaces/mod.ts';
|
||||
|
||||
Deno.test('HttpKernel: matches static route and executes handler', async () => {
|
||||
const kernel = new HttpKernel();
|
||||
@@ -11,7 +11,7 @@ Deno.test('HttpKernel: matches static route and executes handler', async () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/hello' };
|
||||
let called = false;
|
||||
|
||||
kernel.route(def).handle(() => {
|
||||
kernel.route(def).handle((_ctx) => {
|
||||
called = true;
|
||||
return Promise.resolve(new Response('OK', { status: 200 }));
|
||||
});
|
||||
@@ -31,7 +31,7 @@ Deno.test('HttpKernel: supports dynamic matcher', async () => {
|
||||
matcher: (url) => url.pathname === '/dyn' ? { params: {} } : null,
|
||||
};
|
||||
|
||||
kernel.route(def).handle(() =>
|
||||
kernel.route(def).handle((_ctx) =>
|
||||
Promise.resolve(new Response('Dyn', { status: 200 }))
|
||||
);
|
||||
|
||||
@@ -45,15 +45,15 @@ Deno.test('HttpKernel: calls middleware in order and passes to handler', async (
|
||||
const calls: string[] = [];
|
||||
|
||||
kernel.route({ method: 'GET', path: '/test' })
|
||||
.middleware(async (ctx, next) => {
|
||||
.middleware(async (_ctx, next) => {
|
||||
calls.push('mw1');
|
||||
return await next();
|
||||
})
|
||||
.middleware(async (ctx, next) => {
|
||||
.middleware(async (_ctx, next) => {
|
||||
calls.push('mw2');
|
||||
return await next();
|
||||
})
|
||||
.handle(() => {
|
||||
.handle((_ctx) => {
|
||||
calls.push('handler');
|
||||
return Promise.resolve(new Response('done'));
|
||||
});
|
||||
@@ -70,15 +70,15 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
kernel.route({ method: 'GET', path: '/stop' })
|
||||
.middleware(() => {
|
||||
.middleware((_ctx, _next) => {
|
||||
calls.push('mw1');
|
||||
return Promise.resolve(new Response('blocked', { status: 403 }));
|
||||
})
|
||||
.middleware(() => {
|
||||
.middleware((_ctx, _next) => {
|
||||
calls.push('mw2');
|
||||
return Promise.resolve(new Response('should-not-call'));
|
||||
})
|
||||
.handle(() => {
|
||||
.handle((_ctx) => {
|
||||
calls.push('handler');
|
||||
return Promise.resolve(new Response('ok'));
|
||||
});
|
||||
@@ -91,6 +91,34 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
|
||||
assertEquals(calls, ['mw1']);
|
||||
});
|
||||
|
||||
Deno.test('HttpKernel: invalid middleware or handler signature throws at compile time', () => {
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
// Middleware with wrong signature (missing ctx, next)
|
||||
assertThrows(
|
||||
() => {
|
||||
kernel.route({ method: 'GET', path: '/bad-mw' })
|
||||
// @ts-expect-error invalid middleware
|
||||
.middleware(() => new Response('invalid'))
|
||||
.handle((_ctx) => Promise.resolve(new Response('ok')));
|
||||
},
|
||||
TypeError,
|
||||
'Middleware at index 0 is not a valid function.',
|
||||
);
|
||||
|
||||
// Handler with wrong signature (no ctx)
|
||||
assertThrows(
|
||||
() => {
|
||||
kernel.route({ method: 'GET', path: '/bad-handler' })
|
||||
.middleware(async (_ctx, next) => await next())
|
||||
// @ts-expect-error invalid handler
|
||||
.handle(() => new Response('invalid'));
|
||||
},
|
||||
TypeError,
|
||||
'Route handler must be a function returning a Promise<Response>.',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('HttpKernel: 404 for unmatched route', async () => {
|
||||
const kernel = new HttpKernel();
|
||||
const res = await kernel.handle(new Request('http://localhost/nothing'));
|
||||
@@ -101,7 +129,7 @@ Deno.test('HttpKernel: skips route with wrong method', async () => {
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
kernel.route({ method: 'POST', path: '/only-post' })
|
||||
.handle(() => Promise.resolve(new Response('nope')));
|
||||
.handle((_ctx) => Promise.resolve(new Response('nope')));
|
||||
|
||||
const res = await kernel.handle(
|
||||
new Request('http://localhost/only-post', { method: 'GET' }),
|
||||
@@ -113,33 +141,29 @@ Deno.test('HttpKernel: throws on next() called twice', async () => {
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
kernel.route({ method: 'GET', path: '/bad' })
|
||||
.middleware(async (ctx, next) => {
|
||||
.middleware(async (_ctx, next) => {
|
||||
await next();
|
||||
await next(); // ❌
|
||||
return new Response('should never reach');
|
||||
})
|
||||
.handle(() => Promise.resolve(new Response('OK')));
|
||||
.handle((_ctx) => Promise.resolve(new Response('OK')));
|
||||
|
||||
await assertRejects(
|
||||
() => kernel.handle(new Request('http://localhost/bad')),
|
||||
Error,
|
||||
'next() called multiple times',
|
||||
);
|
||||
const res = await kernel.handle(new Request('http://localhost/bad'));
|
||||
assertEquals(res.status, 500);
|
||||
assertEquals(await res.text(), 'Internal Server Error');
|
||||
});
|
||||
|
||||
Deno.test('HttpKernel: handler throws → error propagates', async () => {
|
||||
const kernel = new HttpKernel();
|
||||
|
||||
kernel.route({ method: 'GET', path: '/throw' })
|
||||
.handle(() => {
|
||||
.handle((_ctx) => {
|
||||
throw new Error('fail!');
|
||||
});
|
||||
|
||||
await assertRejects(
|
||||
() => kernel.handle(new Request('http://localhost/throw')),
|
||||
Error,
|
||||
'fail!',
|
||||
);
|
||||
const res = await kernel.handle(new Request('http://localhost/throw'));
|
||||
assertEquals(res.status, 500);
|
||||
assertEquals(await res.text(), 'Internal Server Error');
|
||||
});
|
||||
|
||||
Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async () => {
|
||||
@@ -157,5 +181,5 @@ Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async (
|
||||
|
||||
const res = await kernel.handle(new Request('http://localhost/fail'));
|
||||
assertEquals(res.status, 500);
|
||||
assertEquals(await res.text(), 'Internal error');
|
||||
assertEquals(await res.text(), 'Internal Server Error');
|
||||
});
|
||||
|
||||
@@ -4,20 +4,30 @@ import {
|
||||
assertNotEquals,
|
||||
assertThrows,
|
||||
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
|
||||
import {
|
||||
IHandler,
|
||||
IInternalRoute,
|
||||
IMiddleware,
|
||||
IRouteDefinition,
|
||||
} from '../Interfaces/mod.ts';
|
||||
import type { IInternalRoute, IRouteDefinition } from '../Interfaces/mod.ts';
|
||||
import { RouteBuilder } from '../mod.ts';
|
||||
import type { Handler, Middleware } from '../Types/mod.ts';
|
||||
|
||||
// Dummy objects
|
||||
const dummyHandler: IHandler = async () => new Response('ok');
|
||||
const dummyMiddleware: IMiddleware = async (_, next) => await next();
|
||||
const dummyDef: IRouteDefinition = { method: 'get', path: '/hello' };
|
||||
// deno-lint-ignore require-await
|
||||
const dummyHandler: Handler = async (_) => new Response('ok');
|
||||
// deno-lint-ignore require-await
|
||||
const wrongHandler: Handler = async () => new Response('ok'); // Wrong signature, no ctx
|
||||
const dummyMiddleware: Middleware = async (_, next) => await next();
|
||||
// deno-lint-ignore require-await
|
||||
const wrongMiddleware: Middleware = async () => new Response('ok'); // Wrong signature, no ctx, next
|
||||
const dummyDef: IRouteDefinition = { method: 'GET', path: '/hello' };
|
||||
const dummyMatcher = () => ({ params: {} });
|
||||
|
||||
Deno.test('middleware: throws if middleware signature is wrong', () => {
|
||||
const builder = new RouteBuilder(() => {}, dummyDef);
|
||||
assertThrows(
|
||||
() => builder.middleware(wrongMiddleware).handle(dummyHandler),
|
||||
TypeError,
|
||||
'Middleware at index 0 is not a valid function.',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('middleware: single middleware is registered correctly', () => {
|
||||
let registered: IInternalRoute | null = null as IInternalRoute | null;
|
||||
|
||||
@@ -39,8 +49,8 @@ Deno.test('middleware: middleware is chained immutably', () => {
|
||||
});
|
||||
|
||||
Deno.test('middleware: preserves order of middleware', () => {
|
||||
const mw1: IMiddleware = async (_, next) => await next();
|
||||
const mw2: IMiddleware = async (_, next) => await next();
|
||||
const mw1: Middleware = async (_, next) => await next();
|
||||
const mw2: Middleware = async (_, next) => await next();
|
||||
|
||||
let result: IInternalRoute | null = null as IInternalRoute | null;
|
||||
|
||||
@@ -54,10 +64,19 @@ Deno.test('middleware: preserves order of middleware', () => {
|
||||
assertEquals(result!.middlewares, [mw1, mw2]);
|
||||
});
|
||||
|
||||
Deno.test('handle: throws if handler signature is wrong', () => {
|
||||
const builder = new RouteBuilder(() => {}, dummyDef);
|
||||
assertThrows(
|
||||
() => builder.handle(wrongHandler),
|
||||
TypeError,
|
||||
'Route handler must be a function returning a Promise<Response>.',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('handle: uppercases method', () => {
|
||||
let result: IInternalRoute | null = null as IInternalRoute | null;
|
||||
|
||||
new RouteBuilder((r) => result = r, { method: 'post', path: '/x' })
|
||||
new RouteBuilder((r) => result = r, { method: 'POST', path: '/x' })
|
||||
.handle(dummyHandler);
|
||||
|
||||
assertEquals(result?.method, 'POST');
|
||||
@@ -74,8 +93,18 @@ Deno.test('handle: works with no middleware', async () => {
|
||||
|
||||
const request = new Request('http://localhost');
|
||||
|
||||
const res1 = await route?.handler({ req: request, params: {}, state: {} });
|
||||
const res2 = await dummyHandler({ req: request, params: {}, state: {} });
|
||||
const res1 = await route?.handler({
|
||||
req: request,
|
||||
params: {},
|
||||
state: {},
|
||||
query: {},
|
||||
});
|
||||
const res2 = await dummyHandler({
|
||||
req: request,
|
||||
params: {},
|
||||
state: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
assertEquals(res1?.status, res2?.status);
|
||||
assertEquals(await res1?.text(), await res2?.text());
|
||||
@@ -84,7 +113,7 @@ Deno.test('handle: works with no middleware', async () => {
|
||||
Deno.test('handle: uses custom matcher factory', () => {
|
||||
let called = false;
|
||||
|
||||
const factory = (def: IRouteDefinition) => {
|
||||
const factory = (_def: IRouteDefinition) => {
|
||||
called = true;
|
||||
return dummyMatcher;
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertStrictEquals,
|
||||
} from 'https://deno.land/std/assert/mod.ts';
|
||||
import { IRouteDefinition } from '../Interfaces/mod.ts';
|
||||
import { createRouteMatcher } from '../mod.ts';
|
||||
|
||||
// Dummy request
|
||||
const dummyRequest = new Request('http://localhost');
|
||||
|
||||
Deno.test('createRouteMatcher: static route matches and extracts params', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(new URL('http://localhost/users/42'), dummyRequest);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, { id: '42' });
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: static route with multiple params', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/repo/:owner/:name' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(
|
||||
new URL('http://localhost/repo/max/wiki'),
|
||||
dummyRequest,
|
||||
);
|
||||
|
||||
assert(result);
|
||||
assertEquals(result.params, { owner: 'max', name: 'wiki' });
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: static route does not match wrong path', () => {
|
||||
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(new URL('http://localhost/posts/42'), dummyRequest);
|
||||
|
||||
assertStrictEquals(result, null);
|
||||
});
|
||||
|
||||
Deno.test('createRouteMatcher: uses custom matcher if provided', () => {
|
||||
const def: IRouteDefinition = {
|
||||
method: 'GET',
|
||||
matcher: (url) => url.pathname === '/ping' ? { params: {} } : null,
|
||||
};
|
||||
const matcher = createRouteMatcher(def);
|
||||
|
||||
const result = matcher(new URL('http://localhost/ping'), dummyRequest);
|
||||
assert(result);
|
||||
assertEquals(result.params, {});
|
||||
});
|
||||
14
src/mod.ts
14
src/mod.ts
@@ -1,4 +1,16 @@
|
||||
// deno-coverage-ignore-file
|
||||
export { HttpKernel } from './HttpKernel.ts';
|
||||
export { RouteBuilder } from './RouteBuilder.ts';
|
||||
export { createRouteMatcher } from './Utils.ts';
|
||||
export { createRouteMatcher } from './Utils/createRouteMatcher.ts';
|
||||
|
||||
// Errors
|
||||
export * from './Errors/mod.ts';
|
||||
|
||||
// Interfaces
|
||||
export * from './Interfaces/mod.ts';
|
||||
|
||||
// Types
|
||||
export * from './Types/mod.ts';
|
||||
|
||||
// Utils
|
||||
export * from './Utils/mod.ts';
|
||||
|
||||
Reference in New Issue
Block a user