Documentation
¶
Overview ¶
Package proxy provides an HTTPS reverse proxy with subdomain-based routing.
Package proxy provides HTTP/HTTPS reverse proxy with subdomain-based routing. It allows mapping subdomains to local ports (e.g., app.local.dev:6789 → localhost:3000).
Index ¶
- Variables
- func CaptureAllowedDirs(primaryDir string) []string
- func FinalizeRequestBody(rc io.ReadCloser)
- func GenerateRequestID(timestamp time.Time, method, url string) string
- func LoadCapturedBody(body *CapturedBody, allowedDirs []string) ([]byte, error)
- type CaptureManager
- func (cm *CaptureManager) CaptureDir() string
- func (cm *CaptureManager) CaptureRequest(requestID string, r *http.Request, maxBodySize int64) (*CapturedBody, io.ReadCloser, http.Header)
- func (cm *CaptureManager) Cleanup() error
- func (cm *CaptureManager) CleanupRequest(requestID string)
- func (cm *CaptureManager) DiskBudget() int64
- func (cm *CaptureManager) DiskStats() (used, budget int64)
- func (cm *CaptureManager) DiskUsed() int64
- func (cm *CaptureManager) Enabled() bool
- func (cm *CaptureManager) FinalizeResponse(requestID string, crw *CaptureResponseWriter) (*CapturedBody, http.Header)
- func (cm *CaptureManager) LoadBody(body *CapturedBody) ([]byte, error)
- func (cm *CaptureManager) SetDiskBudget(budget int64)
- func (cm *CaptureManager) WrapResponseWriter(w http.ResponseWriter, maxBodySize int64) *CaptureResponseWriter
- type CaptureResponseWriter
- func (crw *CaptureResponseWriter) CapturedBody() []byte
- func (crw *CaptureResponseWriter) Flush()
- func (crw *CaptureResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error)
- func (crw *CaptureResponseWriter) Hijacked() bool
- func (crw *CaptureResponseWriter) Push(target string, opts *http.PushOptions) error
- func (h *CaptureResponseWriter) SetFirstResponseCallback(fn func(statusCode int))
- func (crw *CaptureResponseWriter) StatusCode() int
- func (crw *CaptureResponseWriter) TotalSeen() int64
- func (crw *CaptureResponseWriter) Truncated() bool
- func (crw *CaptureResponseWriter) Unwrap() http.ResponseWriter
- func (crw *CaptureResponseWriter) Write(p []byte) (int, error)
- func (crw *CaptureResponseWriter) WriteHeader(code int)
- type CapturedBody
- type DecodedBody
- type EvictionCallback
- type PortConflictError
- type RequestDetails
- type RequestFilter
- type RequestManager
- func (m *RequestManager) Close()
- func (m *RequestManager) Count() int
- func (m *RequestManager) DroppedEvents() int64
- func (m *RequestManager) GetByID(id string) (RequestRecord, bool)
- func (m *RequestManager) PurgeByProject(projectDir string)
- func (m *RequestManager) Recent(filter RequestFilter) []RequestRecord
- func (m *RequestManager) RecentPage(filter RequestFilter) (records []RequestRecord, nextBeforeID string, anchorFound bool)
- func (m *RequestManager) Record(record RequestRecord) bool
- func (m *RequestManager) SetEvictionCallback(fn EvictionCallback)
- func (m *RequestManager) Subscribe(filter RequestFilter) *RequestSubscription
- func (m *RequestManager) Unsubscribe(id string)
- func (m *RequestManager) Upsert(record RequestRecord) bool
- type RequestRecord
- type RequestSubscription
- type Service
Constants ¶
This section is empty.
Variables ¶
var ErrPortInUse = errors.New("port already in use")
ErrPortInUse is returned when a proxy port is already bound by another process.
Functions ¶
func CaptureAllowedDirs ¶ added in v0.2.0
CaptureAllowedDirs builds the FilePath allowlist passed to LoadCapturedBody: primaryDir (the caller's own capture dir, skipped when empty) plus the shared daemon capture dir under the user's home (skipped when the home dir cannot be resolved). Centralizing keeps the "the daemon capture dir is always allowed" policy in one place rather than duplicated across the api and tui callers.
func FinalizeRequestBody ¶ added in v0.2.0
func FinalizeRequestBody(rc io.ReadCloser)
FinalizeRequestBody forces finalization of a request body previously wrapped by CaptureRequest. Idempotent; a non-wrapped body is a no-op. Proxy handlers call this after the reverse proxy returns and BEFORE recording, so the CapturedBody snapshot is complete when the record is published (SSE subscribers serialize records at notify time). Without it, a canceled request's transport goroutine may still be draining the body, and its later Close-triggered finalize would race the serialization; after this call that finalize is a no-op and late writes are discarded.
func GenerateRequestID ¶ added in v0.2.0
GenerateRequestID creates a short hash ID (12 chars, git-style) from request data plus a per-process counter, so IDs are unique within a process even for simultaneous identical requests. (Truncating the hash to 48 bits keeps the birthday-collision residual across a DefaultProxyRequestBufferSize-record ring negligible, unlike the 28 bits of a 7-char ID whose ~0.2%-per-full-ring collision odds could overwrite capture files.) Exported so the shared daemon can generate a request ID before proxying (needed for capture file naming).
func LoadCapturedBody ¶ added in v0.2.0
func LoadCapturedBody(body *CapturedBody, allowedDirs []string) ([]byte, error)
LoadCapturedBody returns a captured body's raw retained bytes, reading from disk when the body was spilled to a FilePath.
- A nil body yields (nil, nil).
- A body marked Evicted (its data was dropped when the record left the ring's detail window, D9b) yields an os.ErrNotExist-wrapped error, so it travels the same "evicted" path as a body whose spilled file is gone rather than masquerading as an empty body.
- An inline body returns a copy of Data (callers must not mutate the record).
- A FilePath body MUST resolve within one of allowedDirs; a path that escapes every allowed directory is rejected with an error rather than read. This prevents a socket-supplied path from exfiltrating arbitrary files through the project API.
- os.ReadFile errors (missing/evicted file) propagate to the caller.
Types ¶
type CaptureManager ¶
type CaptureManager struct {
// contains filtered or unexported fields
}
CaptureManager handles request/response body capture with hybrid memory/disk storage.
func NewCaptureManager ¶
func NewCaptureManager(cfg *config.CaptureConfig, workDir string) (*CaptureManager, error)
NewCaptureManager creates a new capture manager. If cfg is nil or capture is not enabled, returns a manager that does nothing.
This constructor treats workDir as a WORK directory: the capture directory is derived as workDir/.prox/capture. Callers that already hold an exact capture directory (e.g. the shared daemon, whose capture dir is ~/.prox/capture) must use NewCaptureManagerAt instead to avoid a doubled ".prox/capture" suffix.
func NewCaptureManagerAt ¶ added in v0.2.0
func NewCaptureManagerAt(captureDir string, maxBodySize int64) (*CaptureManager, error)
NewCaptureManagerAt creates an enabled capture manager rooted at an EXACT capture directory (no ".prox/capture" suffix is appended). It is the shared setup that NewCaptureManager delegates to once it has resolved the capture directory and body-size limit. Any existing files under captureDir are removed (previous-run cleanup) and the directory is created.
func (*CaptureManager) CaptureDir ¶ added in v0.2.0
func (cm *CaptureManager) CaptureDir() string
CaptureDir returns the directory where captured body files are stored, or the empty string when capture is disabled. Used by consumers building the LoadCapturedBody allowlist.
func (*CaptureManager) CaptureRequest ¶
func (cm *CaptureManager) CaptureRequest(requestID string, r *http.Request, maxBodySize int64) (*CapturedBody, io.ReadCloser, http.Header)
CaptureRequest captures the request body using a TeeReader bounded by maxBodySize (D13, #49) and returns the cloned request headers: the daemon passes the matched route's cap so each project honors its own limit through the one shared capture dir; the standalone proxy passes 0, which falls back to the manager's configured cap (see effectiveLimit).
Even a bodyless request (every GET/HEAD, whose headers still reach the stored Details) has its headers cloned and returned here.
Returns the captured body info and a new ReadCloser to use in place of the original body; reading the returned ReadCloser also captures the data.
func (*CaptureManager) Cleanup ¶
func (cm *CaptureManager) Cleanup() error
Cleanup removes the entire capture directory. It takes the accountant lock so the whole-dir removal and the accounting reset are atomic against concurrent spills (#69): a store() that raced in first is fully accounted, and one that races AFTER this fails its write with ENOENT (the dir is gone) → the failed-write inline fallback, never a tracked FilePath into a directory that no longer exists. groups/diskUsed are reset to zero so DiskUsed() does not go stale after cleanup.
func (*CaptureManager) CleanupRequest ¶
func (cm *CaptureManager) CleanupRequest(requestID string)
CleanupRequest removes disk files associated with a specific request, routing through the accountant (#69) so the freed bytes are subtracted from capture_disk_used. Idempotent — a record already budget-evicted is a safe no-op — so a ring eviction or PurgeByProject following a budget eviction does not double-count.
func (*CaptureManager) DiskBudget ¶ added in v0.2.3
func (cm *CaptureManager) DiskBudget() int64
DiskBudget returns the current effective capture disk budget in bytes (#69).
func (*CaptureManager) DiskStats ¶ added in v0.2.3
func (cm *CaptureManager) DiskStats() (used, budget int64)
DiskStats returns capture_disk_used and capture_disk_budget read under ONE accountant lock (#69), so a consumer never publishes a used/budget pair that never coexisted. The daemon /status handler uses this rather than separate DiskUsed()/DiskBudget() calls.
func (*CaptureManager) DiskUsed ¶ added in v0.2.3
func (cm *CaptureManager) DiskUsed() int64
DiskUsed returns the total logical bytes of spilled capture body files currently accounted (capture_disk_used, #69).
func (*CaptureManager) Enabled ¶
func (cm *CaptureManager) Enabled() bool
Enabled returns whether capture is enabled.
func (*CaptureManager) FinalizeResponse ¶ added in v0.2.0
func (cm *CaptureManager) FinalizeResponse(requestID string, crw *CaptureResponseWriter) (*CapturedBody, http.Header)
FinalizeResponse captures the response body from a CaptureResponseWriter and returns the cloned response headers. Should be called after the response has been fully written. A disabled manager stores nothing but still returns the header clone.
func (*CaptureManager) LoadBody ¶
func (cm *CaptureManager) LoadBody(body *CapturedBody) ([]byte, error)
LoadBody loads a captured body's data, reading from disk if necessary. Returns a copy of the data to prevent callers from modifying the original. FilePath bodies are constrained to the manager's own capture directory via LoadCapturedBody's allowlist.
func (*CaptureManager) SetDiskBudget ¶ added in v0.2.3
func (cm *CaptureManager) SetDiskBudget(budget int64)
SetDiskBudget updates the capture disk budget and enforces it immediately (#69): a budget-lowering re-register (or standalone construction) evicts oldest record groups until the total spilled bytes fit, without waiting for the next spill. A non-positive budget resets to DefaultCaptureDiskBudget.
func (*CaptureManager) WrapResponseWriter ¶ added in v0.2.0
func (cm *CaptureManager) WrapResponseWriter(w http.ResponseWriter, maxBodySize int64) *CaptureResponseWriter
WrapResponseWriter wraps w in a CaptureResponseWriter that records up to maxBodySize bytes (D13, #49) while forwarding all writes downstream: the daemon passes the matched route's cap so each project honors its own quota, and a maxBodySize of 0 falls back to the manager's configured cap (see effectiveLimit). The returned writer preserves http.Flusher/Hijacker/Pusher/Unwrap behavior.
type CaptureResponseWriter ¶ added in v0.2.0
type CaptureResponseWriter struct {
http.ResponseWriter
// contains filtered or unexported fields
}
CaptureResponseWriter wraps an http.ResponseWriter to capture the response body. It intercepts writes to capture up to maxBodySize bytes while still forwarding all data to the underlying ResponseWriter. It also implements http.Flusher, http.Hijacker, and http.Pusher for compatibility with streaming and WebSocket connections.
func (*CaptureResponseWriter) CapturedBody ¶ added in v0.2.0
func (crw *CaptureResponseWriter) CapturedBody() []byte
CapturedBody returns the captured response body.
func (*CaptureResponseWriter) Flush ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Flush()
Flush implements http.Flusher for streaming responses (SSE).
func (*CaptureResponseWriter) Hijack ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error)
Hijack implements http.Hijacker for WebSocket support.
func (*CaptureResponseWriter) Hijacked ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Hijacked() bool
Hijacked reports whether the connection was taken over (WebSocket upgrade). After a hijack all traffic bypasses this writer, so the captured status/body do not describe the response — callers should record metadata only rather than finalize garbage Details. Single-goroutine access per the http.ResponseWriter contract.
func (*CaptureResponseWriter) Push ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Push(target string, opts *http.PushOptions) error
Push implements http.Pusher for HTTP/2 server push.
func (*CaptureResponseWriter) SetFirstResponseCallback ¶ added in v0.2.0
func (h *CaptureResponseWriter) SetFirstResponseCallback(fn func(statusCode int))
SetFirstResponseCallback registers a callback that fires exactly once at the first FINAL response event (see fireFirstResponse).
func (*CaptureResponseWriter) StatusCode ¶ added in v0.2.0
func (crw *CaptureResponseWriter) StatusCode() int
StatusCode returns the captured status code.
func (*CaptureResponseWriter) TotalSeen ¶ added in v0.2.0
func (crw *CaptureResponseWriter) TotalSeen() int64
TotalSeen returns the total number of bytes observed by Write, counting bytes that were not retained after truncation.
func (*CaptureResponseWriter) Truncated ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Truncated() bool
Truncated returns whether the body was truncated.
func (*CaptureResponseWriter) Unwrap ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Unwrap() http.ResponseWriter
Unwrap returns the underlying ResponseWriter for Go 1.20+ http.ResponseController compatibility.
func (*CaptureResponseWriter) Write ¶ added in v0.2.0
func (crw *CaptureResponseWriter) Write(p []byte) (int, error)
func (*CaptureResponseWriter) WriteHeader ¶ added in v0.2.0
func (crw *CaptureResponseWriter) WriteHeader(code int)
type CapturedBody ¶
type CapturedBody struct {
// Size is the total bytes observed by the capture wrapper, including
// bytes discarded past the truncation cap (not Content-Length, not
// decoded size).
Size int64 `json:"size"`
CapturedSize int64 `json:"captured_size"` // Bytes actually retained after truncation
Truncated bool `json:"truncated"` // True if body was truncated due to size limit
ContentType string `json:"content_type"` // Content-Type header value
ContentEncoding string `json:"content_encoding,omitempty"` // Content-Encoding header value (raw wire bytes; not decoded here)
IsBinary bool `json:"is_binary"` // True if body appears to be binary data
Data []byte `json:"data"` // Inline data for small bodies
FilePath string `json:"file_path"` // Disk path for large bodies (Data is nil when set)
// Evicted marks a body whose DATA is gone but whose metadata (and the
// record's headers) is retained: the record fell outside the ring's
// captured-body detail window (constants.ProxyRequestDetailWindow, D9b), so
// Data was dropped and any spilled file unlinked. LoadCapturedBody turns
// this into an os.ErrNotExist-wrapped error so it flows through the SAME
// path as a disk-budget-evicted file and reports unavailable_reason
// "evicted" — callers need no new case. It crosses the daemon→forwarder
// wire so a backfilled record arrives already marked.
Evicted bool `json:"evicted,omitempty"`
}
CapturedBody represents a captured request or response body.
type DecodedBody ¶ added in v0.2.0
type DecodedBody struct {
// Data is the bytes to serve. For a supported, successful decode these are
// the decoded bytes; otherwise they are the raw retained bytes.
Data []byte
// IsBinary reflects the SERVED bytes (post-decode). It may legitimately
// diverge from CapturedBody.IsBinary, which describes the raw wire bytes.
IsBinary bool
// ContentEncoding is the stored (lowercased/trimmed) content-encoding token,
// empty for identity/unencoded bodies.
ContentEncoding string
// Available is false when the body could not be loaded (e.g. its disk file
// was evicted). When false, Data is nil and UnavailableReason explains why.
Available bool
// Available is false (e.g. "evicted").
UnavailableReason string
}
DecodedBody is the structured result of loading and content-decoding a captured body. It carries decoded (serve-ready) bytes plus the semantics a caller needs to render them safely.
func DecodeCapturedBody ¶ added in v0.2.0
func DecodeCapturedBody(body *CapturedBody, raw []byte) DecodedBody
DecodeCapturedBody content-decodes raw (per body.ContentEncoding) and returns serve-ready bytes with post-decode binary semantics.
Supported encodings are gzip/x-gzip, deflate (zlib-wrapped per RFC 9110, with a fallback to raw deflate for servers that send it unwrapped), zstd, and br (case-insensitive, surrounding whitespace tolerated); identity/empty means no decode. Any other token (chained values like "gzip, br"), a truncated body, a decode failure, or a decoded size exceeding the cap all fall back to serving the raw bytes with IsBinary=true so a JSON string conversion cannot mangle them.
func LoadDecodedBody ¶ added in v0.2.0
func LoadDecodedBody(body *CapturedBody, allowedDirs []string) (DecodedBody, error)
LoadDecodedBody composes LoadCapturedBody + DecodeCapturedBody.
A nil body yields an unavailable result. A body whose data is simply gone — a missing capture file (the record is still valid, but its FilePath body was evicted/removed) or a body the ring marked Evicted on leaving the detail window (D9b) — is treated as a benign condition (D7): the result is marked unavailable with reason "evicted" and a nil error, so the caller returns HTTP 200 with no data rather than failing the request. Both cases are detected as errors.Is(err, fs.ErrNotExist) — not os.IsNotExist, which does not unwrap %w-wrapped sentinels. Any other load failure (e.g. an out-of-allowlist path or an I/O error) is marked unavailable with reason "unavailable" and returned together with the underlying error so callers can log it.
type EvictionCallback ¶
type EvictionCallback func(id string)
EvictionCallback is called when a request is evicted from the ring buffer. It receives the request ID for cleanup purposes.
type PortConflictError ¶
type PortConflictError struct {
Port int
Protocol string // "HTTP" or "HTTPS"
Cause error // original net.Listen error
}
PortConflictError carries metadata about which port and protocol conflicted. It wraps both the ErrPortInUse sentinel and the original OS error so that errors.Is works for either (Go 1.20+ multi-unwrap).
func (*PortConflictError) Error ¶
func (e *PortConflictError) Error() string
func (*PortConflictError) Unwrap ¶
func (e *PortConflictError) Unwrap() []error
type RequestDetails ¶
type RequestDetails struct {
RequestHeaders map[string][]string `json:"request_headers,omitempty"`
ResponseHeaders map[string][]string `json:"response_headers,omitempty"`
RequestBody *CapturedBody `json:"request_body,omitempty"`
ResponseBody *CapturedBody `json:"response_body,omitempty"`
}
RequestDetails contains captured request/response headers and bodies.
type RequestFilter ¶
type RequestFilter struct {
Subdomain string
Hostnames []string // match if record.Hostname is in this list (empty = match all)
// URLContains matches if record.URL contains this substring
// (case-insensitive). URL is path+query only (no scheme/host, matching
// the TUI 's' filter's reference behavior), since RequestRecord.URL is
// populated from r.URL.String() on the server side. Empty = match all.
URLContains string
ProjectDir string // match if record.ProjectDir equals this exactly (empty = match all)
Method string
MinStatus int
MaxStatus int
Since time.Time
Limit int
// BeforeID anchors RecentPage (and, transitively, Recent) at the ring
// position of the record with this ID: only records strictly OLDER than
// the anchor, BY RING POSITION, are considered (D12, #50). This is
// arrival order, not time order. Backfill interleavings and
// completion-after-eviction re-appends (see Upsert: an in-flight record
// whose original slot was evicted re-enters the ring at the newest
// position when its completion arrives) mean a record's ring position
// can diverge from its Timestamp — a page can legitimately contain a
// record with a newer Timestamp than the anchor. Empty = no anchor
// (start at the newest record, i.e. today's Recent behavior).
BeforeID string
}
RequestFilter specifies criteria for filtering requests.
type RequestManager ¶
type RequestManager struct {
// contains filtered or unexported fields
}
RequestManager tracks proxied requests in a ring buffer and supports subscriptions.
func NewReplicaRequestManager ¶ added in v0.3.0
func NewReplicaRequestManager(capacity int) *RequestManager
NewReplicaRequestManager creates a request manager for a ring that REPLICATES records captured elsewhere (the forwarder-fed project-local ring in shared mode) rather than owning capture itself. It runs the timestamp-ordered body window (bodyWindow) instead of the position-ordered detail window, bounding retained INLINE body data at constants.ProxyRequestDetailWindow records — parity with a capture-owning ring's inline worst case.
A replica needs its own bound because upstream stripping does not reach it: the daemon's detail window publishes no event when it drops a body, so a record forwarded LIVE arrives with its body and keeps it locally forever, long after the daemon has evicted it. Only BACKFILL-delivered records past the daemon's window arrive already marked CapturedBody.Evicted.
The window is ordered by the daemon-supplied Timestamp, not by ring position, because position on a replica is not recency: the forwarder's backfill deliberately races the live event stream and Upsert treats a final record as terminal, so a record delivered live BEFORE its backfill copy sinks toward the ring's oldest position while still being one of the newest requests. A position window would strip its body while the daemon still serves it; timestamp order ranks it correctly, because Timestamp is set once daemon-side and rides the wire verbatim. That is a bounded approximation of daemon truth, not a mirror of it — a clock step can change WHICH record is stripped, never HOW MANY retain data.
Spilled (FilePath) bodies are exempt: they cost this ring no memory and the daemon's disk truth is authoritative on load. See the bodyWindow field.
func NewRequestManager ¶
func NewRequestManager(capacity int) *RequestManager
NewRequestManager creates a new request manager with the specified buffer capacity and the default captured-body detail window (constants.ProxyRequestDetailWindow).
func (*RequestManager) Close ¶
func (m *RequestManager) Close()
Close closes all subscription channels and cleans up resources. It latches: subsequent Subscribe calls receive an already-closed channel, so a stream request racing a shutdown-time Close cannot re-subscribe and pin the API server open. Idempotent.
func (*RequestManager) Count ¶
func (m *RequestManager) Count() int
Count returns the number of requests currently in the buffer.
func (*RequestManager) DroppedEvents ¶ added in v0.2.1
func (m *RequestManager) DroppedEvents() int64
DroppedEvents returns the manager-wide number of subscriber notifications dropped because a subscription's channel was full (D9). It is monotonic for the life of the manager.
func (*RequestManager) GetByID ¶
func (m *RequestManager) GetByID(id string) (RequestRecord, bool)
GetByID returns a request record by its ID. Returns the record and true if found, or an empty record and false if not found.
func (*RequestManager) PurgeByProject ¶ added in v0.2.0
func (m *RequestManager) PurgeByProject(projectDir string)
PurgeByProject removes all records owned by the given project from the ring buffer and calls the eviction callback for each purged record that carried captured Details (so its on-disk body files get cleaned up). It compacts the buffer to preserve the contiguous ring invariant. Scoping by project (not hostname) ensures two projects sharing a hostname on different ports don't purge each other's records.
The compaction needs no detail-window work (D9b): it preserves arrival order and only removes records, so a surviving record's newest→oldest offset can only shrink. Records can move INTO the window (they stay stripped — evicted bodies are gone for good) but never out of it, so no new violation is created. The replica's timestamp-ordered body window is keyed by SLOT rather than by offset, so compaction DOES invalidate it; it is rebuilt below.
func (*RequestManager) Recent ¶
func (m *RequestManager) Recent(filter RequestFilter) []RequestRecord
Recent returns the most recent requests matching the filter. It is RecentPage without the cursor metadata, kept as the simple signature for the many callers (CLI, daemon-internal endpoints, tests) that don't page.
func (*RequestManager) RecentPage ¶ added in v0.2.1
func (m *RequestManager) RecentPage(filter RequestFilter) (records []RequestRecord, nextBeforeID string, anchorFound bool)
RecentPage returns up to filter.Limit matching records, newest first, optionally anchored at filter.BeforeID (ring-position cursor pagination, D12/#50). It is the shared scan behind Recent.
nextBeforeID is the ID of the OLDEST SCANNED record in this call's scan window — not the oldest RETURNED record. The scan continues past non-matching records (up to the ring's oldest record) until it collects filter.Limit matches, so a page whose filter excludes everything remaining still advances all the way through the ring and reports where it stopped, rather than stalling a poller that keeps re-requesting the same cursor. nextBeforeID is empty when the scan reached the ring's oldest record (no older records remain: this is the last page).
anchorFound is true when filter.BeforeID is empty (no anchor requested) or names a record that both exists in the ring and matches the rest of filter. An anchor that is unknown, evicted, or excluded by scope (e.g. a different filter.ProjectDir than the anchor's) reports anchorFound=false with no records — callers must treat both cases identically (410 Gone) so an out-of-scope anchor can't be distinguished from a nonexistent one and leak the other scope's record existence.
func (*RequestManager) Record ¶
func (m *RequestManager) Record(record RequestRecord) bool
Record adds a new request record to the buffer and notifies subscribers. If the record doesn't have an ID, one is generated. It reports whether the record was accepted: false means the manager was already Closed (see writesClosed) and the caller owns any capture-file cleanup for the record.
func (*RequestManager) SetEvictionCallback ¶
func (m *RequestManager) SetEvictionCallback(fn EvictionCallback)
SetEvictionCallback sets the callback to be invoked when requests are evicted.
func (*RequestManager) Subscribe ¶
func (m *RequestManager) Subscribe(filter RequestFilter) *RequestSubscription
Subscribe creates a subscription for real-time request updates. After Close, the returned subscription's channel is already closed, so an SSE handler that races the shutdown-time Close observes end-of-stream immediately instead of blocking on a channel nothing will ever close.
func (*RequestManager) Unsubscribe ¶
func (m *RequestManager) Unsubscribe(id string)
Unsubscribe removes a subscription.
func (*RequestManager) Upsert ¶ added in v0.2.0
func (m *RequestManager) Upsert(record RequestRecord) bool
Upsert applies a record as a monotonic two-state transition keyed by ID:
existing absent → append (Record's eviction logic) + notify existing in-flight, incoming in-flight → no-op (duplicate delivery) existing in-flight, incoming final → replace in place + notify existing final → no-op (final is terminal)
The no-op rows make concurrent interleavings safe: replaying a snapshot while live stream events apply converges to the final record in any order, with no duplicate or regressed notifications. Replace-in-place keeps the ring slot (no head/count change, no ring eviction) — safe because in-flight records carry no Details, so the transition only ever adds capture state. The one exception is the captured-body windows: a completion landing in a slot that has already aged out of the POSITION window (D9b) has its bodies stripped before it is stored, so a slow request cannot reintroduce body data outside the window, and on a replica the completion joins the timestamp window (bodyWindow) and may be stripped by it — including by its own arrival. Either way the record NOTIFIED is the record stored (see the case below).
Unlike Record, subscribers are notified INSIDE the ring critical section: same-ID notifications can never be observed out of transition order. notifySubscribers only performs non-blocking channel sends (plus, on overflow, a subscription removal) under subMu, and no path acquires mu while holding subMu, so this cannot block or deadlock. The eviction callback (disk IO) still runs after unlock. Upsert reports whether the record was accepted; false means the manager was already Closed (writesClosed) and the caller owns capture-file cleanup.
type RequestRecord ¶
type RequestRecord struct {
// ID is a 12-character hash generated from timestamp, method, and URL.
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
URL string `json:"url"`
Subdomain string `json:"subdomain"`
Hostname string `json:"hostname,omitempty"` // full hostname (e.g., api.local.dev)
StatusCode int `json:"status_code"`
Duration time.Duration `json:"duration"`
RemoteAddr string `json:"remote_addr"`
// ProjectDir identifies the project that owns the route this request was
// proxied for. Set daemon-side so records/purges are scoped to a project
// even when two projects own the same hostname on different ports.
ProjectDir string `json:"project_dir,omitempty"`
// InFlight marks a record published at response-header time, before the
// response body finished. Such records carry the header-time status but
// zero Duration and nil Details; a completion update (same ID, InFlight
// false) replaces them via Upsert. Completed records are terminal:
// omitempty keeps their JSON identical to the pre-in-flight wire format.
InFlight bool `json:"in_flight,omitempty"`
// Details contains captured headers and bodies (nil when capture is disabled)
Details *RequestDetails `json:"details,omitempty"`
}
RequestRecord represents a single proxied request.
func (RequestRecord) StaleAt ¶ added in v0.2.1
func (r RequestRecord) StaleAt(now time.Time) bool
StaleAt reports whether this record is stale as of now (D8, #53): still marked in-flight, but running longer than constants.InFlightStaleAfter. "Stale" means completion-unknown, not broken — see the constant's doc comment. Final (non-in-flight) records are never stale: their completion, whatever it was, is already known. This is the single staleness check; every consumer (API response conversion, CLI rendering, TUI rendering) calls it rather than re-deriving the condition.
type RequestSubscription ¶
type RequestSubscription struct {
ID string
Filter RequestFilter
Ch chan RequestRecord
// contains filtered or unexported fields
}
RequestSubscription represents a subscription to request updates.
dropped counts events this subscription lost because its channel was full when notifySubscribers tried to deliver (D9). It is an atomic so notifySubscribers can bump it under the manager's read lock; the first drop (0→1 transition) logs once so a slow subscriber is visible without spamming.
closed latches the channel close so the several paths that can end a subscription — Unsubscribe, manager Close, and the overflow drop (C6) — can never double-close it.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service manages the HTTP/HTTPS reverse proxy servers.
func NewService ¶
func NewService(cfg *config.ProxyConfig, services map[string]config.ServiceConfig, certsCfg *config.CertsConfig, logger *slog.Logger, workDir string) (*Service, error)
NewService creates a new proxy service. Returns an error if cfg is nil when proxy is expected to be enabled. workDir is used for storing captured request/response bodies on disk.
func (*Service) CaptureManager ¶
func (s *Service) CaptureManager() *CaptureManager
CaptureManager returns the capture manager for loading captured bodies.
func (*Service) RequestManager ¶
func (s *Service) RequestManager() *RequestManager
RequestManager returns the request manager for tracking proxy requests.