init: project init
This commit is contained in:
commit
e69d3e4a8a
69
.opencode/skills/caveman/SKILL.md
Normal file
69
.opencode/skills/caveman/SKILL.md
Normal 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
11
cmd/tap/main.go
Normal 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
114
examples/echo/main.go
Normal 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
61
flake.lock
generated
Normal 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
41
flake.nix
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
62
internal/app/process.go
Normal 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
33
internal/app/proxy.go
Normal 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
52
internal/app/session.go
Normal 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
52
internal/cli/run.go
Normal 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)
|
||||
}
|
||||
6
internal/model/command.go
Normal file
6
internal/model/command.go
Normal file
@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
type Command struct {
|
||||
Name string
|
||||
Args []string
|
||||
}
|
||||
36
internal/model/message.go
Normal file
36
internal/model/message.go
Normal 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
12
internal/model/proxy.go
Normal file
@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ProxyServer struct {
|
||||
Listener *net.Listener
|
||||
Server *http.Server
|
||||
Url string
|
||||
}
|
||||
72
internal/process/runner.go
Normal file
72
internal/process/runner.go
Normal 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
142
internal/proxy/handler.go
Normal 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
38
internal/proxy/server.go
Normal 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
2
internal/tui/model.go
Normal file
@ -0,0 +1,2 @@
|
||||
package tui
|
||||
|
||||
2
internal/tui/update.go
Normal file
2
internal/tui/update.go
Normal file
@ -0,0 +1,2 @@
|
||||
package tui
|
||||
|
||||
2
internal/tui/view.go
Normal file
2
internal/tui/view.go
Normal file
@ -0,0 +1,2 @@
|
||||
package tui
|
||||
|
||||
224
proto/main.go
Normal file
224
proto/main.go
Normal 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
303
proto/proxy.go
Normal 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
42
proto/server.go
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user