Skip to content

Commit 843e71e

Browse files
rludersclaude
andcommitted
feat: initial release of lane middleware library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
0 parents  commit 843e71e

17 files changed

Lines changed: 1131 additions & 0 deletions

‎.github/workflows/ci.yml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
env:
16+
GOWORK: off
17+
18+
steps:
19+
- name: Check out repository
20+
uses: actions/checkout@v4
21+
22+
- name: Set up Go
23+
uses: actions/setup-go@v5
24+
with:
25+
go-version-file: go.mod
26+
cache: true
27+
28+
- name: Run tests
29+
run: go test ./...

‎.github/workflows/release.yml‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump:
7+
description: Version bump to release
8+
required: true
9+
default: patch
10+
type: choice
11+
options:
12+
- patch
13+
- minor
14+
- major
15+
16+
permissions:
17+
contents: write
18+
19+
jobs:
20+
release:
21+
runs-on: ubuntu-latest
22+
env:
23+
GOWORK: off
24+
25+
steps:
26+
- name: Check out repository
27+
uses: actions/checkout@v4
28+
with:
29+
fetch-depth: 0
30+
31+
- name: Set up Go
32+
uses: actions/setup-go@v5
33+
with:
34+
go-version-file: go.mod
35+
cache: true
36+
37+
- name: Verify release branch
38+
run: |
39+
if [ "${GITHUB_REF_NAME}" != "main" ]; then
40+
echo "Releases can only be created from main. Current ref: ${GITHUB_REF_NAME}" >&2
41+
exit 1
42+
fi
43+
44+
- name: Run tests
45+
run: go test ./...
46+
47+
- name: Compute next version
48+
id: version
49+
env:
50+
BUMP: ${{ inputs.bump }}
51+
run: |
52+
latest_tag="$(git tag --list 'v*' --sort=-version:refname | head -n 1)"
53+
if [ -z "${latest_tag}" ]; then
54+
latest_tag="v0.0.0"
55+
fi
56+
57+
version="${latest_tag#v}"
58+
major="${version%%.*}"
59+
rest="${version#*.}"
60+
minor="${rest%%.*}"
61+
patch="${rest#*.}"
62+
63+
case "${BUMP}" in
64+
patch)
65+
patch=$((patch + 1))
66+
;;
67+
minor)
68+
minor=$((minor + 1))
69+
patch=0
70+
;;
71+
major)
72+
major=$((major + 1))
73+
minor=0
74+
patch=0
75+
;;
76+
*)
77+
echo "Unsupported bump type: ${BUMP}" >&2
78+
exit 1
79+
;;
80+
esac
81+
82+
next_tag="v${major}.${minor}.${patch}"
83+
echo "latest_tag=${latest_tag}" >> "${GITHUB_OUTPUT}"
84+
echo "next_tag=${next_tag}" >> "${GITHUB_OUTPUT}"
85+
echo "Next release: ${next_tag}"
86+
87+
- name: Create and push tag
88+
env:
89+
NEXT_TAG: ${{ steps.version.outputs.next_tag }}
90+
run: |
91+
if git rev-parse "${NEXT_TAG}" >/dev/null 2>&1; then
92+
echo "Tag ${NEXT_TAG} already exists." >&2
93+
exit 1
94+
fi
95+
96+
git config user.name "github-actions[bot]"
97+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
98+
git tag "${NEXT_TAG}"
99+
git push origin "${NEXT_TAG}"
100+
101+
- name: Publish GitHub release
102+
env:
103+
GH_TOKEN: ${{ github.token }}
104+
NEXT_TAG: ${{ steps.version.outputs.next_tag }}
105+
run: gh release create "${NEXT_TAG}" --generate-notes --verify-tag

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Ricardo Lüders
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
<p align="center">
2+
<a href="https://github.com/rluders/lane/actions/workflows/ci.yml">
3+
<img src="https://github.com/rluders/lane/actions/workflows/ci.yml/badge.svg" alt="CI">
4+
</a>
5+
<a href="https://go.dev/doc/devel/release">
6+
<img src="https://img.shields.io/badge/go-1.26+-00ADD8?logo=go&logoColor=white" alt="Go 1.26+">
7+
</a>
8+
<a href="https://pkg.go.dev/github.com/rluders/lane">
9+
<img src="https://pkg.go.dev/badge/github.com/rluders/lane.svg" alt="Go Reference">
10+
</a>
11+
<a href="https://goreportcard.com/report/github.com/rluders/lane">
12+
<img src="https://goreportcard.com/badge/github.com/rluders/lane" alt="Go Report Card">
13+
</a>
14+
<a href="https://github.com/rluders/lane/releases">
15+
<img src="https://img.shields.io/github/v/release/rluders/lane?sort=semver" alt="GitHub release">
16+
</a>
17+
<a href="https://codecov.io/gh/rluders/lane">
18+
<img src="https://codecov.io/gh/rluders/lane/branch/main/graph/badge.svg" alt="codecov">
19+
</a>
20+
<a href="LICENSE">
21+
<img src="https://img.shields.io/github/license/rluders/lane" alt="License">
22+
</a>
23+
<img src="https://img.shields.io/badge/dependencies-1-blue" alt="1 dependency">
24+
</p>
25+
26+
`lane` is a lightweight Go runtime that coordinates the lifecycle of service Runners.
27+
28+
## 🤔 What is lane?
29+
30+
`lane` manages the startup, health, and graceful shutdown of one or more service components — HTTP servers, background workers, schedulers — under a unified context. Register your runners, call `Run`, and lane handles the rest.
31+
32+
## 🎯 Why use it?
33+
34+
Wiring SIGINT/SIGTERM handling, concurrent startup, health probes, and ordered shutdown correctly is tedious boilerplate. `lane` does it once, predictably, so services stay focused on their own logic.
35+
36+
## 🚫 What it does NOT do
37+
38+
- It is not a dependency injection framework
39+
- It does not wire, store, or own your service dependencies
40+
- It does not prescribe configuration, logging format, or database access patterns
41+
- It does not define application structure beyond the `Runner` interface
42+
43+
Dependency wiring is the caller's responsibility.
44+
45+
## ⚡ Quick Example
46+
47+
```go
48+
package main
49+
50+
import (
51+
"context"
52+
"log/slog"
53+
"net/http"
54+
"os"
55+
"time"
56+
57+
"github.com/rluders/lane"
58+
"github.com/rluders/lane/runners"
59+
)
60+
61+
func main() {
62+
lane.RunHealthCheck(":8080")
63+
64+
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
65+
66+
mux := http.NewServeMux()
67+
68+
l := lane.New(log, lane.WithShutdownTimeout(10*time.Second))
69+
70+
mux.Handle("GET /ready", lane.ReadinessHandler(l.Health()))
71+
mux.Handle("GET /live", lane.LivenessHandler())
72+
73+
server := &http.Server{Addr: ":8080", Handler: mux}
74+
l.AddRunner(runners.NewHTTPRunner("api", server, log))
75+
76+
if err := l.Run(context.Background()); err != nil {
77+
log.Error("lane error", "error", err)
78+
os.Exit(1)
79+
}
80+
}
81+
```
82+
83+
## 🔄 Lifecycle
84+
85+
```mermaid
86+
flowchart TD
87+
A[lane.New] --> B[AddRunner x N]
88+
B --> C[l.Run]
89+
C --> D[Start all runners concurrently]
90+
D --> E[health.SetReady true]
91+
E --> F{waiting}
92+
F -- SIGINT/SIGTERM --> G[Signal received]
93+
F -- runner error --> H[Context cancelled]
94+
G --> I[health.SetReady false]
95+
H --> I
96+
I --> J[Stop runners in LIFO order]
97+
J --> K[Shutdown timeout context]
98+
K --> L[Return first error or nil]
99+
```
100+
101+
Runners are started concurrently. Shutdown is sequential in **reverse registration order** (LIFO), so dependents stop before the services they depend on.
102+
103+
## 🧩 Runner Interface
104+
105+
Any component that can start and stop is a Runner:
106+
107+
```go
108+
type Runner interface {
109+
Name() string
110+
Start(ctx context.Context) error
111+
Stop(ctx context.Context) error
112+
}
113+
```
114+
115+
**Contract:**
116+
- `Start` **must block** until the runner stops or fails
117+
- `Stop` initiates graceful shutdown and **must respect the context deadline**
118+
- If `Start` returns a non-nil error, lane cancels all other runners
119+
120+
## 📦 Built-in Runners
121+
122+
All implementations live in the `runners/` sub-package.
123+
124+
| Runner | Constructor | Description |
125+
|--------|-------------|-------------|
126+
| HTTP | `runners.NewHTTPRunner(name, server, log)` | Wraps `*http.Server`. Calls `Shutdown` on stop. |
127+
| HTTPS | `runners.NewHTTPSRunner(name, server, certFile, keyFile, log)` | Same as HTTPRunner with TLS. |
128+
| Worker | `runners.NewWorkerRunner(name, fn, log)` | Runs a `WorkFn` in a loop until ctx is cancelled. |
129+
| Scheduler | `runners.NewSchedulerRunner(name, interval, fn, log)` | Runs a `JobFn` on a fixed interval. Missed ticks are dropped. |
130+
131+
### Worker example
132+
133+
```go
134+
worker := runners.NewWorkerRunner("processor", func(ctx context.Context) error {
135+
return processNextMessage(ctx)
136+
}, log)
137+
l.AddRunner(worker)
138+
```
139+
140+
### Scheduler example
141+
142+
```go
143+
sched := runners.NewSchedulerRunner("cleanup", 5*time.Minute, func(ctx context.Context) error {
144+
return purgeExpiredSessions(ctx)
145+
}, log)
146+
l.AddRunner(sched)
147+
```
148+
149+
## 🏥 Health Checks
150+
151+
`HealthState` tracks readiness. Lane sets it to ready after all runners start, and back to not-ready at the beginning of shutdown.
152+
153+
```go
154+
mux.Handle("GET /ready", lane.ReadinessHandler(l.Health(), db.PingContext))
155+
mux.Handle("GET /live", lane.LivenessHandler())
156+
```
157+
158+
`ReadinessHandler` accepts zero or more probe functions of type `func(ctx context.Context) error`. All probes must pass for the endpoint to return 200. If any probe fails, the endpoint returns 503.
159+
160+
`LivenessHandler` always returns 200 — a running process is a live process.
161+
162+
### Container health probe
163+
164+
Call `RunHealthCheck` at the very top of `main()` to support Docker `HEALTHCHECK CMD`-based probes:
165+
166+
```go
167+
func main() {
168+
lane.RunHealthCheck(":8080")
169+
// ... rest of main
170+
}
171+
```
172+
173+
When invoked as `myservice healthcheck`, the process exits 0 (healthy) or 1 (unhealthy) immediately without starting the service.
174+
175+
## 🛡️ Recovery
176+
177+
### Goroutine recovery
178+
179+
`lane.Go` runs a goroutine with structured panic recovery. On panic it logs the stack trace and calls the provided cancel function to propagate the failure up to lane.
180+
181+
```go
182+
lane.Go(ctx, log, "worker-name", cancel, func(ctx context.Context) {
183+
// critical goroutine — a panic here cancels the application context
184+
})
185+
```
186+
187+
### HTTP handler recovery
188+
189+
`RecoverMiddleware` wraps HTTP handlers with panic recovery. On panic it logs structured context (method, path, stack trace) and returns HTTP 500. The service continues — a single handler panic is recoverable.
190+
191+
```go
192+
handler = lane.RecoverMiddleware(log)(handler)
193+
```
194+
195+
## ⏱️ Shutdown Timeout
196+
197+
Default shutdown timeout is **30 seconds**. Override with `WithShutdownTimeout`:
198+
199+
```go
200+
l := lane.New(log, lane.WithShutdownTimeout(10*time.Second))
201+
```
202+
203+
Each runner's `Stop` is called with a context that respects this deadline. If a runner does not stop within the timeout, shutdown proceeds without it.
204+
205+
## 🗓️ When to use lane
206+
207+
Use it when you want:
208+
209+
- ✅ Go services with multiple concurrent components (API server + worker + scheduler)
210+
- ✅ Predictable SIGTERM handling and readiness probes in containers
211+
- ✅ Correct lifecycle management without pulling in a full service framework
212+
213+
Avoid it if you need:
214+
215+
- ❌ A dependency injection container
216+
- ❌ A full application framework with routing conventions
217+
- ❌ Managed configuration, observability, or deployment tooling
218+
219+
## 📄 License
220+
221+
MIT — see [LICENSE](LICENSE).

‎go.mod‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/rluders/lane
2+
3+
go 1.26
4+
5+
require golang.org/x/sync v0.20.0

‎go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
2+
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=

0 commit comments

Comments
 (0)