Adding Custom Components
You can build components for the Outline SDK and share them as a Go library in
your own repository. As the author of that library, you can expose Register*
functions that apps call to add configuration types to their registry for the
SDK's configurl package.
This makes your components easy for other apps in the Outline ecosystem to use.
Also expose constructors or types for direct use. An app should be able to create your components with ordinary Go arguments, without parsing configuration strings. Keep the component behavior in those constructors or types, and let the registration functions handle configuration parsing and call them.
In this guide, you will write a component library and an app that imports it. The library provides two dialers and their configuration types:
| Configuration | Behavior |
|---|---|
delay:200ms | Wait 200 milliseconds before opening a connection. |
timeout:t=1s&error=too+slow | Allow up to one second to open a connection, returning too slow if the timeout expires. |
The delay takes a single duration; the timeout takes key=value options. You
need basic Go knowledge and Go 1.24.1 or later.
The example connects to a local TCP listener, so no Outline server or access key
is required.
How registration works
A stream dialer is an object with a DialStream(ctx, "host:port") method
that opens a connection, such as TCP. A registry maps each configuration
type name, such as delay, to a parser for a given component type: a
function that reads that part of
the configuration and builds the component.
configurl.NewDefaultRegistry() creates a
MutableRegistry with the
built-in configuration types already registered. A
Registry only looks up parsers; MutableRegistry adds
registration. Parsers receive the read-only Registry.
Each part of a configuration string is parsed as a URL with Go's net/url.
The type name is the URL scheme, and for delay:200ms, the text after the
colon, 200ms, is the URL's opaque part.
To add a type, call configurl.Register with the registry, a name,
and a parser function of type configurl.Parser. For delay:200ms,
the name is delay, and the parser reads the duration, builds the dialer it
wraps, and returns the wrapper. A registry stores parsers by Go result type and
name, so delay for transport.StreamDialer and delay for
transport.PacketDialer are separate entries. Register infers the result
type from the parser's signature, and the type argument of
configurl.New[T] selects which entry to use.
Configurations can be joined with |. Each part wraps the dialer built from
the parts to its left. For example:
delay:2s|timeout:t=100ms&error=too+slow
The outer timeout wrapper starts its 100-millisecond timer, then calls the
delay wrapper. The delay waits for two seconds, but the timeout cancels it
first, producing too slow. Underneath both wrappers is the default TCP dialer;
in this case, the delay is cancelled before TCP dialing begins. The |
character describes composition, not a choice between strategies.
Step 1: Create a Go module
Run these commands in a terminal:
mkdir configurl-example
cd configurl-example
go mod init example.com/configurl-example
go get golang.getoutline.org/sdk/x/configurl@v0.2.3-0.20261030210620-71f60768ef4c
mkdir components
This guide pins a version of the SDK's x module. Its APIs are experimental,
so keep the version pinned when using them. Use the golang.getoutline.org
import paths shown here, including for the core transport package; older
examples may use GitHub import paths.
For a runnable example, the library and app live in one module, with the library in a separate Go package. Keep each component in its own file:
configurl-example/
├── go.mod
├── components/
│ ├── delay.go # Component library: delay dialer, parser, and registration
│ └── timeout.go # Component library: timeout dialer, parser, and registration
└── main.go # App: imports the library and registers its components
When publishing your library, the components package can live in its own
repository and Go module. Apps then add that module as a dependency and import
its published package path instead of the local example path used below.
Step 2: Write the component library
The library exposes a constructor and a registration function for each component. The constructors take Go values and implement the behavior. Each registration function registers a parser, which turns a configuration string into constructor arguments.
Delay component
Create components/delay.go:
package components
import (
"context"
"errors"
"time"
"golang.getoutline.org/sdk/transport"
"golang.getoutline.org/sdk/x/configurl"
)
// NewDelayDialer waits before opening each connection through base.
func NewDelayDialer(base transport.StreamDialer, duration time.Duration) (transport.StreamDialer, error) {
if base == nil {
return nil, errors.New("delay: base dialer must not be nil")
}
if duration <= 0 {
return nil, errors.New("delay: duration must be positive")
}
dialStream := func(ctx context.Context, address string) (transport.StreamConn, error) {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
return base.DialStream(ctx, address)
}
}
return transport.FuncStreamDialer(dialStream), nil
}
func parseDelayDialer(ctx context.Context, registry configurl.Registry, config *configurl.Config) (transport.StreamDialer, error) {
// In "delay:200ms", URL.Opaque is "200ms".
duration, err := time.ParseDuration(config.URL.Opaque)
if err != nil {
return nil, errors.New("delay: expected a duration, such as 200ms")
}
base, err := configurl.NewFromConfig[transport.StreamDialer](ctx, registry, config.BaseConfig)
if err != nil {
return nil, err
}
return NewDelayDialer(base, duration)
}
// RegisterDelay adds the "<name>:<duration>" configuration type, such as "delay:200ms".
func RegisterDelay(registry configurl.MutableRegistry, name string) {
configurl.Register(registry, name, parseDelayDialer)
}
Timeout component
Create components/timeout.go in the same package:
package components
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"golang.getoutline.org/sdk/transport"
"golang.getoutline.org/sdk/x/configurl"
)
// NewTimeoutDialer limits how long opening a connection through base can take.
func NewTimeoutDialer(base transport.StreamDialer, duration time.Duration, message string) (transport.StreamDialer, error) {
if base == nil {
return nil, errors.New("timeout: base dialer must not be nil")
}
if duration <= 0 {
return nil, errors.New("timeout: duration must be positive")
}
if message == "" {
return nil, errors.New("timeout: error must not be empty")
}
timeoutErr := errors.New(message)
dialStream := func(ctx context.Context, address string) (transport.StreamConn, error) {
// Apply a fresh timeout and error cause for each dial.
dialCtx, cancel := context.WithTimeoutCause(ctx, duration, timeoutErr)
defer cancel()
conn, err := base.DialStream(dialCtx, address)
if err != nil && context.Cause(dialCtx) == timeoutErr {
return nil, timeoutErr
}
return conn, err
}
return transport.FuncStreamDialer(dialStream), nil
}
func parseTimeoutDialer(ctx context.Context, registry configurl.Registry, config *configurl.Config) (transport.StreamDialer, error) {
// In "timeout:t=1s&error=too+slow", parse the key=value options.
options, err := url.ParseQuery(config.URL.Opaque)
if err != nil {
return nil, fmt.Errorf("timeout: invalid options: %w", err)
}
duration, err := time.ParseDuration(options.Get("t"))
if err != nil {
return nil, errors.New("timeout: t must be a duration, such as 1s")
}
base, err := configurl.NewFromConfig[transport.StreamDialer](ctx, registry, config.BaseConfig)
if err != nil {
return nil, err
}
return NewTimeoutDialer(base, duration, options.Get("error"))
}
// RegisterTimeout adds the "<name>:t=<duration>&error=<message>" configuration type,
// such as "timeout:t=1s&error=too+slow".
func RegisterTimeout(registry configurl.MutableRegistry, name string) {
configurl.Register(registry, name, parseTimeoutDialer)
}
Parse your configuration
Each parser receives a context, a registry, and a parsed
configurl.Config:
config.URLdescribes the current part. Fordelay:200ms, itsSchemeisdelayand itsOpaquefield is200ms.- For
timeout:t=1s&error=too+slow,Opaqueholdst=1s&error=too+slow.url.ParseQuery(config.URL.Opaque)turns that text into options. Botht(a positive duration) anderror(a nonempty message) are required here. These options follow the colon directly; there is no?, soURL.Query()would not read them. config.BaseConfigdescribes the parts to the left. Passing it toconfigurl.NewFromConfigbuilds the dialer that your wrapper uses, which preserves composition. When there are no parts to the left, it isnil, and the registry returns its default TCP dialer.
The error option uses URL query encoding: too+slow becomes too slow.
Encode literal &, |, and + in values as %26, %7C, and %2B, respectively,
so they do not become separators or spaces. Also encode ?, #, and %: URL
parsing removes text after an unencoded ? or # from the opaque part. When
creating configurations in Go, use url.Values.Encode() to encode the option
values.
Use configurl.New for a configuration string and
NewFromConfig for an already parsed *configurl.Config.
The type argument, transport.StreamDialer here, selects which kind of
component to build. Inside a parser, pass config.BaseConfig, not config
itself: passing the current config would call the same parser again
indefinitely.
Build dependencies with the registry and ctx your parser receives, never a
registry captured when you registered. An app's registry customizations, such
as a different default dialer, then also apply to your dependencies. The
context is for construction only: the returned dialer uses the context of each
DialStream call.
Apply the behavior when dialing
transport.FuncStreamDialer turns a Go function into a stream dialer. Its
function runs on every dial and uses that call's context. The delay selects
between its timer and context cancellation; using time.Sleep here would make
it wait out the entire delay even after the timeout expires.
The timeout uses context.WithTimeoutCause to associate the configured error
with its deadline. When that deadline interrupts the underlying dialer, the
wrapper returns the custom error. The cause check keeps caller cancellation
and earlier caller deadlines from being relabeled with our message.
Both wrappers affect opening a connection; they do not delay data transfer or set read or write deadlines on the returned connection. The underlying dialer must honor its context for the timeout to take effect.
Step 3: Import and register the components in your app
Create main.go at the module root, alongside the components directory. The
app imports the library and calls its exported Register* functions on the
app's registry before building a dialer. The app chooses each configuration
type's name, which lets it avoid collisions with built-in types or other
libraries:
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"example.com/configurl-example/components"
"golang.getoutline.org/sdk/transport"
"golang.getoutline.org/sdk/x/configurl"
)
func main() {
configText := flag.String("transport", "delay:200ms|timeout:t=1s&error=too+slow", "configurl configuration")
flag.Parse()
log.SetFlags(0)
registry := configurl.NewDefaultRegistry()
// Register the "delay" and "timeout" configuration types.
components.RegisterDelay(registry, "delay")
components.RegisterTimeout(registry, "timeout")
dialer, err := configurl.New[transport.StreamDialer](context.Background(), registry, *configText)
if err != nil {
log.Fatal(err)
}
// A local listener lets us try opening a TCP connection without a server setup.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
defer listener.Close()
conn, err := dialer.DialStream(context.Background(), listener.Addr().String())
if err != nil {
log.Fatal(err)
}
defer conn.Close()
fmt.Println("connected")
}
Step 4: Run it and trigger a timeout
From the same directory, run:
go mod tidy
go run .
The default configuration waits 200 milliseconds before opening a local TCP connection, within the one-second timeout. It prints:
connected
Now make the delay longer than the timeout:
go run . -transport 'delay:2s|timeout:t=100ms&error=too+slow'
After about 100 milliseconds of dialing (excluding compilation time), the timeout interrupts the delay. The program prints your decoded error message and exits unsuccessfully:
too slow
exit status 1
Quote configurations containing | or & so your shell passes the entire
string to the program.
See why order matters
Put the delay outside the timeout:
go run . -transport 'timeout:t=1s&error=too+slow|delay:2s'
This prints connected after about two seconds. The delay wrapper runs first;
only after it finishes does it call the timeout wrapper, starting the
one-second timer for the local TCP connection. To include the delay in the time
limit, put the timeout to its right, as in the previous example.
Use components directly
Expose components for direct use as well as through configuration. For example,
this helper constructs the same stack as the app's default configuration using
Go arguments. It belongs in application code that imports your components
package, time, and golang.getoutline.org/sdk/transport:
func newDialer() (transport.StreamDialer, error) {
delayed, err := components.NewDelayDialer(&transport.TCPDialer{}, 200*time.Millisecond)
if err != nil {
return nil, err
}
return components.NewTimeoutDialer(delayed, time.Second, "too slow")
}
An app can call this helper in place of creating a registry and parsing a configuration string. Both approaches use the same component implementations and validation.
Share your components with the ecosystem
Publish your component package as a Go module in your own repository, with
exported constructors and Register* functions like the ones above. Apps add
your module as a dependency, import your package, and call its registration
functions on their registry. The component code and registration
helpers stay in your library; each app chooses which components to register.
This lets you make components available to the Outline ecosystem independently
of SDK releases.
Document the supported configuration syntax alongside the direct Go API. Keep constructors usable on their own so apps that build their transport stacks in Go can use the same components without configuration strings. A configuration string can only select components that an app has included and registered; it does not download a library or register new code automatically.
Registration scope
Registration belongs to the registry you configured: a new NewDefaultRegistry() call
does not inherit your custom types, and registering here does not add delay
or timeout to other applications or prebuilt SDK tools. Register types during
application setup, before building or using components from the registry. Use
a distinct lowercase name: URL parsing lowercases schemes, so a type registered
as Delay never matches. Registering an existing name for the same result type
replaces its parser.
To replace a default base underneath every configuration (the TCP stream
dialer, UDP packet dialer, or UDP packet listener), for example with one that
protects sockets on Android, call configurl.RegisterBase
with a parser for that result type. See
Custom base components.
If a configuration names a type that the registry doesn't have,
configurl.New returns an error matching errors.ErrUnsupported, which you
can check with errors.Is. Malformed options return your parser's error
instead.
These examples register stream dialers only. Registrations are specific to a
parser's Go result type. For datagram connections, write a parser that returns
transport.PacketDialer or transport.PacketListener and register it with the
same configurl.Register function. A stream registration does not enable the
same name for packet dialers or packet listeners.
To implement your own strategy, replace the option parsing and wrappers with
your configuration format and dialer implementation. For wrappers, keep the
NewFromConfig call on BaseConfig so they compose with existing strategies. For the complete
API, see Defining custom strategies
and the SDK's dialer concepts.