init: project init

This commit is contained in:
Hayden Hargreaves 2026-04-13 20:28:10 -07:00
commit e69d3e4a8a
23 changed files with 1383 additions and 0 deletions

View File

@ -0,0 +1,69 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
wenyan-lite, wenyan-full, wenyan-ultra.
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
license: Proprietary
compatibility: opencode
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Persistence
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
Default: **full**. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | What change |
|-------|------------|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
- wenyan-full: "物出新參照致重繪。useMemo .Wrap之。"
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
Example — "Explain database connection pooling."
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
## Auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
> DROP TABLE users;
> ```
> Caveman resume. Verify backup exist first.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.

11
cmd/tap/main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"os"
"termtap.dev/internal/cli"
)
func main() {
cli.Run(os.Args)
}

114
examples/echo/main.go Normal file
View File

@ -0,0 +1,114 @@
package main
// This is a runable example which will spawn two servers, one we can access
// which hits the other and response with the data provided.
import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"sync"
)
func main() {
upstreamHost, err := findNonLoopbackIPv4()
if err != nil {
log.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if err := startUpstream(upstreamHost); err != nil {
log.Fatal(err)
}
}()
go func() {
defer wg.Done()
if err := startFrontend(upstreamHost); err != nil {
log.Fatal(err)
}
}()
wg.Wait()
}
func startFrontend(upstreamHost string) error {
mux := http.NewServeMux()
mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
message := req.URL.Query().Get("message")
upstreamURL := fmt.Sprintf("http://%s:3001/echo?message=%s", upstreamHost, url.QueryEscape(message))
resp, err := http.Get(upstreamURL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
})
log.Printf("frontend listening on http://127.0.0.1:3000/echo?message=hello")
log.Printf("frontend calls upstream at http://%s:3001/echo", upstreamHost)
return http.ListenAndServe("127.0.0.1:3000", mux)
}
func startUpstream(upstreamHost string) error {
mux := http.NewServeMux()
mux.HandleFunc("/echo", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
message := req.URL.Query().Get("message")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(message))
})
log.Printf("upstream listening on http://%s:3001/echo?message=hello", upstreamHost)
return http.ListenAndServe(":3001", mux)
}
func findNonLoopbackIPv4() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil {
continue
}
ip := ipNet.IP.To4()
if ip == nil || ip.IsLoopback() {
continue
}
return ip.String(), nil
}
return "", fmt.Errorf("no non-loopback IPv4 address found; this demo needs one so outbound traffic does not bypass the proxy")
}

61
flake.lock generated Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

41
flake.nix Normal file
View File

@ -0,0 +1,41 @@
{
description = "TermTap Development Flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go
gopls
go-tools
gcc_multi
glibc_multi
];
name = "TermTap";
inherit (pkgs) zsh;
shellHook = ''
# Use the .local directory instead of home
export GOPATH="$HOME/.local/go"
echo "Settings GOPATH to: $HOME/.local/go "
export GOOS=linux
export GOARCH=amd64
export CGO_CFLAGS=-Wno-error=cpp;
exec zsh
'';
};
}
);
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module termtap.dev
go 1.26.1
require github.com/google/uuid v1.6.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
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=

62
internal/app/process.go Normal file
View File

@ -0,0 +1,62 @@
package app
import (
"errors"
"fmt"
"os"
"os/exec"
"termtap.dev/internal/model"
"termtap.dev/internal/process"
)
func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh <-chan os.Signal) {
ch <- model.Message{
Type: model.MessageTypeProcessStarting,
Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)),
}
proc := process.NewProcess(cmd, addr, ch)
if err := proc.Start(); err != nil {
ch <- model.Message{
Type: model.MessageTypeProcessExited,
Body: fmt.Sprintf("%q", err),
}
return
}
// Listen for SIGTERM from main process
go func() {
sig := <-sigCh
ch <- model.Message{
Type: model.MessageTypeProcessSignaled,
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Process.Pid),
PID: proc.Process.Pid,
}
if proc.Process != nil {
_ = proc.Process.Signal(sig)
}
}()
if err := proc.Wait(); err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
ch <- model.Message{
Type: model.MessageTypeProcessExited,
Body: "process killed itself",
PID: proc.Process.Pid,
ExitCode: exitErr.ExitCode(),
}
return
}
ch <- model.Message{
Type: model.MessageTypeFatal,
Body: fmt.Sprintf("%q", err),
}
return
}
}

33
internal/app/proxy.go Normal file
View File

@ -0,0 +1,33 @@
package app
import (
"fmt"
"termtap.dev/internal/model"
"termtap.dev/internal/proxy"
)
func StartProxy(addr string, ch chan<- model.Message) {
ps, err := proxy.NewProxyServer(addr, ch)
if err != nil {
ch <- model.Message{
Type: model.MessageTypeFatal,
Body: fmt.Sprintf("%q", err),
}
return
}
defer proxy.Destory(ps)
ch <- model.Message{
Type: model.MessageTypeProxyStarting,
Body: fmt.Sprintf("proxy server started on %s", addr),
}
if err := ps.Server.Serve(*ps.Listener); err != nil {
ch <- model.Message{
Type: model.MessageTypeFatal,
Body: fmt.Sprintf("%q", err),
}
return
}
}

52
internal/app/session.go Normal file
View File

@ -0,0 +1,52 @@
package app
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"termtap.dev/internal/model"
)
func StartSession(cmd model.Command, addr string) error {
// Event type?
msgs := make(chan model.Message, 128)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
// Start process and proxy
go StartProxy(addr, msgs)
go StartProcess(cmd, addr, msgs, sigCh)
var events []model.Message
for {
select {
case _ = <-sigCh:
printEvents(events)
return nil
case msg := <-msgs:
{
events = append(events, msg)
switch msg.Type {
case model.MessageTypeFatal:
return fmt.Errorf("%s", msg.Body)
default:
log.Printf("[%s] %s", msg.Type, msg.Body)
}
}
}
}
}
// DEBUG
func printEvents(events []model.Message) {
for _, event := range events {
fmt.Printf("%+v\n", event)
}
}

52
internal/cli/run.go Normal file
View File

@ -0,0 +1,52 @@
package cli
import (
"fmt"
"log"
"os"
"termtap.dev/internal/app"
"termtap.dev/internal/model"
)
// This should be configurable at some point, just in case they build on 8080
const proxy_addr = "127.0.0.1:8080"
func Run(args []string) {
cmd, ok := parseCommand(args)
if !ok {
displayHelp()
return
}
err := app.StartSession(cmd, proxy_addr)
if err != nil {
log.Fatalln(err)
}
}
func parseCommand(args []string) (model.Command, bool) {
if len(args) < 4 {
return model.Command{}, false
}
if args[1] != "run" || args[2] != "--" {
return model.Command{}, false
}
args = args[3:]
if len(args) == 1 {
return model.Command{Name: args[0], Args: []string{}}, true
}
return model.Command{Name: args[0], Args: args[1:]}, true
}
func displayHelp() {
helpText := `
usage:
tap run -- <command> [args...]
`
fmt.Fprintln(os.Stderr, helpText)
}

View File

@ -0,0 +1,6 @@
package model
type Command struct {
Name string
Args []string
}

36
internal/model/message.go Normal file
View File

@ -0,0 +1,36 @@
package model
type MessageType string
const (
MessageTypeSessionStarted MessageType = "SessionStarted"
MessageTypeSessionStopped MessageType = "SessionStopped"
MessageTypeProxyStarting MessageType = "ProxyStarting"
MessageTypeProxyStarted MessageType = "ProxyStarted"
MessageTypeProxyStopped MessageType = "ProxyStopped"
MessageTypeRequestStarted MessageType = "RequestStarted"
MessageTypeRequestFinished MessageType = "RequestFinished"
MessageTypeRequestFailed MessageType = "RequestFailed"
MessageTypeProcessStarting MessageType = "ProcessStarting"
MessageTypeProcessStarted MessageType = "ProcessStarted"
MessageTypeProcessExited MessageType = "ProcessExited"
MessageTypeProcessSignaled MessageType = "ProcessSignaled"
MessageTypeProcessStdout MessageType = "ProcessStdout"
MessageTypeProcessStderr MessageType = "ProcessStderr"
MessageTypeFatal MessageType = "Fatal"
MessageTypeWarn MessageType = "Warn"
)
type Message struct {
Type MessageType
Body string
PID int
RequestID string
URL string
Status int
ExitCode int
}

12
internal/model/proxy.go Normal file
View File

@ -0,0 +1,12 @@
package model
import (
"net"
"net/http"
)
type ProxyServer struct {
Listener *net.Listener
Server *http.Server
Url string
}

View File

@ -0,0 +1,72 @@
package process
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"strings"
"termtap.dev/internal/model"
)
func CommandString(c model.Command) string {
return fmt.Sprintf("%s %s", c.Name, strings.Join(c.Args, " "))
}
func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *exec.Cmd {
proc := exec.Command(cmd.Name, cmd.Args...)
injectEnv(proc, addr)
stdout, err := proc.StdoutPipe()
if err != nil {
ch <- model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("could not open stdout pipe: %q", err),
PID: proc.Process.Pid,
}
} else {
go readPipe(stdout, model.MessageTypeProcessStdout, ch)
}
stderr, err := proc.StderrPipe()
if err != nil {
ch <- model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("could not open stderr pipe: %q", err),
PID: proc.Process.Pid,
}
} else {
go readPipe(stderr, model.MessageTypeProcessStderr, ch)
}
return proc
}
func injectEnv(proc *exec.Cmd, addr string) {
proxyAddr := "http://" + addr
injected := []string{
"HTTP_PROXY=" + proxyAddr,
"http_proxy=" + proxyAddr,
"HTTPS_PROXY=" + proxyAddr, // TODO: HTTP NOT SUPPORTED
"https_proxy=" + proxyAddr,
// "ALL_PROXY=" + proxyAddr,
// "all_proxy=" + proxyAddr,
"NO_PROXY=",
"no_proxy=",
}
proc.Env = append(os.Environ(), injected...)
}
func readPipe(pipe io.Reader, t model.MessageType, ch chan<- model.Message) {
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
ch <- model.Message{
Type: t,
Body: scanner.Text(),
}
}
}

142
internal/proxy/handler.go Normal file
View File

@ -0,0 +1,142 @@
package proxy
import (
"bytes"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"termtap.dev/internal/model"
)
// NOTE: Much of this code is AI generated, and is not expected to make it into production
const maxPreviewBytes = 1024
func proxyHandler(ch chan<- model.Message) http.Handler {
transport := http.DefaultTransport
// TODO: This should be wired into the main channel, but that will require a model package
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
ch <- model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host),
}
return
}
if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
ch <- model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
}
return
}
start := time.Now()
// requestPreview, err := readAndRestoreBody(&req.Body)
// if err != nil {
// http.Error(w, "failed to read request body", http.StatusBadRequest)
// log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err)
// return
// }
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
ch <- model.Message{
Type: model.MessageTypeRequestStarted,
Body: fmt.Sprintf("-> %s %s", outReq.Method, outReq.URL.String()),
}
resp, err := transport.RoundTrip(outReq)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
ch <- model.Message{
Type: model.MessageTypeRequestFailed,
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
}
return
}
defer resp.Body.Close()
// responsePreview, err := readAndRestoreBody(&resp.Body)
// if err != nil {
// http.Error(w, "bad gateway", http.StatusBadGateway)
// log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
// return
// }
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
ch <- model.Message{
Type: model.MessageTypeRequestFailed,
Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err),
}
return
}
ch <- model.Message{
Type: model.MessageTypeRequestFinished,
Body: fmt.Sprintf("<- %s %s %d %s",
outReq.Method,
outReq.URL.String(),
resp.StatusCode,
time.Since(start).Round(time.Millisecond),
),
}
})
}
func copyHeader(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func readAndRestoreBody(body *io.ReadCloser) (string, error) {
if body == nil || *body == nil {
return "", nil
}
payload, err := io.ReadAll(*body)
if err != nil {
return "", err
}
*body = io.NopCloser(bytes.NewReader(payload))
preview := payload
if len(preview) > maxPreviewBytes {
preview = preview[:maxPreviewBytes]
}
text := strings.ReplaceAll(string(preview), "\n", "\\n")
if len(payload) > maxPreviewBytes {
text += "..."
}
return text, nil
}
func formatHeaders(headers http.Header) string {
if len(headers) == 0 {
return "<none>"
}
parts := make([]string, 0, len(headers))
for key, values := range headers {
parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ",")))
}
sort.Strings(parts)
return strings.Join(parts, ", ")
}

38
internal/proxy/server.go Normal file
View File

@ -0,0 +1,38 @@
package proxy
import (
"context"
"fmt"
"net"
"net/http"
"time"
"termtap.dev/internal/model"
)
func NewProxyServer(addr string, ch chan<- model.Message) (*model.ProxyServer, error) {
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
url := fmt.Sprintf("http://%s", listener.Addr().String())
ps := &model.ProxyServer{
Listener: &listener,
Server: &http.Server{Handler: proxyHandler(ch)},
Url: url,
}
return ps, nil
}
// BUG: Not sure what all this does
func Destory(ps *model.ProxyServer) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if ps != nil && ps.Server != nil {
_ = ps.Server.Shutdown(ctx)
}
}

2
internal/tui/model.go Normal file
View File

@ -0,0 +1,2 @@
package tui

2
internal/tui/update.go Normal file
View File

@ -0,0 +1,2 @@
package tui

2
internal/tui/view.go Normal file
View File

@ -0,0 +1,2 @@
package tui

224
proto/main.go Normal file
View File

@ -0,0 +1,224 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func main() {
if err := parseArgs(); err != nil {
panic(err)
}
}
func parseArgs() error {
if len(os.Args) < 3 {
return fmt.Errorf("Must use this right")
}
if os.Args[1] != "run" || os.Args[2] != "--" {
return fmt.Errorf("Must use this right")
}
cmd := os.Args[3:]
return run(cmd)
}
func run(cmd []string) error {
fmt.Printf("%+v\n", cmd)
server, url, err := proxy()
if err != nil {
return err
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
println(url)
env := []string{
"HTTP_PROXY=" + url,
"http_proxy=" + url,
"HTTPS_PROXY=" + url,
"https_proxy=" + url,
"ALL_PROXY=" + url,
"all_proxy=" + url,
"NO_PROXY=",
"no_proxy=",
}
proc := exec.Command(cmd[0], cmd[1:]...)
proc.Stdin = os.Stdin
proc.Stdout = os.Stdout
proc.Stderr = os.Stderr
proc.Env = append(os.Environ(), env...)
if err := proc.Start(); err != nil {
return err
}
if err := proc.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("wait for command: %w", err)
}
return nil
}
func proxy() (*http.Server, string, error) {
addr := "127.0.0.1:8080"
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, "", err
}
server := &http.Server{Handler: handler()}
go func() {
if err := server.Serve(listener); err != nil {
fmt.Printf("%q", err)
}
}()
url := "http://" + listener.Addr().String()
return server, url, nil
}
func handler() http.Handler {
transport := http.DefaultTransport
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
log.Printf("!! CONNECT %s not supported", req.Host)
return
}
if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
log.Printf("!! rejected non-proxy request %s %s", req.Method, req.URL.String())
return
}
startedAt := time.Now()
id := uuid.New().String()
// requestPreview, err := readAndRestoreBody(&req.Body)
// if err != nil {
// http.Error(w, "failed to read request body", http.StatusBadRequest)
// log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err)
// return
// }
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
log.Printf(
"[%s] -> %s %s\n",
id,
outReq.Method,
outReq.URL.String(),
// formatHeaders(outReq.Header),
// requestPreview,
)
resp, err := transport.RoundTrip(outReq)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
log.Printf("!! upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
defer resp.Body.Close()
// responsePreview, err := readAndRestoreBody(&resp.Body)
// if err != nil {
// http.Error(w, "bad gateway", http.StatusBadGateway)
// log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
// return
// }
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
log.Printf("!! write response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
log.Printf(
"[%s] <- %s %s %d %s\n",
id,
outReq.Method,
outReq.URL.String(),
resp.StatusCode,
time.Since(startedAt).Round(time.Millisecond),
)
})
}
const maxPreviewBytes = 1024
func copyHeader(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func readAndRestoreBody(body *io.ReadCloser) (string, error) {
if body == nil || *body == nil {
return "", nil
}
payload, err := io.ReadAll(*body)
if err != nil {
return "", err
}
*body = io.NopCloser(bytes.NewReader(payload))
preview := payload
if len(preview) > maxPreviewBytes {
preview = preview[:maxPreviewBytes]
}
text := strings.ReplaceAll(string(preview), "\n", "\\n")
if len(payload) > maxPreviewBytes {
text += "..."
}
return text, nil
}
func formatHeaders(headers http.Header) string {
if len(headers) == 0 {
return "<none>"
}
parts := make([]string, 0, len(headers))
for key, values := range headers {
parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ",")))
}
sort.Strings(parts)
return strings.Join(parts, ", ")
}

303
proto/proxy.go Normal file
View File

@ -0,0 +1,303 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"sort"
"strings"
"syscall"
"time"
)
const maxPreviewBytes = 1024
func test() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "run":
if err := runCommand(os.Args[2:]); err != nil {
log.Fatal(err)
}
case "proxy":
if err := runProxy(os.Args[2:]); err != nil {
log.Fatal(err)
}
default:
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " tap run -- <command> [args...]")
fmt.Fprintln(os.Stderr, " tap proxy [-listen 127.0.0.1:8080]")
}
func runCommand(args []string) error {
runFlags := flag.NewFlagSet("run", flag.ExitOnError)
listenAddr := runFlags.String("listen", "127.0.0.1:0", "proxy listen address")
runFlags.SetOutput(io.Discard)
if err := runFlags.Parse(args); err != nil {
return err
}
commandArgs := runFlags.Args()
if len(commandArgs) == 0 {
return errors.New("run requires a command after `--`")
}
if commandArgs[0] == "--" {
commandArgs = commandArgs[1:]
}
if len(commandArgs) == 0 {
return errors.New("run requires a command after `--`")
}
server, proxyURL, err := startProxy(*listenAddr)
if err != nil {
return err
}
defer shutdownServer(server)
log.Printf("proxy listening on %s", proxyURL)
cmd := exec.Command(commandArgs[0], commandArgs[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = withProxyEnv(os.Environ(), proxyURL)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start command: %w", err)
}
forwardSignals(cmd.Process)
if err := cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("wait for command: %w", err)
}
return nil
}
func runProxy(args []string) error {
proxyFlags := flag.NewFlagSet("proxy", flag.ExitOnError)
listenAddr := proxyFlags.String("listen", "127.0.0.1:8080", "proxy listen address")
if err := proxyFlags.Parse(args); err != nil {
return err
}
server, proxyURL, err := startProxy(*listenAddr)
if err != nil {
return err
}
defer shutdownServer(server)
log.Printf("proxy listening on %s", proxyURL)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(stop)
<-stop
return nil
}
func startProxy(listenAddr string) (*http.Server, string, error) {
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, "", fmt.Errorf("listen on %s: %w", listenAddr, err)
}
server := &http.Server{Handler: newForwardProxy()}
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("proxy server error: %v", err)
}
}()
proxyURL := "http://" + listener.Addr().String()
return server, proxyURL, nil
}
func shutdownServer(server *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}
func withProxyEnv(env []string, proxyURL string) []string {
filtered := make([]string, 0, len(env)+5)
for _, entry := range env {
if hasEnvKey(entry, "HTTP_PROXY") || hasEnvKey(entry, "http_proxy") || hasEnvKey(entry, "HTTPS_PROXY") || hasEnvKey(entry, "https_proxy") || hasEnvKey(entry, "ALL_PROXY") || hasEnvKey(entry, "all_proxy") || hasEnvKey(entry, "NO_PROXY") || hasEnvKey(entry, "no_proxy") {
continue
}
filtered = append(filtered, entry)
}
filtered = append(filtered,
"HTTP_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"https_proxy="+proxyURL,
"ALL_PROXY="+proxyURL,
"all_proxy="+proxyURL,
"NO_PROXY=",
"no_proxy=",
)
return filtered
}
func hasEnvKey(entry, key string) bool {
return strings.HasPrefix(entry, key+"=")
}
func forwardSignals(process *os.Process) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
go func() {
for sig := range ch {
_ = process.Signal(sig)
}
}()
}
func newForwardProxy() http.Handler {
transport := http.DefaultTransport
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
log.Printf("!! CONNECT %s not supported", req.Host)
return
}
if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
log.Printf("!! rejected non-proxy request %s %s", req.Method, req.URL.String())
return
}
startedAt := time.Now()
requestPreview, err := readAndRestoreBody(&req.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
log.Printf("!! read request body %s %s: %v", req.Method, req.URL.String(), err)
return
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
log.Printf(
"-> %s %s\n request headers: %s\n request body: %q",
outReq.Method,
outReq.URL.String(),
formatHeaders(outReq.Header),
requestPreview,
)
resp, err := transport.RoundTrip(outReq)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
log.Printf("!! upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
defer resp.Body.Close()
responsePreview, err := readAndRestoreBody(&resp.Body)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
log.Printf("!! read response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
log.Printf("!! write response body %s %s: %v", outReq.Method, outReq.URL.String(), err)
return
}
log.Printf(
"<- %s %s %d %s\n response headers: %s\n response body: %q",
outReq.Method,
outReq.URL.String(),
resp.StatusCode,
time.Since(startedAt).Round(time.Millisecond),
formatHeaders(resp.Header),
responsePreview,
)
})
}
func copyHeader(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func readAndRestoreBody(body *io.ReadCloser) (string, error) {
if body == nil || *body == nil {
return "", nil
}
payload, err := io.ReadAll(*body)
if err != nil {
return "", err
}
*body = io.NopCloser(bytes.NewReader(payload))
preview := payload
if len(preview) > maxPreviewBytes {
preview = preview[:maxPreviewBytes]
}
text := strings.ReplaceAll(string(preview), "\n", "\\n")
if len(payload) > maxPreviewBytes {
text += "..."
}
return text, nil
}
func formatHeaders(headers http.Header) string {
if len(headers) == 0 {
return "<none>"
}
parts := make([]string, 0, len(headers))
for key, values := range headers {
parts = append(parts, fmt.Sprintf("%s=%q", key, strings.Join(values, ",")))
}
sort.Strings(parts)
return strings.Join(parts, ", ")
}

42
proto/server.go Normal file
View File

@ -0,0 +1,42 @@
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
if err := startDemoServer("127.0.0.1:3000"); err != nil {
panic(err)
}
}
func startDemoServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/send", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp, err := http.Get("http://example.com")
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "sent request to http://example.com\nstatus: %s\nbytes: %d\n", resp.Status, len(body))
})
fmt.Printf("demo server listening on http://%s/send\n", addr)
return http.ListenAndServe(addr, mux)
}