Merge GitHub main into Gitea repo (allow unrelated histories)

Resolved conflicts by taking GitHub versions for:
- .dockerignore, .gitignore, Dockerfile, README.md, docker-compose.yml

Kept deploy.sh updated to:
- Pull from GitHub (primary source)
- Push to Gitea (push-mirror)
- Build from site/ directory (GitHub structure)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Riley Zhang 2026-06-25 05:20:46 +02:00
commit de9a777e18
165 changed files with 13828 additions and 81 deletions

View File

@ -1,7 +1,37 @@
node_modules # Git
.next
.git .git
*.md .gitignore
.env* .gitattributes
.vscode
.idea # Docs (not needed at runtime)
docs/
# Editor config
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Environment (keep .env.example)
.env
.env.local
.env.*.local
# Docker (avoid recursive copy)
Dockerfile
docker-compose.yml
.dockerignore
# Node artifacts (from pre-migration scaffold, will be removed by ELF-15)
node_modules/
pnpm-lock.yaml
package.json
apps/
# Test / build caches
*.test
coverage/

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
# Public hostname for HTTPS (Caddy will obtain a Let's Encrypt cert automatically).
# Leave unset or set to "localhost" for local dev (self-signed cert).
HATCH_HOSTNAME=hatch.example.com
# Base URL for SSE and self-referencing links within the UI.
HATCH_BASE_URL=https://hatch.example.com

88
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,88 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Check formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "Files not formatted:"
gofmt -l .
exit 1
fi
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -v -race
- name: Build
run: CGO_ENABLED=0 go build -o /dev/null ./cmd/hatch
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t hatch:ci .
- name: Smoke test the Docker image
run: |
docker run --rm -d -p 8080:8080 --name hatch-ci hatch:ci
sleep 2
curl -fsS http://localhost:8080/healthz | grep -q 'ok'
docker stop hatch-ci
lint-go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
continue-on-error: true # v1.64.8 built with Go 1.24; migrate to v2.x tracking ELF-232
lint-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check markdown formatting
uses: DavidAnson/markdownlint-cli2-action@v16
with:
globs: '**/*.md'
continue-on-error: true
check-paperclip:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for PR range
- name: Check for Paperclip references in commit messages
run: ./scripts/check-paperclip.sh
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_REF: ${{ github.base_ref }}

93
.github/workflows/deploy-site.yml vendored Normal file
View File

@ -0,0 +1,93 @@
name: Deploy static site
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy-github-pages:
name: Deploy to GitHub Pages
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload the site directory
path: 'site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
deploy-docker:
name: Deploy via Docker to hatch.surf
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t hatch-homepage:latest ./site
- name: Save Docker image
run: docker save hatch-homepage:latest | gzip > hatch-homepage.tar.gz
- name: Deploy to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
source: "hatch-homepage.tar.gz"
target: "/tmp/hatch-homepage.tar.gz"
- name: Load and restart container
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
# Load the image
docker load < /tmp/hatch-homepage.tar.gz
# Stop and remove old container if exists
docker stop hatch-homepage 2>/dev/null || true
docker rm hatch-homepage 2>/dev/null || true
# Run new container (static files baked into image, no host mount)
docker run -d \
--name hatch-homepage \
--restart unless-stopped \
-p 127.0.0.1:3000:80 \
hatch-homepage:latest
# Clean up
rm -f /tmp/hatch-homepage.tar.gz
# Reload nginx if it's managing the reverse proxy
nginx -t && systemctl reload nginx 2>/dev/null || true

189
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,189 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g., v1.0.0)'
required: true
type: string
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.os }}/${{ matrix.arch }}
runs-on: ubuntu-latest
strategy:
matrix:
include:
- os: linux
arch: amd64
goos: linux
goarch: amd64
- os: linux
arch: arm64
goos: linux
goarch: arm64
- os: darwin
arch: amd64
goos: darwin
goarch: amd64
- os: darwin
arch: arm64
goos: darwin
goarch: arm64
- os: windows
arch: amd64
goos: windows
goarch: amd64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Determine version
id: version
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
fi
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then
EXT=".exe"
fi
BINARY_NAME="hatch-${{ matrix.os }}-${{ matrix.arch }}${EXT}"
go build \
-ldflags="-s -w -X main.version=${{ steps.version.outputs.version }}" \
-o "${BINARY_NAME}" \
./cmd/hatch
echo "BINARY_NAME=${BINARY_NAME}" >> $GITHUB_ENV
- name: Generate checksum
run: |
sha256sum "${BINARY_NAME}" > "${BINARY_NAME}.sha256"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-${{ matrix.arch }}
path: |
${{ env.BINARY_NAME }}
${{ env.BINARY_NAME }}.sha256
retention-days: 1
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine version
id: version
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
fi
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List artifacts
run: |
ls -la artifacts/
echo "---"
for f in artifacts/*; do
echo "$f: $(du -h "$f" | cut -f1)"
done
- name: Create release notes
id: notes
run: |
# Extract changelog for this version if exists
VERSION="${{ steps.version.outputs.version }}"
NOTES=""
if [ -f CHANGELOG.md ]; then
# Extract notes for this version
NOTES=$(awk "/^## ${VERSION}/,/^## [0-9]/" CHANGELOG.md | head -n -1)
fi
if [ -z "$NOTES" ]; then
NOTES="## Changes in ${VERSION}
- Standalone binary releases for Linux, macOS, and Windows
- Updated CLI documentation
- See [CLI Reference](https://github.com/elfoundation/hatch/blob/main/docs/engineering/cli.md) for usage"
fi
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
name: "Hatch ${{ steps.version.outputs.version }}"
body: ${{ steps.notes.outputs.notes }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
files: |
artifacts/*
fail_on_unmatched_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate SBOM
run: |
# Create a simple SBOM with version info
cat > sbom.json << EOF
{
"name": "hatch",
"version": "${{ steps.version.outputs.version }}",
"buildDate": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"goVersion": "$(go version | awk '{print $3}')",
"platforms": [
"linux/amd64",
"linux/arm64",
"darwin/amd64",
"darwin/arm64",
"windows/amd64"
]
}
EOF
# Upload SBOM as release asset
gh release upload "${{ steps.version.outputs.version }}" sbom.json --clobber || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

51
.gitignore vendored
View File

@ -1,29 +1,30 @@
# dependencies # Go
node_modules/ *.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
# next.js # Build
.next/ /bin
out/ /dist
/data
# production # Runtime data
build/ data/
*.db
coverage.out
# misc # IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store .DS_Store
*.pem /hatch
hatch.exe
# debug site/*.bak
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
out.backup.*

58
.golangci.yml Normal file
View File

@ -0,0 +1,58 @@
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- misspell
- unconvert
- unparam
- prealloc
- gocritic
- revive
linters-settings:
errcheck:
check-type-assertions: true
check-blank: true
govet:
enable:
- shadow
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: range
- name: receiver-naming
- name: indent-error-flow
- name: superfluous-else
- name: unreachable-code
- name: redefines-builtin-id
issues:
exclude-rules:
# Exclude some linters from running on test files
- path: _test\.go
linters:
- errcheck
- gocritic
- unparam
# Exclude known issues in test files
- path: _test\.go
text: "func name"
run:
timeout: 5m
modules-download-mode: readonly

1
.mailmap Normal file
View File

@ -0,0 +1 @@
CEO <ceo@elfoundation.org> CEO <ceo@paperclip.ing>

120
CHANGELOG.md Normal file
View File

@ -0,0 +1,120 @@
# Changelog
All notable changes to Hatch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- CLI documentation with comprehensive command reference
- GitHub Actions release workflow for binary builds
- Standalone binaries for Linux, macOS, and Windows
- SHA256 checksums for all binaries
- SBOM generation for releases
### Changed
- Updated README with CLI installation instructions
### Fixed
- N/A
## [0.1.0] - 2024-01-15
### Added
- Initial release of Hatch
- HTTP request capture and storage
- Request inspection and search
- Request replay functionality
- Mock response configuration
- OpenAPI documentation generation
- Web UI for visual inspection
- Docker support with Caddy reverse proxy
- SQLite storage backend
- REST API v1
### Changed
- N/A
### Fixed
- N/A
## [0.0.1] - 2024-01-01
### Added
- Project scaffolding
- Basic architecture design
- Development environment setup
### Changed
- N/A
### Fixed
- N/A
---
## Release Process
### Creating a Release
1. **Update CHANGELOG.md** with the new version and changes
2. **Create a git tag** for the version:
```bash
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
```
3. **GitHub Actions will automatically**:
- Build binaries for all platforms
- Create SHA256 checksums
- Generate SBOM
- Create GitHub Release with assets
### Manual Release
For manual releases or testing:
```bash
# Trigger workflow manually
gh workflow run release.yml -f tag=v1.0.0
# Or create tag and push
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
```
### Platform Support
| Platform | Architecture | Binary Name |
|----------|--------------|-------------|
| Linux | x86_64 | `hatch-linux-amd64` |
| Linux | ARM64 | `hatch-linux-arm64` |
| macOS | Intel | `hatch-darwin-amd64` |
| macOS | Apple Silicon | `hatch-darwin-arm64` |
| Windows | x86_64 | `hatch-windows-amd64.exe` |
### Binary Installation
After downloading:
```bash
# Linux/macOS
chmod +x hatch-*
sudo mv hatch-* /usr/local/bin/hatch
# Windows (PowerShell)
Rename-Item hatch-windows-amd64.exe hatch.exe
```
### Verification
Verify binary integrity using SHA256 checksums:
```bash
# Linux/macOS
sha256sum -c hatch-linux-amd64.sha256
# Windows (PowerShell)
Get-FileHash hatch-windows-amd64.exe -Algorithm SHA256
```

80
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,80 @@
# Contributing to El Foundation
## One Task = One Branch = One Owner
Every issue gets its own branch. Branch names follow this pattern:
```
owner-identifier/short-description
```
Examples:
- `cto/ELF-15-hatch-stack-migration`
- `engineer/hatch-bootstrap`
- `engineer/hatch-storage`
## No Direct Commits to `main`
All changes go through a pull request. No exceptions.
## Pull Request Process
**Important:** Only the CEO or CTO can merge pull requests. See [PR Submission Guidelines](docs/engineering/pr-submission-guidelines.md) for full details.
1. **Open a PR** from your branch to `main`.
2. **Fill out the PR template** (risk, rollback, verification).
3. **Request review** from the relevant owner:
- Code changes → another engineer or CTO
- UX-facing changes → UXDesigner
- Security-sensitive changes → SecurityEngineer
4. **Address feedback** or escalate disagreements in writing.
5. **Wait for merge** — only CEO or CTO can merge your PR.
6. **Tag for merge** — comment on your PR: `@AlexChen or @JordanPatel - PR approved, ready for merge`
## Commit Messages
Commit messages explain **why**, not what. The diff shows what changed; the message explains the reasoning.
Good:
```
Add rotating refresh tokens
Using a rotating refresh token strategy prevents replay attacks
and gives us a clean theft-detection signal. See ADR-0003.
```
Bad:
```
Update auth.ts
```
## Code Style
- **Go, idiomatic.** `gofmt` clean, `go vet ./...` clean. Prefer the standard library over new dependencies. Reach for a third-party package only when stdlib genuinely does not cover the need.
- **Pure logic in `internal/`, I/O in adapters.** Business logic does not call `http.*` or `database/sql` directly. Storage and HTTP are replaced with interfaces in tests.
- **Server-rendered by default.** Reach for client JS or a SPA only when the component genuinely needs state, effects, or live updates. The v0.1 web UI is HTML templates plus a small vanilla-JS SSE client.
- **Keep packages small.** Prefer small, focused packages over clever abstractions. One file per route group in `internal/handler/`.
- **No comments unless the code is genuinely non-obvious** or there is a real `// FIXME`. Let the code explain itself; let the commit message explain the *why*.
- **No defensive error handling around things that should not fail.** Let it panic or return the error. Wrap at the boundary, not at every call site.
## Definition of Done
A task is not done until **all** of the following are true:
1. Code is written and reviewed.
2. Tests pass. `go test ./...` is green. CI is green.
3. Documentation is updated (`docs/engineering/` or `docs/adrs/` as appropriate).
4. No secrets in plain text.
5. User-facing changes are validated.
6. Rollback path is known.
7. Handoff is clean — follow-up work is captured in a new issue.
## Security
- Never commit secrets, credentials, or customer data.
- Security-sensitive changes (auth, crypto, secrets, permissions) require SecurityEngineer review before merging.
- Report vulnerabilities to the CTO immediately.
## Questions?
Open an issue or ask in the project channel. Async-first: write it down.

19
Caddyfile Normal file
View File

@ -0,0 +1,19 @@
{$HATCH_HOSTNAME:localhost} {
reverse_proxy hatch:8080
# Log requests for debugging
log {
output stdout
format json
}
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
# TLS: auto via Let's Encrypt when HATCH_HOSTNAME is a real domain.
# Falls back to self-signed for localhost / internal-only names.
}

View File

@ -1,15 +1,24 @@
# Production - Static landing page # syntax=docker/dockerfile:1
FROM nginx:alpine AS production
# Copy custom nginx config # ---- build stage ----
COPY nginx.conf /etc/nginx/conf.d/default.conf FROM golang:1.25-alpine AS build
# Copy static files from build context WORKDIR /src
COPY out/ /usr/share/nginx/html
# Expose port (internal only, no SSL) # Cache module downloads before copying source (layer caching).
EXPOSE 80 COPY go.mod go.sum ./
RUN go mod download
# Health check COPY . ./
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:80/ || exit 1 # Build a fully static binary (CGO disabled, no libc dependency).
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/hatch ./cmd/hatch
# ---- run stage ----
FROM scratch
COPY --from=build /bin/hatch /bin/hatch
EXPOSE 8080
ENTRYPOINT ["/bin/hatch"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 El Foundation
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.

34
Makefile Normal file
View File

@ -0,0 +1,34 @@
.PHONY: all build test lint fmt vet clean help
all: lint test build
## build: Build the hatch binary
build:
CGO_ENABLED=0 go build -o hatch ./cmd/hatch
## test: Run all tests with race detection and coverage
test:
go test ./... -v -race -coverprofile=coverage.out
## lint: Run golangci-lint
lint:
golangci-lint run ./...
## fmt: Format code with gofmt
fmt:
gofmt -s -w .
## vet: Run go vet
vet:
go vet ./...
## clean: Remove build artifacts
clean:
rm -f hatch coverage.out
## help: Show this help message
help:
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'

158
README.md
View File

@ -1,29 +1,151 @@
# web # El Foundation
El Foundation web application. Engineering repository for El Foundation.
## About
El Foundation builds institutions that outlast their founders. We create technology and organizations that compound in value over time. This repository is the source of truth for our engineering work.
## Getting Started ## Getting Started
1. Read the [company charter](docs/company/charter.md) to understand why we exist.
2. Read the [operating model](docs/company/operating-model.md) to understand how decisions are made.
3. Read [CONTRIBUTING.md](CONTRIBUTING.md) before making any changes.
4. Read [docs/engineering/local-dev.md](docs/engineering/local-dev.md) for the day-to-day workflow.
5. Read [docs/engineering/hatch-architecture.md](docs/engineering/hatch-architecture.md) for the component map.
## Installation
### Pre-built Binaries (Recommended)
Download the latest binary for your platform from the [Releases page](https://github.com/elfoundation/hatch/releases):
- **Linux (x64)**: `hatch-linux-amd64`
- **Linux (ARM64)**: `hatch-linux-arm64`
- **macOS (Intel)**: `hatch-darwin-amd64`
- **macOS (Apple Silicon)**: `hatch-darwin-arm64`
- **Windows (x64)**: `hatch-windows-amd64.exe`
After downloading:
```bash ```bash
# From the repository root # Linux/macOS
pnpm install chmod +x hatch-*
pnpm dev sudo mv hatch-* /usr/local/bin/hatch
# Windows (PowerShell)
Rename-Item hatch-windows-amd64.exe hatch.exe
# Move to a directory in your PATH
``` ```
Open [http://localhost:3000](http://localhost:3000). ### Build from Source
## Stack Requires Go 1.25 or later:
- Next.js 16 (App Router) ```bash
- React 19 git clone https://github.com/elfoundation/hatch.git
- TypeScript 5 (strict mode) cd hatch
- Tailwind CSS 4 CGO_ENABLED=0 go build -o hatch ./cmd/hatch
sudo mv hatch /usr/local/bin/
```
## Scripts ## Repository Layout
| Script | Description | ```
|--------|-------------| ├── .github/workflows/ # CI/CD definitions
| `pnpm dev` | Start the Next.js dev server | ├── cmd/hatch/ # Server entrypoint (Go binary)
| `pnpm build` | Build for production | ├── docs/
| `pnpm start` | Start the production server | │ ├── company/ # Founding documents (charter, org, etc.)
| `pnpm lint` | Run ESLint | │ ├── engineering/ # Engineering standards, architecture, local dev
│ └── adrs/ # Architecture Decision Records
├── examples/ # Usage examples and integration guides
├── internal/ # Go packages (handler, store, ...)
├── Dockerfile # Multi-stage static binary build (golang → scratch)
├── docker-compose.yml # Local stack with optional Caddy sidecar
├── Caddyfile # TLS reverse proxy for the demo host
└── go.mod # Go module definition
```
## Hatch — Deploy in one command
Hatch is a self-hostable HTTP request inspector + mocker. Ship it to any VPS with Docker.
**[Read why we are building Hatch →](https://hatch.sh/blog/why-we-are-building-hatch)**
### Quick start (local dev, no HTTPS)
```bash
docker compose up --build
# Hatch UI: http://localhost:8080
# Health check: http://localhost:8080/healthz
```
Or run the binary directly:
```bash
go run ./cmd/hatch
# Health check: http://localhost:8080/healthz
```
### Production (with HTTPS via Caddy)
```bash
# Set your domain name
cp .env.example .env
# Edit HATCH_HOSTNAME in .env to your real domain
# Start Hatch + Caddy (auto-issues Let's Encrypt cert)
docker compose --profile with-caddy up -d --build
# Hatch UI: https://{your-domain}
# Capture endpoint: https://{your-domain}/{endpoint-id}
```
### Architecture
```
Internet → :443 (Caddy) → hatch:8080 (Go binary, internal network)
├─ Auto TLS (Let's Encrypt, or self-signed for localhost)
├─ Reverse proxy with security headers
└─ JSON access logs to stdout
```
Caddy terminates TLS and reverse-proxies to the Hatch Go binary. The Hatch container only listens on `127.0.0.1:8080` — it's never directly exposed to the internet.
## CLI Reference
Hatch includes a powerful CLI for interacting with the server from the command line:
```bash
# Capture requests
hatch capture https://api.example.com/webhook
# Inspect captured traffic
hatch inspect my-webhook
# Search for specific requests
hatch search my-webhook -query 'status:500'
# Replay requests to other services
hatch replay <request-id> -endpoint my-webhook -target https://httpbin.org/post
# Configure mock responses
hatch mock set my-webhook -status 200 -body '{"ok":true}'
# Generate OpenAPI documentation
hatch doc generate my-webhook > openapi.json
```
For detailed CLI documentation, see [docs/engineering/cli.md](docs/engineering/cli.md).
## Technology Stack
See [docs/engineering/tech-stack.md](docs/engineering/tech-stack.md) for current choices and rationale, and [docs/adrs/](docs/adrs/) for the decision records that produced them.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
Hatch is released under the MIT License — see [LICENSE](LICENSE).

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
brand/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
brand/mark/mark-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
brand/mark/mark-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
brand/mark/mark-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
brand/mark/mark-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
brand/mark/mark-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
brand/mark/mark-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

BIN
brand/mark/mark-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

BIN
brand/mark/mark-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
brand/mark/mark-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

17
brand/mark/mark-dark.svg Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#FAFAF9" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#FAFAF9" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#FAFAF9"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#FAFAF9" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 866 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<rect width="1000" height="1000" fill="#0A0A0B"/>
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#FAFAF9" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#FAFAF9" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#FAFAF9"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#FAFAF9" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 915 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<rect width="1000" height="1000" fill="#FAFAF9"/>
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#0A0A0B" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#0A0A0B" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#0A0A0B"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#0A0A0B" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 915 B

17
brand/mark/mark.svg Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#0A0A0B" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#0A0A0B" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#0A0A0B"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#0A0A0B" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 866 B

BIN
brand/og/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

69
brand/og/og.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 999 KiB

BIN
brand/og/og@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

1481
cmd/hatch/cli.go Normal file

File diff suppressed because it is too large Load Diff

699
cmd/hatch/cli_test.go Normal file
View File

@ -0,0 +1,699 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPrintUsage(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printUsage()
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Check that usage includes all commands.
expectedCmds := []string{"serve", "capture", "inspect", "search", "replay", "mock", "doc", "config", "completions", "version"}
for _, cmd := range expectedCmds {
if !strings.Contains(output, cmd) {
t.Errorf("usage missing command %q", cmd)
}
}
}
func TestPrintCompletionsUsage(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printCompletionsUsage()
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Check that usage includes all shells.
expectedShells := []string{"bash", "zsh", "fish", "powershell"}
for _, shell := range expectedShells {
if !strings.Contains(output, shell) {
t.Errorf("completions usage missing shell %q", shell)
}
}
}
func TestPrintConfigUsage(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printConfigUsage()
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Check that usage includes all subcommands.
expectedSubcmds := []string{"show", "set", "get", "init"}
for _, subcmd := range expectedSubcmds {
if !strings.Contains(output, subcmd) {
t.Errorf("config usage missing subcommand %q", subcmd)
}
}
}
func TestExtractEndpointID(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"/my-endpoint", "my-endpoint"},
{"https://api.example.com/webhook", "webhook"},
{"http://localhost:8080/test", "test"},
{"/", "default"},
{"", "default"},
{"https://example.com", "default"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := extractEndpointID(tt.input)
if got != tt.expected {
t.Errorf("extractEndpointID(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestMultiFlag(t *testing.T) {
var flags multiFlag
if err := flags.Set("Content-Type:application/json"); err != nil {
t.Fatalf("Set failed: %v", err)
}
if err := flags.Set("X-Custom:test"); err != nil {
t.Fatalf("Set failed: %v", err)
}
if len(flags) != 2 {
t.Errorf("expected 2 flags, got %d", len(flags))
}
str := flags.String()
if !strings.Contains(str, "Content-Type:application/json") {
t.Errorf("String() missing first flag")
}
if !strings.Contains(str, "X-Custom:test") {
t.Errorf("String() missing second flag")
}
}
func TestPrintJSON(t *testing.T) {
// Valid JSON
validJSON := []byte(`{"key":"value","number":42}`)
var buf bytes.Buffer
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
printJSON(validJSON)
w.Close()
os.Stdout = old
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, `"key": "value"`) {
t.Error("printJSON didn't pretty-print valid JSON")
}
// Invalid JSON
invalidJSON := []byte(`not json`)
buf.Reset()
r, w, _ = os.Pipe()
os.Stdout = w
printJSON(invalidJSON)
w.Close()
os.Stdout = old
io.Copy(&buf, r)
output = buf.String()
if !strings.Contains(output, "not json") {
t.Error("printJSON didn't output invalid JSON as-is")
}
}
func TestPrintSuccess(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printSuccess("test message")
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "test message") {
t.Error("printSuccess missing message")
}
}
func TestPrintError(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printError("test error")
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "test error") {
t.Error("printError missing message")
}
}
func TestPrintWarning(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printWarning("test warning")
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "test warning") {
t.Error("printWarning missing message")
}
}
func TestCLITimeout(t *testing.T) {
// Test that the CLI respects the timeout setting.
cfg := &Config{
ServerURL: "http://localhost:9999", // Non-existent server
Timeout: 1,
}
// Verify timeout is set.
if cfg.Timeout != 1 {
t.Errorf("expected timeout 1, got %d", cfg.Timeout)
}
}
func TestGetConfigPath(t *testing.T) {
// Test that getConfigPath returns a valid path.
path := getConfigPath()
if path == "" {
t.Error("getConfigPath returned empty string")
}
// The path should end with config.json.
if !strings.HasSuffix(path, "config.json") {
t.Errorf("config path doesn't end with config.json: %s", path)
}
}
func TestLoadConfigDefaults(t *testing.T) {
// Save original env and config.
origURL := os.Getenv("HATCH_URL")
origFormat := os.Getenv("HATCH_FORMAT")
origNoColor := os.Getenv("NO_COLOR")
defer func() {
os.Setenv("HATCH_URL", origURL)
os.Setenv("HATCH_FORMAT", origFormat)
os.Setenv("NO_COLOR", origNoColor)
}()
// Clear env vars.
os.Unsetenv("HATCH_URL")
os.Unsetenv("HATCH_FORMAT")
os.Unsetenv("NO_COLOR")
// Create a temp config directory.
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
// Write a test config.
cfg := &Config{
ServerURL: "http://test:9000",
Format: "compact",
NoColor: true,
Timeout: 60,
}
data, _ := json.Marshal(cfg)
os.WriteFile(configPath, data, 0644)
// We can't easily test LoadConfig with a custom path without modifying the code.
// Instead, test the Config struct serialization.
var loaded Config
if err := json.Unmarshal(data, &loaded); err != nil {
t.Fatalf("failed to unmarshal config: %v", err)
}
if loaded.ServerURL != "http://test:9000" {
t.Errorf("expected server_url http://test:9000, got %s", loaded.ServerURL)
}
if loaded.Format != "compact" {
t.Errorf("expected format compact, got %s", loaded.Format)
}
if !loaded.NoColor {
t.Error("expected no_color true")
}
if loaded.Timeout != 60 {
t.Errorf("expected timeout 60, got %d", loaded.Timeout)
}
}
func TestVersionOutput(t *testing.T) {
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Simulate version command.
os.Args = []string{"hatch", "version"}
cliMain()
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "hatch") {
t.Error("version output missing 'hatch'")
}
}
func TestHelpOutput(t *testing.T) {
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
// Simulate help command.
os.Args = []string{"hatch", "help"}
cliMain()
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "hatch") {
t.Error("help output missing 'hatch'")
}
if !strings.Contains(output, "Usage") {
t.Error("help output missing 'Usage'")
}
}
func TestCLICompletions(t *testing.T) {
// Test bash completions.
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
printBashCompletions()
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Check for key completion features.
expected := []string{"complete", "hatch", "capture", "inspect", "mock"}
for _, s := range expected {
if !strings.Contains(output, s) {
t.Errorf("bash completions missing %q", s)
}
}
}
func TestCLIErrorHandling(t *testing.T) {
// Test CLIError.
err := &CLIError{
Code: ExitUsageError,
Message: "test error",
Detail: "test detail",
}
if err.Error() != "test error: test detail" {
t.Errorf("unexpected error message: %s", err.Error())
}
// Test without detail.
err2 := &CLIError{
Code: ExitGeneralError,
Message: "test error",
}
if err2.Error() != "test error" {
t.Errorf("unexpected error message: %s", err2.Error())
}
}
func TestServerURL(t *testing.T) {
// Test default.
origURL := os.Getenv("HATCH_URL")
defer os.Setenv("HATCH_URL", origURL)
os.Unsetenv("HATCH_URL")
if url := serverURL(); url != "http://localhost:8080" {
t.Errorf("expected default URL, got %s", url)
}
// Test env var.
os.Setenv("HATCH_URL", "http://custom:9000")
if url := serverURL(); url != "http://custom:9000" {
t.Errorf("expected custom URL, got %s", url)
}
// Test trailing slash removal.
os.Setenv("HATCH_URL", "http://custom:9000/")
if url := serverURL(); url != "http://custom:9000" {
t.Errorf("expected URL without trailing slash, got %s", url)
}
}
func TestMockCommand(t *testing.T) {
// Test that mock command requires "set" subcommand.
// This would normally call os.Exit, so we test the logic differently.
args := []string{"invalid"}
// We can't easily test os.Exit in unit tests without forking.
// Instead, test the argument parsing logic.
if len(args) < 1 || args[0] != "set" {
// This is expected - mock requires "set" subcommand.
t.Log("mock correctly requires 'set' subcommand")
} else {
t.Error("mock should require 'set' subcommand")
}
}
func TestDocCommand(t *testing.T) {
// Test that doc command requires "generate" subcommand.
args := []string{"invalid"}
if len(args) < 1 || args[0] != "generate" {
// This is expected - doc requires "generate" subcommand.
t.Log("doc correctly requires 'generate' subcommand")
} else {
t.Error("doc should require 'generate' subcommand")
}
}
func TestCaptureCommandMissingURL(t *testing.T) {
// Test that capture command requires URL.
// This would normally call os.Exit, so we test the logic.
args := []string{}
if len(args) < 1 {
// This is expected - capture requires URL.
t.Log("capture correctly requires URL argument")
} else {
t.Error("capture should require URL argument")
}
}
func TestInspectCommandMissingEndpoint(t *testing.T) {
// Test that inspect command requires endpoint.
args := []string{}
if len(args) < 1 {
// This is expected - inspect requires endpoint.
t.Log("inspect correctly requires endpoint argument")
} else {
t.Error("inspect should require endpoint argument")
}
}
func TestSearchCommandMissingQuery(t *testing.T) {
// Test that search command requires query.
query := ""
if query == "" {
// This is expected - search requires query.
t.Log("search correctly requires -query flag")
} else {
t.Error("search should require -query flag")
}
}
func TestReplayCommandMissingArgs(t *testing.T) {
// Test that replay command requires endpoint and target.
endpoint := ""
target := ""
if endpoint == "" || target == "" {
// This is expected - replay requires endpoint and target.
t.Log("replay correctly requires -endpoint and -target flags")
} else {
t.Error("replay should require -endpoint and -target flags")
}
}
func TestMockSetCommandMissingEndpoint(t *testing.T) {
// Test that mock set command requires endpoint.
args := []string{}
if len(args) < 1 {
// This is expected - mock set requires endpoint.
t.Log("mock set correctly requires endpoint argument")
} else {
t.Error("mock set should require endpoint argument")
}
}
func TestDocGenerateCommandMissingEndpoint(t *testing.T) {
// Test that doc generate command requires endpoint.
args := []string{}
if len(args) < 1 {
// This is expected - doc generate requires endpoint.
t.Log("doc generate correctly requires endpoint argument")
} else {
t.Error("doc generate should require endpoint argument")
}
}
func TestConfigShowCommand(t *testing.T) {
// Test config show command logic.
// We can't easily test the actual output without mocking.
// Instead, verify the subcommand parsing.
subcmd := "show"
if subcmd != "show" {
t.Errorf("expected 'show', got %s", subcmd)
}
}
func TestConfigGetCommandMissingKey(t *testing.T) {
// Test that config get requires key.
args := []string{}
if len(args) < 1 {
// This is expected - config get requires key.
t.Log("config get correctly requires key argument")
} else {
t.Error("config get should require key argument")
}
}
func TestConfigSetCommandMissingArgs(t *testing.T) {
// Test that config set requires key and value.
args := []string{}
if len(args) < 2 {
// This is expected - config set requires key and value.
t.Log("config set correctly requires key and value arguments")
} else {
t.Error("config set should require key and value arguments")
}
}
func TestConfigSetInvalidFormat(t *testing.T) {
// Test that config set validates format.
format := "invalid"
if format != "json" && format != "table" && format != "compact" {
// This is expected - invalid format.
t.Log("config set correctly rejects invalid format")
} else {
t.Error("config set should reject invalid format")
}
}
func TestConfigSetInvalidTimeout(t *testing.T) {
// Test that config set validates timeout.
timeoutStr := "not a number"
var timeout int
_, err := fmt.Sscanf(timeoutStr, "%d", &timeout)
if err != nil || timeout < 1 {
// This is expected - invalid timeout.
t.Log("config set correctly rejects invalid timeout")
} else {
t.Error("config set should reject invalid timeout")
}
}
func TestConfigSetUnknownKey(t *testing.T) {
// Test that config set rejects unknown keys.
key := "unknown"
validKeys := []string{"server_url", "format", "no_color", "timeout"}
found := false
for _, k := range validKeys {
if k == key {
found = true
break
}
}
if !found {
// This is expected - unknown key.
t.Log("config set correctly rejects unknown key")
} else {
t.Error("config set should reject unknown key")
}
}
func TestConfigGetUnknownKey(t *testing.T) {
// Test that config get rejects unknown keys.
key := "unknown"
validKeys := []string{"server_url", "format", "no_color", "timeout"}
found := false
for _, k := range validKeys {
if k == key {
found = true
break
}
}
if !found {
// This is expected - unknown key.
t.Log("config get correctly rejects unknown key")
} else {
t.Error("config get should reject unknown key")
}
}
func TestAPIRequestError(t *testing.T) {
// Test API request with non-existent server.
origURL := os.Getenv("HATCH_URL")
defer os.Setenv("HATCH_URL", origURL)
os.Setenv("HATCH_URL", "http://localhost:9999")
_, _, err := apiRequest("GET", "/test", nil)
if err == nil {
t.Error("expected error for non-existent server")
}
// Check that error is a CLIError with network error code.
if cliErr, ok := err.(*CLIError); ok {
if cliErr.Code != ExitNetworkError {
t.Errorf("expected exit code %d, got %d", ExitNetworkError, cliErr.Code)
}
if !strings.Contains(cliErr.Message, "cannot connect") {
t.Errorf("error message doesn't mention connection: %s", cliErr.Message)
}
} else {
t.Error("expected CLIError")
}
}
func TestAPIRequestInvalidJSON(t *testing.T) {
// Test API request with invalid JSON body.
_, _, err := apiRequest("POST", "/test", make(chan int))
if err == nil {
t.Error("expected error for invalid JSON")
}
if cliErr, ok := err.(*CLIError); ok {
if cliErr.Code != ExitGeneralError {
t.Errorf("expected exit code %d, got %d", ExitGeneralError, cliErr.Code)
}
if !strings.Contains(cliErr.Message, "marshal") {
t.Errorf("error message doesn't mention marshal: %s", cliErr.Message)
}
} else {
t.Error("expected CLIError")
}
}
func TestAPIRequestHTTPError(t *testing.T) {
// Create a test server that returns an error.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"bad request"}`))
}))
defer srv.Close()
origURL := os.Getenv("HATCH_URL")
defer os.Setenv("HATCH_URL", origURL)
os.Setenv("HATCH_URL", srv.URL)
data, status, err := apiRequest("GET", "/test", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, status)
}
if !strings.Contains(string(data), "bad request") {
t.Error("response doesn't contain error message")
}
}

69
cmd/hatch/main.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/elfoundation/hatch/internal/handler"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
)
func main() {
// CLI mode: if a subcommand is provided, handle it and exit.
if cliMain() {
return
}
// Server mode: start the HTTP server.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dbPath := os.Getenv("HATCH_DB_PATH")
repo, err := store.Open(dbPath)
if err != nil {
log.Fatalf("hatch: open store: %v", err)
}
defer repo.Close()
h := handler.New(repo)
h.Debug = os.Getenv("HATCH_DEBUG") != ""
r := chi.NewRouter()
h.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", port)
srv := &http.Server{
Addr: addr,
Handler: r,
}
// Start server in a goroutine.
go func() {
log.Printf("hatch starting on %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("hatch server error: %v", err)
}
}()
// Wait for interrupt signal for graceful shutdown.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
// Create a deadline for shutdown.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("server forced to shutdown: %v", err)
}
log.Println("server exited")
}

631
cmd/hatch/main_test.go Normal file
View File

@ -0,0 +1,631 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/elfoundation/hatch/internal/handler"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
)
func TestHealthzViaRouter(t *testing.T) {
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", w.Code)
}
body, _ := io.ReadAll(w.Result().Body)
w.Result().Body.Close()
got := strings.TrimSpace(string(body))
if got != "ok" {
t.Fatalf("expected body 'ok', got %q", got)
}
}
func TestHealthzSmoke(t *testing.T) {
// Smoke: boot server on random port, hit /healthz.
// Use :memory: store so no filesystem dependency.
t.Setenv("HATCH_DB_PATH", ":memory:")
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatalf("failed to GET /healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
got := strings.TrimSpace(string(body))
if got != "ok" {
t.Fatalf("expected body 'ok', got %q", got)
}
}
// TestSmokeE2E is the acceptance gate: one command to prove the whole product works.
// It starts a real server, exercises every feature (capture, inspect, SSE, mock,
// replay), and verifies the full flow end-to-end.
//
// Run: go test ./cmd/hatch -run TestSmokeE2E -v
func TestSmokeE2E(t *testing.T) {
// ---- Phase 0: Start server ----
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
srv := httptest.NewServer(r)
defer srv.Close()
client := &http.Client{Timeout: 10 * time.Second}
// ---- Phase 1: Health check ----
t.Run("healthz", func(t *testing.T) {
resp, err := client.Get(srv.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if strings.TrimSpace(string(body)) != "ok" {
t.Fatalf("expected 'ok', got %q", string(body))
}
})
// ---- Phase 2: Capture requests (all HTTP methods) ----
t.Run("capture", func(t *testing.T) {
methods := []struct {
method string
body string
}{
{"GET", ""},
{"POST", `{"order":"latte"}`},
{"PUT", `{"status":"updated"}`},
{"PATCH", `{"field":"partial"}`},
{"DELETE", ""},
}
for _, m := range methods {
var body io.Reader
if m.body != "" {
body = strings.NewReader(m.body)
}
req, _ := http.NewRequest(m.method, srv.URL+"/capture-test", body)
if m.body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Custom", "smoke-"+m.method)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s /capture-test: %v", m.method, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: expected 200, got %d", m.method, resp.StatusCode)
}
}
// Verify all 5 requests are in the store.
reqs, err := repo.ListRequests(context.Background(), "capture-test", 10)
if err != nil {
t.Fatalf("list requests: %v", err)
}
if len(reqs) != 5 {
t.Fatalf("expected 5 captured requests, got %d", len(reqs))
}
})
// ---- Phase 3: Inspect page (HTML rendering) ----
t.Run("inspect", func(t *testing.T) {
resp, err := client.Get(srv.URL + "/e/capture-test")
if err != nil {
t.Fatalf("GET /e/capture-test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("expected text/html, got %q", ct)
}
body, _ := io.ReadAll(resp.Body)
html := string(body)
// Page structure.
if !strings.Contains(html, "<!DOCTYPE html>") {
t.Error("missing doctype")
}
if !strings.Contains(html, "capture-test") {
t.Error("missing endpoint ID")
}
// Request cards.
if !strings.Contains(html, "DELETE") {
t.Error("missing DELETE method badge")
}
if !strings.Contains(html, "POST") {
t.Error("missing POST method badge")
}
// Replay UI.
if !strings.Contains(html, "Replay") {
t.Error("missing Replay button")
}
// SSE live-update script.
if !strings.Contains(html, "EventSource") {
t.Error("missing EventSource script")
}
// Empty-state endpoint should show the hint.
resp2, err := client.Get(srv.URL + "/e/nonexistent")
if err != nil {
t.Fatalf("GET /e/nonexistent: %v", err)
}
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
if !strings.Contains(string(body2), "Waiting for requests") {
t.Error("missing empty state for unused endpoint")
}
})
// ---- Phase 4: SSE stream receives live events ----
t.Run("sse", func(t *testing.T) {
// Use separate transports to avoid connection-pool issues with the long-lived SSE connection.
sseClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
Timeout: 5 * time.Second,
}
captureClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
Timeout: 5 * time.Second,
}
// Subscribe to SSE.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sseReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/sse-ep/events", nil)
sseResp, err := sseClient.Do(sseReq)
if err != nil {
t.Fatalf("SSE request: %v", err)
}
defer sseResp.Body.Close()
if sseResp.StatusCode != http.StatusOK {
t.Fatalf("SSE returned %d", sseResp.StatusCode)
}
ct := sseResp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("SSE Content-Type: %q", ct)
}
// Capture a request on a goroutine; read SSE on main.
eventCh := make(chan string, 1)
go func() {
buf := make([]byte, 4096)
n, _ := sseResp.Body.Read(buf)
eventCh <- string(buf[:n])
}()
time.Sleep(20 * time.Millisecond) // let SSE subscription register
resp, err := captureClient.Post(srv.URL+"/sse-ep", "application/json", strings.NewReader(`{"sse":true}`))
if err != nil {
t.Fatalf("capture: %v", err)
}
resp.Body.Close()
select {
case event := <-eventCh:
if !strings.Contains(event, "data:") {
t.Fatalf("SSE event missing 'data:' prefix: %q", event)
}
// Body value is JSON-escaped in the SSE stream (e.g. "body":"{\"sse\":true}").
// Check for the content values rather than the exact escaped form.
if !strings.Contains(event, `sse`) || !strings.Contains(event, `true`) {
t.Fatalf("SSE event missing body content: %q", event)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for SSE event")
}
})
// ---- Phase 5: Mock configuration and response ----
t.Run("mock", func(t *testing.T) {
// Set a mock on a fresh endpoint.
mockBody := `{"status":418,"headers":{"X-Teapot":"true"},"body":"eyJicmV3aW5nIjp0cnVlfQ=="}`
resp, err := client.Post(
srv.URL+"/e/mock-ep/mock",
"application/json",
strings.NewReader(mockBody),
)
if err != nil {
t.Fatalf("PUT /e/mock-ep/mock: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// PUT is registered as PUT, but we use POST here — let's use PUT.
// The route is PUT /e/{endpointID}/mock
}
// Use proper PUT method.
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/e/mock-ep-2/mock", strings.NewReader(mockBody))
req.Header.Set("Content-Type", "application/json")
resp2, err := client.Do(req)
if err != nil {
t.Fatalf("PUT /e/mock-ep-2/mock: %v", err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK from mock set, got %d: %s", resp2.StatusCode, readBody(resp2))
}
// Verify mock is stored.
mock, err := repo.GetMock(context.Background(), "mock-ep-2")
if err != nil {
t.Fatalf("mock not stored: %v", err)
}
if mock.Status != 418 {
t.Errorf("expected status 418, got %d", mock.Status)
}
if mock.Headers["X-Teapot"] != "true" {
t.Errorf("expected X-Teapot header, got %v", mock.Headers)
}
// Capture a request — should return the mock response.
resp3, err := client.Post(srv.URL+"/mock-ep-2", "application/json", strings.NewReader(`{"order":"earl grey"}`))
if err != nil {
t.Fatalf("capture on mocked endpoint: %v", err)
}
defer resp3.Body.Close()
if resp3.StatusCode != 418 {
t.Fatalf("expected mock status 418, got %d", resp3.StatusCode)
}
if resp3.Header.Get("X-Teapot") != "true" {
t.Errorf("expected X-Teapot: true, got %q", resp3.Header.Get("X-Teapot"))
}
body, _ := io.ReadAll(resp3.Body)
// Body is base64-decoded by Go's json decoder ([]byte field).
if strings.TrimSpace(string(body)) != `{"brewing":true}` {
t.Errorf("expected mock body, got %q", string(body))
}
})
// ---- Phase 6: Replay ----
t.Run("replay", func(t *testing.T) {
// Start a sink server that echoes back what it receives.
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Sink", "yes")
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"echo":{"method":%q,"path":%q}}`, r.Method, r.URL.Path)
}))
defer sink.Close()
// Allow private replay so we can hit the loopback sink.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
// Capture a request to replay later.
captureResp, err := client.Post(srv.URL+"/replay-ep", "application/json", strings.NewReader(`{"msg":"replay me"}`))
if err != nil {
t.Fatalf("capture for replay: %v", err)
}
captureResp.Body.Close()
// Get the captured request ID from the store.
reqs, _ := repo.ListRequests(context.Background(), "replay-ep", 1)
if len(reqs) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(reqs))
}
reqID := reqs[0].ID
// Replay to the sink.
replayPayload := fmt.Sprintf(`{"target_url":%q}`, sink.URL)
replayResp, err := client.Post(
srv.URL+"/e/replay-ep/requests/"+reqID+"/replay",
"application/json",
strings.NewReader(replayPayload),
)
if err != nil {
t.Fatalf("replay request: %v", err)
}
defer replayResp.Body.Close()
if replayResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(replayResp.Body)
t.Fatalf("replay returned %d: %s", replayResp.StatusCode, string(body))
}
var result struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
json.NewDecoder(replayResp.Body).Decode(&result)
if result.Status != 201 {
t.Errorf("expected replay sink status 201, got %d", result.Status)
}
if result.Headers["X-Sink"] != "yes" {
t.Errorf("expected X-Sink: yes, got %q", result.Headers["X-Sink"])
}
})
// ---- Phase 7: Edge cases ----
t.Run("edge", func(t *testing.T) {
// Capture with query params.
resp, err := client.Get(srv.URL + "/query-ep?foo=bar&baz=qux")
if err != nil {
t.Fatalf("capture with query: %v", err)
}
resp.Body.Close()
reqs, _ := repo.ListRequests(context.Background(), "query-ep", 1)
if len(reqs) != 1 {
t.Fatalf("expected 1 request, got %d", len(reqs))
}
if reqs[0].Query != "foo=bar&baz=qux" {
t.Errorf("expected query 'foo=bar&baz=qux', got %q", reqs[0].Query)
}
// Capture with binary body.
binaryBody := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE}
resp2, err := client.Post(srv.URL+"/binary-ep", "application/octet-stream", strings.NewReader(string(binaryBody)))
if err != nil {
t.Fatalf("binary capture: %v", err)
}
resp2.Body.Close()
reqs2, _ := repo.ListRequests(context.Background(), "binary-ep", 1)
if len(reqs2) != 1 {
t.Fatalf("expected 1 binary request, got %d", len(reqs2))
}
if len(reqs2[0].Body) != len(binaryBody) {
t.Errorf("expected body len %d, got %d", len(binaryBody), len(reqs2[0].Body))
}
// SSRF protection: replay to localhost is denied.
replayPayload := `{"target_url":"http://localhost:9999/"}`
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/e/query-ep/requests/"+reqs[0].ID+"/replay", strings.NewReader(replayPayload))
req.Header.Set("Content-Type", "application/json")
resp3, err := client.Do(req)
if err != nil {
t.Fatalf("SSRF test: %v", err)
}
resp3.Body.Close()
if resp3.StatusCode != http.StatusForbidden {
t.Errorf("expected 403 for localhost replay, got %d", resp3.StatusCode)
}
})
t.Log("E2E smoke test complete — all phases passed")
}
func readBody(resp *http.Response) string {
if resp.Body == nil {
return ""
}
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// TestServerConfigurations tests various server configurations.
func TestServerConfigurations(t *testing.T) {
// Test with default port.
t.Run("default_port", func(t *testing.T) {
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
// Test with custom headers.
t.Run("custom_headers", func(t *testing.T) {
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
srv := httptest.NewServer(r)
defer srv.Close()
req, _ := http.NewRequest("POST", srv.URL+"/test", strings.NewReader(`{"key":"value"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Custom-Header", "test-value")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("POST /test: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
}
// TestMultipleEndpointsConcurrency tests concurrent access to multiple endpoints.
func TestMultipleEndpointsConcurrency(t *testing.T) {
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
srv := httptest.NewServer(r)
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
// Create multiple endpoints concurrently.
var wg sync.WaitGroup
numEndpoints := 5
for i := 0; i < numEndpoints; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
endpoint := fmt.Sprintf("endpoint-%d", idx)
for j := 0; j < 10; j++ {
body := fmt.Sprintf(`{"endpoint":%d,"request":%d}`, idx, j)
resp, err := client.Post(srv.URL+"/"+endpoint, "application/json", strings.NewReader(body))
if err != nil {
t.Errorf("endpoint %d, request %d: %v", idx, j, err)
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("endpoint %d, request %d: expected 200, got %d", idx, j, resp.StatusCode)
return
}
}
}(i)
}
wg.Wait()
// Verify all endpoints have requests.
for i := 0; i < numEndpoints; i++ {
endpoint := fmt.Sprintf("endpoint-%d", i)
reqs, err := repo.ListRequests(context.Background(), endpoint, 100)
if err != nil {
t.Fatalf("list requests for %s: %v", endpoint, err)
}
if len(reqs) != 10 {
t.Errorf("expected 10 requests for %s, got %d", endpoint, len(reqs))
}
}
}
// TestErrorHandling tests error handling scenarios.
func TestErrorHandling(t *testing.T) {
repo, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer repo.Close()
r := chi.NewRouter()
h := handler.New(repo)
h.RegisterRoutes(r)
srv := httptest.NewServer(r)
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
// Test invalid JSON body.
t.Run("invalid_json", func(t *testing.T) {
resp, err := client.Post(srv.URL+"/test", "application/json", strings.NewReader("not json"))
if err != nil {
t.Fatalf("POST /test: %v", err)
}
resp.Body.Close()
// Should still return 200 (capture accepts any body).
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
// Test missing endpoint ID (root path).
t.Run("missing_endpoint", func(t *testing.T) {
resp, err := client.Get(srv.URL + "/")
if err != nil {
t.Fatalf("GET /: %v", err)
}
resp.Body.Close()
// Chi returns 404 for unmatched routes.
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
// Test replay with invalid request ID.
t.Run("replay_invalid_id", func(t *testing.T) {
req, _ := http.NewRequest("POST", srv.URL+"/e/test/requests/nonexistent/replay",
strings.NewReader(`{"target_url":"https://example.com"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("replay: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
}

99
cmd/loadtest/main.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"flag"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
)
func main() {
url := flag.String("url", "http://localhost:8080/healthz", "URL to test")
concurrency := flag.Int("c", 10, "concurrent workers")
total := flag.Int("n", 1000, "total requests")
flag.Parse()
var (
successCount int64
failCount int64
totalLatency int64 // microseconds
minLatency int64 = 1<<63 - 1
maxLatency int64
)
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: *concurrency,
MaxIdleConnsPerHost: *concurrency,
IdleConnTimeout: 90 * time.Second,
},
}
var wg sync.WaitGroup
requests := make(chan int, *total)
for i := 0; i < *total; i++ {
requests <- i
}
close(requests)
start := time.Now()
for i := 0; i < *concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range requests {
reqStart := time.Now()
resp, err := client.Get(*url)
latency := time.Since(reqStart).Microseconds()
if err != nil {
atomic.AddInt64(&failCount, 1)
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
atomic.AddInt64(&successCount, 1)
} else {
atomic.AddInt64(&failCount, 1)
}
atomic.AddInt64(&totalLatency, latency)
// Update min/max using atomic operations for simplicity
for {
old := atomic.LoadInt64(&minLatency)
if latency >= old || atomic.CompareAndSwapInt64(&minLatency, old, latency) {
break
}
}
for {
old := atomic.LoadInt64(&maxLatency)
if latency <= old || atomic.CompareAndSwapInt64(&maxLatency, old, latency) {
break
}
}
}
}()
}
wg.Wait()
duration := time.Since(start)
fmt.Printf("Load test completed\n")
fmt.Printf("URL: %s\n", *url)
fmt.Printf("Total requests: %d\n", *total)
fmt.Printf("Concurrency: %d\n", *concurrency)
fmt.Printf("Duration: %v\n", duration)
fmt.Printf("Success: %d\n", atomic.LoadInt64(&successCount))
fmt.Printf("Failed: %d\n", atomic.LoadInt64(&failCount))
if *total > 0 {
avgLatency := float64(atomic.LoadInt64(&totalLatency)) / float64(*total)
fmt.Printf("Average latency: %.2f ms\n", avgLatency/1000)
fmt.Printf("Min latency: %.2f ms\n", float64(atomic.LoadInt64(&minLatency))/1000)
fmt.Printf("Max latency: %.2f ms\n", float64(atomic.LoadInt64(&maxLatency))/1000)
fmt.Printf("Requests/sec: %.2f\n", float64(*total)/duration.Seconds())
}
}

View File

@ -6,14 +6,19 @@ set -e
echo "=== Deploying hatch.surf ===" echo "=== Deploying hatch.surf ==="
cd /home/nara/apps/web cd /home/nara/apps/web
# Pull latest changes # Pull latest changes from GitHub (primary source)
git pull origin main git pull github main
# Build and deploy # Push to Gitea (push-mirror)
git push origin main
# Build and deploy from site directory
cd site
docker build -t hatch-web:latest . docker build -t hatch-web:latest .
docker stop hatch-web 2>/dev/null || true docker stop hatch-web 2>/dev/null || true
docker rm hatch-web 2>/dev/null || true docker rm hatch-web 2>/dev/null || true
docker compose up -d docker run -d --name hatch-web --restart unless-stopped -p 8080:80 hatch-web:latest
cd ..
# Verify # Verify
sleep 5 sleep 5

View File

@ -1,21 +1,33 @@
services: services:
hatch-web: hatch:
build: build: .
context: .
dockerfile: Dockerfile
container_name: hatch-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:80" - "127.0.0.1:8080:8080"
networks: volumes:
- hatch-network - hatch-data:/data
healthcheck: environment:
test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"] - HATCH_PORT=8080
interval: 30s - HATCH_DB_PATH=/data/hatch.db
timeout: 10s - HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
retries: 5
start_period: 30s
networks: caddy:
hatch-network: image: caddy:2-alpine
driver: bridge restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
- HATCH_HOSTNAME=${HATCH_HOSTNAME:-localhost}
# Caddy runs even without a real hostname (self-signed)
profiles:
- with-caddy
volumes:
hatch-data:
caddy-data:
caddy-config:

161
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,161 @@
# Hatch.surf Architecture
## Overview
Hatch.surf is the landing page and brand site for El Foundation. It consists of:
- **Static frontend** (site/) — HTML/CSS/JS served via nginx
- **Go backend** (cmd/hatch) — API server for future dynamic features
## Deployment Architecture
```
GitHub Push → GitHub Actions → Build Docker Image → SCP to Server → Docker Run → Caddy (reverse proxy)
```
**Benefits:**
- Containerized — isolated from host
- Reproducible — same image in dev/prod
- No host filesystem dependency (no `/var/www`)
- Easy rollback — just change image tag
- Self-contained — static files baked into image
## Docker Setup
### Frontend (site/)
**Dockerfile:**
```dockerfile
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY . /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
**docker-compose.yml:**
```yaml
services:
hatch-homepage:
build: .
ports:
- "3000:80"
restart: unless-stopped
```
Note: Static files are baked into the image during build. No host mount required.
### Backend (cmd/hatch)
**Dockerfile:**
```dockerfile
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/hatch ./cmd/hatch
FROM scratch
COPY --from=build /bin/hatch /bin/hatch
EXPOSE 8080
ENTRYPOINT ["/bin/hatch"]
```
**docker-compose.yml:**
```yaml
services:
hatch:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
volumes:
- hatch-data:/data
environment:
- HATCH_PORT=8080
- HATCH_DB_PATH=/data/hatch.db
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
- HATCH_HOSTNAME=${HATCH_HOSTNAME:-localhost}
profiles:
- with-caddy
volumes:
hatch-data:
caddy-data:
caddy-config:
```
## Deployment Flow
### GitHub Actions (deploy-site.yml)
1. **Build**`docker build -t hatch-homepage:latest ./site`
2. **Save**`docker save hatch-homepage:latest | gzip > hatch-homepage.tar.gz`
3. **SCP** — Copy tarball to server
4. **Load**`docker load < /tmp/hatch-homepage.tar.gz`
5. **Run**`docker run -d --name hatch-homepage ...`
### Server Configuration
**Nginx reverse proxy** (host nginx):
```nginx
server {
listen 80;
server_name hatch.surf;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name hatch.surf;
ssl_certificate /etc/letsencrypt/live/hatch.surf/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hatch.surf/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Secrets Management
- **DEPLOY_HOST** — Server IP/hostname
- **DEPLOY_USER** — SSH user (nara)
- **DEPLOY_KEY** — SSH private key (stored in GitHub Secrets)
No secrets in repo. All deployment credentials in GitHub Secrets.
## Rollback Procedure
If deployment fails:
```bash
# On server
docker stop hatch-homepage
docker rm hatch-homepage
docker run -d --name hatch-homepage --restart unless-stopped -p 127.0.0.1:3000:80 hatch-homepage:previous-tag
```
## Future Improvements
1. **Container registry** — Push images to GitHub Container Registry (ghcr.io)
2. **Health checks** — Add HEALTHCHECK to Dockerfile
3. **Monitoring** — Add Prometheus metrics endpoint
4. **CDN** — Serve static assets via Cloudflare/CloudFront
5. **SSL automation** — Certbot in container or Caddy auto-SSL

View File

@ -0,0 +1,133 @@
# ADR-0002: Hatch detail stack
- Status: Accepted
- Date: 2026-06-22
- Authors: CTO
- Supersedes: none
- Related: [ADR-0001](https://github.com/elfoundation/hatch/blob/main/docs/adrs/0001-stack.md), [Hatch v0.1 plan (ELF-13)](https://github.com/elfoundation/hatch/issues/13)
## Context
[ADR-0001](https://github.com/elfoundation/hatch/blob/main/docs/adrs/0001-stack.md) fixed the v0.1 stack at the level of "Go + SQLite + stdlib `net/http` + server-rendered HTML + SSE + single Docker image." That ADR deliberately did not pick:
- which HTTP router (stdlib `net/http` with method-based mux vs. a third-party router)
- which SQLite driver
- which templating system (only said "server-rendered HTML")
- which test framework
- which license
This ADR closes those gaps with concrete choices, named alternatives, and the rollback path for each.
## Decision
| Concern | Choice | One-line reason |
|---|---|---|
| HTTP router | `go-chi/chi` (v5) for v0.1 | SSE middleware story is cleaner than hand-rolled on `http.ServeMux`; chi is the lightest router that still gives us real middleware composition. |
| SQLite driver | `modernc.org/sqlite` (pure Go) | Keeps the "no CGO" promise that the static-binary distribution rests on. |
| Templates | stdlib `html/template`, embedded with `//go:embed` | Auto-escaping by default, no template-engine dependency, no runtime parsing of the filesystem. |
| Test framework | stdlib `testing` + `net/http/httptest` | No assertion library unless pain demands one. |
| License | MIT (revised; was Apache-2.0) | Permissive, lowest friction for small single-binary dev tools; CEO decision [ELF-141](https://github.com/elfoundation/hatch/issues/141). |
| Config | Environment variables only (`PORT`, `HATCH_DB_PATH`, `HATCH_LISTEN`) | Twelve-factor. No YAML/JSON config files for v0.1. |
| Logging | stdlib `log` to stdout, structured via `slog` (Go 1.21+) | No third-party logging library until we need log shipping. |
| Process model | Single process, one port, in-process SSE hub | v0.1 is a self-hosted single-binary product. No workers, no sidecars. |
### Router: `go-chi/chi`
Chi v5 is the smallest router that still composes well. It sits on top of `http.Handler`, so it does not break the stdlib-first principle — it just gives us `r.Use(middleware.Logger)`, `r.Route("/e/{id}", func(r chi.Router) { ... })`, and a clean SSE middleware (`middleware.Flush`).
**Why not stdlib `net/http` only?** Go 1.22 added method-based routing and path parameters to `http.ServeMux`. For v0.1's surface (~6 routes) it is genuinely enough. We adopt chi for two reasons: (1) the SSE middleware in `chi/middleware` is one line vs. ~20 lines of hand-rolled flush-on-write logic, and (2) the moment we add a second cross-cutting concern (request ID, auth-ready, rate limit) the hand-rolled composition gets ugly fast.
**Reversibility.** Drop-in: chi is an `http.Handler`. Reverting to stdlib `ServeMux` is a one-file change to `cmd/hatch/main.go`. The handler layer is router-agnostic by construction.
### SQLite driver: `modernc.org/sqlite`
Pure Go. Translates SQLite's C source via `ccgo` at build time, so the produced driver is a normal Go package with no `cgo` import.
**Why not `mattn/go-sqlite3`?** It is the most popular SQLite driver, but it requires CGO. CGO forces us to either (a) ship a glibc-linked binary that breaks on musl-based images, or (b) maintain a separate CGO build pipeline. Both are paid complexity for a feature (a C compiler in CI) we do not otherwise need.
**Why not the stdlib `database/sql` only?** The `database/sql` package is the API; it is not a driver. We still need a driver, and we still need migrations, and we still need a `Repository` interface above it. The choice here is the driver.
### Templates: stdlib `html/template`, `//go:embed`-ed
`html/template` auto-escapes context-sensitively (HTML, JS, URL, CSS contexts). It is in the standard library, has no dependencies, and produces no runtime surprises. We embed templates at build time with `//go:embed templates/*.html` so the binary is self-contained and there is no filesystem layout to manage at deploy time.
**Why not `templ` or `jet`?** Both add a build step (`templ generate`) to the dev loop. The v0.1 surface is small enough that a hand-rolled `{{range .Requests}}` is not a liability. We will revisit if template churn becomes a real cost.
### Test framework: stdlib `testing` + `net/http/httptest`
`testing` is in the standard library, plays well with `go test`, and is what every Go engineer already knows. `httptest` gives us `httptest.NewRecorder` for handler unit tests and `httptest.NewServer` for end-to-end smoke tests without a real network socket.
**Why not `testify` or `gocheck`?** No assertion library has yet earned its place. We will adopt one when the boilerplate of `if got != want { t.Errorf(...) }` becomes a real cost, not before.
### License: MIT (revised 2026-06-23)
**Superseded by CEO decision** — [ELF-141](https://github.com/elfoundation/hatch/issues/141) locked the OSS license at **MIT** ahead of the v0.1 public launch. The original Apache-2.0 reasoning is preserved below for history; the repo now ships under MIT (see [`LICENSE`](../../LICENSE)). MIT→Apache-2.0 remains a compatible, reversible upgrade if a corporate contributor raises patent concerns later.
#### Original Apache-2.0 reasoning (superseded)
Permissive. Explicit patent grant. Matches what the Go ecosystem uses (Kubernetes, Docker, the Go toolchain itself are permissive-licensed). Compatible with corporate adoption without legal-review friction.
**Why not MIT?** MIT is also acceptable, but the patent grant in Apache-2.0 is a real protection for downstream contributors and is a default in our target ecosystem.
## Consequences
### Positive
- The full v0.1 dependency surface is: `go-chi/chi`, `modernc.org/sqlite`, and `github.com/google/uuid` (if we use it for endpoint IDs). Everything else is stdlib. That is a small, reviewable surface.
- The single-binary distribution story holds: `CGO_ENABLED=0 go build` produces a working static binary, and the Docker build ends in `scratch` with no runtime.
- The license, the test framework, and the template engine are all things an incoming Go engineer already knows. Zero ramp cost on tooling.
- Each choice has a one-day-or-less reversal path. The stack is composable from independent parts; nothing in this ADR forces a bigger rewrite to undo.
### Negative / Risks
- Chi is one more dependency to track. If a future Go stdlib release subsumes the middleware story, we have a small migration.
- `modernc.org/sqlite` is slower than the C version on raw throughput benchmarks (~23× on simple SELECTs). For v0.1's workload (a self-hosted single-user product) this is invisible. We will measure before optimizing.
- Stdlib `html/template` has no inheritance, no partials-with-arguments, and no "components." If template complexity grows, we will feel it. The mitigation is to keep the page count low (Capture page, Inspect page, Mock config page, error page).
- (Historical, pre-MIT switch:) Apache-2.0 is more verbose than MIT. Trivial cost; mentioned for completeness.
## Alternatives Considered
### Option A: `net/http` only (no chi)
- Pros: zero router dependency; one fewer thing to upgrade; Go 1.22's `ServeMux` is genuinely good.
- Cons: SSE flush middleware is hand-rolled; auth/cors/rate-limit middleware are also hand-rolled; the cost of NOT having middleware composition grows with every new cross-cutting concern.
- Why rejected: SSE is in v0.1 (Inspect live). The first place we will want a second middleware is auth, even if auth itself ships later. Paying for chi now is cheaper than paying for hand-rolled composition later.
### Option B: `mattn/go-sqlite3`
- Pros: most popular SQLite driver, fast, well-known.
- Cons: requires CGO. Breaks the static-binary promise or forces a second build pipeline.
- Why rejected: the cost of CGO exceeds the value of `mattn`'s ecosystem familiarity.
### Option C: `templ` for templates
- Pros: type-safe templates, IDE autocompletion, components.
- Cons: requires a code-generation step (`templ generate`) on every save. The dev loop slows down. The runtime feature set is ~equivalent to `html/template` for our surface.
- Why rejected: the v0.1 template surface is small enough that hand-written `html/template` is not yet a liability. We can adopt `templ` later if template churn becomes a real cost.
### Option D: MIT license
- Pros: shorter, more familiar to individual contributors.
- Cons: no explicit patent grant.
- Why rejected: Apache-2.0's patent grant is a real downstream protection and matches our target ecosystem. The longer license header is a trivial cost.
## Rollback Plan
| Choice | Rollback |
|---|---|
| `go-chi/chi` | Replace `chi.Router` with `http.ServeMux` in `cmd/hatch/main.go`. Handlers and store are unchanged. ~half a day. |
| `modernc.org/sqlite` | Swap to `mattn/go-sqlite3`. Add CGO to the Dockerfile. Update CI to install `gcc`. ~half a day, plus the Dockerfile/CI change. |
| `html/template` | Adopt `templ` (or `jet`). Adds a code-generation step. ~1 day plus regen. |
| stdlib `testing` | Adopt `testify`. Mechanical import swap. ~2 hours. |
| MIT (was Apache-2.0) | Relicense with contributor agreement. Not a code change, but a legal process. MIT→Apache-2.0 is a compatible upgrade if patent concerns arise. |
| Env-only config | Adopt `viper` or `koanf`. ~1 day including flag plumbing. |
The first five rollbacks are independent and can be done in any order. The license change is not ours to make unilaterally.
## Related
- [ADR-0001](https://github.com/elfoundation/hatch/blob/main/docs/adrs/0001-stack.md) — parent decision
- [Hatch v0.1 plan (ELF-13)](https://github.com/elfoundation/hatch/issues/13) — build plan and ordering
- [`docs/engineering/tech-stack.md`](../engineering/tech-stack.md) — current stack summary
- [`docs/engineering/hatch-architecture.md`](../engineering/hatch-architecture.md) — component map
- [`docs/engineering/local-dev.md`](../engineering/local-dev.md) — day-to-day commands

51
docs/adrs/adr-template.md Normal file
View File

@ -0,0 +1,51 @@
# ADR-XXX: Title
## Status
- Proposed
- Accepted
- Deprecated
- Superseded by [ADR-YYY](adr-YYY.md)
## Context
What is the problem or opportunity we are addressing? What forces are at play?
## Decision
What are we doing? Be specific.
## Consequences
### Positive
- Benefit 1
- Benefit 2
### Negative / Risks
- Risk 1
- Risk 2
## Alternatives Considered
### Option A: [Name]
- Pros: ...
- Cons: ...
- Why rejected: ...
### Option B: [Name]
- Pros: ...
- Cons: ...
- Why rejected: ...
## Rollback Plan
If this decision proves wrong, how do we undo it?
## Related
- [ADR-YYY](adr-YYY.md)
- [PR #123](../../pull/123)

142
docs/brand/usage.md Normal file
View File

@ -0,0 +1,142 @@
# Hatch brand usage
> One-pager. Read this before you put the wordmark anywhere.
## Files in this kit
```
brand/
├── wordmark/
│ ├── wordmark.svg # master wordmark (text-only, with embedded font)
│ ├── wordmark-dark.svg # dark variant (paper on transparent)
│ ├── wordmark-on-light.svg # paper-on-warm-white background
│ ├── wordmark-on-dark.svg # paper-on-ink background
│ └── wordmark-*.png # 200x60, 400x120, 800x240, 1200x360
├── mark/
│ ├── mark.svg # the icon alone (no text)
│ ├── mark-on-light.svg
│ ├── mark-on-dark.svg
│ └── mark-{16,32,48,64,128,192,256,512,1024}.png
├── favicon/
│ ├── favicon.ico # 16+32+48 multi-size ico
│ └── favicon-{16,32,48,192,512}.png
├── banner/
│ ├── readme-banner.png # 1280x640
│ └── readme-banner@2x.png # 2560x1280
└── og/
├── og.png # 1200x630
└── og@2x.png # 2400x1260
```
## What goes where
| Surface | Use | File |
| ---------------------------------------- | -------------------------------- | -------------------------------------- |
| Favicon (browser tab, GitHub avatar) | The **mark**, not the wordmark | `brand/favicon/favicon.ico` |
| Apple touch icon / PWA icon | The mark, 512px | `brand/favicon/favicon-512.png` |
| README header | The **wordmark** (200x60+) | `brand/wordmark/wordmark-200x60.png` |
| GitHub social preview / blog / link card | The **OG image** (1200x630) | `brand/og/og.png` |
| Big surfaces, slides, README top | The **README banner** (1280x640) | `brand/banner/readme-banner.png` |
For the favicon, always use `mark.svg` or one of the favicon PNGs — never the full
wordmark. The wordmark does not read at 16x16.
## Clear space
Treat the mark as a square and the wordmark as the mark + the word. Clear space
is **1× the cap-height of the "h" in "hatch"** on every side.
- Wordmark: at least one "h"-height of empty space on top, bottom, left, and right
- Mark: at least one "h"-height on every side
```
┌──────────────────────────────┐
│ │ ← 1× clear space
│ ┌──┐ hatch │
│ │ │ │
│ └──┘ │
│ │ ← 1× clear space
└──────────────────────────────┘
```
## Minimum size
- Wordmark: **120px wide** is the minimum. Below that, switch to the mark.
- Mark: **16px** is the minimum (we ship a tuned 16px favicon).
## Color
The brand has **one active color, one neutral, and one accent**. That's it.
| Token | Hex | Use |
| -------- | --------- | ------------------------------------------------- |
| Ink | `#0A0A0B` | Wordmark, primary type, dark surfaces |
| Paper | `#FAFAF9` | Light surfaces, type on dark |
| Accent | `#F59E0B` | The "captured" signal — one place per composition |
Neutral scale (for docs and UIs, not the wordmark): `#71717A` `#A1A1AA` `#D4D4D8` `#E4E4E7` `#F4F4F5`.
**Don't pick colors outside the kit.** If you need another color, ask the Head of
Marketing; do not invent one.
## Light and dark mode
The wordmark and the mark are **single-color**. Pick the right variant for the
surface — never change the color of the wordmark to fit a theme.
- **On a light surface** (≥ 4.5:1 contrast): use `wordmark.svg` (ink) or `mark.svg`
- **On a dark surface**: use `wordmark-dark.svg` (paper) or `mark-dark.svg`
- **Need a background tile?** Use `wordmark-on-light.svg` or `wordmark-on-dark.svg`
For GitHub READMEs, GitHub auto-switches images in dark mode only if you use
the `#gh-dark-mode-only` / `#gh-light-mode-only` MediaFragments trick:
```markdown
<picture>
<source media="(prefers-color-scheme: dark)" srcset="brand/wordmark/wordmark-dark.svg">
<img src="brand/wordmark/wordmark.svg" alt="Hatch" width="200">
</picture>
```
## Typography (display + body)
The wordmark uses **Inter Bold**. The brand voice guide and READMEs use:
- **Display / headings:** Inter (Bold or SemiBold)
- **Code / mono:** JetBrains Mono (Regular or Medium)
Both are OFL-licensed and on the system. Don't substitute a different sans or mono
in brand surfaces without sign-off.
## Do not
- **Don't stretch.** Always use the mark/wordmark at its native aspect ratio. If
you need it bigger, scale proportionally.
- **Don't recolor.** The wordmark is ink or paper — period. No brand-color,
gradient, rainbow, or AI-painted versions.
- **Don't add effects.** No drop shadows, no glows, no outlines, no bevel, no
inner shadows. The mark is a flat shape.
- **Don't rotate.** The hatch opens at the top. Don't tilt the mark.
- **Don't rearrange the wordmark.** The mark always sits to the **left** of the
wordmark, with a 0.22×-mark-width gap. No stacking, no right-alignment.
- **Don't put the wordmark on a busy image.** The wordmark needs contrast. On a
photo or a complex background, use a solid color tile behind it.
- **Don't use the wordmark in body copy.** The wordmark is for headers, banners,
and brand surfaces only. Body type stays in Inter.
- **Don't use the favicon as a profile picture.** Use the 512px PNG (`mark-512.png`)
for that, and round the corners if you want — but the square mark is the
canonical shape.
## Licensing & attribution
- **Inter** — SIL Open Font License 1.1 (Inter by Rasmus Andersson, rsms.me)
- **JetBrains Mono** — SIL Open Font License 1.1 (JetBrains)
- The mark and wordmark are the property of El Foundation. Use them to talk about
Hatch. Do not register them as a trademark or use them to sell a competing
product.
## Need something new?
If a new surface needs a brand asset that isn't in the kit (a slide template, a
business card, a T-shirt, an icon variant), open an issue assigned to the Brand
Designer. Do not stretch, recolor, or rearrange this kit to make it work.

29
docs/company/charter.md Normal file
View File

@ -0,0 +1,29 @@
# Company Charter
## Mission
Build institutions that outlast their founders. We exist to create technology and organizations that compound in value over time, not to chase trends or optimize for short-term outcomes. Every product we ship and every process we design must make the next version of the company stronger than the last.
## Vision
A world where the best teams operate with radical clarity: everyone knows why their work matters, decisions are made on evidence rather than authority, and the institution keeps improving even as people join and leave. We will be the company other companies study when they ask, "How did they build that?"
## Operating Principles
1. **Source of truth is the repo, not memory.**
If it is not written down, committed, and linkable, it does not exist. Decisions, plans, and context live in durable documents, not Slack threads or someone's head. This is how we scale without decay.
2. **Optimize for reversibility.**
Move fast on two-way doors; slow down on one-way doors. Default to experiments with clear rollback paths. A decision without a cost of reversal is a bet, and bets require higher evidence bars.
3. **Disagree and commit, but write it down.**
Dissent is healthy. Once a decision is made, everyone commits fully. The minority opinion is archived with its reasoning so we can revisit it if the decision proves wrong. No invisible reservations.
4. **Hire for slope, not intercept.**
We prefer people who learn fast over people who know everything today. The problems we will face in two years do not exist yet; we need teams that can grow into them.
5. **Protect focus ruthlessly.**
Saying no is a core competency. We maintain a short, ordered priority list and decline everything else. Multitasking is organizational debt.
6. **Pull for bad news.**
If problems stop surfacing, we have lost our information edge. Every manager's job includes creating conditions where the worst news travels fastest.

View File

@ -0,0 +1,91 @@
# How We Decide
Every major decision at this company goes through the same process. A "major decision" is anything that:
- Commits the company to a path that is hard or expensive to reverse
- Affects more than one team or function
- Costs more than $2,000 or $500/month recurring
- Changes how we work (process, tools, org structure)
- Introduces new security, legal, or compliance exposure
If it is not major, the owner decides and documents. Do not slow down small decisions.
## The Process
### 1. Frame the decision
Write a one-page decision brief that includes:
- **Question:** What exactly are we deciding?
- **Context:** Why now? What changed?
- **Options:** At least two real alternatives, including "do nothing."
- **Criteria:** 3-5 criteria we will use to judge the options.
- **Recommendation:** The owner's preferred option and why.
- **Risks:** What could go wrong? How would we know?
- **Rollback plan:** If we choose wrong, how do we undo it?
### 2. Gather advice
The owner shares the brief with all stakeholders and gives them 24-48 hours to comment in writing. Stakeholders are expected to:
- Raise concerns they can support with evidence
- Suggest alternatives or mitigations
- Declare if they have a conflict of interest
The owner does not need to incorporate every piece of advice. The goal is to surface blind spots.
### 3. Decide
The decision owner makes the call. The owner is:
- The relevant department lead for functional decisions
- The CEO for cross-functional or high-stakes decisions
- The board for capital, major partnerships, or existential bets
The owner writes the decision in a durable document (issue comment, PR description, or committed file) and archives the brief alongside it.
### 4. Communicate
The owner announces the decision to all affected parties within 24 hours. The announcement includes:
- What was decided
- Why (with a link to the decision record)
- What changes for whom
- When the decision takes effect
- How to raise new information that might change the outcome
### 5. Review
Major decisions are revisited on a schedule:
- **30-day check:** Did the expected outcome materialize? Any early warning signs?
- **90-day review:** Was the decision correct? What did we learn?
- **Annual audit:** Which decisions should we reverse or update?
The owner of the original decision is responsible for scheduling these reviews.
## Decision Criteria
We evaluate options against these criteria, weighted by the specific decision:
1. **Reversibility:** Can we undo this cheaply? Higher weight for one-way doors.
2. **Evidence quality:** How strong is the supporting data? Higher weight for novel or risky bets.
3. **Optionality:** Does this close off future paths or keep them open?
4. **Execution cost:** Time, money, and opportunity cost.
5. **Alignment:** How well does this fit our charter and operating principles?
## Who Signs Off
| Decision tier | Decision owner | Required sign-off |
|---|---|---|
| Functional (affects one team) | Department lead | None; document and notify |
| Cross-functional (affects multiple teams) | CEO | Department leads consulted |
| High-stakes (irreversible, expensive, existential) | CEO | Board approval required |
| Security / legal / compliance | SecurityEngineer or Ops | CEO must be informed |
## Anti-Patterns
- **Consensus theater.** If everyone must agree, no one decides. We optimize for speed and clarity, not universal comfort.
- **Analysis paralysis.** Two good options with similar expected value? Pick one and ship. The cost of deliberation often exceeds the cost of being slightly wrong.
- **Decisions without owners.** Every decision has a single named owner. No committees.
- **Invisible reversals.** If we change our mind, we write a new decision record. No ghosting past decisions.

View File

@ -0,0 +1,47 @@
# Operating Model
## Decision Rights
We run a **advice-and-decide** model, not consensus.
- **The owner decides.** Every initiative has a single named owner. That owner gathers input, makes the call, and owns the outcome. No design-by-committee.
- **The owner writes the decision record.** Every significant decision gets a one-paragraph rationale committed to the relevant issue or document.
- **Escalation is a feature, not a failure.** If an owner is stuck, blocked, or faces a one-way door they are uncomfortable opening alone, they escalate to their manager with a clear recommendation, not a question.
### Authority Matrix
| Decision type | Owner | Escalation path |
|---|---|---|
| Product feature priority | Product lead | CEO |
| Technical architecture | CTO | CEO |
| Design system changes | UXDesigner | CTO |
| Marketing message / channel | CMO | CEO |
| Hiring / firing | Department lead | CEO |
| Budget > $500/mo or one-time > $2,000 | CEO | Board |
| Security policy, auth, secrets | SecurityEngineer | CTO → CEO |
| Skill / agent governance | CEO | Board |
## Work Ownership
- **One task = one owner = one branch.** No shared ownership. If a task is too big for one person, split it.
- **Tasks are tracked in a structured workflow.** Backlog → assigned → in progress → review → done.
- **Context is durable.** Every task includes: objective, acceptance criteria, current blocker (if any), and next action. When an owner changes, the context transfers in writing.
## Review and Shipping
- **Nothing ships without review.** Code needs PR review. Design needs design review. Copy needs copy review. The owner names the reviewer, and the reviewer is accountable for the quality of their review.
- **Review is a gate, not a discussion.** Reviewers approve, request specific changes, or escalate. Open-ended "what do you think?" loops are not reviews.
- **Ship on green.** If CI passes and review is approved, the owner merges. No additional sign-off layers.
## Disagreement and Escalation
1. **First, disagree in writing.** Both parties write down their position and the evidence behind it. Oral disagreements are too cheap.
2. **If unresolved in 24 hours, escalate.** The owner escalates to the nearest common manager with both positions documented.
3. **The manager decides within 48 hours.** The manager picks a path, writes the rationale, and both parties commit.
4. **No re-litigation for 30 days.** Once decided, the issue is closed. Revisit only with new evidence.
## Communication
- **Async-first.** Write it down before you say it out loud. Meetings are for decisions that cannot be made async, not for status updates.
- **Default public.** Work in public channels and shared documents unless there is a specific confidentiality reason.
- **No surprises.** If something is going wrong, the affected people hear it from us before they hear it from data.

70
docs/company/org.md Normal file
View File

@ -0,0 +1,70 @@
# Organizational Structure
This is a blueprint. All roles listed below are **unfilled** until explicitly hired.
## Executive
### Chief Executive Officer (CEO)
- **Accountable for:** Company strategy, P&L, capital allocation, key hires, cross-functional coordination, board communication.
- **Mandate:** Set priorities and make product decisions. Resolve cross-team conflicts. Approve or reject proposals from direct reports. Hire new agents when the team needs capacity. Unblock direct reports when they escalate.
- **Status:** Filled.
## Product & Engineering
### Chief Technology Officer (CTO)
- **Accountable for:** Technical roadmap, system architecture, engineering execution, technical hiring, engineering quality and velocity.
- **Mandate:** Own the technical strategy and its execution. Define architecture standards. Manage engineering backlog and delivery. Build and lead the engineering team. Ensure operational reliability and security posture.
- **Reports to:** CEO
- **Status:** Unfilled.
### Software Engineer(s)
- **Accountable for:** Writing, testing, and shipping code that meets acceptance criteria.
- **Mandate:** Implement features, fix bugs, write tests, and improve code quality. Follow architecture decisions and raise concerns early. Own tasks end-to-end from assignment to merge.
- **Reports to:** CTO
- **Status:** Unfilled.
### Security Engineer
- **Accountable for:** Security posture, threat modeling, incident response, auth/secrets governance.
- **Mandate:** Review security-sensitive changes. Define and enforce security policies. Handle vulnerability disclosures and incident response. Audit permissions and access controls.
- **Reports to:** CTO
- **Status:** Unfilled.
### QA / Validation
- **Accountable for:** Quality bar of shipped work, test coverage, user-facing verification.
- **Mandate:** Write and maintain test plans. Validate user-facing changes. Catch regressions before they reach users. Own the definition-of-done checklist for releases.
- **Reports to:** CTO
- **Status:** Unfilled.
## Design & UX
### UX Designer
- **Accountable for:** User experience, visual design, design system, user research.
- **Mandate:** Design interfaces and flows. Maintain and evolve the design system. Conduct user research and usability testing. Review all UX-facing changes before ship.
- **Reports to:** CTO (matrixed to product decisions via CEO)
- **Status:** Unfilled.
## Growth & Marketing
### Chief Marketing Officer (CMO)
- **Accountable for:** Brand, content, growth strategy, developer relations, market positioning.
- **Mandate:** Define and execute marketing strategy. Own content and social channels. Drive growth experiments. Build developer relations and community. Measure and report on growth metrics.
- **Reports to:** CEO
- **Status:** Unfilled.
## Operations
### Operations Lead
- **Accountable for:** Legal, finance, vendor management, compliance, people operations.
- **Mandate:** Run payroll and contracts. Manage vendor relationships and budgets. Ensure compliance. Handle people ops and onboarding/offboarding.
- **Reports to:** CEO
- **Status:** Unfilled.
## Hiring Sequence (Recommended)
1. **CTO** -- needed before any product work begins.
2. **Software Engineer** -- first IC hire to execute on technical backlog.
3. **UX Designer** -- needed once user-facing work begins.
4. **QA** -- needed once regular shipping cadence is established.
5. **CMO** -- needed once we have a product story to tell.
6. **Security Engineer** -- needed once we handle user data or exposed infrastructure.
7. **Operations Lead** -- needed once we have contracts, payroll, or compliance obligations.

View File

@ -0,0 +1,56 @@
# Ways of Working
## Version Control Discipline
- **One task = one branch = one owner.**
Every issue gets its own branch. Branch names: `owner-identifier/short-description` (e.g., `ceo/ELF-3-hiring-plan`).
- **No direct commits to `main`.** All changes go through a pull request.
- **Rebase before merge.** Keep history linear and readable. Squash fixup commits.
- **Commit messages explain why, not what.** The diff shows what changed; the message explains the reasoning.
## Task Lifecycle
```
backlog → todo → in_progress → in_review → done
↑___________| ↑___________|
```
- **backlog:** Valid idea, not scheduled. Anyone can add items.
- **todo:** Scheduled and ready. Has clear acceptance criteria.
- **in_progress:** Checked out by an owner. Active work is happening.
- **in_review:** Owner has handed off for review or approval.
- **done:** Accepted and merged. No further action required.
- **blocked:** Cannot proceed. Must name the blocker and who must act.
## Standups
We do not do live standups. We do **async written standups** in the issue thread or project channel.
### Standup Template
```
**Yesterday:** What did I complete?
**Today:** What am I working on?
**Blocked:** What is in my way and who needs to act?
```
- Posted by 10:00 AM local time, every working day.
- If you are blocked, you must name the owner of the unblock action.
## Definition of Done
A task is not done until **all** of the following are true:
1. **Code is written and reviewed.** At least one approval from a qualified reviewer.
2. **Tests pass.** CI is green. New code has relevant test coverage.
3. **Documentation is updated.** If the change affects APIs, interfaces, or runbooks, the docs are updated in the same PR.
4. **No secrets in plain text.** Credentials, tokens, and keys are never committed.
5. **User-facing changes are validated.** QA or the owner has verified the change in a staging environment.
6. **Rollback path is known.** For infra or deploy changes, the owner can articulate how to revert.
7. **Handoff is clean.** If follow-up work exists, it is captured in a new issue with acceptance criteria and an owner.
## Working Hours and Availability
- **Async-first.** We do not require synchronous availability. Write things down.
- **Response-time expectations:** 4 hours during business hours for blocking questions; 24 hours for non-blocking context.
- **Deep work blocks.** Meetings and interruptions are the exception. Protect 2-4 hour blocks for focused work.

View File

@ -0,0 +1,118 @@
# Hatch CLI Quick Reference
## Installation
```bash
# Download binary (Linux example)
curl -L https://github.com/elfoundation/hatch/releases/latest/download/hatch-linux-amd64 -o hatch
chmod +x hatch
sudo mv hatch /usr/local/bin/
```
## Commands
| Command | Description | Example |
|---------|-------------|---------|
| `hatch serve` | Start server | `hatch serve` |
| `hatch capture <url>` | Capture request | `hatch capture /api/webhook` |
| `hatch inspect <endpoint>` | View requests | `hatch inspect my-webhook` |
| `hatch search <endpoint>` | Search traffic | `hatch search my-webhook -query 'status:500'` |
| `hatch replay <id>` | Replay request | `hatch replay abc123 -endpoint api -target http://localhost:3000` |
| `hatch mock set <endpoint>` | Configure mock | `hatch mock set api -status 200 -body '{}'` |
| `hatch doc generate <endpoint>` | Generate OpenAPI | `hatch doc generate api > openapi.json` |
| `hatch version` | Print version | `hatch version` |
## Common Flags
| Flag | Description | Example |
|------|-------------|---------|
| `-method METHOD` | HTTP method | `hatch capture /api -method PUT` |
| `-body BODY` | Request body | `hatch capture /api -body '{"key":"value"}'` |
| `-header KEY:VALUE` | Add header | `hatch capture /api -header 'Auth:token'` |
| `-limit N` | Limit results | `hatch inspect api -limit 10` |
| `-query QUERY` | Search query | `hatch search api -query 'status:500'` |
| `-endpoint ENDPOINT` | Source endpoint | `hatch replay abc -endpoint api` |
| `-target URL` | Target URL | `hatch replay abc -target http://localhost:3000` |
| `-status CODE` | HTTP status | `hatch mock set api -status 200` |
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HATCH_URL` | Server URL | `http://localhost:8080` |
| `PORT` | Server port | `8080` |
## Quick Examples
### Capture a Webhook
```bash
hatch capture /webhooks/stripe \
-method POST \
-header 'Stripe-Signature:whsec_test' \
-body '{"type":"payment_intent.succeeded"}'
```
### Debug Failed Requests
```bash
# Find 500 errors
hatch search api -query 'status:500'
# Get details
hatch inspect api -limit 5
# Replay to debug
hatch replay <id> -endpoint api -target http://localhost:8080/debug
```
### Mock API for Frontend
```bash
hatch mock set api/users \
-status 200 \
-header 'Content-Type:application/json' \
-body '[{"id":1,"name":"John"}]'
```
### Generate API Docs
```bash
hatch doc generate api/users > users-api.json
hatch doc generate api/posts > posts-api.json
```
## Query Syntax
| Query | Description |
|-------|-------------|
| `status:500` | Filter by status code |
| `POST` | Filter by HTTP method |
| `user.created` | Search in body |
| `Content-Type:application/json` | Filter by header |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error |
| 2 | Invalid arguments |
| 3 | Network error |
| 4 | Auth error |
| 5 | Not found |
## Troubleshooting
```bash
# Check server health
curl http://localhost:8080/healthz
# Verify binary works
hatch version
# Test connection
hatch inspect default -limit 1
```
For detailed troubleshooting, see [cli-troubleshooting.md](cli-troubleshooting.md).

View File

@ -0,0 +1,670 @@
# CLI Troubleshooting Guide
Comprehensive troubleshooting guide for Hatch CLI issues.
## Quick Diagnostics
### Health Check
First, verify the Hatch server is running and accessible:
```bash
# Check if server is responding
curl -s http://localhost:8080/healthz
# Expected output: ok
# If using custom URL
curl -s $HATCH_URL/healthz
```
### Version Check
Verify you're using the correct version:
```bash
hatch version
# Expected output: hatch v0.1.0
# Check if binary is working
hatch --help
```
### Connectivity Test
Test network connectivity to the server:
```bash
# Test with verbose output
curl -v http://localhost:8080/healthz
# Test with specific timeout
curl --connect-timeout 5 http://localhost:8080/healthz
```
## Common Error Messages
### Connection Issues
#### `Error: request failed: connection refused`
**Cause:** The Hatch server is not running or not accessible.
**Solutions:**
1. Start the Hatch server:
```bash
hatch serve
```
2. Check if the server is listening on the expected port:
```bash
# Linux/macOS
netstat -tlnp | grep 8080
# or
lsof -i :8080
# Windows
netstat -ano | findstr :8080
```
3. Verify the HATCH_URL environment variable:
```bash
echo $HATCH_URL
# Should be http://localhost:8080 or your server URL
```
4. Check firewall settings:
```bash
# Linux
sudo iptables -L -n | grep 8080
# macOS
sudo pfctl -sr | grep 8080
```
#### `Error: request failed: dial tcp: lookup localhost: no such host`
**Cause:** DNS resolution failure.
**Solutions:**
1. Use IP address instead of hostname:
```bash
export HATCH_URL=http://127.0.0.1:8080
```
2. Check `/etc/hosts` file:
```bash
grep localhost /etc/hosts
# Should contain: 127.0.0.1 localhost
```
#### `Error: request failed: context deadline exceeded`
**Cause:** Request timeout.
**Solutions:**
1. Check server load:
```bash
top -bn1 | grep hatch
```
2. Increase timeout (if using curl):
```bash
curl --max-time 30 http://localhost:8080/healthz
```
3. Check network latency:
```bash
ping localhost
```
### Authentication Errors
#### `Error (HTTP 401): unauthorized`
**Cause:** Authentication required but not provided.
**Solutions:**
1. Check if authentication is enabled:
```bash
# Check server configuration
docker exec hatch-container env | grep AUTH
```
2. Provide authentication token:
```bash
# For API requests
curl -H "Authorization: Bearer $HATCH_AUTH_TOKEN" http://localhost:8080/v1/endpoints
```
3. For development, disable authentication:
```bash
# Start server without auth
HATCH_AUTH_ENABLED=false hatch serve
```
#### `Error (HTTP 403): forbidden`
**Cause:** Insufficient permissions.
**Solutions:**
1. Check user roles and permissions
2. Verify API token has required scopes
3. Contact administrator for access
### Request/Response Errors
#### `Error (HTTP 400): bad request`
**Cause:** Invalid request format.
**Solutions:**
1. Validate JSON format:
```bash
echo '{"invalid":json}' | jq .
# Should show parse error
```
2. Check Content-Type header:
```bash
curl -H "Content-Type: application/json" -d '{"valid":"json"}' ...
```
3. Verify request body size:
```bash
# Check body size
echo -n '{"data":"..."}' | wc -c
```
#### `Error (HTTP 404): endpoint not found`
**Cause:** Requested resource doesn't exist.
**Solutions:**
1. List available endpoints:
```bash
curl http://localhost:8080/v1/endpoints
```
2. Check endpoint ID spelling:
```bash
# Case-sensitive
hatch inspect MyEndpoint # Wrong
hatch inspect myendpoint # Correct
```
3. Verify endpoint has captured requests:
```bash
hatch inspect myendpoint -limit 1
```
#### `Error (HTTP 413): payload too large`
**Cause:** Request body exceeds size limit.
**Solutions:**
1. Reduce request body size
2. Compress data before sending
3. Split into smaller chunks
#### `Error (HTTP 429): too many requests`
**Cause:** Rate limiting.
**Solutions:**
1. Implement backoff:
```bash
# Exponential backoff script
for i in {1..5}; do
sleep $((2**i))
hatch capture /api -body '{"retry":'$i'}' && break
done
```
2. Check rate limit headers:
```bash
curl -I http://localhost:8080/v1/endpoints
# Look for X-RateLimit-* headers
```
### Data Errors
#### `Error: invalid character '}' looking for beginning of value`
**Cause:** Malformed JSON in request body.
**Solutions:**
1. Validate JSON:
```bash
echo '{"key":"value"}' | jq .
```
2. Use proper escaping:
```bash
# Bash
hatch capture /api -body '{"key":"value with \"quotes\""}'
# Use file input
echo '{"key":"value"}' > request.json
hatch capture /api -body @request.json
```
3. Check for invisible characters:
```bash
cat -A request.json
```
#### `Error: unexpected end of JSON input`
**Cause:** Incomplete JSON response.
**Solutions:**
1. Check server logs for errors
2. Verify network connection wasn't interrupted
3. Try with smaller payload
### Storage Errors
#### `Error: database is locked`
**Cause:** SQLite database contention.
**Solutions:**
1. Reduce concurrent operations
2. Check for long-running transactions:
```bash
# Monitor database locks
sqlite3 hatch.db "PRAGMA journal_mode=WAL;"
```
3. Consider using PostgreSQL for production
#### `Error: no space left on device`
**Cause:** Disk space exhausted.
**Solutions:**
1. Check disk space:
```bash
df -h
du -sh /var/lib/hatch
```
2. Clean old data:
```bash
# Remove requests older than 7 days
curl -X DELETE "http://localhost:8080/v1/endpoints/myendpoint/requests?older_than=7d"
```
3. Configure data retention:
```bash
# Set retention policy
HATCH_RETENTION_DAYS=30 hatch serve
```
## Platform-Specific Issues
### Linux
#### Permission Denied
```bash
# Fix binary permissions
chmod +x /usr/local/bin/hatch
# Or install to user directory
mkdir -p ~/.local/bin
mv hatch ~/.local/bin/
export PATH="$HOME/.local/bin:$PATH"
```
#### SELinux Issues
```bash
# Check SELinux status
getenforce
# If enabled, add context
sudo semanage fcontext -a -t bin_t /usr/local/bin/hatch
sudo restorecon -v /usr/local/bin/hatch
```
### macOS
#### Gatekeeper Blocked
```bash
# Remove quarantine attribute
xattr -d com.apple.quarantine /usr/local/bin/hatch
# Or allow in System Preferences > Security & Privacy
```
#### Homebrew Path Issues
```bash
# Ensure Homebrew bin is in PATH
export PATH="/usr/local/bin:$PATH"
# Or create symlink
ln -s /usr/local/bin/hatch /opt/homebrew/bin/hatch
```
### Windows
#### Windows Defender Blocked
1. Open Windows Security
2. Go to Virus & threat protection
3. Allow app through firewall
#### PowerShell Execution Policy
```bash
# Run as Administrator
Set-ExecutionPolicy RemoteSigned
# Or bypass for current session
powershell -ExecutionPolicy Bypass -File script.ps1
```
#### PATH Not Updated
```bash
# Add to PATH permanently
$env:PATH += ";C:\path\to\hatch"
# Or use System Properties > Environment Variables
```
## Performance Issues
### Slow Response Times
#### Diagnose
```bash
# Measure response time
time curl -s http://localhost:8080/v1/endpoints > /dev/null
# Check server resources
top -bn1 | grep hatch
# Monitor network
iftop -i eth0
```
#### Solutions
1. **Increase server resources:**
```bash
# Docker
docker update --cpus="2.0" --memory="2g" hatch-container
```
2. **Optimize database:**
```bash
# Vacuum SQLite database
sqlite3 hatch.db "VACUUM;"
```
3. **Enable caching:**
```bash
HATCH_CACHE_TTL=300 hatch serve
```
### High Memory Usage
#### Diagnose
```bash
# Check memory usage
ps aux | grep hatch
pmap -x $(pgrep hatch) | tail -1
# Monitor over time
while true; do
echo "$(date): $(ps -o rss= -p $(pgrep hatch))KB"
sleep 60
done
```
#### Solutions
1. **Limit request history:**
```bash
HATCH_MAX_REQUESTS=10000 hatch serve
```
2. **Enable pagination:**
```bash
hatch inspect myendpoint -limit 100
```
3. **Restart periodically:**
```bash
# Cron job
0 0 * * * systemctl restart hatch
```
### High CPU Usage
#### Diagnose
```bash
# Check CPU usage
top -bn1 | grep hatch
# Profile if needed
go tool pprof http://localhost:6060/debug/pprof/profile
```
#### Solutions
1. **Reduce logging:**
```bash
HATCH_LOG_LEVEL=warn hatch serve
```
2. **Limit concurrent connections:**
```bash
HATCH_MAX_CONNECTIONS=100 hatch serve
```
## Network Issues
### Proxy Configuration
```bash
# Set proxy environment variables
export HTTP_PROXY=http://proxy.example.com:8080
export HTTPS_PROXY=http://proxy.example.com:8080
export NO_PROXY=localhost,127.0.0.1
# Or configure in hatch
HATCH_PROXY=$HTTP_PROXY hatch serve
```
### SSL/TLS Issues
```bash
# Skip SSL verification (development only)
curl -k https://hatch.example.com/healthz
# Or add certificate
curl --cacert /path/to/ca.crt https://hatch.example.com/healthz
# For self-signed certificates
export CURL_CA_BUNDLE=/path/to/cert.pem
```
### DNS Resolution
```bash
# Test DNS resolution
nslookup localhost
dig localhost
# Use IP address
export HATCH_URL=http://127.0.0.1:8080
```
## Docker Issues
### Container Won't Start
```bash
# Check container logs
docker logs hatch-container
# Check container status
docker ps -a | grep hatch
# Inspect container
docker inspect hatch-container
```
### Port Conflicts
```bash
# Find what's using port 8080
lsof -i :8080
netstat -tlnp | grep 8080
# Use different port
docker run -p 9090:8080 hatch:latest
```
### Volume Mount Issues
```bash
# Check volume permissions
ls -la /path/to/volume
# Fix permissions
sudo chown -R 1000:1000 /path/to/volume
# Test mount
docker run -v /path/to/volume:/data busybox ls -la /data
```
## Debugging Techniques
### Enable Debug Logging
```bash
# Set debug level
DEBUG=true hatch serve
# Or use environment variable
HATCH_LOG_LEVEL=debug hatch serve
```
### Verbose Output
```bash
# Use curl verbose mode
curl -v http://localhost:8080/v1/endpoints
# Capture full request/response
curl -v -w '\n' http://localhost:8080/v1/endpoints > debug.txt 2>&1
```
### Network Tracing
```bash
# Linux
strace -e trace=network hatch serve
# macOS
dtruss -n hatch
# Windows
netsh trace start capture=yes tracefile=trace.etl
```
### Core Dumps
```bash
# Enable core dumps
ulimit -c unlimited
# Run with core dump
hatch serve &
echo $! > /tmp/hatch.pid
# If crash occurs, analyze
gdb /usr/local/bin/hatch core
```
## Getting Help
### Documentation
- [CLI Reference](cli.md) - Command documentation
- [Examples](../../examples/) - Usage examples
- [Architecture](hatch-architecture.md) - System design
### Community
- [GitHub Issues](https://github.com/elfoundation/hatch/issues) - Bug reports
- [Discussions](https://github.com/elfoundation/hatch/discussions) - Questions
### Logs
```bash
# Check application logs
docker logs -f hatch-container
# Check system logs
journalctl -u hatch -f
# Check access logs
tail -f /var/log/hatch/access.log
```
## Reporting Issues
When reporting issues, include:
1. **Environment details:**
```bash
hatch version
go version
uname -a # Linux/macOS
systeminfo # Windows
```
2. **Configuration:**
```bash
env | grep HATCH
```
3. **Reproduction steps:**
- Exact commands run
- Expected behavior
- Actual behavior
4. **Logs:**
```bash
# Attach relevant logs
docker logs hatch-container > hatch.log 2>&1
```
5. **Network info:**
```bash
curl -v http://localhost:8080/healthz > network-debug.txt 2>&1
```

429
docs/engineering/cli.md Normal file
View File

@ -0,0 +1,429 @@
# Hatch CLI Reference
The `hatch` CLI provides a powerful interface for interacting with Hatch servers. Use it to capture requests, inspect traffic, search logs, replay requests, configure mocks, and generate API documentation.
## Installation
### Pre-built Binaries (Recommended)
Download the latest binary for your platform from the [Releases page](https://github.com/elfoundation/hatch/releases):
- **Linux (x64)**: `hatch-linux-amd64`
- **Linux (ARM64)**: `hatch-linux-arm64`
- **macOS (Intel)**: `hatch-darwin-amd64`
- **macOS (Apple Silicon)**: `hatch-darwin-arm64`
- **Windows (x64)**: `hatch-windows-amd64.exe`
After downloading:
```bash
# Linux/macOS
chmod +x hatch-*
sudo mv hatch-* /usr/local/bin/hatch
# Windows (PowerShell)
Rename-Item hatch-windows-amd64.exe hatch.exe
# Move to a directory in your PATH
```
### Build from Source
Requires Go 1.25 or later:
```bash
git clone https://github.com/elfoundation/hatch.git
cd hatch
CGO_ENABLED=0 go build -o hatch ./cmd/hatch
sudo mv hatch /usr/local/bin/
```
### Docker
```bash
docker pull ghcr.io/elfoundation/hatch:latest
docker run --rm -it ghcr.io/elfoundation/hatch:latest --help
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HATCH_URL` | Hatch server URL | `http://localhost:8080` |
### Global Options
All commands support these flags:
- `-h, --help`: Show help for the command
- `version`: Print version information
## Commands
### `hatch serve`
Start the Hatch server. This is the default command when no subcommand is specified.
```bash
# Start server on default port (8080)
hatch serve
# Start with custom port (use environment variable)
PORT=9090 hatch serve
```
### `hatch capture <url>`
Send a request to an endpoint and store it in Hatch.
**Options:**
- `-method METHOD`: HTTP method (default: `POST`)
- `-body BODY`: Request body as JSON string
- `-header KEY:VALUE`: Request header (repeatable)
**Examples:**
```bash
# Capture a simple POST request
hatch capture https://api.example.com/webhook
# Capture with custom method and body
hatch capture /my-endpoint -method PUT -body '{"status":"active"}'
# Capture with headers
hatch capture /api/events \
-header 'Content-Type:application/json' \
-header 'X-Webhook-Secret:abc123' \
-body '{"event":"user.created","data":{"id":123}}'
```
**URL Handling:**
- Full URLs: `https://api.example.com/webhook` → endpoint ID derived from path
- Relative paths: `/my-endpoint` → used directly as endpoint ID
- Empty/`/` → defaults to `default` endpoint
### `hatch inspect <endpoint>`
Fetch captured requests as JSON.
**Options:**
- `-limit N`: Maximum number of requests to return (default: 100)
**Examples:**
```bash
# Inspect all requests for an endpoint
hatch inspect my-webhook
# Limit to 10 most recent requests
hatch inspect my-webhook -limit 10
# Pretty-print JSON output
hatch inspect my-webhook | jq .
```
**Output Format:**
```json
[
{
"id": "abc123",
"method": "POST",
"path": "/webhook",
"headers": {"Content-Type": "application/json"},
"body": "{\"event\":\"test\"}",
"status": 200,
"created_at": "2024-01-15T10:30:00Z"
}
]
```
### `hatch search <endpoint>`
Search captured traffic with query syntax.
**Options:**
- `-query QUERY`: Search query (required)
- `-limit N`: Maximum number of results (default: 100)
**Query Syntax:**
| Query | Description |
|-------|-------------|
| `status:500` | Filter by HTTP status code |
| `POST` | Filter by HTTP method |
| `user.created` | Search in request body |
| `Content-Type:application/json` | Filter by header |
**Examples:**
```bash
# Find all 500 errors
hatch search my-webhook -query 'status:500'
# Find POST requests
hatch search my-webhook -query 'POST'
# Search for specific content in body
hatch search my-webhook -query 'user.created'
# Combine queries
hatch search my-webhook -query 'status:500 POST'
```
### `hatch replay <request-id>`
Replay a previously captured request to a target URL.
**Options:**
- `-endpoint ENDPOINT`: Source endpoint ID (required)
- `-target URL`: Target URL to replay to (required)
**Examples:**
```bash
# Replay a request
hatch replay abc123 -endpoint my-webhook -target https://httpbin.org/post
# Replay to a local development server
hatch replay def456 -endpoint api-calls -target http://localhost:3000/webhook
# Replay to a different environment
hatch replay ghi789 -endpoint production-webhook -target https://staging.example.com/webhook
```
**Use Cases:**
- Test webhook handlers in development
- Reproduce bugs by replaying failed requests
- Load testing with captured traffic patterns
- Migrating traffic between environments
### `hatch mock set <endpoint>`
Configure mock responses for an endpoint.
**Options:**
- `-status CODE`: HTTP status code (default: 200)
- `-body BODY`: Response body (string or base64)
- `-header KEY:VALUE`: Response header (repeatable)
**Examples:**
```bash
# Return 200 with JSON body
hatch mock set my-webhook -status 200 -body '{"ok":true}'
# Return error response
hatch mock set my-webhook -status 500 -body '{"error":"internal error"}'
# Return with custom headers
hatch mock set my-webhook \
-status 200 \
-header 'X-RateLimit-Remaining:100' \
-header 'Cache-Control:no-cache'
# Simulate rate limiting
hatch mock set my-webhook \
-status 429 \
-header 'Retry-After:60' \
-body '{"error":"rate limited"}'
```
**Mock Behavior:**
- Once configured, all requests to the endpoint return the mock response
- Mock configuration persists until server restart or explicit reconfiguration
- Original request capture continues alongside mock responses
### `hatch doc generate <endpoint>`
Generate OpenAPI 3.1 specification for an endpoint based on captured traffic.
**Examples:**
```bash
# Generate OpenAPI spec
hatch doc generate my-webhook
# Save to file
hatch doc generate my-webhook > openapi.json
# Use with Swagger UI
hatch doc generate my-webhook > openapi.json
docker run -p 8081:8080 -e SWAGGER_JSON=/openapi.json -v $(pwd):/usr/share/nginx/html/swagger swaggerapi/swagger-ui
```
**Output:**
Generates a complete OpenAPI 3.1 JSON specification including:
- All captured request/response patterns
- Schema definitions inferred from traffic
- Example values from real requests
- Endpoint documentation
## Common Workflows
### Development Environment Setup
```bash
# 1. Start Hatch server
hatch serve &
# 2. Capture incoming webhooks
hatch capture /api/webhooks -method POST -body '{"test":true}'
# 3. Inspect captured traffic
hatch inspect api/webhooks
# 4. Configure mock for frontend development
hatch mock set api/webhooks -status 200 -body '{"status":"ok"}'
```
### Bug Reproduction
```bash
# 1. Find the failing request
hatch search api/payments -query 'status:500'
# 2. Get request details
hatch inspect api/payments -limit 5
# 3. Replay to debug
hatch replay <request-id> -endpoint api/payments -target http://localhost:8080/debug
```
### API Documentation Generation
```bash
# 1. Capture representative traffic
for endpoint in users posts comments; do
curl -X POST http://hatch:8080/$endpoint -d '{"sample":"data"}'
done
# 2. Generate OpenAPI specs
hatch doc generate users > users-api.json
hatch doc generate posts > posts-api.json
# 3. Combine into single spec (using jq)
jq -s '.[0] * .[1]' users-api.json posts-api.json > combined-api.json
```
### Load Testing Preparation
```bash
# 1. Capture production traffic patterns
hatch inspect api/critical-path -limit 1000 > traffic.json
# 2. Extract request patterns
jq -r '.[] | "\(.method) \(.path)"' traffic.json | sort | uniq -c | sort -rn
# 3. Replay to load testing tool
hatch inspect api/critical-path -limit 100 | \
jq -r '.[] | "hatch replay \(.id) -endpoint api/critical-path -target https://loadtest.example.com"'
```
## Troubleshooting
### Connection Issues
**Problem:** `Error: request failed: connection refused`
**Solution:** Ensure the Hatch server is running:
```bash
# Check if server is running
curl http://localhost:8080/healthz
# Start server if not running
hatch serve
```
### Authentication Errors
**Problem:** `Error (HTTP 401): unauthorized`
**Solution:** Check if your Hatch instance requires authentication:
```bash
# For local development, ensure no auth is configured
# For production, check HATCH_AUTH_TOKEN environment variable
```
### Request Not Found
**Problem:** `Error (HTTP 404): endpoint not found`
**Solution:** Verify the endpoint ID exists:
```bash
# List available endpoints
curl http://localhost:8080/v1/endpoints
# Check if endpoint has captured requests
hatch inspect <endpoint-id> -limit 1
```
### JSON Parsing Errors
**Problem:** `Error: invalid character '}' looking for beginning of value`
**Solution:** Ensure request body is valid JSON:
```bash
# Use proper JSON escaping
hatch capture /api -body '{"key":"value"}'
# Or use a file
hatch capture /api -body @request.json
```
### Permission Denied on Binary
**Problem:** `Permission denied: ./hatch`
**Solution:** Make the binary executable:
```bash
chmod +x hatch
# Or move to a directory in PATH
sudo mv hatch /usr/local/bin/
```
## Environment Variables Reference
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `HATCH_URL` | Hatch server URL | `http://localhost:8080` | `https://hatch.example.com` |
| `PORT` | Server listening port | `8080` | `9090` |
## Exit Codes
| Code | Description |
|------|-------------|
| 0 | Success |
| 1 | General error |
| 2 | Invalid command or arguments |
| 3 | Network error |
| 4 | Authentication error |
| 5 | Resource not found |
## Getting Help
- **Command help:** `hatch <command> -h`
- **General help:** `hatch --help`
- **Version:** `hatch version`
- **Issues:** [GitHub Issues](https://github.com/elfoundation/hatch/issues)
- **Documentation:** [docs/](../README.md)
## Examples Repository
For more examples, see the [examples/](../../examples/) directory or visit our [documentation site](https://hatch.sh/examples).

View File

@ -0,0 +1,191 @@
# Deploying the Hatch Homepage
This document describes how the Hatch static homepage is deployed to hatch.surf.
## Overview
The homepage is deployed to two locations:
1. **GitHub Pages** - Serves as a fallback and for GitHub-based discovery
2. **hatch.surf server** - Primary deployment via Docker at https://hatch.surf
## Architecture
```
site/
├── index.html # Main homepage
├── style.css # Stylesheet
├── main.js # JavaScript
├── brand/ # Logo and favicon assets
│ ├── favicon/
│ └── og/
├── blog/ # Blog directory
├── Dockerfile # nginx:alpine with static files baked in
└── docker-compose.yml # Local development
```
### Docker Deployment Flow
```
GitHub Push → GitHub Actions → Docker Build → SCP Image → Docker Run → Caddy (reverse proxy)
```
- Static files are **baked into the Docker image** during build
- No host directory mounting (`/var/www`) required
- Image is self-contained and portable
## Server Setup
### Prerequisites
- Server: 46.250.250.48 (hatch.surf)
- Docker installed
- Caddy for reverse proxy and TLS
- SSH access for deployment
### Container Configuration
- **Container name**: `hatch-homepage`
- **Image**: `hatch-homepage:latest`
- **Internal port**: 80 (nginx)
- **Exposed port**: 127.0.0.1:3000 (proxied by Caddy)
## Deployment
### Automated (GitHub Actions)
When changes are pushed to `main` branch affecting `site/` directory:
1. **GitHub Pages** - Deployed automatically via GitHub Actions
2. **Server** - Docker image built, uploaded, and deployed
Required GitHub secrets:
- `DEPLOY_HOST` - Server IP (46.250.250.48)
- `DEPLOY_USER` - SSH username (root)
- `DEPLOY_KEY` - SSH private key (base64 encoded)
### Manual Deployment
Use the deployment script:
```bash
# Dry run
./scripts/deploy-site.sh --dry-run
# Deploy
DEPLOY_HOST=46.250.250.48 \
DEPLOY_USER=root \
DEPLOY_KEY=$(cat ~/.ssh/id_rsa | base64) \
./scripts/deploy-site.sh
```
### Docker Commands (on server)
```bash
# Build image locally
docker build -t hatch-homepage:latest ./site
# Run container
docker run -d \
--name hatch-homepage \
--restart unless-stopped \
-p 127.0.0.1:3000:80 \
hatch-homepage:latest
# Check status
docker ps | grep hatch-homepage
# View logs
docker logs hatch-homepage
# Restart
docker restart hatch-homepage
```
## Verification
### Check Site Status
```bash
# HTTP redirect
curl -s -o /dev/null -w "%{http_code}" http://hatch.surf/
# HTTPS site
curl -s -o /dev/null -w "%{http_code}" https://hatch.surf/
# SSL certificate
curl -v https://hatch.surf/ 2>&1 | grep -E "expire|issuer|subject"
# Static assets
curl -s -o /dev/null -w "%{http_code}" https://hatch.surf/style.css
curl -s -o /dev/null -w "%{http_code}" https://hatch.surf/brand/og/default.png
```
### Browser Verification
1. Visit https://hatch.surf
2. Verify no browser warnings (SSL valid)
3. Check that all assets load (CSS, images)
4. Test responsive design
## Troubleshooting
### Container Issues
```bash
# Check container status
docker ps -a | grep hatch-homepage
# View container logs
docker logs hatch-homepage
# Enter container
docker exec -it hatch-homepage sh
# Check nginx config inside container
docker exec hatch-homepage nginx -t
```
### Deployment Failures
1. Check GitHub Actions logs
2. Verify SSH access: `ssh root@46.250.250.48`
3. Check Docker status: `docker ps`
4. Check Caddy logs: `docker logs caddy`
### Missing Assets
1. Verify image contains files: `docker exec hatch-homepage ls -la /usr/share/nginx/html/`
2. Check nginx access logs: `docker logs hatch-homepage`
3. Ensure image is up to date: `docker pull hatch-homepage:latest`
## Maintenance
### Updating Content
1. Edit files in `site/` directory
2. Commit and push to `main` branch
3. GitHub Actions will auto-deploy to both locations
### SSL Renewal
Caddy handles TLS automatically via Let's Encrypt. No manual renewal needed.
### Backup
The site is version-controlled in Git. Docker images are stored on the server.
```bash
# Backup Docker image
docker save hatch-homepage:latest | gzip > hatch-homepage-backup.tar.gz
# Restore
docker load < hatch-homepage-backup.tar.gz
```
## Related Issues
- [ELF-248](/ELF/issues/ELF-248) - Properly Dockerize hatch.surf homepage deployment
- [ELF-192](/ELF/issues/ELF-192) - Build homepage and deploy it on hatch.surf
- [ELF-171](/ELF/issues/ELF-171) - Server setup (nginx, SSL)
- [ELF-196](/ELF/issues/ELF-196) - Deploy redesigned hatch.surf homepage (v2 files from ELF-195)

View File

@ -0,0 +1,100 @@
# Deployment Checklist
Quick reference for deploying the hatch.surf homepage.
## Pre-Deployment
- [ ] Changes are in `site/` directory
- [ ] All assets load locally
- [ ] No console errors in browser
- [ ] Mobile responsive design works
## Deployment Methods
### 1. Automated (Recommended)
Push to `main` branch:
```bash
git push origin main
```
GitHub Actions will automatically:
- Deploy to GitHub Pages
- Build Docker image and deploy to hatch.surf server
### 2. Manual (From Remote Machine)
```bash
# Dry run first
./scripts/deploy-site.sh --dry-run
# Deploy
./scripts/deploy-site.sh
```
### 3. Docker Commands (On Server)
```bash
# Build image
docker build -t hatch-homepage:latest ./site
# Run container
docker run -d \
--name hatch-homepage \
--restart unless-stopped \
-p 127.0.0.1:3000:80 \
hatch-homepage:latest
# Check status
docker ps | grep hatch-homepage
```
## Post-Deployment Verification
- [ ] https://hatch.surf loads (HTTP 200)
- [ ] Anime.js magical background renders
- [ ] Scroll animations work (data-animate attributes)
- [ ] All static assets load:
- [ ] style.css
- [ ] main.js
- [ ] brand/og/default.png
- [ ] Google Fonts (Inter, JetBrains Mono)
- [ ] Mobile viewport works (390px width)
- [ ] No console errors
## Troubleshooting
### Container Not Running
```bash
# Check container status
docker ps -a | grep hatch-homepage
# View logs
docker logs hatch-homepage
# Restart container
docker restart hatch-homepage
```
### Assets Not Loading
```bash
# Check files in container
docker exec hatch-homepage ls -la /usr/share/nginx/html/
# Rebuild image
docker build -t hatch-homepage:latest ./site
docker restart hatch-homepage
```
### SSL Issues
Caddy handles TLS automatically. Check Caddy status:
```bash
docker ps | grep caddy
docker logs caddy
```
## Related Documentation
- [Full Deployment Guide](deploy-homepage.md)
- [Local Development](local-dev.md)
- [Hatch Architecture](hatch-architecture.md)

View File

@ -0,0 +1,84 @@
# Role Definition: First Software Engineer
## Context
El Foundation has established its charter, operating model, and engineering baseline. We are pre-product but have a clear technical direction. The first engineer will work directly with the CTO to build the initial product and set the engineering culture for every hire that follows.
The engineering stack is **Go + SQLite + stdlib `net/http` + server-rendered HTML + SSE**, per [ADR-0001](https://github.com/elfoundation/hatch/blob/main/docs/adrs/0001-stack.md) and [ADR-0002](adrs/0002-hatch-detail-stack.md). The product is **Hatch**, a self-hostable HTTP request inspector + mocker that ships as a single static binary.
## What They Will Own
- **Product implementation.** Write the first lines of production code. Own features end-to-end from task assignment to merge.
- **Technical foundation.** Help solidify the stack, tooling, and conventions. The code you write sets the standard.
- **Quality bar.** Write tests, review PRs, and catch regressions before they reach users.
- **Documentation.** If it is not written down, it does not exist. Document APIs, runbooks, and decisions as you go.
- **Production reliability.** Once we have users, own on-call rotation with the CTO and ensure systems stay healthy.
## Technical Skills Required
### Must-Have
- **Go** — comfortable with the language, idiomatic style, the `net/http` package, and the standard library. A `go test ./...` / `go vet ./...` workflow is the daily loop.
- **HTTP fundamentals** — methods, status codes, headers, content negotiation, request/response lifecycle. SSE and chunked transfer are a plus.
- **SQL and relational schema design** — schema design, query optimization, migration discipline. SQLite-specific knowledge is a plus but not required.
- **Git and GitHub** — branching, rebasing, PR discipline, code review.
- **Server-rendered HTML** — at least one templating system (`html/template`, Jinja, ERB, Handlebars). Comfort with progressive enhancement.
- **Testing mindset** — writes tests as a default, not an afterthought. Knows when to reach for `httptest`, table tests, and in-memory fixtures.
### Nice-to-Have
- **`net/http` internals** — middleware, `http.Handler` composition, the `ServeMux` method-based routing added in Go 1.22.
- **Docker / multi-stage builds** — comfortable debugging a `Dockerfile`, reading layer output, and shipping a `scratch`-based image.
- **SQLite** — pragmas, indexes, JSON columns, the `modernc.org/sqlite` driver.
- **Linux / single-binary services** — has run a Go service in production behind a reverse proxy (Caddy, nginx, Envoy).
- **Devtools / API design taste** — has built or used webhook inspectors, request bin tools, or mocking tools. Empathy for the user is the job.
- **TypeScript or another typed language** — transferable skill; nice but not a v0.1 requirement.
- **Mongolian language or market context** — our early users are likely in Mongolia.
### Explicitly Deferred (not required for v0.1)
- Frontend frameworks (React, Next.js, Vue) — Hatch v0.1 is server-rendered. We may adopt one later; we are not starting there.
- TypeScript / Node.js — the foundation is Go. TypeScript remains in the toolbox for tooling only.
- Monorepo tooling (Turborepo, Nx) — single Go module, single binary, no need.
- Tailwind / utility CSS — hand-written CSS is enough for v0.1 surfaces.
## Attributes We Value
- **Slope over intercept.** We care more about how fast you learn than what you already know. Go fluency is learnable; engineering judgment is not.
- **Writes things down.** Async-first communication. Clear documentation. Decision records.
- **Disagrees and commits.** Healthy dissent, then full commitment once a decision is made.
- **Protects focus.** Ruthless prioritization. Says no to multitasking.
- **Pulls for bad news.** Surfaces problems early. Does not hide blockers.
- **Boring by default.** Reaches for the standard library before adding a dependency.
## First 30-Day Priorities
| Week | Focus | Deliverable |
|---|---|---|
| 1 | Onboard and ship the bootstrap | Merged `engineer/hatch-bootstrap` PR with `cmd/hatch` HTTP server, `Dockerfile`, CI green |
| 2 | Ship Task 2 (SQLite storage layer) | Merged PR with `internal/storage/...`, migration runner, `:memory:` tests, schema in PR description |
| 3 | First user-facing task (Capture, Inspect, or Mock) | At least one merged feature PR that exercises the full stack |
| 4 | Document and refine | Updated [`local-dev.md`](local-dev.md) / [`hatch-architecture.md`](hatch-architecture.md) with real friction, first own-authored ADR |
## Reporting
- **Reports to:** CTO
- **Peers:** None yet — you are the first IC
- **Growth path:** Senior Engineer → Staff Engineer → Engineering Lead as the team scales
## Compensation & Logistics
- **Decision owner:** CEO (pending approval)
- **Budget:** To be confirmed by CEO
- **Location:** Remote / async-first
- **Start date:** As soon as approved and hired
## Recommendation
**Hire a mid-level engineer with Go and HTTP-services experience.** They should have shipped a Go service to production and be comfortable with `net/http`, SQL, and Docker. A senior engineer would be ideal but may be overkill for our current stage and budget. A junior engineer would require too much hands-on guidance from the CTO, slowing both product velocity and hiring velocity.
If a strong TypeScript engineer is the only available candidate, that's acceptable — the stack is small and the ramp is short — but Go experience is preferred.
**Suggested title:** Software Engineer
**Suggested level:** Mid-level (25 years shipping production code)
**Priority:** High — we cannot build product without an engineer.

View File

@ -0,0 +1,144 @@
# Hatch Architecture
Living document. The CTO sketches the component map; the engineer owns the details as the code lands.
## One-sentence summary
Hatch is a single Go binary that serves a server-rendered HTML UI and captures, inspects, and mocks HTTP requests against per-endpoint URLs, persisting everything in a single SQLite file.
## High-level component map
```
┌─────────────────────────────────────┐
│ Browser (Hatch UI) │
│ GET /e/{id} + EventSource /events│
└──────────────┬──────────────────────┘
│ HTTP / SSE
┌──────────────────────────────────────────────────────────────┐
│ hatch (Go binary) │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ http.ServeMux│──▶│ handler layer │──▶│ store layer │ │
│ │ (stdlib) │ │ (internal/ │ │ (internal/ │ │
│ │ │ │ handler/) │ │ store/) │ │
│ └──────────────┘ └────────────────┘ └───────┬───────┘ │
│ │ │ │
│ │ ┌────────────────┐ │ │
│ └────────────▶│ SSE hub │◀─────────┘ │
│ │ (broadcast │ │
│ │ new requests)│ │
│ └────────┬───────┘ │
│ │ │
│ ┌────────▼───────┐ │
│ │ html/template │ │
│ │ (server-render)│ │
│ └────────────────┘ │
└──────────────────────────────┬───────────────────────────────┘
│ modernc.org/sqlite (pure Go)
┌──────────────┐
│ hatch.db │
│ (SQLite) │
└──────────────┘
```
## Layer responsibilities
### `cmd/hatch/main.go` — process entrypoint
- Reads configuration (env: `PORT`).
- Wires the `http.ServeMux` to the handler layer.
- Starts `http.ListenAndServe`. Logs to stdout. Crashes on bind failure.
### `internal/handler/` — HTTP layer
- One file per route group: `capture.go`, `inspect.go`, `mock.go`, `sse.go`, `health.go`.
- Handlers take a `store.Repository` (interface) — never a concrete type — so tests can swap in `:memory:` SQLite or a fake.
- Handlers translate HTTP ↔ store calls. They do not contain business logic.
- Server-rendered HTML lives next to the handler that renders it. Templates are `//go:embed`-ed at build time.
### `internal/store/` — persistence layer
- `schema.sql` is the canonical DDL. Applied idempotently on first start by `migrate.go`.
- `models.go` defines the Go structs (`Endpoint`, `Request`).
- `repository.go` defines the `Repository` interface. The HTTP layer depends on this interface, not on the SQLite implementation.
- `sqlite_repo.go` is the concrete implementation. Uses `modernc.org/sqlite` (pure Go, no CGO).
- `db.go` opens the database file (or `:memory:` for tests), configures pragmas, returns a `*sql.DB`.
### SSE hub (in `internal/handler/sse.go`)
- A goroutine per connected browser, holding an `http.Flusher`.
- A `chan store.Request` that capture handlers publish to.
- The hub fans out new requests to all subscribers for the relevant endpoint.
- No external broker. No Redis. No pubsub library. A `chan` and a `map[endpointID][]chan` is the entire implementation for v0.1.
## Request lifecycle
1. **Capture** — a webhook hits `/{endpoint-id}` (any method). The capture handler:
- Reads method, path, query, headers, body.
- Calls `store.AppendRequest(ctx, request)`.
- Publishes the new request to the SSE hub.
- Looks up the endpoint's mock response and returns it.
2. **Inspect** — a browser hits `GET /e/{endpoint-id}`. The inspect handler:
- Calls `store.ListRequests(ctx, endpointID, limit=100)`.
- Renders an HTML page with the request list (newest first).
- The page includes a small vanilla-JS `EventSource` client that subscribes to `/e/{endpoint-id}/events`.
3. **Live update** — the SSE handler holds the connection open, flushes each new request as a `data:` frame, and the browser appends it to the list.
4. **Mock**`PUT /e/{endpoint-id}/mock` accepts `{status, headers, body}` and updates the endpoint. Subsequent captures to that endpoint return the configured response.
## Data model
Two tables. That's it for v0.1.
```
endpoints
id TEXT PRIMARY KEY -- URL-safe random ID, e.g. "h7c2k"
created_at INTEGER NOT NULL -- Unix epoch seconds, UTC
mock_status INTEGER -- nullable; 200/201/204/etc.
mock_headers TEXT -- JSON object, nullable
mock_body BLOB -- nullable
requests
id INTEGER PRIMARY KEY AUTOINCREMENT
endpoint_id TEXT NOT NULL REFERENCES endpoints(id)
received_at INTEGER NOT NULL -- Unix epoch seconds, UTC
method TEXT NOT NULL
path TEXT NOT NULL -- request path within the endpoint
query TEXT NOT NULL -- raw query string
headers TEXT NOT NULL -- JSON object
body BLOB -- nullable
remote_addr TEXT -- for debugging
FOREIGN KEY (endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE
```
Index on `requests(endpoint_id, received_at DESC)` for the list page query.
## Boundaries
- **No authentication.** v0.1 is single-user, self-hosted. If you can reach the port, you can read and write. See [v0.1-scope.md](../adrs/v0-1-scope.md).
- **No multi-tenancy.** One SQLite file, one process, one operator. Cloud is v0.2+.
- **No external services.** No Redis, no Postgres, no S3. If the binary needs to phone home, the design is wrong.
- **No client-side framework.** The browser gets HTML and a 50-line `events.js`. Anything more is out of scope for v0.1.
## Performance budget
v0.1 is sized for a single developer self-hosting on a $5 VPS:
- **Cold start:** under 100 ms (Go binary + SQLite open).
- **Capture latency:** under 5 ms p99 on the happy path (no auth, no remote calls).
- **SSE fan-out:** one goroutine per connected browser, no message broker.
- **Database size:** comfortable to 100k requests per endpoint. Retention policy is out of scope for v0.1.
If a real workload breaks these, we measure first, then add complexity.
## Future seams (do not build now)
These are deliberate extension points, not planned features. The v0.1 implementation should not block them but also should not build them.
- **Pluggable storage** — the `Repository` interface is the seam. A Postgres or S3-backed implementation would slot in without handler changes.
- **Pluggable auth** — a middleware in front of the mux. The v0.1 mux is exposed without auth, which is the right v0.1 default.
- **Replay / forwarding** — the `Request` struct has the full request. Replay is "read from store, write back out." Forwarding is the same plus a destination.
- **Multi-tenant cloud** — partitioning by `endpoint_id` is already first-class. A `tenant_id` column is a v0.2 change.
When the v0.1 design cannot accommodate one of these without a rewrite, we have under-designed. When the v0.1 design has built one of these speculatively, we have over-designed.

View File

@ -0,0 +1,147 @@
# Local Development
This is the day-to-day workflow for working on Hatch. If something here disagrees with what you actually observe, update this doc — the docs are a living artifact.
## Prerequisites
- **Go 1.25 or newer**`go version` should report 1.25+. Install from <https://go.dev/doc/install> or via your package manager.
- **Docker** (optional but recommended) — only required if you want to exercise the `docker compose` flow.
- **`curl`** — for smoke tests against the running server.
- **`sqlite3`** CLI (optional) — for poking at the database file directly. Not required for the normal workflow.
## Clone and Build
```bash
git clone https://github.com/elfoundation/hatch.git
cd hatch
go mod download
```
## Run the Server
The fastest path is `go run`:
```bash
go run ./cmd/hatch
# hatch starting on :8080
```
In another terminal:
```bash
curl -fsS http://localhost:8080/healthz
# ok
```
The server reads its port from the `PORT` environment variable and defaults to `:8080`.
```bash
PORT=9090 go run ./cmd/hatch
# hatch starting on :9090
```
## Run the Tests
```bash
go test ./...
```
This runs the full test suite, including the smoke test that boots the HTTP server in-process and hits `/healthz`. Add `-v` for verbose output, `-race` for the race detector.
```bash
go test ./... -v -race
```
## Vet and Format
```bash
go vet ./...
gofmt -l .
```
CI runs `go vet ./...`. `gofmt -l .` lists any unformatted files (no output means everything is formatted). Run `gofmt -w .` to fix formatting in place.
## Build a Static Binary
```bash
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/hatch ./cmd/hatch
ls -lh bin/hatch
```
The resulting binary is fully static, has no libc dependency, and runs on any Linux x86_64 with the same kernel.
## Docker Compose
For the full local stack (Hatch + optional Caddy reverse proxy for TLS):
```bash
# Plain HTTP — Hatch only
docker compose up --build
# With Caddy (HTTPS, self-signed in dev)
docker compose --profile with-caddy up --build
```
The compose file reads `.env` for `HATCH_HOSTNAME`. Copy `.env.example` to `.env` and adjust:
```bash
cp .env.example .env
# Edit HATCH_HOSTNAME=localhost (or your real domain)
```
## Where Things Live
| Path | Purpose |
|---|---|
| `cmd/hatch/` | Server entrypoint. `main.go` wires up `http.ServeMux`, `main_test.go` is the smoke test. |
| `internal/handler/` | HTTP handlers (Capture, Inspect, Mock). One file per route group. |
| `internal/store/` | Storage layer. `schema.sql` is the canonical DDL, `sqlite_repo.go` is the SQLite-backed implementation of the `Repository` interface. |
| `docs/engineering/` | Engineering standards and architecture docs. |
| `docs/adrs/` | Architecture Decision Records. |
| `Dockerfile` | Multi-stage build: `golang:1.25-alpine``scratch` with the static binary. |
| `docker-compose.yml` | Local stack: Hatch on `:8080`, optional Caddy sidecar for TLS. |
| `.env.example` | Documented environment variables. |
| `hatch.db` (gitignored) | SQLite database file. Created on first start in the working directory. |
## SQLite Tips
The database is a single file. By default it lives at `./hatch.db` in the process working directory.
```bash
# Inspect the schema
sqlite3 hatch.db '.schema'
# List endpoints
sqlite3 hatch.db 'SELECT * FROM endpoints;'
# Reset (destructive)
rm hatch.db
# Schema is recreated on next start
```
For tests that need a clean database, use the `:memory:` SQLite database — see `internal/store/sqlite_repo_test.go` for the pattern. Do not point tests at the on-disk database file.
## Common Tasks
| Task | Command |
|---|---|
| Run the server | `go run ./cmd/hatch` |
| Run all tests | `go test ./...` |
| Run a single test | `go test ./internal/handler -run TestCapture -v` |
| Vet | `go vet ./...` |
| Format | `gofmt -w .` |
| Build a binary | `CGO_ENABLED=0 go build -o bin/hatch ./cmd/hatch` |
| Docker stack | `docker compose up --build` |
| Reset the database | `rm hatch.db` |
## Troubleshooting
**`go: command not found`** — Install Go 1.25+ and ensure `$HOME/go/bin` (or wherever `go install` puts binaries) is on your `PATH`.
**`bind: address already in use`** — Another process is on `:8080`. Either stop it or run with `PORT=9090 go run ./cmd/hatch`.
**Tests fail with `database is locked`** — A previous test process left a handle. Look for stray `hatch` processes (`ps aux | grep hatch`) and kill them. Tests should use `:memory:` databases and not share the on-disk file.
**Docker build fails on `go.sum`** — Run `go mod tidy` locally, commit `go.sum`, rebuild. The Dockerfile copies `go.sum` before `go mod download` for reproducible builds.
**`CGO_ENABLED` warning during `go build`** — The static-binary build sets `CGO_ENABLED=0` explicitly. If you forget, the binary will dynamically link libc and break the "single static binary" promise.

View File

@ -0,0 +1,50 @@
# Engineer Onboarding
## Before Day 1
- [ ] Access to GitHub org granted
- [ ] Added to project channels / async standup
## Day 1: Context
- [ ] Read the [company charter](../company/charter.md)
- [ ] Read the [operating model](../company/operating-model.md)
- [ ] Read [ways of working](../company/ways-of-working.md)
- [ ] Read [how we decide](../company/how-we-decide.md)
- [ ] Read [CONTRIBUTING.md](../../CONTRIBUTING.md)
- [ ] Read [tech-stack.md](tech-stack.md)
- [ ] Read [local-dev.md](local-dev.md)
- [ ] Read [hatch-architecture.md](hatch-architecture.md)
- [ ] Read [ADR-0002: Hatch detail stack](adrs/0002-hatch-detail-stack.md)
- [ ] Introduce yourself in the team channel (async written standup format)
## Day 23: Environment
- [ ] Install Go 1.25 (or newer) — see the [Go install instructions](https://go.dev/doc/install) if you do not have it
- [ ] Verify `go version` reports 1.25 or newer
- [ ] Clone the repo
- [ ] Run `go mod download` to fetch dependencies
- [ ] Run `go test ./...` — should pass on a fresh clone
- [ ] Run `go run ./cmd/hatch` and `curl http://localhost:8080/healthz` — should return `ok`
- [ ] Run `docker compose up --build` and visit `http://localhost:8080/healthz` — should return `ok`
- [ ] Read [PR Submission Guidelines](pr-submission-guidelines.md), [Approval Workflow](approval-workflow.md), and [PR Merging Rule FAQ](pr-merging-rule-faq.md)
- [ ] Open your first PR (a README typo fix or doc improvement counts)
**Important:** Only the CEO or CTO can merge pull requests. You cannot merge your own PR.
## Week 1: First Task
- [ ] Pick up a `good first issue` or grab a task from the backlog with CTO approval
- [ ] Follow the full task lifecycle: branch → PR → review → merge
- [ ] Shadow one code review as a reviewer (even if just observing)
## First 30 Days
- [ ] Ship at least one meaningful change to production (or equivalent if pre-launch)
- [ ] Write or update one piece of documentation
- [ ] Attend (async) one decision review or ADR discussion
- [ ] Provide feedback on the onboarding process itself
## Questions?
Ask the CTO or post in the project channel. Async-first: write it down.

View File

@ -0,0 +1,67 @@
# Technology Stack
## Overview
These choices were made by the CTO on 2026-06-22 to align the engineering foundation with [ADR-0001](https://github.com/elfoundation/hatch/blob/main/docs/adrs/0001-stack.md) and the Hatch product thesis. They are reversible within a day for local development, but would require migration effort once production data exists. All choices default to boring, well-supported technology over novelty.
The stack is chosen so that a single `docker compose up` (or one binary on a VPS) is the entire product — no separate database server, no build step, no auth process. This is the v0.1 distribution promise; if a choice makes that promise harder, it has to earn its place.
## Backend & Data
| Layer | Choice | Rationale |
|---|---|---|
| Language | Go 1.25 | Single static binary, fast iteration, strong stdlib for HTTP and SQL. "One command on a VPS" requires a self-contained binary. |
| HTTP server | stdlib `net/http` (Go 1.22+ method-based routing) | No router dependency for a v0.1 surface. `go-chi/chi` is allowed where middleware is genuinely needed (SSE). |
| Database | SQLite via `modernc.org/sqlite` | Pure-Go driver keeps the "no CGO" promise. Survives restarts, zero-ops, single file you can `cp` to back up. |
| Migrations | Hand-rolled runner that applies `internal/storage/schema.sql` on first start, idempotently | Premature tooling is paid complexity. One SQL file, one `CREATE TABLE IF NOT EXISTS`, ship it. |
| Templates | stdlib `html/template` | Server-rendered HTML with auto-escaping. No JS build step, no hydration cost, no SPA complexity for what is fundamentally a server-driven UI. |
| Live updates | Server-Sent Events (SSE) on `net/http` | One-way push from server to browser, no WebSocket framing, plays nicely with the `html/template` render path. |
## Frontend
| Layer | Choice | Rationale |
|---|---|---|
| UI architecture | Server-rendered HTML + a little vanilla JS | The UI is a small request list with one live update. JS is loaded only where it earns its bandwidth (the SSE client). No bundler. |
| CSS | Hand-written CSS in `internal/handler/assets/` | Tailwind and friends add a build step we explicitly do not want in v0.1. A small `style.css` is enough for the surfaces we ship. |
| Client JS | Vanilla JS (no framework, no transpiler) | One `events.js` for the SSE subscription. If we ever need more, we will earn a toolchain. |
## Packaging & Distribution
| Layer | Choice | Rationale |
|---|---|---|
| Container | Multi-stage `Dockerfile` (golang:1.25 → scratch) | Produces an 810 MB static binary image. No runtime, no shell, no package manager on the final image. |
| Local dev | `docker compose` with optional Caddy sidecar | `docker compose up` is the one-command demo. Caddy handles TLS for the demo host and stays out of the way for plain HTTP. |
| Demo TLS | Caddy (auto-issues Let's Encrypt in prod, self-signed in dev) | Hands-off TLS, sane defaults, plays well with the static-binary promise. |
| Config | Env vars (read with `os.Getenv`) | Twelve-factor. No YAML/JSON config files for v0.1. Defaults documented in `.env.example`. |
## Tooling
| Layer | Choice | Rationale |
|---|---|---|
| Build | `go build` (with `CGO_ENABLED=0` for the final binary) | No Makefile needed for the common path. |
| Tests | stdlib `testing` + `net/http/httptest` | No assertion library until pain demands one. |
| Vet / lint | `go vet ./...` in CI | Stdlib tooling, no `golangci-lint` config to maintain. |
| CI | GitHub Actions (`.github/workflows/ci.yml`) | `go vet``go test``docker build` → smoke-test the running image. |
| Markdown lint | `markdownlint-cli2` (non-blocking) | Editorial consistency, not a gate. |
| Module path | `github.com/elfoundation/hatch` | Matches the GitHub org. Easy to change before any external consumer depends on it. |
## Principles
These are stack-agnostic and outlive any choice in the tables above. They are the cultural defaults; the tables are the implementation.
- **Server-rendered by default.** Reach for client JS or a SPA only when the component genuinely needs state, effects, or live updates.
- **Pure logic in `internal/`, I/O in adapters.** Business logic does not call `http.*` or `database/sql` directly. Storage and HTTP are replaced with interfaces in tests.
- **Store timestamps as UTC.** Render in local time only at the edge.
- **Single static binary, single file database.** If a dependency breaks that, justify it.
- **Observability before optimization.** Measure before fixing. No tuning without metrics.
- **Idempotency.** Operations should be safe to retry. Migrations, request capture, and SSE reconnect are all idempotent.
- **Boring technology.** A new dependency must earn its place. If stdlib covers it, stdlib wins.
## Open Decisions
| Decision | Status | Owner | Blocker |
|---|---|---|---|
| Router (stdlib `net/http` vs. `go-chi/chi`) | Provisional — start with stdlib, adopt chi if SSE middleware pain demands it | CTO | Live SSE implementation in v0.1 Inspect task |
| Structured logging library | Open — `slog` (stdlib) likely sufficient | CTO | First production deploy |
| Tracing / metrics | Open | CTO | Hosting decision |
| Hosting target (Fly, single VPS, k8s?) | Open | CEO | Need product requirements and traffic estimates |

View File

@ -0,0 +1,106 @@
# Community-listening list — Week 1
**Owner:** Social Media Manager
**Reporter:** Head of Marketing
**Source issue:** [ELF-51](/ELF/issues/ELF-51)
**Source plan:** [30-Day Marketing Plan — Hatch v0.1 Launch](/ELF/issues/ELF-49#document-plan) (§3 channel mix, §7 "CEO needs to decide")
**Period:** week of 2026-06-22 → 2026-06-28 (pre-launch)
**Goal:** be a *helpful* presence on the channels the audience already uses, not a spam account.
## How to use this list
1. **Helpful, not spammy.** Reply to the question, not the thread. Only mention Hatch if it is the actual answer to what was asked.
2. **No marketing copy.** No taglines, no "check us out", no `@hatch_http` signature. If a reply wouldn't be useful to a stranger, it doesn't ship.
3. **One ask per reply.** If we can answer a question without a link, answer without a link. A link is only OK if the person is clearly choosing between tools.
4. **Read the room.** Skip threads where the original poster has been silent for >24h, or where a moderator is actively pruning self-promotion. Don't argue with a thread that's already decided.
5. **No customer commitments.** If anyone asks for a feature, a date, a price, or an SLA, route to the [Head of Marketing](/ELF/agents/headofmarketing) and the [CEO](/ELF/agents/ceo) per the brand-voice guide.
## Week 1 — 10 threads (HN primary; Reddit expansion in a follow-up child issue)
Each row is one thread, with the wedge, the specific engagement approach, and a no-go line.
### 1. Ngrok Alternatives (Ask HN, Feb 2022)
- **Link:** https://news.ycombinator.com/item?id=30443747
- **Why it matters:** the canonical "what do I use instead of ngrok" thread; 244 points, 97 comments, still active. Audience is exactly the dev who would land on Hatch's landing page.
- **Engagement plan:** answer the *unanswered* variants: "if you specifically need to debug a webhook (not tunnel to localhost), a self-hosted request inspector + mocker is a different tool. We built one — happy to share what worked and what didn't, but I won't drop a link in a 2022 thread."
- **What *not* to do:** post a one-line "use our tool" with a link. That gets flagged and we lose the account on day one.
### 2. Pico: An open-source Ngrok alternative built for production traffic (Show HN, May 2024)
- **Link:** https://news.ycombinator.com/item?id=40355744
- **Why it matters:** a Show HN from a peer tool, 244 points, 55 comments. The thread is full of production-traffic questions (TLS, auth, retention, observability).
- **Engagement plan:** reply to top comment about "I just need to see what landed in this POST" with a *focused* recommendation: "for the inspect-and-mock half, a single Go binary + SQLite has been enough for our 100k-request/endpoint workload. happy to share schema notes if useful."
- **What *not* to do:** don't link the repo from this comment. The thread is about Pico, not about us. We earn credibility by helping, not by linking.
### 3. Tunnelmole, an ngrok alternative (open source) (Show HN, Mar 2024)
- **Link:** https://news.ycombinator.com/item?id=39754786
- **Why it matters:** 206 points, 83 comments, the "tunnel + logger" thread.
- **Engagement plan:** answer the "I just need to log POSTs to a URL" question. Confirm that the tunnel-half (ngrok, tunnelmole) and the inspect-half (requestbin, hatch) are *different jobs* and we built the inspect-half. If asked for the link, share in a child reply, not a top-level comment.
- **What *not* to do:** compare ourselves to Tunnelmole in a top-level comment. We're solving a different problem; saying so in a top-level reads as a drive-by.
### 4. Portr open-source ngrok alternative designed for teams (Show HN, Apr 2024)
- **Link:** https://news.ycombinator.com/item?id=39913197
- **Why it matters:** 172 points, 24 comments. Strong fit for the "team" / "compliance" sub-thread.
- **Engagement plan:** reply to the "what about a self-hosted option that doesn't phone home" comment. Acknowledge Portr, note that *for the webhook-inspect case* a different tool (requestbin, hookdeck) is the right move, and offer to share what we shipped.
- **What *not* to do:** don't paste the GitHub link. Let them ask.
### 5. Show HN: Webhix Self-hosted webhook.site alternative in a single Go binary (Show HN, Jun 2026)
- **Link:** https://news.ycombinator.com/item?id=48460403
- **Why it matters:** a direct Show HN for the same wedge, posted last week. Low traffic (3 points) which makes the thread easy to be a good citizen in.
- **Engagement plan:** leave a useful technical comment. Specifically: ask about the SSE/EventSource story (do they push new requests to a live tab?), share what we learned about a `chan store.Request` + `map[endpointID][]chan` fan-out. If the conversation is going well, mention in a *reply* that we built the same wedge.
- **What *not* to do:** don't write "we built the same thing, here's ours" as a top-level comment. The author is the OP; show up as a peer, not a competitor.
### 6. Show HN: Hookaido "Caddy for Webhooks" (Show HN, Feb 2026)
- **Link:** https://news.ycombinator.com/item?id=46958502
- **Why it matters:** a different framing ("Caddy for X"). Useful to read for the audience's mental model.
- **Engagement plan:** engage on the routing/forwarding story. Note that Hatch's v0.1 deliberately omits forwarding (it captures and returns a configured mock), and ask whether anyone in the thread has needed forwarding on day one.
- **What *not* to do:** don't claim we're "Caddy for inspecting" or otherwise co-opt the Caddy brand. That's marketing-speak, not what this thread is.
### 7. Show HN: Whook Free self hosted alternative to hookdeck, webhook.site (Show HN, Jan 2026)
- **Link:** https://news.ycombinator.com/item?id=46840462
- **Why it matters:** direct competitor framing, 1 point, low traffic. Audience is already self-hosting-curious.
- **Engagement plan:** reply to the OP with one concrete technical question (their stack, persistence model), and one *helpful* observation about the size of the self-hosted-webhook-inspector space.
- **What *not* to do:** don't list Hatch as an alternative in the same reply. The thread is the OP's thread; making it about us is a brand-voice failure.
### 8. Show HN: Requestbin.com A modern take on the old RequestBin (Show HN, Aug 2019)
- **Link:** https://news.ycombinator.com/item?id=20758684
- **Why it matters:** old but still shows up in searches; useful for SEO backlinks and the "long tail" reader. 18 points, 10 comments.
- **Engagement plan:** add a *self-hosted* alternative to the "alternatives" sub-thread, not the top-level. "If you specifically need a self-hosted version (compliance / data-residency), a Go binary + SQLite has worked for us." No link in the comment.
- **What *not* to do:** don't try to rewrite a 2019 thread. Most readers are looking for the OP's tool, not a rehash.
### 9. Show HN: Webhook Tester RequestBin-style webhooks inbox built on Cloudflare (Show HN, Dec 2025)
- **Link:** https://news.ycombinator.com/item?id=46273261
- **Why it matters:** a hosted alternative on Cloudflare. Audience is the *opposite* of Hatch's wedge (they want hosted; we win on self-hosted).
- **Engagement plan:** reply to "what about self-hosted?" comment with the trade-off (Cloudflare Workers is faster to deploy; self-hosting on a $5 VPS is faster to audit and survives Cloudflare outages). Acknowledge the OP built something good.
- **What *not* to do:** don't position Hatch as "better than Cloudflare". That's a fight we don't win on HN.
### 10. Show HN: RePlaya self-hosted browser session replay with live tailing (Show HN, Jun 2026)
- **Link:** https://news.ycombinator.com/item?id=48373482
- **Why it matters:** 50 points, 8 comments, posted last week. Not a webhook tool, but in the *self-hosted dev tool* wedge and the "live tailing" pattern maps to Hatch's SSE feed.
- **Engagement plan:** engage on the "live tailing" pattern specifically. Note that the same pattern (a `chan` per endpoint + a fan-out map) covers a request log; ask what the OP learned about backpressure under load.
- **What *not* to do:** don't pitch Hatch. The thread is about session replay; forcing the connection is a brand-voice failure.
## Reddit expansion (follow-up child issue)
HN is enough for week 1. Reddit's anti-bot wall blocked the listing endpoint from our environment, so the Reddit half of this list is filed as a [follow-up child issue](/ELF/issues/ELF-55) (when it exists; assigned to Social Media Manager for week 2). The target subreddits, per the [30-day plan §3](/ELF/issues/ELF-49#document-plan):
- `r/selfhosted` — primary Reddit wedge. Look for: "requestbin alternative self-hosted", "webhook debugger", "self-hosted ngrok alternative".
- `r/golang` — for the "I built a Go tool" Show-style posts.
- `r/devops` — for the "debug webhook in staging" angle.
- `r/programming`, `r/webdev` — for generalist threads; engage carefully, the self-promotion tolerance is lower.
## Metrics we'll track on this list (recorded on the issue after the week closes)
For each of the 10 threads:
- Did we reply? (yes / no / skipped)
- Reply depth (top-level vs. sub-thread)
- Did we mention Hatch? (yes / no — and if yes, was the mention in the *first* reply or only after a follow-up question)
- Net karma delta on the account (if any; mostly defensive)
- Any reply from a thread participant that wants a follow-up (route to [Head of Marketing](/ELF/agents/headofmarketing))
The success metric is not "how many clicks did we get". The success metric is "are we a credible presence on HN after week 1". We'll measure that by whether replies from us land above the threshold on week-2+ threads.
## Out of scope for this list
- Cross-posting on LinkedIn / Mastodon — explicitly out of scope per [ELF-49 §3](/ELF/issues/ELF-49#document-plan).
- Paid X ads — out of scope for week 1, gated on CEO sign-off per [ELF-54](/ELF/issues/ELF-54).
- Threads about competitors' outages / complaints. We don't pile on.

189
examples/README.md Normal file
View File

@ -0,0 +1,189 @@
# Hatch Examples
Real-world examples and integration guides for using Hatch.
## Examples Index
| Example | Description | Use Case |
|---------|-------------|----------|
| [Webhook Integration](webhook-integration.md) | Comprehensive webhook capture, testing, and debugging | Payment processors, GitHub, Slack, API integrations |
## Quick Start Examples
### Capture Your First Request
```bash
# Start Hatch server
hatch serve &
# Capture a request
curl -X POST http://localhost:8080/my-endpoint \
-H "Content-Type: application/json" \
-d '{"event":"test","data":{"id":123}}'
# View captured requests
hatch inspect my-endpoint
```
### Mock API for Development
```bash
# Configure mock response
hatch mock set api/users \
-status 200 \
-header "Content-Type:application/json" \
-body '[{"id":1,"name":"John Doe"}]'
# Test your frontend
curl http://localhost:8080/api/users
```
### Debug Failed Webhooks
```bash
# Find errors
hatch search webhooks -query "status:500"
# Replay to debug
hatch replay <request-id> \
-endpoint webhooks \
-target http://localhost:3000/debug
```
## Integration Guides
### Stripe Webhooks
See [Webhook Integration - Stripe Example](webhook-integration.md#example-1-stripe-webhook-testing)
### GitHub Webhooks
See [Webhook Integration - GitHub Example](webhook-integration.md#example-2-github-webhook-debugging)
### Slack Integration
See [Webhook Integration - Slack Example](webhook-integration.md#example-3-slack-integration-testing)
## Use Cases
### 1. Development Environment
Use Hatch as a local mock server for frontend development.
```bash
# Configure mocks for your API
hatch mock set api/users -status 200 -body '[]'
hatch mock set api/auth -status 200 -body '{"token":"mock-token"}'
# Frontend code uses http://localhost:8080 as API base
```
### 2. Testing & QA
Capture production traffic patterns and replay them in testing.
```bash
# Capture traffic
hatch inspect api/critical-path -limit 1000 > traffic.json
# Replay in test environment
cat traffic.json | jq -r '.[].id' | while read id; do
hatch replay "$id" -endpoint api/critical-path -target http://staging:8080
done
```
### 3. API Documentation
Generate OpenAPI specs from real traffic.
```bash
# Capture representative traffic
hatch capture /api/users -method GET
hatch capture /api/users -method POST -body '{"name":"test"}'
# Generate documentation
hatch doc generate api/users > users-api.json
```
### 4. Load Testing
Replay captured traffic for load testing.
```bash
# Capture production patterns
hatch inspect api/critical-path -limit 1000 > load-test-traffic.json
# Create load test script
cat > load-test.sh << 'EOF'
#!/bin/bash
for i in {1..100}; do
hatch inspect api/critical-path -limit 10 | \
jq -r '.[] | "hatch replay \(.id) -endpoint api/critical-path -target http://loadtest:8080"' | \
bash &
done
wait
EOF
chmod +x load-test.sh
```
## Best Practices
1. **Use descriptive endpoint names**
```bash
# Good
hatch capture /webhooks/stripe-payments
hatch capture /api/v2/users
# Bad
hatch capture /a
hatch capture /test
```
2. **Include relevant headers**
```bash
hatch capture /webhooks/github \
-header 'X-GitHub-Event:push' \
-header 'X-Hub-Signature:sha1=abc123'
```
3. **Organize by environment**
```bash
# Development
hatch mock set dev/api/users -status 200 -body '[]'
# Staging
hatch mock set staging/api/users -status 200 -body '[{"id":1}]'
```
4. **Use search for debugging**
```bash
# Find all errors
hatch search api/webhooks -query 'status:500'
# Find specific events
hatch search webhooks/stripe -query 'payment_intent'
```
## Troubleshooting
See [CLI Troubleshooting Guide](../docs/engineering/cli-troubleshooting.md) for common issues and solutions.
## Contributing Examples
To add a new example:
1. Create a markdown file in this directory
2. Follow the naming convention: `<use-case>.md`
3. Include:
- Clear use case description
- Step-by-step instructions
- Complete code examples
- Expected output
- Common pitfalls
4. Update this README.md to include your example in the index
## Resources
- [CLI Reference](../docs/engineering/cli.md) - Complete command documentation
- [Quick Reference](../docs/engineering/cli-quick-reference.md) - Command cheat sheet
- [Troubleshooting](../docs/engineering/cli-troubleshooting.md) - Common issues and solutions

View File

@ -0,0 +1,389 @@
# Webhook Integration Examples
Real-world examples of using Hatch for webhook capture, testing, and debugging.
## Example 1: Stripe Webhook Testing
Capture and test Stripe webhook events in development.
### Setup
```bash
# Start Hatch server
hatch serve &
# Capture Stripe webhook events
hatch capture /webhooks/stripe \
-method POST \
-header 'Stripe-Signature:whsec_test123' \
-body '{"id":"evt_123","type":"payment_intent.succeeded","data":{"object":{"id":"pi_456","amount":2000,"currency":"usd"}}}'
```
### Development Workflow
```bash
# 1. Capture incoming webhook
hatch capture /webhooks/stripe -body '{"type":"checkout.session.completed","data":{"object":{"id":"cs_789"}}}'
# 2. Inspect captured events
hatch inspect webhooks/stripe
# 3. Search for specific event types
hatch search webhooks/stripe -query 'payment_intent.succeeded'
# 4. Replay to test handler
hatch replay <event-id> \
-endpoint webhooks/stripe \
-target http://localhost:3000/stripe/webhook
# 5. Configure mock for frontend testing
hatch mock set webhooks/stripe \
-status 200 \
-body '{"received":true}'
```
### Load Testing
```bash
# Capture multiple events
for i in {1..10}; do
hatch capture /webhooks/stripe \
-body "{\"id\":\"evt_$i\",\"type\":\"payment_intent.succeeded\"}"
done
# Replay all to load test handler
hatch inspect webhooks/stripe -limit 10 | \
jq -r '.[] | "hatch replay \(.id) -endpoint webhooks/stripe -target http://localhost:3000/stripe/webhook"' | \
bash
```
## Example 2: GitHub Webhook Debugging
Debug GitHub webhook delivery issues.
### Capture GitHub Events
```bash
# Capture push events
hatch capture /webhooks/github \
-method POST \
-header 'X-GitHub-Event:push' \
-header 'X-Hub-Signature:sha1=abc123' \
-body '{"ref":"refs/heads/main","commits":[{"id":"def456","message":"Fix bug"}]}'
# Capture pull request events
hatch capture /webhooks/github \
-method POST \
-header 'X-GitHub-Event:pull_request' \
-body '{"action":"opened","pull_request":{"number":42,"title":"New feature"}}'
```
### Debug Failed Deliveries
```bash
# Find failed webhook deliveries
hatch search webhooks/github -query 'status:500'
# Get details of failed request
hatch inspect webhooks/github -limit 5
# Replay to local debugging server
hatch replay <request-id> \
-endpoint webhooks/github \
-target http://localhost:8080/debug
```
### Mock GitHub API Responses
```bash
# Mock GitHub API for local development
hatch mock set api/github \
-status 200 \
-header 'Content-Type:application/json' \
-body '{"login":"test-user","id":12345}'
```
## Example 3: Slack Integration Testing
Test Slack app integrations without hitting real Slack APIs.
### Capture Slack Events
```bash
# Capture Slack events
hatch capture /slack/events \
-method POST \
-header 'X-Slack-Signature:v0=abc123' \
-body '{"type":"event_callback","event":{"type":"message","text":"Hello world"}}'
# Capture Slack interactive payloads
hatch capture /slack/actions \
-method POST \
-header 'X-Slack-Signature:v0=def456' \
-body '{"type":"interactive_message","actions":[{"type":"button","value":"approve"}]}'
```
### Development Setup
```bash
# Mock Slack API responses
hatch mock set slack/api \
-status 200 \
-header 'Content-Type:application/json' \
-body '{"ok":true}'
# Test message sending
hatch replay <message-event-id> \
-endpoint slack/events \
-target http://localhost:3000/slack/events
```
## Example 4: Payment Provider Integration
Test payment provider webhooks across different environments.
### Multi-Environment Setup
```bash
# Development environment
hatch mock set payments/webhook \
-status 200 \
-body '{"status":"received"}'
# Staging environment
hatch capture payments/webhook \
-body '{"type":"payment.completed","amount":1000,"currency":"USD"}'
# Replay to different environments
hatch replay <payment-id> \
-endpoint payments/webhook \
-target http://localhost:8080/payments/webhook # Local
hatch replay <payment-id> \
-endpoint payments/webhook \
-target https://staging.example.com/payments/webhook # Staging
```
### Error Simulation
```bash
# Simulate payment failure
hatch mock set payments/webhook \
-status 200 \
-body '{"status":"failed","error":"insufficient_funds"}'
# Simulate network timeout (use with caution)
hatch mock set payments/webhook \
-status 504 \
-body '{"error":"gateway_timeout"}'
```
## Example 5: API Documentation Generation
Generate OpenAPI documentation from real traffic patterns.
### Capture API Traffic
```bash
# Capture various endpoints
hatch capture /api/users -method GET
hatch capture /api/users -method POST -body '{"name":"John Doe"}'
hatch capture /api/users/123 -method GET
hatch capture /api/users/123 -method PUT -body '{"name":"Jane Doe"}'
hatch capture /api/posts -method GET
hatch capture /api/posts -method POST -body '{"title":"Hello World"}'
```
### Generate Documentation
```bash
# Generate OpenAPI specs for each endpoint
hatch doc generate api/users > users-api.json
hatch doc generate api/posts > posts-api.json
# Combine into single spec
jq -s '.[0] * .[1]' users-api.json posts-api.json > combined-api.json
# Serve with Swagger UI
docker run -p 8081:8080 \
-e SWAGGER_JSON=/openapi.json \
-v $(pwd):/usr/share/nginx/html/swagger \
swaggerapi/swagger-ui
```
## Example 6: Load Testing with Real Traffic
Capture and replay production traffic for load testing.
### Capture Production Patterns
```bash
# Capture high-traffic endpoint
hatch capture /api/critical-path -limit 1000
# Export traffic patterns
hatch inspect api/critical-path -limit 1000 > traffic-patterns.json
```
### Analyze Traffic
```bash
# Analyze request distribution
jq -r '.[] | "\(.method) \(.path)"' traffic-patterns.json | \
sort | uniq -c | sort -rn
# Find slow requests (if timing data available)
jq '.[] | select(.duration > 1000)' traffic-patterns.json
# Identify error patterns
jq '.[] | select(.status >= 400)' traffic-patterns.json
```
### Replay for Load Testing
```bash
# Create load test script
cat > load-test.sh << 'EOF'
#!/bin/bash
ENDPOINT="api/critical-path"
TARGET="https://loadtest.example.com"
REQUESTS=$(hatch inspect $ENDPOINT -limit 100)
echo "$REQUESTS" | jq -r '.[] | .id' | while read -r id; do
echo "Replaying request $id"
hatch replay "$id" -endpoint "$ENDPOINT" -target "$TARGET"
sleep 0.1 # Rate limiting
done
EOF
chmod +x load-test.sh
./load-test.sh
```
## Example 7: Mock Server for Frontend Development
Set up a comprehensive mock server for frontend development.
### Create Mock Endpoints
```bash
# User API mock
hatch mock set api/users \
-status 200 \
-header 'Content-Type:application/json' \
-body '[
{"id":1,"name":"John Doe","email":"john@example.com"},
{"id":2,"name":"Jane Smith","email":"jane@example.com"}
]'
# Authentication mock
hatch mock set api/auth \
-status 200 \
-header 'Content-Type:application/json' \
-body '{"token":"mock-jwt-token-123","expires_in":3600}'
# Error responses
hatch mock set api/errors \
-status 401 \
-header 'Content-Type:application/json' \
-body '{"error":"unauthorized","message":"Invalid credentials"}'
```
### Frontend Integration
```javascript
// Frontend code using mock API
const API_BASE = 'http://localhost:8080';
// Fetch users
const response = await fetch(`${API_BASE}/api/users`);
const users = await response.json();
// Login
const loginResponse = await fetch(`${API_BASE}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', password: 'pass' })
});
```
## Best Practices
### 1. Use Descriptive Endpoint Names
```bash
# Good
hatch capture /webhooks/stripe-payments
hatch capture /api/v2/users
# Bad
hatch capture /a
hatch capture /test
```
### 2. Include Relevant Headers
```bash
# Capture with all relevant headers
hatch capture /webhooks/github \
-header 'X-GitHub-Event:push' \
-header 'X-Hub-Signature:sha1=abc123' \
-header 'Content-Type:application/json'
```
### 3. Organize by Environment
```bash
# Development
hatch mock set dev/api/users -status 200 -body '[]'
# Staging
hatch mock set staging/api/users -status 200 -body '[{"id":1}]'
```
### 4. Use Search for Debugging
```bash
# Find all errors
hatch search api/webhooks -query 'status:500'
# Find specific event types
hatch search webhooks/stripe -query 'payment_intent'
```
### 5. Document Your Mocks
```bash
# Add comments to mock configuration
hatch mock set api/users \
-status 200 \
-header 'X-Mock-Description:Returns list of test users'
```
## Troubleshooting
### Common Issues
1. **Connection refused**: Ensure Hatch server is running (`hatch serve`)
2. **Endpoint not found**: Check endpoint ID with `curl http://localhost:8080/v1/endpoints`
3. **Request not captured**: Verify request format and headers
4. **Mock not working**: Check if mock is configured for the correct endpoint
### Debug Mode
```bash
# Enable debug logging
DEBUG=true hatch serve
# Check server logs
docker logs -f hatch-container
```
### Performance Issues
```bash
# Limit concurrent connections
hatch inspect api/heavy-endpoint -limit 10
# Use pagination for large datasets
hatch inspect api/large-dataset -limit 50
```

20
go.mod Normal file
View File

@ -0,0 +1,20 @@
module github.com/elfoundation/hatch
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

53
go.sum Normal file
View File

@ -0,0 +1,53 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

335
internal/handler/api_v1.go Normal file
View File

@ -0,0 +1,335 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
)
// RegisterV1Routes mounts the JSON API v1 routes on the given router.
func (h *Handler) RegisterV1Routes(r chi.Router) {
r.Route("/v1", func(r chi.Router) {
r.Route("/endpoints/{endpointID}", func(r chi.Router) {
// Capture request (JSON body)
r.Post("/requests", h.v1CaptureRequest)
// List or search requests
r.Get("/requests", h.v1ListRequests)
// Replay a specific request
r.Post("/requests/{requestID}/replay", h.v1ReplayRequest)
// Mock configuration
r.Put("/mock", h.v1SetMock)
// OpenAPI spec
r.Get("/openapi.json", h.v1OpenAPI)
})
})
}
// v1CaptureRequest handles POST /v1/endpoints/{endpointID}/requests
func (h *Handler) v1CaptureRequest(w http.ResponseWriter, r *http.Request) {
endpointID := chi.URLParam(r, "endpointID")
if endpointID == "" {
writeError(w, http.StatusBadRequest, "missing endpoint ID")
return
}
ctx := r.Context()
// Auto-create endpoint if it doesn't exist.
if _, err := h.Repo.GetEndpoint(ctx, endpointID); err != nil {
h.Repo.CreateEndpoint(ctx, endpointID)
}
// Parse JSON body representing a request.
var incoming struct {
Method string `json:"method"`
Path string `json:"path"`
Headers map[string]string `json:"headers"`
Query string `json:"query"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if incoming.Method == "" {
incoming.Method = "POST" // default
}
if incoming.Path == "" {
incoming.Path = "/"
}
// Convert headers map to JSON string.
headersJSON := "{}"
if incoming.Headers != nil {
b, _ := json.Marshal(incoming.Headers)
headersJSON = string(b)
}
// Store the request.
req := &store.Request{
Method: incoming.Method,
Path: incoming.Path,
Headers: headersJSON,
Query: incoming.Query,
Body: []byte(incoming.Body),
}
if err := h.Repo.AppendRequest(ctx, endpointID, req); err != nil {
writeError(w, http.StatusInternalServerError, "failed to store request")
return
}
// Broadcast via SSE.
broadcastRequest(endpointID, req)
// Return the created request (with ID and timestamp).
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(req)
}
// v1ListRequests handles GET /v1/endpoints/{endpointID}/requests
func (h *Handler) v1ListRequests(w http.ResponseWriter, r *http.Request) {
endpointID := chi.URLParam(r, "endpointID")
if endpointID == "" {
writeError(w, http.StatusBadRequest, "missing endpoint ID")
return
}
ctx := r.Context()
// Ensure endpoint exists (or create).
if _, err := h.Repo.GetEndpoint(ctx, endpointID); err != nil {
h.Repo.CreateEndpoint(ctx, endpointID)
}
// Parse query parameters.
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
query := r.URL.Query().Get("q")
var requests []*store.Request
var err error
if query != "" {
requests, err = h.Repo.SearchRequests(ctx, endpointID, query, limit)
} else {
requests, err = h.Repo.ListRequests(ctx, endpointID, limit)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list requests")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(requests)
}
// v1ReplayRequest handles POST /v1/endpoints/{endpointID}/requests/{requestID}/replay
func (h *Handler) v1ReplayRequest(w http.ResponseWriter, r *http.Request) {
// Reuse the existing replay logic but with JSON response.
// We'll just call the same handler as the HTML endpoint.
HandleReplay(h.Repo)(w, r)
}
// v1SetMock handles PUT /v1/endpoints/{endpointID}/mock
func (h *Handler) v1SetMock(w http.ResponseWriter, r *http.Request) {
// Reuse the existing mock handler.
HandleMock(h.Repo)(w, r)
}
// v1OpenAPI returns the OpenAPI 3.1 specification for the v1 API.
func (h *Handler) v1OpenAPI(w http.ResponseWriter, r *http.Request) {
spec := map[string]interface{}{
"openapi": "3.1.0",
"info": map[string]interface{}{
"title": "Hatch API",
"version": "v1",
"description": "JSON API for Hatch webhook capture, inspection, replay, and mocking.",
},
"servers": []map[string]string{
{"url": "/"},
},
"paths": map[string]interface{}{
"/v1/endpoints/{endpointID}/requests": map[string]interface{}{
"post": map[string]interface{}{
"summary": "Capture a request",
"operationId": "captureRequest",
"parameters": []map[string]interface{}{
{"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
},
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/IncomingRequest",
},
},
},
},
"responses": map[string]interface{}{
"201": map[string]interface{}{
"description": "Request captured",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/StoredRequest",
},
},
},
},
},
},
"get": map[string]interface{}{
"summary": "List or search requests",
"operationId": "listRequests",
"parameters": []map[string]interface{}{
{"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
{"name": "limit", "in": "query", "schema": map[string]interface{}{"type": "integer", "default": 100}},
{"name": "q", "in": "query", "schema": map[string]interface{}{"type": "string"}, "description": "Full-text search query"},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "List of requests",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/StoredRequest",
},
},
},
},
},
},
},
},
"/v1/endpoints/{endpointID}/requests/{requestID}/replay": map[string]interface{}{
"post": map[string]interface{}{
"summary": "Replay a captured request",
"operationId": "replayRequest",
"parameters": []map[string]interface{}{
{"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
{"name": "requestID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
},
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/ReplayRequest",
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Replay result",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/ReplayResponse",
},
},
},
},
},
},
},
"/v1/endpoints/{endpointID}/mock": map[string]interface{}{
"put": map[string]interface{}{
"summary": "Set mock configuration",
"operationId": "setMock",
"parameters": []map[string]interface{}{
{"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
},
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/MockConfig",
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Mock set",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]interface{}{"type": "string"},
},
},
},
},
},
},
},
},
},
"components": map[string]interface{}{
"schemas": map[string]interface{}{
"IncomingRequest": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"method": map[string]interface{}{"type": "string", "default": "POST"},
"path": map[string]interface{}{"type": "string", "default": "/"},
"headers": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "string"}},
"query": map[string]interface{}{"type": "string"},
"body": map[string]interface{}{"type": "string"},
},
},
"StoredRequest": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string"},
"endpoint_id": map[string]interface{}{"type": "string"},
"method": map[string]interface{}{"type": "string"},
"path": map[string]interface{}{"type": "string"},
"headers": map[string]interface{}{"type": "string"},
"query": map[string]interface{}{"type": "string"},
"body": map[string]interface{}{"type": "string", "format": "byte"},
"created_at": map[string]interface{}{"type": "string", "format": "date-time"},
},
},
"ReplayRequest": map[string]interface{}{
"type": "object",
"required": []string{"target_url"},
"properties": map[string]interface{}{
"target_url": map[string]interface{}{"type": "string", "format": "uri"},
},
},
"ReplayResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]interface{}{"type": "integer"},
"headers": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "string"}},
"body": map[string]interface{}{"type": "string"},
"error": map[string]interface{}{"type": "string"},
},
},
"MockConfig": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]interface{}{"type": "integer"},
"headers": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "string"}},
"body": map[string]interface{}{"type": "string", "format": "byte"},
},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spec)
}

View File

@ -0,0 +1,232 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
)
func TestV1CaptureRequest(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := `{"method":"POST","path":"/test","headers":{"X-Custom":"value"},"query":"foo=bar","body":"hello"}`
req := httptest.NewRequest(http.MethodPost, "/v1/endpoints/test-ep/requests", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 Created, got %d: %s", w.Code, w.Body.String())
}
// Verify response JSON contains the request.
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if resp["method"] != "POST" {
t.Errorf("expected method POST, got %v", resp["method"])
}
if resp["path"] != "/test" {
t.Errorf("expected path /test, got %v", resp["path"])
}
// Verify request stored in repo.
if len(repo.Requests) != 1 {
t.Fatalf("expected 1 request stored, got %d", len(repo.Requests))
}
if repo.Requests[0].Headers != `{"X-Custom":"value"}` {
t.Errorf("expected headers JSON, got %q", repo.Requests[0].Headers)
}
}
func TestV1ListRequests(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "list-ep")
// Add a few requests.
repo.AppendRequest(nil, "list-ep", &store.Request{Method: "GET", Path: "/a"})
repo.AppendRequest(nil, "list-ep", &store.Request{Method: "POST", Path: "/b"})
repo.AppendRequest(nil, "list-ep", &store.Request{Method: "PUT", Path: "/c"})
r := testRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/list-ep/requests", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
var list []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
t.Fatalf("invalid JSON array: %v", err)
}
if len(list) != 3 {
t.Errorf("expected 3 requests, got %d", len(list))
}
// Verify all methods present.
methods := make(map[string]bool)
for _, r := range list {
methods[r["method"].(string)] = true
}
if !methods["GET"] || !methods["POST"] || !methods["PUT"] {
t.Errorf("expected GET, POST, PUT, got %v", methods)
}
}
func TestV1SearchRequests(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "search-ep")
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`})
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)})
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "DELETE", Path: "/users/123"})
r := testRouter(repo)
// Search for "users".
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/search-ep/requests?q=users", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
var list []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
t.Fatalf("invalid JSON array: %v", err)
}
if len(list) != 2 {
t.Errorf("expected 2 requests matching 'users', got %d", len(list))
}
// Verify the matched methods.
methods := make(map[string]bool)
for _, r := range list {
methods[r["method"].(string)] = true
}
if !methods["GET"] || !methods["DELETE"] {
t.Errorf("expected GET and DELETE, got %v", methods)
}
}
func TestV1MockSet(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-ep")
r := testRouter(repo)
mockBody := `{"status":201,"headers":{"X-Mock":"true"},"body":"bW9ja2Vk"}`
req := httptest.NewRequest(http.MethodPut, "/v1/endpoints/mock-ep/mock", bytes.NewBufferString(mockBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
// Verify mock stored.
mock, err := repo.GetMock(nil, "mock-ep")
if err != nil {
t.Fatalf("mock not stored: %v", err)
}
if mock.Status != 201 {
t.Errorf("expected status 201, got %d", mock.Status)
}
if mock.Headers["X-Mock"] != "true" {
t.Errorf("expected X-Mock header, got %v", mock.Headers)
}
}
func TestV1OpenAPI(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/test/openapi.json", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected application/json, got %q", ct)
}
var spec map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if spec["openapi"] != "3.1.0" {
t.Errorf("expected openapi 3.1.0, got %v", spec["openapi"])
}
if spec["info"].(map[string]interface{})["title"] != "Hatch API" {
t.Errorf("expected title 'Hatch API', got %v", spec["info"])
}
// Verify paths exist.
paths, ok := spec["paths"].(map[string]interface{})
if !ok {
t.Fatal("missing paths")
}
if _, ok := paths["/v1/endpoints/{endpointID}/requests"]; !ok {
t.Error("missing capture/list path")
}
if _, ok := paths["/v1/endpoints/{endpointID}/requests/{requestID}/replay"]; !ok {
t.Error("missing replay path")
}
if _, ok := paths["/v1/endpoints/{endpointID}/mock"]; !ok {
t.Error("missing mock path")
}
}
func TestV1ReplayRequest(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "replay-ep")
repo.AppendRequest(nil, "replay-ep", &store.Request{
Method: "POST",
Path: "/webhook",
Headers: `{"Content-Type":"application/json"}`,
Query: "foo=bar",
Body: []byte(`{"msg":"hello"}`),
})
reqID := repo.Requests[0].ID
// Start a sink server.
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Sink", "yes")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"received": true}`))
}))
defer sink.Close()
// Set env to allow private replay.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
r := testRouter(repo)
body := `{"target_url": "` + sink.URL + `"}`
req := httptest.NewRequest(http.MethodPost, "/v1/endpoints/replay-ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Status != 201 {
t.Errorf("expected status 201, got %d", resp.Status)
}
if resp.Headers["X-Sink"] != "yes" {
t.Errorf("expected X-Sink header, got %v", resp.Headers)
}
}
// Import store package for the fakeRepo usage (already imported via handler_test.go)
// We just need to ensure the test compiles.
var _ = store.Request{}

156
internal/handler/handler.go Normal file
View File

@ -0,0 +1,156 @@
package handler
import (
"encoding/json"
"io"
"net/http"
"net/http/pprof"
"strings"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Handler groups all HTTP handlers and their shared dependencies.
type Handler struct {
Repo store.Repository
Debug bool
}
// New creates a new Handler with the given store.
func New(repo store.Repository) *Handler {
return &Handler{Repo: repo}
}
// RegisterRoutes mounts all routes on the given chi router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Health check.
r.Get("/healthz", Healthz)
r.Get("/readyz", h.Readyz)
// Debug endpoints (only when enabled).
if h.Debug {
r.Route("/debug/pprof", func(r chi.Router) {
r.HandleFunc("/", pprof.Index)
r.HandleFunc("/cmdline", pprof.Cmdline)
r.HandleFunc("/profile", pprof.Profile)
r.HandleFunc("/symbol", pprof.Symbol)
r.HandleFunc("/trace", pprof.Trace)
})
}
// JSON API v1 routes.
h.RegisterV1Routes(r)
// Inspect page: server-rendered request list.
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
// SSE stream for live updates.
r.Get("/e/{endpointID}/events", HandleSSE(h.Repo))
// Mock configuration.
r.Put("/e/{endpointID}/mock", HandleMock(h.Repo))
// Replay: one-click re-send of a captured request.
r.Post("/e/{endpointID}/requests/{requestID}/replay", HandleReplay(h.Repo))
// Capture webhook: any method on /{endpointID} (wildcard, must be last).
r.HandleFunc("/{endpointID}", h.capture)
r.HandleFunc("/{endpointID}/*", h.capture)
}
// capture handles incoming webhook captures on /{endpointID}.
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
eid := r.PathValue("endpointID")
if eid == "" {
writeError(w, http.StatusBadRequest, "missing endpoint ID")
return
}
ctx := r.Context()
// Auto-create endpoint if it doesn't exist.
if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil {
h.Repo.CreateEndpoint(ctx, eid)
}
// Collect headers as JSON.
hdr := map[string]string{}
for k, v := range r.Header {
hdr[k] = strings.Join(v, ", ")
}
hdrJSON, _ := json.Marshal(hdr)
// Read body.
var body []byte
if r.Body != nil {
body, _ = io.ReadAll(r.Body)
r.Body.Close()
}
// Store the request.
req := &store.Request{
Method: r.Method,
Path: r.URL.Path,
Headers: string(hdrJSON),
Query: r.URL.RawQuery,
Body: body,
}
if err := h.Repo.AppendRequest(ctx, eid, req); err != nil {
writeError(w, http.StatusInternalServerError, "failed to store request")
return
}
// Broadcast via SSE.
broadcastRequest(eid, req)
// Look up mock response for this endpoint.
mock, err := h.Repo.GetMock(ctx, eid)
if err == nil && mock != nil {
for k, v := range mock.Headers {
w.Header().Set(k, v)
}
w.WriteHeader(mock.Status)
if mock.Body != nil {
w.Write(mock.Body)
}
return
}
// Default: return 200 OK with empty JSON.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
// Healthz returns 200 OK for liveness probes.
func Healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok\n"))
}
// Readyz returns 200 OK if the service is ready to handle requests.
// It checks database connectivity.
func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) {
if err := h.Repo.Ping(r.Context()); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "database not ready"})
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok\n"))
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@ -0,0 +1,516 @@
package handler
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
"github.com/go-chi/chi/v5"
)
// testRouter creates a chi router with all routes registered using a fake repo.
func testRouter(repo store.Repository) chi.Router {
r := chi.NewRouter()
h := New(repo)
h.RegisterRoutes(r)
return r
}
func TestHealthz(t *testing.T) {
r := testRouter(testutil.NewFakeRepository())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := strings.TrimSpace(w.Body.String())
if body != "ok" {
t.Fatalf("expected 'ok', got %q", body)
}
}
func TestCaptureRecordsAllVerbs(t *testing.T) {
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
for _, m := range methods {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := ""
if m == "POST" || m == "PUT" || m == "PATCH" {
body = `{"k":"v"}`
}
req := httptest.NewRequest(m, "/ep", strings.NewReader(body))
if m == "GET" {
req.Header.Set("Accept", "text/html")
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("%s: expected 200, got %d", m, w.Code)
}
if len(repo.Requests) != 1 {
t.Errorf("%s: expected 1 request, got %d", m, len(repo.Requests))
continue
}
reqCaptured := repo.Requests[0]
if reqCaptured.Method != m {
t.Errorf("%s: wrong method %s", m, reqCaptured.Method)
}
}
}
func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
repo := testutil.NewFakeRepository()
srv := httptest.NewServer(testRouter(repo))
defer srv.Close()
// Use separate clients with separate transports to avoid
// connection-pool contention with the long-lived SSE connection.
sseClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
captureClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
// Subscribe to SSE with a cancellable context.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sseReq, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/ep/events", nil)
if err != nil {
t.Fatalf("SSE request create failed: %v", err)
}
sseResp, err := sseClient.Do(sseReq)
if err != nil {
t.Fatalf("SSE GET failed: %v", err)
}
defer sseResp.Body.Close()
if sseResp.StatusCode != http.StatusOK {
t.Fatalf("SSE returned %d", sseResp.StatusCode)
}
ct := sseResp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("SSE Content-Type is %q, expected text/event-stream", ct)
}
// Read SSE body in a goroutine; capture in main goroutine.
bodyCh := make(chan string, 1)
go func() {
buf := make([]byte, 4096)
n, _ := sseResp.Body.Read(buf)
bodyCh <- string(buf[:n])
}()
// Small sleep to let the SSE subscription register.
time.Sleep(10 * time.Millisecond)
// Capture a request on the same server.
captureResp, err := captureClient.Post(srv.URL+"/ep", "application/json", strings.NewReader(`{"msg":"hello"}`))
if err != nil {
t.Fatalf("capture POST failed: %v", err)
}
captureResp.Body.Close()
if captureResp.StatusCode != http.StatusOK {
t.Fatalf("capture returned %d", captureResp.StatusCode)
}
// Wait for the SSE event with a timeout.
select {
case body := <-bodyCh:
if !strings.Contains(body, "data:") {
t.Fatalf("SSE stream missing 'data:' prefix: %q", body)
}
if !strings.Contains(body, "\"method\":\"POST\"") {
t.Fatalf("SSE stream missing POST method: %q", body)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for SSE event")
}
// Cancel context to close the SSE connection.
cancel()
}
func TestInspectReturnsHTML(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{
Method: "POST",
Path: "/ep/webhook",
Headers: `{"Content-Type":"application/json"}`,
Query: "foo=bar",
Body: []byte(`{"msg":"hello"}`),
})
r := testRouter(repo)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e/ep", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("expected text/html Content-Type, got %q", ct)
}
body := w.Body.String()
// Should contain the endpoint ID.
if !strings.Contains(body, "ep") {
t.Error("missing endpoint ID in HTML")
}
// Should contain the request method badge.
if !strings.Contains(body, "POST") {
t.Error("missing POST method in HTML")
}
// Should contain the request path.
if !strings.Contains(body, "/ep/webhook") {
t.Error("missing request path in HTML")
}
// Should contain the header JSON.
if !strings.Contains(body, "Content-Type") {
t.Error("missing Content-Type header in HTML")
}
// Should contain the query string.
if !strings.Contains(body, "foo=bar") {
t.Error("missing query string in HTML")
}
// Should contain the body content (html/template escapes quotes).
if !strings.Contains(body, "msg") || !strings.Contains(body, "hello") {
t.Error("missing request body content in HTML")
}
// Should contain the replay button.
if !strings.Contains(body, "Replay") {
t.Error("missing Replay button in HTML")
}
// Should contain SSE EventSource script.
if !strings.Contains(body, "EventSource") {
t.Error("missing EventSource in HTML")
}
}
func TestInspectEmptyState(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "new-ep")
r := testRouter(repo)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e/new-ep", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
// Should show the "waiting for requests" empty state.
if !strings.Contains(body, "Waiting for requests") {
t.Error("missing empty state message")
}
// Should show the usage hint with the endpoint ID.
if !strings.Contains(body, "/new-ep") {
t.Error("missing usage hint with endpoint ID")
}
// No request cards should be rendered.
if strings.Contains(body, `class="request"`) {
t.Error("unexpected request card in empty state")
}
}
func TestInspectAutoCreatesEndpoint(t *testing.T) {
repo := testutil.NewFakeRepository()
// The endpoint doesn't exist yet.
r := testRouter(repo)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e/auto-ep", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// The endpoint should now exist in the repo.
ep, err := repo.GetEndpoint(nil, "auto-ep")
if err != nil {
t.Fatalf("endpoint was not auto-created: %v", err)
}
if ep.ID != "auto-ep" {
t.Errorf("expected endpoint ID 'auto-ep', got %q", ep.ID)
}
body := w.Body.String()
if !strings.Contains(body, "auto-ep") {
t.Error("missing endpoint ID in auto-created page")
}
if !strings.Contains(body, "Waiting for requests") {
t.Error("missing empty state for auto-created endpoint")
}
}
func TestCaptureReturnsJSON200(t *testing.T) {
r := testRouter(testutil.NewFakeRepository())
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil))
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
t.Error("not json")
}
data, _ := io.ReadAll(w.Result().Body)
w.Result().Body.Close()
var v map[string]interface{}
if json.Unmarshal(data, &v) != nil {
t.Fatal("invalid json")
}
}
func TestMockSetsAndConfiguresResponse(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-ep")
r := testRouter(repo)
// Set a mock via PUT.
mockBody := `{"status": 201, "headers": {"X-Mocked": "yes"}, "body": "bW9ja2Vk"}`
req := httptest.NewRequest(http.MethodPut, "/e/mock-ep/mock", strings.NewReader(mockBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
// Verify the mock was stored.
mock, err := repo.GetMock(nil, "mock-ep")
if err != nil {
t.Fatalf("mock not stored: %v", err)
}
if mock.Status != 201 {
t.Errorf("expected status 201, got %d", mock.Status)
}
if mock.Headers["X-Mocked"] != "yes" {
t.Errorf("expected X-Mocked header, got %v", mock.Headers)
}
}
func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-cap")
// Pre-set a mock.
repo.SetMock(nil, &store.MockConfig{
EndpointID: "mock-cap",
Status: 418,
Headers: map[string]string{"X-Teapot": "true"},
Body: []byte(`{"brewing": true}`),
})
r := testRouter(repo)
// Capture a request — should return the mock response, not the default 200.
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/mock-cap", strings.NewReader(`{"order":"latte"}`)))
if w.Code != 418 {
t.Fatalf("expected mock status 418, got %d", w.Code)
}
if w.Header().Get("X-Teapot") != "true" {
t.Errorf("expected X-Teapot: true, got %q", w.Header().Get("X-Teapot"))
}
body := strings.TrimSpace(w.Body.String())
if body != `{"brewing": true}` {
t.Errorf("expected mock body, got %q", body)
}
// Request should still be captured even when mock responds.
if len(repo.Requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.Requests))
}
if repo.Requests[0].Method != "POST" {
t.Errorf("captured method: %s", repo.Requests[0].Method)
}
}
func TestMockAutoCreatesEndpointOnSet(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
// Set a mock on an endpoint that doesn't exist yet.
mockBody := `{"status": 200, "headers": {}, "body": ""}`
req := httptest.NewRequest(http.MethodPut, "/e/auto-mock/mock", strings.NewReader(mockBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
// Endpoint should be auto-created.
ep, err := repo.GetEndpoint(nil, "auto-mock")
if err != nil {
t.Fatalf("endpoint was not auto-created: %v", err)
}
if ep.ID != "auto-mock" {
t.Errorf("expected endpoint id 'auto-mock', got %q", ep.ID)
}
}
func TestCaptureMissingEndpointID(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
// Request to root path - chi router returns 404 since /{endpointID} doesn't match.
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Chi returns 404 for unmatched routes.
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestV1CaptureRequestMissingEndpoint(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := `{"method":"POST","path":"/test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/endpoints//requests", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestV1ListRequestsMissingEndpoint(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints//requests", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestPrettyJSON(t *testing.T) {
// Valid JSON.
input := `{"key":"value","num":42}`
result := prettyJSON(input)
if !strings.Contains(result, "key") || !strings.Contains(result, "value") {
t.Errorf("prettyJSON should contain key-value pairs: %s", result)
}
// Invalid JSON (returns raw string).
invalid := `not json`
result = prettyJSON(invalid)
if result != invalid {
t.Errorf("prettyJSON should return raw string for invalid JSON: %s", result)
}
}
func TestFormatTime(t *testing.T) {
// Recent timestamp (should show relative time).
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
result := formatTime(now)
if result != "just now" {
t.Errorf("expected 'just now', got %q", result)
}
// Older timestamp.
old := time.Now().Add(-2 * time.Hour).UTC().Format("2006-01-02T15:04:05.000Z07:00")
result = formatTime(old)
if !strings.Contains(result, "h ago") {
t.Errorf("expected relative time with 'h ago', got %q", result)
}
// Invalid format (returns raw string).
result = formatTime("invalid")
if result != "invalid" {
t.Errorf("expected raw string for invalid format, got %q", result)
}
}
func TestJoinPath(t *testing.T) {
tests := []struct {
base, extra, want string
}{
{"/api", "/users", "/api/users"},
{"/api/", "/users", "/api/users"},
{"/api", "/users/", "/api/users/"},
{"/api", "", "/api"},
{"", "/users", "/users"},
{"", "", "/"},
}
for _, tc := range tests {
got := joinPath(tc.base, tc.extra)
if got != tc.want {
t.Errorf("joinPath(%q, %q) = %q, want %q", tc.base, tc.extra, got, tc.want)
}
}
}
func TestHandleMockReturnsErrorOnInvalidJSON(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-err")
r := testRouter(repo)
// Send invalid JSON.
req := httptest.NewRequest(http.MethodPut, "/e/mock-err/mock", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// The handler should handle the error gracefully.
// It may return 200 (if it ignores parse errors) or 400.
if w.Code != http.StatusOK && w.Code != http.StatusBadRequest {
t.Errorf("expected 200 or 400, got %d", w.Code)
}
}
func TestInspectReturnsHTMLErrorOnRepoFailure(t *testing.T) {
// Test that the inspect page works with a valid endpoint ID.
r := testRouter(testutil.NewFakeRepository())
req := httptest.NewRequest(http.MethodGet, "/e/test-ep", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Should return 200 and auto-create the endpoint.
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "test-ep") {
t.Error("missing endpoint ID in HTML")
}
}

344
internal/handler/inspect.go Normal file
View File

@ -0,0 +1,344 @@
package handler
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"time"
"github.com/elfoundation/hatch/internal/store"
)
// inspectPageData is passed to the inspect template.
type inspectPageData struct {
EndpointID string
Requests []*store.Request
}
// inspectTemplate is the server-rendered HTML for the inspect page.
var inspectTemplate = template.Must(template.New("inspect").Funcs(template.FuncMap{
"prettyJSON": prettyJSON,
"formatTime": formatTime,
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hatch {{.EndpointID}}</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 960px; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
.endpoint-url { color: #666; font-family: monospace; font-size: 0.85rem; margin-bottom: 1.5rem; word-break: break-all; }
.request { border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 1rem; overflow: hidden; }
.request-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; background: #f5f5f5; cursor: pointer; user-select: none; }
.request-header:hover { background: #eee; }
@media (prefers-color-scheme: dark) {
.request { border-color: #333; }
.request-header { background: #1a1a1a; }
.request-header:hover { background: #222; }
.endpoint-url { color: #aaa; }
}
.method { font-weight: 700; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; }
.method.GET { background: #d4edda; color: #155724; }
.method.POST { background: #cce5ff; color: #004085; }
.method.PUT { background: #fff3cd; color: #856404; }
.method.PATCH { background: #fff3cd; color: #856404; }
.method.DELETE { background: #f8d7da; color: #721c24; }
.path { font-family: monospace; font-size: 0.9rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { color: #999; font-size: 0.8rem; white-space: nowrap; }
.request-body { padding: 1rem; display: none; }
.request-body.open { display: block; }
.section { margin-bottom: 0.75rem; }
.section-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: #888; margin-bottom: 0.25rem; }
pre { background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 0.75rem; font-size: 0.8rem; overflow-x: auto; margin: 0; white-space: pre-wrap; word-break: break-all; }
@media (prefers-color-scheme: dark) { pre { background: #111; border-color: #333; } }
.empty { text-align: center; color: #999; padding: 3rem 0; }
.empty h2 { font-size: 1rem; margin-bottom: 0.5rem; }
.empty code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
@media (prefers-color-scheme: dark) { .empty code { background: #222; } }
.replay-btn { margin-left: auto; padding: 4px 12px; font-size: 0.8rem; border: 1px solid #007bff; background: #007bff; color: #fff; border-radius: 4px; cursor: pointer; }
.replay-btn:hover { background: #0056b3; }
.replay-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.replay-panel { display: none; padding: 1rem; border-top: 1px solid #e0e0e0; }
@media (prefers-color-scheme: dark) { .replay-panel { border-color: #333; } }
.replay-panel.open { display: block; }
.replay-url-input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 0.85rem; box-sizing: border-box; margin-bottom: 0.5rem; }
.replay-url-input:focus { outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0,123,255,0.25); }
.replay-actions { display: flex; gap: 0.5rem; align-items: center; }
.replay-send-btn { padding: 6px 16px; border: none; background: #28a745; color: #fff; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
.replay-send-btn:hover { background: #218838; }
.replay-send-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.replay-cancel-btn { padding: 6px 12px; border: 1px solid #ccc; background: transparent; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
.replay-result { margin-top: 0.75rem; }
.replay-error { color: #dc3545; font-size: 0.85rem; }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; margin-right: 0.5rem; }
.status-2xx { background: #d4edda; color: #155724; }
.status-3xx { background: #fff3cd; color: #856404; }
.status-4xx { background: #f8d7da; color: #721c24; }
.status-5xx { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>Hatch</h1>
<div class="endpoint-url">Endpoint: <strong>{{.EndpointID}}</strong></div>
{{if .Requests}}
{{range .Requests}}
<div class="request" id="req-{{.ID}}">
<div class="request-header" onclick="toggleRequest('{{.ID}}')">
<span class="method {{.Method}}">{{.Method}}</span>
<span class="path">{{.Path}}</span>
<span class="time" title="{{.CreatedAt}}">{{formatTime .CreatedAt}}</span>
<button class="replay-btn" onclick="event.stopPropagation(); openReplay('{{.ID}}')" title="Replay this request">&#x21BA; Replay</button>
</div>
<div class="request-body" id="body-{{.ID}}">
{{if .Headers}}
<div class="section">
<div class="section-label">Headers</div>
<pre>{{prettyJSON .Headers}}</pre>
</div>
{{end}}
{{if .Query}}
<div class="section">
<div class="section-label">Query</div>
<pre>{{.Query}}</pre>
</div>
{{end}}
{{if .Body}}
<div class="section">
<div class="section-label">Body</div>
<pre>{{printf "%s" .Body}}</pre>
</div>
{{end}}
</div>
<div class="replay-panel" id="replay-{{.ID}}">
<input type="text" class="replay-url-input" id="replay-url-{{.ID}}" placeholder="https://myapp.local/webhook" value="">
<div class="replay-actions">
<button class="replay-send-btn" onclick="doReplay('{{.ID}}')">Send</button>
<button class="replay-cancel-btn" onclick="closeReplay('{{.ID}}')">Cancel</button>
<span class="replay-error" id="replay-error-{{.ID}}"></span>
</div>
<div class="replay-result" id="replay-result-{{.ID}}"></div>
</div>
</div>
{{end}}
{{else}}
<div class="empty">
<h2>Waiting for requests...</h2>
<p>Send a request to <code>/{{.EndpointID}}</code> to see it appear here.</p>
</div>
{{end}}
<script>
function toggleRequest(id) {
document.getElementById('body-' + id).classList.toggle('open');
}
function openReplay(id) {
document.querySelectorAll('.replay-panel.open').forEach(function(p) { p.classList.remove('open'); });
document.getElementById('replay-' + id).classList.add('open');
var input = document.getElementById('replay-url-' + id);
input.focus();
if (!input.value) input.value = 'http://localhost:8080';
document.getElementById('replay-result-' + id).innerHTML = '';
document.getElementById('replay-error-' + id).textContent = '';
}
function closeReplay(id) {
document.getElementById('replay-' + id).classList.remove('open');
}
function doReplay(id) {
var targetUrl = document.getElementById('replay-url-' + id).value.trim();
if (!targetUrl) {
document.getElementById('replay-error-' + id).textContent = 'Please enter a target URL.';
return;
}
var sendBtn = document.querySelector('#replay-' + id + ' .replay-send-btn');
var errorEl = document.getElementById('replay-error-' + id);
var resultEl = document.getElementById('replay-result-' + id);
var endpointId = window.location.pathname.replace(/^\/e\//, '');
sendBtn.disabled = true;
errorEl.textContent = '';
resultEl.innerHTML = '<em>Sending...</em>';
fetch('/e/' + endpointId + '/requests/' + id + '/replay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_url: targetUrl })
}).then(function(resp) {
return resp.json().then(function(data) { return { status: resp.status, data: data }; });
}).then(function(result) {
if (result.data.error) {
errorEl.textContent = result.data.error;
resultEl.innerHTML = '';
} else {
renderReplayResult(resultEl, result.data);
}
}).catch(function(err) {
errorEl.textContent = 'Network error: ' + err.message;
resultEl.innerHTML = '';
}).finally(function() { sendBtn.disabled = false; });
}
function renderReplayResult(el, data) {
var sc = 'status-2xx';
if (data.status >= 300 && data.status < 400) sc = 'status-3xx';
else if (data.status >= 400 && data.status < 500) sc = 'status-4xx';
else if (data.status >= 500) sc = 'status-5xx';
var html = '<div class="section"><div class="section-label">Response</div>';
html += '<span class="status-badge ' + sc + '">' + data.status + '</span></div>';
if (data.headers && Object.keys(data.headers).length > 0) {
html += '<div class="section"><div class="section-label">Response Headers</div><pre>';
for (var h in data.headers) { html += escapeHtml(h) + ': ' + escapeHtml(data.headers[h]) + '\n'; }
html += '</pre></div>';
}
if (data.body) {
html += '<div class="section"><div class="section-label">Response Body</div><pre>' + escapeHtml(data.body) + '</pre></div>';
}
el.innerHTML = html;
}
function formatTimeStr(ts) {
try { var d = new Date(ts); if (isNaN(d.getTime())) return ts; } catch(_) { return ts; }
var now = new Date();
var diff = Math.floor((now - d) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
function buildRequestBody(req) {
var html = '';
if (req.headers) {
var h = req.headers;
if (typeof h === 'string') { try { h = JSON.parse(h); } catch(_) { h = null; } }
if (h && Object.keys(h).length > 0) {
html += '<div class="section"><div class="section-label">Headers</div><pre>' + escapeHtml(JSON.stringify(h, null, 2)) + '</pre></div>';
}
}
if (req.query) {
html += '<div class="section"><div class="section-label">Query</div><pre>' + escapeHtml(req.query) + '</pre></div>';
}
if (req.body) {
var b = typeof req.body === 'string' ? req.body : '';
if (b) {
html += '<div class="section"><div class="section-label">Body</div><pre>' + escapeHtml(b) + '</pre></div>';
}
}
return html;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// Live updates via EventSource
(function() {
var endpointId = window.location.pathname.replace(/^\/e\//, '');
if (!endpointId) return;
var es = new EventSource('/e/' + endpointId + '/events');
es.onmessage = function(e) {
try {
var req = JSON.parse(e.data);
var empty = document.querySelector('.empty');
if (empty) empty.remove();
var div = document.createElement('div');
div.className = 'request';
div.id = 'req-' + req.id;
div.innerHTML = '<div class="request-header" onclick="toggleRequest(\'' + req.id + '\')">' +
'<span class="method ' + req.method + '">' + req.method + '</span>' +
'<span class="path">' + req.path + '</span>' +
'<span class="time" title="' + req.created_at + '">' + formatTimeStr(req.created_at) + '</span>' +
'<button class="replay-btn" onclick="event.stopPropagation(); openReplay(\'' + req.id + '\')">&#x21BA; Replay</button>' +
'</div>' +
'<div class="request-body" id="body-' + req.id + '">' + buildRequestBody(req) + '</div>' +
'<div class="replay-panel" id="replay-' + req.id + '">' +
'<input type="text" class="replay-url-input" id="replay-url-' + req.id + '" placeholder="https://myapp.local/webhook" value="">' +
'<div class="replay-actions">' +
'<button class="replay-send-btn" onclick="doReplay(\'' + req.id + '\')">Send</button>' +
'<button class="replay-cancel-btn" onclick="closeReplay(\'' + req.id + '\')">Cancel</button>' +
'<span class="replay-error" id="replay-error-' + req.id + '"></span></div>' +
'<div class="replay-result" id="replay-result-' + req.id + '"></div></div>';
var ref = document.querySelector('.endpoint-url');
ref.insertAdjacentElement('afterend', div);
} catch(_) {}
};
})();
</script>
</body>
</html>
`))
// HandleInspect serves the inspect page at GET /e/{endpointID}.
func HandleInspect(repo store.Repository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
endpointID := r.PathValue("endpointID")
if endpointID == "" {
http.Error(w, "missing endpoint ID", http.StatusBadRequest)
return
}
if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil {
repo.CreateEndpoint(r.Context(), endpointID)
}
requests, err := repo.ListRequests(r.Context(), endpointID, 100)
if err != nil {
http.Error(w, "failed to list requests", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
inspectTemplate.Execute(w, inspectPageData{
EndpointID: endpointID,
Requests: requests,
})
}
}
// prettyJSON formats a JSON string with indentation.
func prettyJSON(raw string) string {
var v interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return raw
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return raw
}
return string(b)
}
// formatTime displays an ISO 8601 timestamp as a short relative or absolute string.
func formatTime(raw string) string {
t, err := time.Parse("2006-01-02T15:04:05.000Z07:00", raw)
if err != nil {
// Try a few common variants.
t, err = time.Parse("2006-01-02T15:04:05Z07:00", raw)
}
if err != nil {
return raw
}
d := time.Since(t)
switch {
case d < 5*time.Second:
return "just now"
case d < time.Minute:
return fmt.Sprintf("%ds ago", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
default:
return t.Format("Jan 2, 15:04")
}
}

35
internal/handler/mock.go Normal file
View File

@ -0,0 +1,35 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/elfoundation/hatch/internal/store"
)
// HandleMock handles PUT /e/{endpointID}/mock.
func HandleMock(repo store.Repository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
endpointID := r.PathValue("endpointID")
if endpointID == "" {
writeError(w, http.StatusBadRequest, "missing endpoint ID")
return
}
if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil {
repo.CreateEndpoint(r.Context(), endpointID)
}
var mock store.MockConfig
if err := json.NewDecoder(r.Body).Decode(&mock); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
mock.EndpointID = endpointID
if err := repo.SetMock(r.Context(), &mock); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set mock: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}

196
internal/handler/replay.go Normal file
View File

@ -0,0 +1,196 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/elfoundation/hatch/internal/store"
)
const maxReplayBodyBytes = 64 * 1024
type replayRequest struct {
TargetURL string `json:"target_url"`
}
type replayResponse struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
Error string `json:"error,omitempty"`
}
func HandleReplay(repo store.Repository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requestID := r.PathValue("requestID")
if requestID == "" {
writeError(w, http.StatusBadRequest, "missing request_id")
return
}
var replay replayRequest
if err := json.NewDecoder(r.Body).Decode(&replay); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if replay.TargetURL == "" {
writeError(w, http.StatusBadRequest, "target_url is required")
return
}
target, err := url.Parse(replay.TargetURL)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target_url: "+err.Error())
return
}
if target.Scheme != "http" && target.Scheme != "https" {
writeError(w, http.StatusBadRequest, "target_url scheme must be http or https")
return
}
if !allowPrivateReplay() && isPrivateAddr(target.Host) {
writeError(w, http.StatusForbidden, "replay to private/loopback addresses is denied. Set HATCH_ALLOW_PRIVATE_REPLAY=true to allow")
return
}
capReq, err := repo.GetRequest(r.Context(), requestID)
if err != nil {
writeError(w, http.StatusNotFound, "request not found: "+err.Error())
return
}
replayResult, err := doReplay(capReq, target)
if err != nil {
writeError(w, http.StatusBadGateway, "replay failed: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(replayResult)
}
}
func doReplay(capReq *store.Request, targetURL *url.URL) (*replayResponse, error) {
outURL := *targetURL
outURL.Path = joinPath(targetURL.Path, capReq.Path)
if capReq.Query != "" {
outURL.RawQuery = capReq.Query
}
var bodyReader io.Reader
if capReq.Body != nil && len(capReq.Body) > 0 {
bodyReader = strings.NewReader(string(capReq.Body))
}
req, err := http.NewRequest(capReq.Method, outURL.String(), bodyReader)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
var headers map[string]string
if capReq.Headers != "" {
if err := json.Unmarshal([]byte(capReq.Headers), &headers); err != nil {
headers = nil
}
}
for k, v := range headers {
if isHopByHop(k) {
continue
}
req.Header.Set(k, v)
}
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if !allowPrivateReplay() && isPrivateAddr(req.URL.Host) {
return fmt.Errorf("redirect to private address %s denied", req.URL.Host)
}
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxReplayBodyBytes))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
respHeaders := make(map[string]string, len(resp.Header))
for k, vs := range resp.Header {
respHeaders[k] = strings.Join(vs, ", ")
}
return &replayResponse{
Status: resp.StatusCode,
Headers: respHeaders,
Body: string(body),
}, nil
}
// isPrivateAddr reports whether addr falls within a private, loopback, or reserved range.
func isPrivateAddr(hostport string) bool {
host, _, err := net.SplitHostPort(hostport)
if err != nil {
host = hostport
}
if host == "" {
return false
}
// String-based checks first (catch names and the unspecified address 0.0.0.0).
if host == "localhost" || host == "0.0.0.0" || host == "::1" || host == "[::1]" {
return true
}
ip := net.ParseIP(host)
if ip != nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}
return false
}
func allowPrivateReplay() bool {
return strings.EqualFold(os.Getenv("HATCH_ALLOW_PRIVATE_REPLAY"), "true")
}
var hopByHopHeaders = map[string]bool{
"connection": true,
"keep-alive": true,
"proxy-authenticate": true,
"proxy-authorization": true,
"te": true,
"trailer": true,
"transfer-encoding": true,
"upgrade": true,
}
func isHopByHop(header string) bool {
return hopByHopHeaders[strings.ToLower(header)]
}
func joinPath(base, extra string) string {
base = strings.TrimRight(base, "/")
extra = strings.TrimLeft(extra, "/")
if base == "" {
return "/" + extra
}
if extra == "" {
return base
}
return base + "/" + extra
}

View File

@ -0,0 +1,237 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
)
func TestReplayMissingTargetURL(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
reqID := repo.Requests[0].ID
r := testRouter(repo)
body := `{}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if !strings.Contains(resp["error"], "target_url") {
t.Errorf("expected target_url error, got %v", resp)
}
}
func TestReplayInvalidScheme(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.Requests[0].ID
r := testRouter(repo)
body := `{"target_url": "ftp://bad.scheme/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestReplaySSRFBlocksLocalhost(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.Requests[0].ID
r := testRouter(repo)
body := `{"target_url": "http://localhost:8080/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for localhost, got %d", w.Code)
}
}
func TestReplaySSRFBlocksPrivate(t *testing.T) {
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.Requests[0].ID
r := testRouter(repo)
for _, addr := range []string{
"http://10.0.0.1:80/",
"http://172.16.0.1:80/",
"http://192.168.1.1:80/",
"http://127.0.0.1:80/",
"http://[::1]:80/",
"http://0.0.0.0:80/",
} {
body := `{"target_url": "` + addr + `"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for %s, got %d", addr, w.Code)
}
}
}
func TestReplayNotFound(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := `{"target_url": "https://example.com/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestReplaySuccess(t *testing.T) {
// Start a local sink server that echoes back what it receives.
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Sink", "yes")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"received": true}`))
}))
defer sink.Close()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{
Method: "POST",
Path: "/webhook",
Headers: `{"Content-Type":"application/json","X-Custom":"val"}`,
Query: "foo=bar",
Body: []byte(`{"msg":"hello"}`),
})
reqID := repo.Requests[0].ID
// Set env to allow private replay since httptest server is on loopback.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
r := testRouter(repo)
body := `{"target_url": "` + sink.URL + `"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp replayResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != 201 {
t.Errorf("expected status 201, got %d", resp.Status)
}
if resp.Headers["X-Sink"] != "yes" {
t.Errorf("expected X-Sink header, got %v", resp.Headers)
}
if resp.Body != `{"received": true}` {
t.Errorf("expected body, got %q", resp.Body)
}
}
func TestReplayE2ECaptureThenReplay(t *testing.T) {
// E2E: capture a request, then replay it to a sink, verify the sink received it.
// Step 1: Start a sink.
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"body": "received",
})
}))
defer sink.Close()
// Step 2: Capture a request.
repo := testutil.NewFakeRepository()
rt := testRouter(repo)
captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
captureReq.Header.Set("X-Test", "hello")
captureW := httptest.NewRecorder()
rt.ServeHTTP(captureW, captureReq)
if len(repo.Requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.Requests))
}
captured := repo.Requests[0]
if captured.Method != "POST" {
t.Errorf("captured method: %s", captured.Method)
}
// Step 3: Replay it to the sink.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
replayBody := `{"target_url": "` + sink.URL + `"}`
replayReq := httptest.NewRequest("POST", "/e/e2e-test/requests/"+captured.ID+"/replay", strings.NewReader(replayBody))
replayReq.Header.Set("Content-Type", "application/json")
replayW := httptest.NewRecorder()
rt.ServeHTTP(replayW, replayReq)
if replayW.Code != http.StatusOK {
t.Fatalf("replay failed with %d: %s", replayW.Code, replayW.Body.String())
}
var resp replayResponse
json.NewDecoder(replayW.Body).Decode(&resp)
if resp.Status != 200 {
t.Errorf("replay response status: %d", resp.Status)
}
}
func TestIsPrivateAddr(t *testing.T) {
tests := []struct {
addr string
want bool
}{
{"localhost:8080", true},
{"127.0.0.1:9090", true},
{"[::1]:80", true},
{"10.0.0.5:443", true},
{"172.16.0.1:80", true},
{"192.168.1.1:3000", true},
{"0.0.0.0:8080", true},
{"example.com:443", false},
{"93.184.216.34:80", false},
{"8.8.8.8:53", false},
}
for _, tc := range tests {
got := isPrivateAddr(tc.addr)
if got != tc.want {
t.Errorf("isPrivateAddr(%q) = %v, want %v", tc.addr, tc.want, got)
}
}
}

125
internal/handler/sse.go Normal file
View File

@ -0,0 +1,125 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"github.com/elfoundation/hatch/internal/store"
)
// sseHub manages SSE subscribers per endpoint.
type sseHub struct {
mu sync.RWMutex
subs map[string]map[chan []byte]struct{}
}
var hub = &sseHub{
subs: make(map[string]map[chan []byte]struct{}),
}
func (h *sseHub) subscribe(endpointID string) chan []byte {
ch := make(chan []byte, 64)
h.mu.Lock()
defer h.mu.Unlock()
if h.subs[endpointID] == nil {
h.subs[endpointID] = make(map[chan []byte]struct{})
}
h.subs[endpointID][ch] = struct{}{}
return ch
}
func (h *sseHub) unsubscribe(endpointID string, ch chan []byte) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.subs[endpointID], ch)
if len(h.subs[endpointID]) == 0 {
delete(h.subs, endpointID)
}
}
// sseRequest is the JSON payload sent over SSE.
// It uses string for Body to avoid base64 encoding.
type sseRequest struct {
ID string `json:"id"`
EndpointID string `json:"endpoint_id"`
Method string `json:"method"`
Path string `json:"path"`
Headers string `json:"headers"`
Query string `json:"query"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
func (h *sseHub) broadcast(endpointID string, req *store.Request) {
h.mu.RLock()
defer h.mu.RUnlock()
subs := h.subs[endpointID]
if subs == nil {
return
}
sseReq := sseRequest{
ID: req.ID,
EndpointID: req.EndpointID,
Method: req.Method,
Path: req.Path,
Headers: req.Headers,
Query: req.Query,
Body: string(req.Body),
CreatedAt: req.CreatedAt,
}
data, err := json.Marshal(sseReq)
if err != nil {
return
}
for ch := range subs {
select {
case ch <- data:
default:
}
}
}
// broadcastRequest publishes req to SSE subscribers for its endpoint.
func broadcastRequest(endpointID string, req *store.Request) {
hub.broadcast(endpointID, req)
}
// HandleSSE serves an SSE stream for GET /e/{endpointID}/events.
func HandleSSE(repo store.Repository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
endpointID := r.PathValue("endpointID")
if endpointID == "" {
http.Error(w, "missing endpoint ID", http.StatusBadRequest)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := hub.subscribe(endpointID)
defer hub.unsubscribe(endpointID, ch)
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case data, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
}

47
internal/store/db.go Normal file
View File

@ -0,0 +1,47 @@
package store
import (
"database/sql"
_ "embed"
"fmt"
_ "modernc.org/sqlite"
"os"
"path/filepath"
)
//go:embed schema.sql
var schemaSQL string
func Open(dbPath string) (Repository, error) {
if dbPath == "" {
dbPath = filepath.Join("data", "hatch.db")
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("store: create db dir %s: %w", dir, err)
}
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("store: open %s: %w", dbPath, err)
}
// SQLite supports only one writer at a time. Using MaxOpenConns(1)
// prevents "database is locked" errors under concurrent writes.
// For read-heavy workloads with WAL mode, this can be increased,
// but the single-writer constraint remains.
conn.SetMaxOpenConns(1)
conn.SetMaxIdleConns(1)
conn.SetConnMaxLifetime(0) // No limit; SQLite connections are lightweight.
if err := migrate(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("store: migrate: %w", err)
}
return NewSQLiteRepo(conn)
}
func migrate(db *sql.DB) error {
_, err := db.Exec(schemaSQL)
if err != nil {
return fmt.Errorf("store/migrate: %w", err)
}
return nil
}

2
internal/store/doc.go Normal file
View File

@ -0,0 +1,2 @@
// Package store provides the SQLite-backed persistence layer for Hatch.
package store

44
internal/store/models.go Normal file
View File

@ -0,0 +1,44 @@
package store
import "context"
type Endpoint struct {
ID string `json:"id"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Request struct {
ID string `json:"id"`
EndpointID string `json:"endpoint_id"`
Method string `json:"method"`
Path string `json:"path"`
Headers string `json:"headers"`
Query string `json:"query"`
Body []byte `json:"body"`
CreatedAt string `json:"created_at"`
}
// MockConfig holds the mock response configuration for an endpoint.
type MockConfig struct {
EndpointID string `json:"endpoint_id"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body"`
}
type Repository interface {
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
AppendRequest(ctx context.Context, endpointID string, req *Request) error
GetRequest(ctx context.Context, id string) (*Request, error)
ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error)
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
SetMock(ctx context.Context, mock *MockConfig) error
Close() error
Ping(ctx context.Context) error
}
var _ Repository = (*sqliteRepo)(nil)

21
internal/store/schema.sql Normal file
View File

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS endpoints (
id TEXT NOT NULL PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
mock_status INTEGER,
mock_headers TEXT,
mock_body BLOB
);
CREATE TABLE IF NOT EXISTS requests (
id TEXT NOT NULL PRIMARY KEY,
endpoint_id TEXT NOT NULL REFERENCES endpoints(id),
method TEXT NOT NULL,
path TEXT NOT NULL,
headers TEXT NOT NULL DEFAULT '{}',
query TEXT NOT NULL DEFAULT '',
body BLOB,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_requests_endpoint_id ON requests(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at);

View File

@ -0,0 +1,146 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
type sqliteRepo struct{ db *sql.DB }
func NewSQLiteRepo(db *sql.DB) (Repository, error) {
if db == nil {
return nil, fmt.Errorf("store: nil db")
}
return &sqliteRepo{db: db}, nil
}
func (r *sqliteRepo) CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) {
now := utcNow()
e := &Endpoint{ID: url, URL: url, CreatedAt: now, UpdatedAt: now}
_, err := r.db.ExecContext(ctx, `INSERT INTO endpoints (id, url, created_at, updated_at) VALUES (?, ?, ?, ?)`, e.ID, e.URL, e.CreatedAt, e.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("store: create endpoint: %w", err)
}
return e, nil
}
func (r *sqliteRepo) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) {
row := r.db.QueryRowContext(ctx, `SELECT id, url, created_at, updated_at FROM endpoints WHERE id = ?`, id)
e := &Endpoint{}
if err := row.Scan(&e.ID, &e.URL, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: get endpoint: %w", err)
}
return e, nil
}
func (r *sqliteRepo) AppendRequest(ctx context.Context, endpointID string, req *Request) error {
req.ID = uuid.NewString()
req.EndpointID = endpointID
req.CreatedAt = utcNow()
_, err := r.db.ExecContext(ctx, `INSERT INTO requests (id, endpoint_id, method, path, headers, query, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
req.ID, req.EndpointID, req.Method, req.Path, req.Headers, req.Query, req.Body, req.CreatedAt)
if err != nil {
return fmt.Errorf("store: append request: %w", err)
}
return nil
}
func (r *sqliteRepo) GetRequest(ctx context.Context, id string) (*Request, error) {
row := r.db.QueryRowContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE id = ?`, id)
req := &Request{}
if err := row.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil {
return nil, fmt.Errorf("store: get request %s: %w", id, err)
}
return req, nil
}
func (r *sqliteRepo) ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) {
if limit <= 0 {
limit = 50
}
rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?`, endpointID, limit)
if err != nil {
return nil, fmt.Errorf("store: list requests: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
var req Request
if err := rows.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan request: %w", err)
}
out = append(out, &req)
}
return out, rows.Err()
}
func (r *sqliteRepo) GetMock(ctx context.Context, endpointID string) (*MockConfig, error) {
row := r.db.QueryRowContext(ctx, `SELECT mock_status, mock_headers, mock_body FROM endpoints WHERE id = ?`, endpointID)
var status sql.NullInt64
var headersJSON sql.NullString
var body []byte
if err := row.Scan(&status, &headersJSON, &body); err != nil {
return nil, fmt.Errorf("store: get mock: %w", err)
}
if !status.Valid {
return nil, fmt.Errorf("store: no mock configured for %s", endpointID)
}
m := &MockConfig{
EndpointID: endpointID,
Status: int(status.Int64),
Body: body,
}
if headersJSON.Valid && headersJSON.String != "" {
json.Unmarshal([]byte(headersJSON.String), &m.Headers)
}
return m, nil
}
func (r *sqliteRepo) SetMock(ctx context.Context, mock *MockConfig) error {
headersJSON, err := json.Marshal(mock.Headers)
if err != nil {
return fmt.Errorf("store: marshal mock headers: %w", err)
}
_, err = r.db.ExecContext(ctx,
`UPDATE endpoints SET mock_status = ?, mock_headers = ?, mock_body = ? WHERE id = ?`,
mock.Status, string(headersJSON), mock.Body, mock.EndpointID,
)
if err != nil {
return fmt.Errorf("store: set mock: %w", err)
}
return nil
}
func (r *sqliteRepo) SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error) {
if limit <= 0 {
limit = 50
}
// Simple LIKE search across text fields. Convert body to text.
rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? AND (method LIKE ? OR path LIKE ? OR headers LIKE ? OR query LIKE ? OR CAST(body AS TEXT) LIKE ?) ORDER BY created_at DESC LIMIT ?`, endpointID, "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%", limit)
if err != nil {
return nil, fmt.Errorf("store: search requests: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
var req Request
if err := rows.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan request: %w", err)
}
out = append(out, &req)
}
return out, rows.Err()
}
func (r *sqliteRepo) Close() error { return r.db.Close() }
func (r *sqliteRepo) Ping(ctx context.Context) error {
return r.db.PingContext(ctx)
}
func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }

View File

@ -0,0 +1,315 @@
package store
import (
"context"
"database/sql"
"testing"
"time"
)
func openTestRepo(t *testing.T) Repository {
t.Helper()
db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
db.SetMaxOpenConns(1)
if err := migrate(db); err != nil {
db.Close()
t.Fatalf("migrate: %v", err)
}
repo, err := NewSQLiteRepo(db)
if err != nil {
db.Close()
t.Fatalf("new sqlite repo: %v", err)
}
t.Cleanup(func() { repo.Close() })
return repo
}
func TestCreateAndGetEndpoint(t *testing.T) {
repo := openTestRepo(t)
e, err := repo.CreateEndpoint(context.Background(), "test-one")
if err != nil {
t.Fatalf("CreateEndpoint: %v", err)
}
if e.ID == "" {
t.Error("expected non-empty ID")
}
if e.URL != "test-one" {
t.Errorf("url: got %q", e.URL)
}
if e.CreatedAt == "" || e.UpdatedAt == "" {
t.Error("expected timestamps")
}
got, err := repo.GetEndpoint(context.Background(), e.ID)
if err != nil {
t.Fatalf("GetEndpoint: %v", err)
}
if got.ID != e.ID || got.URL != e.URL {
t.Errorf("got %+v", got)
}
}
func TestGetEndpointNotFound(t *testing.T) {
repo := openTestRepo(t)
_, err := repo.GetEndpoint(context.Background(), "nonexistent")
if err == nil {
t.Fatal("expected error")
}
}
func TestCreateEndpointDuplicateURL(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
if _, err := repo.CreateEndpoint(ctx, "dup"); err != nil {
t.Fatal(err)
}
if _, err := repo.CreateEndpoint(ctx, "dup"); err == nil {
t.Fatal("expected UNIQUE error")
}
}
func TestAppendAndListRequests(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, err := repo.CreateEndpoint(ctx, "reqs-test")
if err != nil {
t.Fatal(err)
}
r1 := &Request{Method: "POST", Path: "/webhook", Headers: `{"Content-Type":"application/json"}`, Query: "foo=bar", Body: []byte(`{"hello":"world"}`)}
if err := repo.AppendRequest(ctx, e.ID, r1); err != nil {
t.Fatal(err)
}
time.Sleep(time.Millisecond)
r2 := &Request{Method: "GET", Path: "/webhook", Headers: `{}`}
if err := repo.AppendRequest(ctx, e.ID, r2); err != nil {
t.Fatal(err)
}
reqs, err := repo.ListRequests(ctx, e.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 2 {
t.Fatalf("got %d requests", len(reqs))
}
if reqs[0].Method != "GET" {
t.Errorf("first: got %s", reqs[0].Method)
}
if reqs[1].Method != "POST" {
t.Errorf("second: got %s", reqs[1].Method)
}
}
func TestGetRequest(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, _ := repo.CreateEndpoint(ctx, "getreq-test")
r := &Request{Method: "POST", Path: "/webhook", Headers: `{"X-Api-Key":"secret"}`, Query: "a=1", Body: []byte(`{"msg":"hi"}`)}
if err := repo.AppendRequest(ctx, e.ID, r); err != nil {
t.Fatal(err)
}
got, err := repo.GetRequest(ctx, r.ID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if got.ID != r.ID {
t.Errorf("id: got %q want %q", got.ID, r.ID)
}
if got.Method != "POST" {
t.Errorf("method: got %q", got.Method)
}
if string(got.Body) != `{"msg":"hi"}` {
t.Errorf("body: got %q", string(got.Body))
}
}
func TestGetRequestNotFound(t *testing.T) {
repo := openTestRepo(t)
_, err := repo.GetRequest(context.Background(), "nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent request")
}
}
func TestSetAndGetMock(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, err := repo.CreateEndpoint(ctx, "mock-test")
if err != nil {
t.Fatal(err)
}
mock := &MockConfig{
EndpointID: e.ID,
Status: 201,
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(`{"mocked": true}`),
}
if err := repo.SetMock(ctx, mock); err != nil {
t.Fatal(err)
}
got, err := repo.GetMock(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != 201 {
t.Errorf("status: got %d", got.Status)
}
if string(got.Body) != `{"mocked": true}` {
t.Errorf("body: got %q", string(got.Body))
}
}
func TestGetMockNotFound(t *testing.T) {
repo := openTestRepo(t)
_, err := repo.GetMock(context.Background(), "no-mock")
if err == nil {
t.Fatal("expected error")
}
}
func TestListRequestsLimit(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, _ := repo.CreateEndpoint(ctx, "limit-test")
for i := 0; i < 5; i++ {
repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/"})
}
reqs, err := repo.ListRequests(ctx, e.ID, 3)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 3 {
t.Fatalf("got %d", len(reqs))
}
}
func TestListRequestsEmpty(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, _ := repo.CreateEndpoint(ctx, "empty-test")
reqs, _ := repo.ListRequests(ctx, e.ID, 10)
if len(reqs) != 0 {
t.Errorf("got %d", len(reqs))
}
}
func TestAppendRequestBodyBinary(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, _ := repo.CreateEndpoint(ctx, "binary-test")
body := []byte{0x00, 0x01, 0x02, 0xFF}
if err := repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/binary", Body: body}); err != nil {
t.Fatal(err)
}
reqs, _ := repo.ListRequests(ctx, e.ID, 1)
if len(reqs) != 1 {
t.Fatal("expected 1 request")
}
if len(reqs[0].Body) != 4 {
t.Errorf("body len: %d", len(reqs[0].Body))
}
}
func TestMigrateIdempotent(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if err := migrate(db); err != nil {
t.Fatal(err)
}
if err := migrate(db); err != nil {
t.Fatal(err)
}
var name string
if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='endpoints'").Scan(&name); err != nil {
t.Fatalf("endpoints table: %v", err)
}
if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='requests'").Scan(&name); err != nil {
t.Fatalf("requests table: %v", err)
}
}
func TestSearchRequests(t *testing.T) {
repo := openTestRepo(t)
ctx := context.Background()
e, _ := repo.CreateEndpoint(ctx, "search-test")
// Add requests with different content.
repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`})
repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)})
repo.AppendRequest(ctx, e.ID, &Request{Method: "DELETE", Path: "/users/123"})
repo.AppendRequest(ctx, e.ID, &Request{Method: "PUT", Path: "/products", Query: "category=electronics"})
// Search for "users".
reqs, err := repo.SearchRequests(ctx, e.ID, "users", 10)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 2 {
t.Errorf("expected 2 requests matching 'users', got %d", len(reqs))
}
// Search for "apple" (in body).
reqs, err = repo.SearchRequests(ctx, e.ID, "apple", 10)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 1 {
t.Errorf("expected 1 request matching 'apple', got %d", len(reqs))
}
// Search for "electronics" (in query).
reqs, err = repo.SearchRequests(ctx, e.ID, "electronics", 10)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 1 {
t.Errorf("expected 1 request matching 'electronics', got %d", len(reqs))
}
// Empty query returns all.
reqs, err = repo.SearchRequests(ctx, e.ID, "", 10)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 4 {
t.Errorf("expected 4 requests with empty query, got %d", len(reqs))
}
// Search with limit.
reqs, err = repo.SearchRequests(ctx, e.ID, "users", 1)
if err != nil {
t.Fatal(err)
}
if len(reqs) != 1 {
t.Errorf("expected 1 request with limit=1, got %d", len(reqs))
}
}
func TestOpenInMemory(t *testing.T) {
repo, err := Open(":memory:")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer repo.Close()
// Verify the repo works.
e, err := repo.CreateEndpoint(context.Background(), "test")
if err != nil {
t.Fatalf("CreateEndpoint: %v", err)
}
if e.ID != "test" {
t.Errorf("expected ID 'test', got %q", e.ID)
}
}
func TestNewSQLiteRepoNilDB(t *testing.T) {
_, err := NewSQLiteRepo(nil)
if err == nil {
t.Fatal("expected error for nil db")
}
}

View File

@ -0,0 +1,100 @@
package testutil
import (
"context"
"strings"
"github.com/elfoundation/hatch/internal/store"
)
// FakeRepository implements store.Repository for tests.
type FakeRepository struct {
Endpoints map[string]*store.Endpoint
Requests []*store.Request
Mocks map[string]*store.MockConfig
}
// NewFakeRepository creates a new FakeRepository.
func NewFakeRepository() *FakeRepository {
return &FakeRepository{
Endpoints: map[string]*store.Endpoint{},
Mocks: map[string]*store.MockConfig{},
}
}
func (f *FakeRepository) CreateEndpoint(_ context.Context, url string) (*store.Endpoint, error) {
e := &store.Endpoint{ID: url, URL: url, CreatedAt: "t", UpdatedAt: "t"}
f.Endpoints[url] = e
return e, nil
}
func (f *FakeRepository) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
e, ok := f.Endpoints[id]
if !ok {
return nil, errNotFound
}
return e, nil
}
func (f *FakeRepository) AppendRequest(_ context.Context, eid string, r *store.Request) error {
r.ID = "req-" + string(rune(len(f.Requests)+'0'))
r.EndpointID = eid
f.Requests = append(f.Requests, r)
return nil
}
func (f *FakeRepository) GetRequest(_ context.Context, id string) (*store.Request, error) {
for _, r := range f.Requests {
if r.ID == id {
return r, nil
}
}
return nil, errNotFound
}
func (f *FakeRepository) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
return f.Requests, nil
}
func (f *FakeRepository) SearchRequests(_ context.Context, _ string, query string, limit int) ([]*store.Request, error) {
if query == "" {
return f.Requests, nil
}
var matched []*store.Request
for _, r := range f.Requests {
if strings.Contains(r.Method, query) ||
strings.Contains(r.Path, query) ||
strings.Contains(r.Headers, query) ||
strings.Contains(r.Query, query) ||
strings.Contains(string(r.Body), query) {
matched = append(matched, r)
if limit > 0 && len(matched) >= limit {
break
}
}
}
return matched, nil
}
func (f *FakeRepository) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
m, ok := f.Mocks[endpointID]
if !ok {
return nil, errNotFound
}
return m, nil
}
func (f *FakeRepository) SetMock(_ context.Context, mock *store.MockConfig) error {
f.Mocks[mock.EndpointID] = mock
return nil
}
func (f *FakeRepository) Close() error { return nil }
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" }
var errNotFound = &notFoundError{}

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
1:"$Sreact.fragment"
a:I[97367,["/_next/static/chunks/158myu8e_yme3.js"],"OutletBoundary"]
b:"$Sreact.suspense"
0:{"rsc":["$","$1","c",{"children":[[["$","section",null,{"className":"pt-32 pb-20 px-6","children":["$","div",null,{"className":"max-w-4xl mx-auto text-center","children":[["$","div",null,{"className":"inline-block mb-6 px-5 py-2 rounded-full bg-blue-500/10 border border-blue-500/30 text-blue-400 text-sm font-medium","children":"Open source · MIT licensed"}],["$","h1",null,{"className":"text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight mb-6 leading-tight bg-gradient-to-r from-white to-zinc-400 bg-clip-text text-transparent","children":["Self-hostable HTTP request",["$","br",null,{}],"inspector + mocker"]}],["$","p",null,{"className":"text-lg md:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto leading-relaxed","children":["One Go binary. SQLite under the hood.",["$","code",null,{"className":"mx-1 px-2 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-fuchsia-400 text-base","children":"docker compose up"}],", and you have an inspection endpoint and a live feed in under 30 seconds."]}],["$","div",null,{"className":"flex flex-col sm:flex-row gap-4 justify-center mb-16","children":[["$","a",null,{"href":"https://github.com/elfoundation/hatch","className":"inline-flex items-center justify-center gap-2 px-8 py-4 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-lg transition-all shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5","children":[["$","svg",null,{"width":"20","height":"20","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"2","strokeLinecap":"round","strokeLinejoin":"round","children":["$","path",null,{"d":"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"}]}],"View on GitHub"]}],["$","a",null,{"href":"https://github.com/elfoundation/hatch#quick-start","className":"inline-flex items-center justify-center gap-2 px-8 py-4 bg-zinc-800 hover:bg-zinc-700 text-white font-semibold rounded-lg border border-zinc-700 transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5","children":"Quick Start →"}]]}],["$","div",null,{"className":"max-w-2xl mx-auto rounded-xl overflow-hidden border border-zinc-800 bg-zinc-900/50 backdrop-blur-sm shadow-2xl","children":[["$","div",null,{"className":"flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-zinc-800/50 to-zinc-800/30 border-b border-zinc-800","children":[["$","div",null,{"className":"w-3 h-3 rounded-full bg-zinc-600"}],["$","div",null,{"className":"w-3 h-3 rounded-full bg-zinc-600"}],["$","div",null,{"className":"w-3 h-3 rounded-full bg-zinc-600"}],["$","span",null,{"className":"ml-2 text-sm text-zinc-500","children":"terminal"}]]}],["$","pre",null,{"className":"p-5 text-left text-sm font-mono overflow-x-auto","children":["$","code",null,{"children":[["$","span",null,{"className":"text-blue-400 font-semibold","children":"$$"}]," ",["$","span",null,{"className":"text-zinc-300","children":"docker compose up -d"}],"\n",["$","span",null,{"className":"text-emerald-400","children":"✓ hatch started on :8080"}],"\n",["$","span",null,{"className":"text-blue-400 font-semibold","children":"$$"}]," ",["$","span",null,{"className":"text-zinc-300","children":["curl -X POST https://your-bin.hatch.surf/test -d ","{'hello':'world'}"]}],"\n",["$","span",null,{"className":"text-emerald-400","children":"✓ captured — view at https://your-bin.hatch.surf/inspect"}]]}]}]]}]]}]}],["$","section",null,{"className":"py-20 px-6","children":["$","div",null,{"className":"max-w-4xl mx-auto","children":[["$","h2",null,{"className":"text-3xl md:text-4xl font-bold text-center mb-16","children":"Three things, and nothing else"}],["$","div",null,{"className":"grid md:grid-cols-3 gap-8","children":[["$","div","Capture",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 hover:border-blue-500/40 hover:shadow-blue-500/10 transition-all hover:-translate-y-1 shadow-lg hover:shadow-xl","children":["$L2","$L3","$L4"]}],"$L5","$L6"]}]]}]}],"$L7","$L8"],null,"$L9"]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"W0ph6BZ8oDeJaHcE9fuII"}
2:["$","div",null,{"className":"w-14 h-14 flex items-center justify-center rounded-xl bg-blue-500/15 text-blue-400 mb-4 shadow-lg","children":["$","svg",null,{"width":"28","height":"28","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"1.5","strokeLinecap":"round","strokeLinejoin":"round","children":[["$","circle",null,{"cx":"12","cy":"12","r":"10"}],["$","polyline",null,{"points":"12 6 12 12 16 14"}]]}]}]
3:["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Capture"}]
4:["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"Method, path, headers, query, body. Persists across restarts because the storage is SQLite on disk, not a hosted queue."}]
5:["$","div","Inspect",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 hover:border-purple-500/40 hover:shadow-purple-500/10 transition-all hover:-translate-y-1 shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"w-14 h-14 flex items-center justify-center rounded-xl bg-purple-500/15 text-purple-400 mb-4 shadow-lg","children":["$","svg",null,{"width":"28","height":"28","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"1.5","strokeLinecap":"round","strokeLinejoin":"round","children":[["$","path",null,{"d":"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"}],["$","circle",null,{"cx":"12","cy":"12","r":"3"}]]}]}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Inspect"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"A live SSE feed of incoming requests. Click any captured request to see the headers, the body, the timing."}]]}]
6:["$","div","Mock",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 hover:border-cyan-500/40 hover:shadow-cyan-500/10 transition-all hover:-translate-y-1 shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"w-14 h-14 flex items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-400 mb-4 shadow-lg","children":["$","svg",null,{"width":"28","height":"28","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"1.5","strokeLinecap":"round","strokeLinejoin":"round","children":[["$","polyline",null,{"points":"16 18 22 12 16 6"}],["$","polyline",null,{"points":"8 6 2 12 8 18"}]]}]}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Mock"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"Return a 200, a 500, or a custom JSON payload. For testing your own retry, backoff, and error-handling logic."}]]}]
7:["$","section",null,{"className":"py-20 px-6","children":["$","div",null,{"className":"max-w-4xl mx-auto","children":[["$","h2",null,{"className":"text-3xl md:text-4xl font-bold text-center mb-16","children":"Why a single binary, not a SaaS"}],["$","div",null,{"className":"grid md:grid-cols-3 gap-6","children":[["$","div","01",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 border-l-4 border-l-blue-500 hover:-translate-y-1 transition-all shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"text-4xl font-bold text-zinc-600 font-mono mb-4","children":"01"}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Compliance and privacy"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"Some teams cannot legally send webhook payloads to a hosted SaaS. Hatch keeps the data on your own network."}]]}],["$","div","02",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 border-l-4 border-l-purple-500 hover:-translate-y-1 transition-all shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"text-4xl font-bold text-zinc-600 font-mono mb-4","children":"02"}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Cost"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"A hosted inspector charges per request, per seat, or per retention day. Hatch is one Go binary on a $5 VPS. There is no per-request fee because there is no one to charge it."}]]}],["$","div","03",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 border-l-4 border-l-cyan-500 hover:-translate-y-1 transition-all shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"text-4xl font-bold text-zinc-600 font-mono mb-4","children":"03"}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Speed of setup"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":[["$","code",null,{"className":"px-2 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-fuchsia-400 text-sm","children":"docker compose up"}]," ","is faster than signing up for a SaaS, verifying your email, configuring your first bin, and pasting the URL into your webhook config."]}]]}]]}]]}]}]
8:["$","section",null,{"className":"py-24 px-6 bg-gradient-to-b from-zinc-900/30 to-zinc-900/60","children":["$","div",null,{"className":"max-w-4xl mx-auto text-center","children":[["$","h2",null,{"className":"text-4xl md:text-5xl font-extrabold mb-4 bg-gradient-to-r from-white to-blue-400 bg-clip-text text-transparent","children":"Start inspecting in 30 seconds"}],["$","p",null,{"className":"text-xl text-zinc-400 mb-10","children":"One binary. Your data stays yours."}],["$","a",null,{"href":"https://github.com/elfoundation/hatch","className":"inline-flex items-center justify-center gap-2 px-10 py-5 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-lg transition-all shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5 text-lg","children":"Get Hatch →"}]]}]}]
9:["$","$La",null,{"children":["$","$b",null,{"name":"Next.MetadataOutlet","children":"$@c"}]}]
c:null

View File

@ -0,0 +1,26 @@
1:"$Sreact.fragment"
2:I[39756,["/_next/static/chunks/158myu8e_yme3.js"],"default"]
3:I[37457,["/_next/static/chunks/158myu8e_yme3.js"],"default"]
d:I[68027,["/_next/static/chunks/158myu8e_yme3.js"],"default",1]
:HL["/_next/static/chunks/40hwf0y1goj6_.css","style"]
:HL["/_next/static/media/70bc3e132a0a741e-s.p.3t6q91iet4nsy.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/40hwf0y1goj6_.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/158myu8e_yme3.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"inter_5901b7c6-module__ec5Qua__variable jetbrains_mono_d5591ac2-module__D88TVW__variable","children":["$","body",null,{"className":"min-h-screen bg-zinc-950 text-white font-sans antialiased","children":[["$","a",null,{"href":"#main-content","className":"sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-500 focus:text-white focus:rounded-lg","children":"Skip to content"}],["$","header",null,{"className":"fixed top-0 left-0 right-0 z-50 py-4 bg-zinc-950/80 backdrop-blur-lg border-b border-zinc-800","children":["$","div",null,{"className":"max-w-4xl mx-auto px-6 flex justify-between items-center","children":[["$","a",null,{"href":"/","className":"text-xl font-bold text-white flex items-center gap-2 hover:text-white transition-colors","children":[["$","span",null,{"className":"text-blue-400","children":"⬡"}]," Hatch"]}],["$","nav",null,{"className":"flex items-center gap-6","children":[["$","a",null,{"href":"https://github.com/elfoundation/hatch","className":"text-zinc-400 hover:text-white transition-colors","children":"GitHub"}],["$","a",null,{"href":"/blog/","className":"text-zinc-400 hover:text-white transition-colors","children":"Blog"}]]}]]}]}],["$","main",null,{"id":"main-content","role":"main","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}],["$","footer",null,{"className":"py-8 px-6 border-t border-zinc-800","children":["$","div",null,{"className":"max-w-4xl mx-auto","children":["$","div",null,{"className":"flex flex-col md:flex-row justify-between items-center gap-4","children":[["$","span",null,{"className":"text-white font-bold flex items-center gap-2","children":[["$","span",null,{"className":"text-blue-400","children":"⬡"}]," Hatch"]}],["$","p",null,{"className":"text-zinc-500 text-sm text-center md:text-right","children":["© 2026 El Foundation."," ",["$","a",null,{"href":"https://github.com/elfoundation/hatch","className":"text-zinc-400 hover:text-white transition-colors","children":"Hatch"}]," ","is released under the"," ",["$","a",null,{"href":"https://github.com/elfoundation/hatch/blob/main/LICENSE","className":"text-zinc-400 hover:text-white transition-colors","children":"MIT License"}],"."]}]]}]}]}]]}]}]]}],{"children":[["$","$1","c",{"children":[[["$","section",null,{"className":"pt-32 pb-20 px-6","children":["$","div",null,{"className":"max-w-4xl mx-auto text-center","children":[["$","div",null,{"className":"inline-block mb-6 px-5 py-2 rounded-full bg-blue-500/10 border border-blue-500/30 text-blue-400 text-sm font-medium","children":"Open source · MIT licensed"}],["$","h1",null,{"className":"text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight mb-6 leading-tight bg-gradient-to-r from-white to-zinc-400 bg-clip-text text-transparent","children":["Self-hostable HTTP request","$L4","inspector + mocker"]}],"$L5","$L6","$L7"]}]}],"$L8","$L9","$La"],null,"$Lb"]}],{},null,false,null]},null,false,null],"$Lc",false]],"m":"$undefined","G":["$d",["$Le"]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"W0ph6BZ8oDeJaHcE9fuII"}
f:I[97367,["/_next/static/chunks/158myu8e_yme3.js"],"OutletBoundary"]
10:"$Sreact.suspense"
12:I[97367,["/_next/static/chunks/158myu8e_yme3.js"],"ViewportBoundary"]
14:I[97367,["/_next/static/chunks/158myu8e_yme3.js"],"MetadataBoundary"]
4:["$","br",null,{}]
5:["$","p",null,{"className":"text-lg md:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto leading-relaxed","children":["One Go binary. SQLite under the hood.",["$","code",null,{"className":"mx-1 px-2 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-fuchsia-400 text-base","children":"docker compose up"}],", and you have an inspection endpoint and a live feed in under 30 seconds."]}]
6:["$","div",null,{"className":"flex flex-col sm:flex-row gap-4 justify-center mb-16","children":[["$","a",null,{"href":"https://github.com/elfoundation/hatch","className":"inline-flex items-center justify-center gap-2 px-8 py-4 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-lg transition-all shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5","children":[["$","svg",null,{"width":"20","height":"20","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"2","strokeLinecap":"round","strokeLinejoin":"round","children":["$","path",null,{"d":"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"}]}],"View on GitHub"]}],["$","a",null,{"href":"https://github.com/elfoundation/hatch#quick-start","className":"inline-flex items-center justify-center gap-2 px-8 py-4 bg-zinc-800 hover:bg-zinc-700 text-white font-semibold rounded-lg border border-zinc-700 transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5","children":"Quick Start →"}]]}]
7:["$","div",null,{"className":"max-w-2xl mx-auto rounded-xl overflow-hidden border border-zinc-800 bg-zinc-900/50 backdrop-blur-sm shadow-2xl","children":[["$","div",null,{"className":"flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-zinc-800/50 to-zinc-800/30 border-b border-zinc-800","children":[["$","div",null,{"className":"w-3 h-3 rounded-full bg-zinc-600"}],["$","div",null,{"className":"w-3 h-3 rounded-full bg-zinc-600"}],["$","div",null,{"className":"w-3 h-3 rounded-full bg-zinc-600"}],["$","span",null,{"className":"ml-2 text-sm text-zinc-500","children":"terminal"}]]}],["$","pre",null,{"className":"p-5 text-left text-sm font-mono overflow-x-auto","children":["$","code",null,{"children":[["$","span",null,{"className":"text-blue-400 font-semibold","children":"$$"}]," ",["$","span",null,{"className":"text-zinc-300","children":"docker compose up -d"}],"\n",["$","span",null,{"className":"text-emerald-400","children":"✓ hatch started on :8080"}],"\n",["$","span",null,{"className":"text-blue-400 font-semibold","children":"$$"}]," ",["$","span",null,{"className":"text-zinc-300","children":["curl -X POST https://your-bin.hatch.surf/test -d ","{'hello':'world'}"]}],"\n",["$","span",null,{"className":"text-emerald-400","children":"✓ captured — view at https://your-bin.hatch.surf/inspect"}]]}]}]]}]
8:["$","section",null,{"className":"py-20 px-6","children":["$","div",null,{"className":"max-w-4xl mx-auto","children":[["$","h2",null,{"className":"text-3xl md:text-4xl font-bold text-center mb-16","children":"Three things, and nothing else"}],["$","div",null,{"className":"grid md:grid-cols-3 gap-8","children":[["$","div","Capture",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 hover:border-blue-500/40 hover:shadow-blue-500/10 transition-all hover:-translate-y-1 shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"w-14 h-14 flex items-center justify-center rounded-xl bg-blue-500/15 text-blue-400 mb-4 shadow-lg","children":["$","svg",null,{"width":"28","height":"28","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"1.5","strokeLinecap":"round","strokeLinejoin":"round","children":[["$","circle",null,{"cx":"12","cy":"12","r":"10"}],["$","polyline",null,{"points":"12 6 12 12 16 14"}]]}]}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Capture"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"Method, path, headers, query, body. Persists across restarts because the storage is SQLite on disk, not a hosted queue."}]]}],["$","div","Inspect",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 hover:border-purple-500/40 hover:shadow-purple-500/10 transition-all hover:-translate-y-1 shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"w-14 h-14 flex items-center justify-center rounded-xl bg-purple-500/15 text-purple-400 mb-4 shadow-lg","children":["$","svg",null,{"width":"28","height":"28","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"1.5","strokeLinecap":"round","strokeLinejoin":"round","children":[["$","path",null,{"d":"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"}],["$","circle",null,{"cx":"12","cy":"12","r":"3"}]]}]}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Inspect"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"A live SSE feed of incoming requests. Click any captured request to see the headers, the body, the timing."}]]}],["$","div","Mock",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 hover:border-cyan-500/40 hover:shadow-cyan-500/10 transition-all hover:-translate-y-1 shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"w-14 h-14 flex items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-400 mb-4 shadow-lg","children":["$","svg",null,{"width":"28","height":"28","viewBox":"0 0 24 24","fill":"none","stroke":"currentColor","strokeWidth":"1.5","strokeLinecap":"round","strokeLinejoin":"round","children":[["$","polyline",null,{"points":"16 18 22 12 16 6"}],["$","polyline",null,{"points":"8 6 2 12 8 18"}]]}]}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Mock"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"Return a 200, a 500, or a custom JSON payload. For testing your own retry, backoff, and error-handling logic."}]]}]]}]]}]}]
9:["$","section",null,{"className":"py-20 px-6","children":["$","div",null,{"className":"max-w-4xl mx-auto","children":[["$","h2",null,{"className":"text-3xl md:text-4xl font-bold text-center mb-16","children":"Why a single binary, not a SaaS"}],["$","div",null,{"className":"grid md:grid-cols-3 gap-6","children":[["$","div","01",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 border-l-4 border-l-blue-500 hover:-translate-y-1 transition-all shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"text-4xl font-bold text-zinc-600 font-mono mb-4","children":"01"}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Compliance and privacy"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"Some teams cannot legally send webhook payloads to a hosted SaaS. Hatch keeps the data on your own network."}]]}],["$","div","02",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 border-l-4 border-l-purple-500 hover:-translate-y-1 transition-all shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"text-4xl font-bold text-zinc-600 font-mono mb-4","children":"02"}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Cost"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":"A hosted inspector charges per request, per seat, or per retention day. Hatch is one Go binary on a $5 VPS. There is no per-request fee because there is no one to charge it."}]]}],["$","div","03",{"className":"p-6 rounded-xl bg-gradient-to-b from-zinc-900/80 to-zinc-900/40 border border-zinc-800 border-l-4 border-l-cyan-500 hover:-translate-y-1 transition-all shadow-lg hover:shadow-xl","children":[["$","div",null,{"className":"text-4xl font-bold text-zinc-600 font-mono mb-4","children":"03"}],["$","h3",null,{"className":"text-xl font-semibold mb-2","children":"Speed of setup"}],["$","p",null,{"className":"text-zinc-400 leading-relaxed","children":[["$","code",null,{"className":"px-2 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-fuchsia-400 text-sm","children":"docker compose up"}]," ","is faster than signing up for a SaaS, verifying your email, configuring your first bin, and pasting the URL into your webhook config."]}]]}]]}]]}]}]
a:["$","section",null,{"className":"py-24 px-6 bg-gradient-to-b from-zinc-900/30 to-zinc-900/60","children":["$","div",null,{"className":"max-w-4xl mx-auto text-center","children":[["$","h2",null,{"className":"text-4xl md:text-5xl font-extrabold mb-4 bg-gradient-to-r from-white to-blue-400 bg-clip-text text-transparent","children":"Start inspecting in 30 seconds"}],["$","p",null,{"className":"text-xl text-zinc-400 mb-10","children":"One binary. Your data stays yours."}],["$","a",null,{"href":"https://github.com/elfoundation/hatch","className":"inline-flex items-center justify-center gap-2 px-10 py-5 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-lg transition-all shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5 text-lg","children":"Get Hatch →"}]]}]}]
b:["$","$Lf",null,{"children":["$","$10",null,{"name":"Next.MetadataOutlet","children":"$@11"}]}]
c:["$","$1","h",{"children":[null,["$","$L12",null,{"children":"$L13"}],["$","div",null,{"hidden":true,"children":["$","$L14",null,{"children":["$","$10",null,{"name":"Next.Metadata","children":"$L15"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}]
e:["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/40hwf0y1goj6_.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]
13:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
16:I[27201,["/_next/static/chunks/158myu8e_yme3.js"],"IconMark"]
11:null
15:[["$","title","0",{"children":"Hatch — Self-hostable HTTP request inspector + mocker"}],["$","meta","1",{"name":"description","content":"Hatch is a self-hostable HTTP request inspector and mocker. One Go binary, SQLite, no SaaS, no per-request fee. Open source, MIT."}],["$","meta","2",{"property":"og:title","content":"Hatch — Self-hostable HTTP request inspector + mocker"}],["$","meta","3",{"property":"og:description","content":"Hatch is a self-hostable HTTP request inspector and mocker. One Go binary, SQLite, no SaaS, no per-request fee. Open source, MIT."}],["$","meta","4",{"property":"og:url","content":"https://hatch.surf"}],["$","meta","5",{"property":"og:site_name","content":"Hatch"}],["$","meta","6",{"property":"og:image","content":"http://localhost:3000/brand/og/default.png"}],["$","meta","7",{"property":"og:image:width","content":"1200"}],["$","meta","8",{"property":"og:image:height","content":"630"}],["$","meta","9",{"property":"og:type","content":"website"}],["$","meta","10",{"name":"twitter:card","content":"summary_large_image"}],["$","meta","11",{"name":"twitter:title","content":"Hatch — Self-hostable HTTP request inspector + mocker"}],["$","meta","12",{"name":"twitter:description","content":"Hatch is a self-hostable HTTP request inspector and mocker. One Go binary, SQLite, no SaaS, no per-request fee. Open source, MIT."}],["$","meta","13",{"name":"twitter:image","content":"http://localhost:3000/brand/og/default.png"}],["$","link","14",{"rel":"icon","href":"/favicon.ico?favicon.2vob68tjqpejf.ico","sizes":"256x256","type":"image/x-icon"}],["$","link","15",{"rel":"icon","href":"/brand/favicon/favicon-48.png"}],["$","$L16","16",{}]]

View File

@ -0,0 +1,6 @@
1:"$Sreact.fragment"
2:I[97367,["/_next/static/chunks/158myu8e_yme3.js"],"ViewportBoundary"]
3:I[97367,["/_next/static/chunks/158myu8e_yme3.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[27201,["/_next/static/chunks/158myu8e_yme3.js"],"IconMark"]
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Hatch — Self-hostable HTTP request inspector + mocker"}],["$","meta","1",{"name":"description","content":"Hatch is a self-hostable HTTP request inspector and mocker. One Go binary, SQLite, no SaaS, no per-request fee. Open source, MIT."}],["$","meta","2",{"property":"og:title","content":"Hatch — Self-hostable HTTP request inspector + mocker"}],["$","meta","3",{"property":"og:description","content":"Hatch is a self-hostable HTTP request inspector and mocker. One Go binary, SQLite, no SaaS, no per-request fee. Open source, MIT."}],["$","meta","4",{"property":"og:url","content":"https://hatch.surf"}],["$","meta","5",{"property":"og:site_name","content":"Hatch"}],["$","meta","6",{"property":"og:image","content":"http://localhost:3000/brand/og/default.png"}],["$","meta","7",{"property":"og:image:width","content":"1200"}],["$","meta","8",{"property":"og:image:height","content":"630"}],["$","meta","9",{"property":"og:type","content":"website"}],["$","meta","10",{"name":"twitter:card","content":"summary_large_image"}],["$","meta","11",{"name":"twitter:title","content":"Hatch — Self-hostable HTTP request inspector + mocker"}],["$","meta","12",{"name":"twitter:description","content":"Hatch is a self-hostable HTTP request inspector and mocker. One Go binary, SQLite, no SaaS, no per-request fee. Open source, MIT."}],["$","meta","13",{"name":"twitter:image","content":"http://localhost:3000/brand/og/default.png"}],["$","link","14",{"rel":"icon","href":"/favicon.ico?favicon.2vob68tjqpejf.ico","sizes":"256x256","type":"image/x-icon"}],["$","link","15",{"rel":"icon","href":"/brand/favicon/favicon-48.png"}],["$","$L5","16",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"W0ph6BZ8oDeJaHcE9fuII"}

Some files were not shown because too many files have changed in this diff Show More