diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1ed18b7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,248 @@
+
+
+[![Contributors][contributors-shield]][contributors-url]
+[![Forks][forks-shield]][forks-url]
+[![Stargazers][stars-shield]][stars-url]
+[![Issues][issues-shield]][issues-url]
+[![Apache 2.0 License][license-shield]][license-url]
+
+
+
+
httpfx
+
+ Uber Fx module for net/http.Client with SOCKS5, HTTP proxy, and environment-based proxy support.
+
+
Report Bug
+ ·
+
Request Feature
+
+
+## Table of Contents
+- [Table of Contents](#table-of-contents)
+- [About The Project](#about-the-project)
+ - [Built With](#built-with)
+- [Getting Started](#getting-started)
+ - [Prerequisites](#prerequisites)
+ - [Installation](#installation)
+- [Usage](#usage)
+ - [Module Setup](#module-setup)
+ - [Configuration Reference](#configuration-reference)
+ - [Factory \& Per-Client Options](#factory--per-client-options)
+ - [Available Options](#available-options)
+ - [Proxy Examples](#proxy-examples)
+- [Roadmap](#roadmap)
+- [Contributing](#contributing)
+- [License](#license)
+- [Acknowledgments](#acknowledgments)
+
+
+---
+
+## About The Project
+
+`httpfx` is an [Uber Fx](https://uber-go.github.io/fx/) module that provides a configured `*http.Client` and a `Factory` for creating additional client instances. It supports:
+
+- **SOCKS5 proxy** via `golang.org/x/net/proxy` (`socks5://user:pass@host:port`)
+- **HTTP-level proxy** via `net/http.Transport.Proxy` (`http://proxy:8080`)
+- **Environment-based proxy** via `ALL_PROXY` env var
+- **Per-host proxy bypass** for hosts that should connect directly
+- **Per-client overrides** via functional options on the `Factory`
+- **Transport tuning** — idle connections, timeouts, pool sizes
+
+### Built With
+
+- [](https://go.dev/)
+- [](https://uber-go.github.io/fx/)
+- [](https://pkg.go.dev/golang.org/x/net/proxy)
+
+(back to top)
+
+---
+
+## Getting Started
+
+### Prerequisites
+
+- Go 1.25+
+- An application using [Uber Fx](https://uber-go.github.io/fx/) for dependency injection
+
+### Installation
+
+```sh
+go get github.com/go-core-fx/httpfx@latest
+```
+
+(back to top)
+
+---
+
+## Usage
+
+### Module Setup
+
+```go
+import (
+ "time"
+
+ "github.com/go-core-fx/httpfx"
+ "go.uber.org/fx"
+)
+
+func main() {
+ fx.New(
+ fx.Provide(func() httpfx.Config {
+ return httpfx.Config{
+ ProxyURL: "socks5://127.0.0.1:1080",
+ Bypass: "localhost,127.0.0.1",
+ Timeout: 30 * time.Second,
+ }
+ }),
+ httpfx.Module(),
+ // ... other modules
+ ).Run()
+}
+```
+
+The module provides both a default `*http.Client` and a `Factory` for creating additional clients.
+
+### Configuration Reference
+
+| Field | Type | Default | Description |
+| --------------------- | --------------- | ------- | --------------------------------------------------------------------------------- |
+| `ProxyURL` | `string` | `""` | SOCKS5 proxy URL (e.g. `socks5://user:pass@host:port`). Takes highest precedence. |
+| `ProxyFromEnv` | `bool` | `false` | Read proxy from `ALL_PROXY` env var when `ProxyURL` is empty. |
+| `Bypass` | `string` | `""` | Comma-separated hosts to bypass the SOCKS proxy (e.g. `localhost,127.0.0.1`). |
+| `Timeout` | `time.Duration` | `0` | Client-level request timeout. Zero means no timeout. |
+| `MaxIdleConns` | `int` | `0` | Maximum idle (keep-alive) connections. Zero means no limit. |
+| `MaxIdleConnsPerHost` | `int` | `0` | Maximum idle connections per host. Zero means Go default (2). |
+| `IdleConnTimeout` | `time.Duration` | `0` | Maximum time a connection stays idle. Zero means no timeout. |
+
+**Proxy precedence:** `ProxyURL` → `ProxyFromEnv`. To disable all proxying, clear `ProxyURL` and set `ProxyFromEnv: false`.
+
+### Factory & Per-Client Options
+
+Inject `httpfx.Factory` to create additional clients with shared base config but per-client overrides:
+
+```go
+func Handler(f httpfx.Factory) {
+ // Default client — uses factory base config
+ defaultClient := f.NewClient()
+
+ // Override timeout for a fast endpoint
+ apiClient := f.NewClient(httpfx.WithTimeout(5 * time.Second))
+
+ // Disable proxy for internal service calls
+ internalClient := f.NewClient(httpfx.WithProxyURL(""))
+
+ // Custom transport for a specific module
+ uploadClient := f.NewClient(
+ httpfx.WithTimeout(5 * time.Minute),
+ httpfx.WithMaxIdleConns(10),
+ )
+}
+```
+
+#### Available Options
+
+| Option | Description |
+| ---------------------------- | -------------------------------------- |
+| `WithProxyURL(url)` | Override SOCKS5 proxy URL |
+| `WithProxyFromEnv(v)` | Override env-based proxy flag |
+| `WithBypass(bypass)` | Override proxy bypass list |
+| `WithTimeout(d)` | Override client timeout |
+| `WithMaxIdleConns(n)` | Override max idle connections |
+| `WithMaxIdleConnsPerHost(n)` | Override max idle connections per host |
+| `WithIdleConnTimeout(d)` | Override idle connection timeout |
+
+### Proxy Examples
+
+**SOCKS5 with authentication:**
+
+```go
+httpfx.Config{
+ ProxyURL: "socks5://user:pass@127.0.0.1:1080",
+}
+```
+
+**Environment-based (reads `ALL_PROXY`):**
+
+```go
+httpfx.Config{
+ ProxyFromEnv: true,
+}
+```
+
+```sh
+export ALL_PROXY="socks5://127.0.0.1:1080"
+```
+
+**SOCKS5 with bypass for local addresses and CIDR ranges:**
+
+```go
+httpfx.Config{
+ ProxyURL: "socks5://127.0.0.1:1080",
+ Bypass: "localhost,127.0.0.1,192.168.0.0/16",
+}
+```
+
+(back to top)
+
+---
+
+## Roadmap
+
+- [x] SOCKS5 proxy support via `golang.org/x/net/proxy`
+- [x] Environment-based proxy (`ALL_PROXY`)
+- [x] Per-host proxy bypass
+- [x] Factory with per-client functional options
+- [x] Transport tuning (idle connections, timeouts)
+- [ ] TLS configuration options
+- [ ] Proxy authentication header support
+
+See the [open issues](https://github.com/go-core-fx/httpfx/issues) for a full list of proposed features.
+
+(back to top)
+
+---
+
+## Contributing
+
+Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
+
+1. Fork the Project
+2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
+3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the Branch (`git push origin feature/AmazingFeature`)
+5. Open a Pull Request
+
+(back to top)
+
+---
+
+## License
+
+Distributed under the Apache License 2.0. See `LICENSE` for more information.
+
+(back to top)
+
+---
+
+## Acknowledgments
+
+- [Uber Fx](https://uber-go.github.io/fx/) — dependency injection framework
+- [golang.org/x/net/proxy](https://pkg.go.dev/golang.org/x/net/proxy) — SOCKS5 and environment-based proxy dialers
+- [Best-README-Template](https://github.com/othneildrew/Best-README-Template) — README structure
+
+(back to top)
+
+
+[contributors-shield]: https://img.shields.io/github/contributors/go-core-fx/httpfx.svg?style=for-the-badge
+[contributors-url]: https://github.com/go-core-fx/httpfx/graphs/contributors
+[forks-shield]: https://img.shields.io/github/forks/go-core-fx/httpfx.svg?style=for-the-badge
+[forks-url]: https://github.com/go-core-fx/httpfx/network/members
+[stars-shield]: https://img.shields.io/github/stars/go-core-fx/httpfx.svg?style=for-the-badge
+[stars-url]: https://github.com/go-core-fx/httpfx/stargazers
+[issues-shield]: https://img.shields.io/github/issues/go-core-fx/httpfx.svg?style=for-the-badge
+[issues-url]: https://github.com/go-core-fx/httpfx/issues
+[license-shield]: https://img.shields.io/github/license/go-core-fx/httpfx.svg?style=for-the-badge
+[license-url]: https://github.com/go-core-fx/httpfx/blob/master/LICENSE
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..5021736
--- /dev/null
+++ b/client.go
@@ -0,0 +1,107 @@
+package httpfx
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+
+ "golang.org/x/net/proxy"
+)
+
+func newClient(config Config) (*http.Client, error) {
+ transport := &http.Transport{
+ MaxIdleConns: config.MaxIdleConns,
+ MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
+ IdleConnTimeout: config.IdleConnTimeout,
+ }
+
+ if err := applyProxy(transport, config); err != nil {
+ return nil, err
+ }
+
+ return &http.Client{
+ Transport: transport,
+ Timeout: config.Timeout,
+ }, nil
+}
+
+func applyProxy(transport *http.Transport, config Config) error {
+ switch {
+ case config.ProxyURL != "":
+ return applySOCKSProxy(transport, config.ProxyURL, config.Bypass)
+ case config.ProxyFromEnv:
+ return applyEnvProxy(transport, config.Bypass)
+ default:
+ return nil
+ }
+}
+
+func applySOCKSProxy(transport *http.Transport, rawURL, bypass string) error {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("%w", ErrInvalidProxyURL)
+ }
+
+ if u.Hostname() == "" {
+ return fmt.Errorf("%w: empty hostname", ErrInvalidProxyURL)
+ }
+
+ dialer, err := proxy.FromURL(u, proxy.Direct)
+ if err != nil {
+ return fmt.Errorf("%w: %w", ErrProxyDialFailed, err)
+ }
+
+ dialer = applyBypass(dialer, bypass)
+ setTransportDialer(transport, dialer)
+
+ return nil
+}
+
+func applyEnvProxy(transport *http.Transport, bypass string) error {
+ hasProxyEnv := os.Getenv("ALL_PROXY") != "" || os.Getenv("all_proxy") != ""
+
+ dialer := proxy.FromEnvironment()
+
+ if dialer == proxy.Direct {
+ if hasProxyEnv {
+ return fmt.Errorf("%w: proxy environment variable set but invalid", ErrInvalidProxyURL)
+ }
+ return nil
+ }
+
+ dialer = applyBypass(dialer, bypass)
+ setTransportDialer(transport, dialer)
+
+ return nil
+}
+
+func applyBypass(dialer proxy.Dialer, bypass string) proxy.Dialer {
+ bypass = strings.TrimSpace(bypass)
+ if bypass == "" {
+ return dialer
+ }
+
+ perHost := proxy.NewPerHost(dialer, proxy.Direct)
+ perHost.AddFromString(bypass)
+
+ return perHost
+}
+
+func setTransportDialer(transport *http.Transport, dialer proxy.Dialer) {
+ if cd, ok := dialer.(proxy.ContextDialer); ok {
+ transport.DialContext = cd.DialContext
+ return
+ }
+
+ transport.DialContext = func(
+ _ context.Context,
+ network,
+ addr string,
+ ) (net.Conn, error) {
+ return dialer.Dial(network, addr)
+ }
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..9c77bd2
--- /dev/null
+++ b/config.go
@@ -0,0 +1,33 @@
+package httpfx
+
+import "time"
+
+// Config holds the HTTP client configuration.
+type Config struct {
+ // ProxyURL is an explicit SOCKS5 proxy URL (e.g., "socks5://user:pass@host:port").
+ // Empty means no proxy. Takes precedence over ProxyFromEnv.
+ ProxyURL string
+
+ // ProxyFromEnv enables reading the proxy from the ALL_PROXY environment variable.
+ // Used only when ProxyURL is empty.
+ ProxyFromEnv bool
+
+ // Bypass is a comma-separated list of hosts that bypass the proxy
+ // (e.g., "localhost,127.0.0.1").
+ Bypass string
+
+ // Timeout is the HTTP client-level timeout. Zero means no timeout.
+ Timeout time.Duration
+
+ // MaxIdleConns is the maximum number of idle (keep-alive) connections.
+ // Zero means no limit.
+ MaxIdleConns int
+
+ // MaxIdleConnsPerHost is the maximum idle connections per host.
+ // Zero means DefaultMaxIdleConnsPerHost (2).
+ MaxIdleConnsPerHost int
+
+ // IdleConnTimeout is the maximum time an idle connection is kept alive.
+ // Zero means no timeout.
+ IdleConnTimeout time.Duration
+}
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..53d03af
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,14 @@
+package httpfx
+
+import "errors"
+
+var (
+ // ErrInvalidConfig is returned when the HTTP client configuration is invalid.
+ ErrInvalidConfig = errors.New("invalid config")
+
+ // ErrInvalidProxyURL is returned when the proxy URL cannot be parsed.
+ ErrInvalidProxyURL = errors.New("invalid proxy URL")
+
+ // ErrProxyDialFailed is returned when the proxy dialer cannot be created.
+ ErrProxyDialFailed = errors.New("proxy dialer creation failed")
+)
diff --git a/factory.go b/factory.go
new file mode 100644
index 0000000..8db0ceb
--- /dev/null
+++ b/factory.go
@@ -0,0 +1,50 @@
+package httpfx
+
+import (
+ "net/http"
+
+ "go.uber.org/zap"
+)
+
+// Factory creates [http.Client] instances with shared proxy and transport configuration.
+type Factory interface {
+ // NewClient creates a new [http.Client] using the factory's base configuration,
+ // with optional per-client overrides via [Option].
+ NewClient(opts ...Option) *http.Client
+}
+
+type factory struct {
+ config Config
+ logger *zap.Logger
+}
+
+// NewFactory creates a new Factory from the provided configuration.
+func NewFactory(config Config, logger *zap.Logger) Factory {
+ return &factory{
+ config: config,
+ logger: logger,
+ }
+}
+
+// NewClient implements [Factory].
+func (f *factory) NewClient(opts ...Option) *http.Client {
+ cfg := f.config
+
+ if len(opts) > 0 {
+ co := new(clientOptions)
+ for _, opt := range opts {
+ opt(co)
+ }
+
+ cfg = co.apply(cfg)
+ }
+
+ client, err := newClient(cfg)
+ if err != nil {
+ f.logger.Error("failed to create HTTP client", zap.Error(err))
+
+ return http.DefaultClient
+ }
+
+ return client
+}
diff --git a/go.mod b/go.mod
index 9cfe8b1..b77cbba 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,16 @@
-module github.com/go-core-fx/template
+module github.com/go-core-fx/httpfx
-go 1.24.3
+go 1.25.0
require (
github.com/go-core-fx/logger v0.0.1
go.uber.org/fx v1.24.0
+ go.uber.org/zap v1.28.0
+ golang.org/x/net v0.57.0
)
require (
go.uber.org/dig v1.19.0 // indirect
- go.uber.org/multierr v1.10.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
- golang.org/x/sys v0.36.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
)
diff --git a/go.sum b/go.sum
index 4f7bf24..9cb2759 100644
--- a/go.sum
+++ b/go.sum
@@ -14,9 +14,19 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
+golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/module.go b/module.go
index 2770bec..d549558 100644
--- a/module.go
+++ b/module.go
@@ -1,16 +1,21 @@
-package template
+package httpfx
import (
+ "net/http"
+
"github.com/go-core-fx/logger"
"go.uber.org/fx"
)
-const ModuleName = "template"
+const ModuleName = "httpfx"
func Module() fx.Option {
return fx.Module(
ModuleName,
logger.WithNamedLogger(ModuleName),
- // fx.Provide(New),
+ fx.Provide(NewFactory),
+ fx.Provide(func(f Factory) *http.Client {
+ return f.NewClient()
+ }),
)
}
diff --git a/options.go b/options.go
new file mode 100644
index 0000000..08b29df
--- /dev/null
+++ b/options.go
@@ -0,0 +1,85 @@
+package httpfx
+
+import "time"
+
+// Option configures per-client overrides on [Factory.NewClient].
+type Option func(*clientOptions)
+
+type clientOptions struct {
+ proxyURL *string
+ proxyFromEnv *bool
+ bypass *string
+ timeout *time.Duration
+ maxIdleConns *int
+ maxIdleConnsPerHost *int
+ idleConnTimeout *time.Duration
+}
+
+func (o *clientOptions) apply(base Config) Config {
+ cfg := base
+
+ if o.proxyURL != nil {
+ cfg.ProxyURL = *o.proxyURL
+ }
+
+ if o.proxyFromEnv != nil {
+ cfg.ProxyFromEnv = *o.proxyFromEnv
+ }
+
+ if o.bypass != nil {
+ cfg.Bypass = *o.bypass
+ }
+
+ if o.timeout != nil {
+ cfg.Timeout = *o.timeout
+ }
+
+ if o.maxIdleConns != nil {
+ cfg.MaxIdleConns = *o.maxIdleConns
+ }
+
+ if o.maxIdleConnsPerHost != nil {
+ cfg.MaxIdleConnsPerHost = *o.maxIdleConnsPerHost
+ }
+
+ if o.idleConnTimeout != nil {
+ cfg.IdleConnTimeout = *o.idleConnTimeout
+ }
+
+ return cfg
+}
+
+// WithProxyURL overrides the SOCKS5 proxy URL for this client.
+func WithProxyURL(rawURL string) Option {
+ return func(o *clientOptions) { o.proxyURL = &rawURL }
+}
+
+// WithProxyFromEnv overrides the ProxyFromEnv flag for this client.
+func WithProxyFromEnv(v bool) Option {
+ return func(o *clientOptions) { o.proxyFromEnv = &v }
+}
+
+// WithBypass overrides the proxy bypass list for this client.
+func WithBypass(bypass string) Option {
+ return func(o *clientOptions) { o.bypass = &bypass }
+}
+
+// WithTimeout overrides the client-level timeout for this client.
+func WithTimeout(t time.Duration) Option {
+ return func(o *clientOptions) { o.timeout = &t }
+}
+
+// WithMaxIdleConns overrides the maximum idle connections for this client.
+func WithMaxIdleConns(n int) Option {
+ return func(o *clientOptions) { o.maxIdleConns = &n }
+}
+
+// WithMaxIdleConnsPerHost overrides the maximum idle connections per host for this client.
+func WithMaxIdleConnsPerHost(n int) Option {
+ return func(o *clientOptions) { o.maxIdleConnsPerHost = &n }
+}
+
+// WithIdleConnTimeout overrides the idle connection timeout for this client.
+func WithIdleConnTimeout(t time.Duration) Option {
+ return func(o *clientOptions) { o.idleConnTimeout = &t }
+}