diff --git a/callback.go b/callback.go
index 23f4a65db03cee5b78f01ba9ad9a1acc803146a4..2b076348d55870b4af7f7d3763becac359a12fd8 100644
--- a/callback.go
+++ b/callback.go
@@ -24,18 +24,30 @@ type Handler interface {
 type Request struct {
 	ctx context.Context
 	msg jsonrpcMessage
+
+	peer PeerInfo
 }
 
 func (r *Request) Method() string {
 	return r.msg.Method
 }
+
 func (r *Request) Params() json.RawMessage {
 	return r.msg.Params
 }
+
 func (r *Request) Context() context.Context {
 	return r.ctx
 }
 
+func (r *Request) Remote() string {
+	return r.peer.RemoteAddr
+}
+
+func (r *Request) Peer() PeerInfo {
+	return r.peer
+}
+
 func (r *Request) WithContext(ctx context.Context) *Request {
 	if ctx == nil {
 		panic("nil context")
@@ -47,6 +59,7 @@ func (r *Request) WithContext(ctx context.Context) *Request {
 	r2.msg = r.msg
 	return r2
 }
+
 func (r *Request) Msg() jsonrpcMessage {
 	return r.msg
 }
diff --git a/doc.go b/doc.go
index 3d620cad584233590929331c15f4ccf66cde51dd..1d2e59de071f8e910232ad0fc997a61f8e06a9ae 100644
--- a/doc.go
+++ b/doc.go
@@ -1,110 +1,4 @@
-// Copyright 2015 The go-ethereum Authors
-// This file is part of the go-ethereum library.
-//
-// The go-ethereum library is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Lesser General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// The go-ethereum library is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Lesser General Public License for more details.
-//
-// You should have received a copy of the GNU Lesser General Public License
-// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
-
 /*
 
-Package rpc implements bi-directional JSON-RPC 2.0 on multiple transports.
-
-It provides access to the exported methods of an object across a network or other I/O
-connection. After creating a server or client instance, objects can be registered to make
-them visible as 'services'. Exported methods that follow specific conventions can be
-called remotely. It also has support for the publish/subscribe pattern.
-
-RPC Methods
-
-Methods that satisfy the following criteria are made available for remote access:
-
- - method must be exported
- - method returns 0, 1 (response or error) or 2 (response and error) values
-
-An example method:
-
- func (s *CalcService) Add(a, b int) (int, error)
-
-When the returned error isn't nil the returned integer is ignored and the error is sent
-back to the client. Otherwise the returned integer is sent back to the client.
-
-Optional arguments are supported by accepting pointer values as arguments. E.g. if we want
-to do the addition in an optional finite field we can accept a mod argument as pointer
-value.
-
- func (s *CalcService) Add(a, b int, mod *int) (int, error)
-
-This RPC method can be called with 2 integers and a null value as third argument. In that
-case the mod argument will be nil. Or it can be called with 3 integers, in that case mod
-will be pointing to the given third argument. Since the optional argument is the last
-argument the RPC package will also accept 2 integers as arguments. It will pass the mod
-argument as nil to the RPC method.
-
-The server offers the ServeCodec method which accepts a ServerCodec instance. It will read
-requests from the codec, process the request and sends the response back to the client
-using the codec. The server can execute requests concurrently. Responses can be sent back
-to the client out of order.
-
-An example server which uses the JSON codec:
-
- type CalculatorService struct {}
-
- func (s *CalculatorService) Add(a, b int) int {
-	return a + b
- }
-
- func (s *CalculatorService) Div(a, b int) (int, error) {
-	if b == 0 {
-		return 0, errors.New("divide by zero")
-	}
-	return a/b, nil
- }
-
- calculator := new(CalculatorService)
- server := NewServer()
- server.RegisterName("calculator", calculator)
- l, _ := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: "/tmp/calculator.sock"})
- server.ServeListener(l)
-
-Subscriptions
-
-The package also supports the publish subscribe pattern through the use of subscriptions.
-A method that is considered eligible for notifications must satisfy the following
-criteria:
-
- - method must be exported
- - first method argument type must be context.Context
- - method must have return types (rpc.Subscription, error)
-
-An example method:
-
- func (s *BlockChainService) NewBlocks(ctx context.Context) (rpc.Subscription, error) {
- 	...
- }
-
-When the service containing the subscription method is registered to the server, for
-example under the "blockchain" namespace, a subscription is created by calling the
-"blockchain_subscribe" method.
-
-Subscriptions are deleted when the user sends an unsubscribe request or when the
-connection which was used to create the subscription is closed. This can be initiated by
-the client and server. The server will close the connection for any write error.
-
-For more information about subscriptions, see https://github.com/ethereum/go-ethereum/wiki/RPC-PUB-SUB.
-
-Reverse Calls
-
-In any method handler, an instance of rpc.Client can be accessed through the
-ClientFromContext method. Using this client instance, server-to-client method calls can be
-performed on the RPC connection.
-*/
+ */
 package jrpc
diff --git a/handler.go b/handler.go
index be05ee8bcc1859fca8167bd664d8b1c46852afe0..1c4265f6483bf465df77d8c60550dbaad138a131 100644
--- a/handler.go
+++ b/handler.go
@@ -55,6 +55,8 @@ type handler struct {
 	cancelRoot func()                // cancel function for rootCtx
 	conn       jsonWriter            // where responses will be sent
 	log        *zlog.Logger
+
+	peer PeerInfo
 }
 
 type callProc struct {
@@ -64,6 +66,7 @@ type callProc struct {
 func newHandler(connCtx context.Context, conn jsonWriter, reg Router) *handler {
 	rootCtx, cancelRoot := context.WithCancel(connCtx)
 	h := &handler{
+		peer:       PeerInfoFromContext(connCtx),
 		reg:        reg,
 		conn:       conn,
 		respWait:   make(map[string]*requestOp),
@@ -71,10 +74,13 @@ func newHandler(connCtx context.Context, conn jsonWriter, reg Router) *handler {
 		cancelRoot: cancelRoot,
 		log:        zlog.Ctx(connCtx),
 	}
-	if conn.remoteAddr() != "" {
+	if h.peer.RemoteAddr != "" {
 		cl := h.log.With().Str("conn", conn.remoteAddr()).Logger()
 		h.log = &cl
 	}
+	if h.peer.RemoteAddr == "" {
+		h.log.Error().Msg("CONNECTION WITHOUT REMOTE IP DETECTED. PLEASE MAKE SURE YOU KNOW WHAT YOU ARE DOING, OTHERWISE, THIS COULD BE A SECURITY ISSUE")
+	}
 	return h
 }
 
@@ -204,28 +210,29 @@ func (h *handler) handleResponse(msg *jsonrpcMessage) {
 }
 
 // handleCallMsg executes a call message and returns the answer.
+// TODO: export prometheus metrics maybe?
 func (h *handler) handleCallMsg(ctx *callProc, msg *jsonrpcMessage) *jsonrpcMessage {
-	start := NewTimer()
+	// start := NewTimer()
 	switch {
 	case msg.isNotification():
 		h.handleCall(ctx, msg)
-		h.log.Debug().Str("method", msg.Method).Dur("duration", start.Since()).Msg("Served")
+		//	h.log.Debug().Str("method", msg.Method).Dur("duration", start.Since()).Msg("Served")
 		return nil
 	case msg.isCall():
 		resp := h.handleCall(ctx, msg)
-		var ctx []any
-		log2 := h.log.With()
-		log2.Str("reqid", string(msg.ID)).Dur("duration", start.Since())
+		// var ctx []any
+		//		log2 := h.log.With()
+		//		log2.Str("reqid", string(msg.ID)).Dur("duration", start.Since())
 		if resp.Error != nil {
-			log2.Str("err", resp.Error.Message)
-			if resp.Error.Data != nil {
-				log2.Interface("errdata", resp.Error.Data)
-			}
-			sl := log2.Logger()
-			sl.Warn().Str("method", msg.Method).Interface("ctx", ctx).Msg("Served")
+			//			log2.Str("err", resp.Error.Message)
+			//			if resp.Error.Data != nil {
+			//				log2.Interface("errdata", resp.Error.Data)
+			//			}
+			//		sl := log2.Logger()
+			//		sl.Warn().Str("method", msg.Method).Interface("ctx", ctx).Msg("Served")
 		} else {
-			sl := log2.Logger()
-			sl.Debug().Str("method", msg.Method).Interface("ctx", ctx).Msg("Served")
+			//			sl := log2.Logger()
+			//			sl.Debug().Str("method", msg.Method).Interface("ctx", ctx).Msg("Served")
 		}
 		return resp
 	case msg.hasValidID():
@@ -241,29 +248,25 @@ func (h *handler) handleCall(cp *callProc, msg *jsonrpcMessage) *jsonrpcMessage
 	if !callb {
 		return msg.errorResponse(&methodNotFoundError{method: msg.Method})
 	}
+
 	start := time.Now()
-	//TODO:  there's a copy here
-	req := &Request{msg: *msg}
-	req.WithContext(cp.ctx)
-	answer := h.runHandler(req)
-	// Collect the statistics for RPC calls if metrics is enabled.
-	// We only care about pure rpc call. Filter out subscription.
-	rpcRequestGauge.Inc(1)
-	if answer.Error != nil {
-		failedRequestGauge.Inc(1)
-	} else {
-		successfulRequestGauge.Inc(1)
+	// TODO:  there's a copy here
+	// TODO: add buffer pool here
+	req := &Request{ctx: cp.ctx, msg: *msg, peer: h.peer}
+	mw := NewReaderResponseWriterMsg(req)
+	h.reg.ServeRPC(mw, req)
+	{
+		// Collect the statistics for RPC calls if metrics is enabled.
+		// We only care about pure rpc call. Filter out subscription.
+		rpcRequestGauge.Inc(1)
+		if mw.msg.Error != nil {
+			failedRequestGauge.Inc(1)
+		} else {
+			successfulRequestGauge.Inc(1)
+		}
+		rpcServingTimer.UpdateSince(start)
+		updateServeTimeHistogram(msg.Method, mw.msg.Error == nil, time.Since(start))
 	}
-	rpcServingTimer.UpdateSince(start)
-	updateServeTimeHistogram(msg.Method, answer.Error == nil, time.Since(start))
-	return answer
-}
-
-//TODO: add buffer pool here
-func (h *handler) runHandler(r *Request) *jsonrpcMessage {
-	// NOTE: this is where the callback is invoked
-	mw := NewReaderResponseWriterMsg(r)
-	h.reg.ServeRPC(mw, r)
 	return mw.msg
 }
 
diff --git a/middleware/logger.go b/middleware/logger.go
new file mode 100644
index 0000000000000000000000000000000000000000..553bddf707c3a83289e6987627a7ed847413d895
--- /dev/null
+++ b/middleware/logger.go
@@ -0,0 +1,171 @@
+package middleware
+
+// from
+// https://github.com/go-chi/chi
+
+import (
+	"bytes"
+	"context"
+	"log"
+	"net/http"
+	"os"
+	"runtime"
+	"time"
+
+	"gfx.cafe/open/jrpc"
+)
+
+var (
+	// LogEntryCtxKey is the context.Context key to store the request log entry.
+	LogEntryCtxKey = &contextKey{"LogEntry"}
+
+	// DefaultLogger is called by the Logger middleware handler to log each request.
+	// Its made a package-level variable so that it can be reconfigured for custom
+	// logging configurations.
+	DefaultLogger func(next jrpc.Handler) jrpc.Handler
+)
+
+// Logger is a middleware that logs the start and end of each request, along
+// with some useful data about what was requested, what the response status was,
+// and how long it took to return. When standard output is a TTY, Logger will
+// print in color, otherwise it will print in black and white. Logger prints a
+// request ID if one is provided.
+//
+// IMPORTANT NOTE: Logger should go before any other middleware that may change
+// the response, such as middleware.Recoverer. Example:
+//     r := chi.NewRouter()
+//     r.Use(middleware.Logger)        // <--<< Logger should come before Recoverer
+//     r.Use(middleware.Recoverer)
+//     r.Get("/", handler)
+func Logger(next jrpc.Handler) jrpc.Handler {
+	return DefaultLogger(next)
+}
+
+// RequestLogger returns a logger handler using a custom LogFormatter.
+func RequestLogger(f LogFormatter) func(next jrpc.Handler) jrpc.Handler {
+	return func(next jrpc.Handler) jrpc.Handler {
+		fn := func(w jrpc.ResponseWriter, r *jrpc.Request) {
+			entry := f.NewLogEntry(r)
+			ww := NewWrapResponseWriter(w, r.ProtoMajor)
+
+			t1 := time.Now()
+			defer func() {
+				entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), nil)
+			}()
+
+			next.ServeRPC(ww, WithLogEntry(r, entry))
+		}
+		return jrpc.HandlerFunc(fn)
+	}
+}
+
+// LogFormatter initiates the beginning of a new LogEntry per request.
+// See DefaultLogFormatter for an example implementation.
+type LogFormatter interface {
+	NewLogEntry(r *jrpc.Request) LogEntry
+}
+
+// LogEntry records the final log when a request completes.
+// See defaultLogEntry for an example implementation.
+type LogEntry interface {
+	Write(status, bytes int, elapsed time.Duration, extra interface{})
+	Panic(v interface{}, stack []byte)
+}
+
+// GetLogEntry returns the in-context LogEntry for a request.
+func GetLogEntry(r *jrpc.Request) LogEntry {
+	entry, _ := r.Context().Value(LogEntryCtxKey).(LogEntry)
+	return entry
+}
+
+// WithLogEntry sets the in-context LogEntry for a request.
+func WithLogEntry(r *jrpc.Request, entry LogEntry) *jrpc.Request {
+	r = r.WithContext(context.WithValue(r.Context(), LogEntryCtxKey, entry))
+	return r
+}
+
+// LoggerInterface accepts printing to stdlib logger or compatible logger.
+type LoggerInterface interface {
+	Print(v ...interface{})
+}
+
+// DefaultLogFormatter is a simple logger that implements a LogFormatter.
+type DefaultLogFormatter struct {
+	Logger  LoggerInterface
+	NoColor bool
+}
+
+// NewLogEntry creates a new LogEntry for the request.
+func (l *DefaultLogFormatter) NewLogEntry(r *jrpc.Request) LogEntry {
+	useColor := !l.NoColor
+	entry := &defaultLogEntry{
+		DefaultLogFormatter: l,
+		request:             r,
+		buf:                 &bytes.Buffer{},
+		useColor:            useColor,
+	}
+
+	reqID := GetReqID(r.Context())
+	if reqID != "" {
+		cW(entry.buf, useColor, nYellow, "[%s] ", reqID)
+	}
+	cW(entry.buf, useColor, nCyan, "\"")
+	cW(entry.buf, useColor, bMagenta, "%s ", r.Method)
+
+	scheme := "jrpc"
+	cW(entry.buf, useColor, nCyan, "%s://%s%s %s\" ", scheme, r.Method(), r.Params(), r.Msg().ID)
+
+	entry.buf.WriteString("from ")
+	// TODO: remote addr/ real ip
+	// entry.buf.WriteString(r.RemoteAddr)
+	entry.buf.WriteString(" - ")
+
+	return entry
+}
+
+type defaultLogEntry struct {
+	*DefaultLogFormatter
+	request  *jrpc.Request
+	buf      *bytes.Buffer
+	useColor bool
+}
+
+func (l *defaultLogEntry) Write(status, bytes int, elapsed time.Duration, extra interface{}) {
+	switch {
+	case status < 200:
+		cW(l.buf, l.useColor, bBlue, "%03d", status)
+	case status < 300:
+		cW(l.buf, l.useColor, bGreen, "%03d", status)
+	case status < 400:
+		cW(l.buf, l.useColor, bCyan, "%03d", status)
+	case status < 500:
+		cW(l.buf, l.useColor, bYellow, "%03d", status)
+	default:
+		cW(l.buf, l.useColor, bRed, "%03d", status)
+	}
+
+	cW(l.buf, l.useColor, bBlue, " %dB", bytes)
+
+	l.buf.WriteString(" in ")
+	if elapsed < 500*time.Millisecond {
+		cW(l.buf, l.useColor, nGreen, "%s", elapsed)
+	} else if elapsed < 5*time.Second {
+		cW(l.buf, l.useColor, nYellow, "%s", elapsed)
+	} else {
+		cW(l.buf, l.useColor, nRed, "%s", elapsed)
+	}
+
+	l.Logger.Print(l.buf.String())
+}
+
+func (l *defaultLogEntry) Panic(v interface{}, stack []byte) {
+	PrintPrettyStack(v)
+}
+
+func init() {
+	color := true
+	if runtime.GOOS == "windows" {
+		color = false
+	}
+	DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: !color})
+}
diff --git a/middleware/middleware.go b/middleware/middleware.go
new file mode 100644
index 0000000000000000000000000000000000000000..77cf2de09856a09f47ec858c2cb9dbd79b77f2ff
--- /dev/null
+++ b/middleware/middleware.go
@@ -0,0 +1,23 @@
+package middleware
+
+import "gfx.cafe/open/jrpc"
+
+// New will create a new middleware handler from a jrpc.Handler.
+func New(h jrpc.Handler) func(next jrpc.Handler) jrpc.Handler {
+	return func(next jrpc.Handler) jrpc.Handler {
+		return jrpc.HandlerFunc(func(w jrpc.ResponseWriter, r *jrpc.Request) {
+			h.ServeRPC(w, r)
+		})
+	}
+}
+
+// contextKey is a value for use with context.WithValue. It's used as
+// a pointer so it fits in an interface{} without allocation. This technique
+// for defining context keys was copied from Go 1.7's new use of context in net/jrpc.
+type contextKey struct {
+	name string
+}
+
+func (k *contextKey) String() string {
+	return "jrpc/middleware context value " + k.name
+}
diff --git a/middleware/request_id.go b/middleware/request_id.go
new file mode 100644
index 0000000000000000000000000000000000000000..2ed30185c877b082edead3c6b99ce55cc24cc1ee
--- /dev/null
+++ b/middleware/request_id.go
@@ -0,0 +1,96 @@
+package middleware
+
+// Ported from Goji's middleware, source:
+// jrpcs://github.com/zenazn/goji/tree/master/web/middleware
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/base64"
+	"fmt"
+	"os"
+	"strings"
+	"sync/atomic"
+
+	"gfx.cafe/open/jrpc"
+)
+
+// Key to use when setting the request ID.
+type ctxKeyRequestID int
+
+// RequestIDKey is the key that holds the unique request ID in a request context.
+const RequestIDKey ctxKeyRequestID = 0
+
+// RequestIDHeader is the name of the jrpc Header which contains the request id.
+// Exported so that it can be changed by developers
+var RequestIDHeader = "X-Request-Id"
+
+var (
+	prefix string
+	reqid  uint64
+)
+
+// A quick note on the statistics here: we're trying to calculate the chance that
+// two randomly generated base62 prefixes will collide. We use the formula from
+// jrpc://en.wikipedia.org/wiki/Birthday_problem
+//
+// P[m, n] \approx 1 - e^{-m^2/2n}
+//
+// We ballpark an upper bound for $m$ by imagining (for whatever reason) a server
+// that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$
+//
+// For a $k$ character base-62 identifier, we have $n(k) = 62^k$
+//
+// Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for
+// our purposes, and is surely more than anyone would ever need in practice -- a
+// process that is rebooted a handful of times a day for a hundred years has less
+// than a millionth of a percent chance of generating two colliding IDs.
+
+func init() {
+	hostname, err := os.Hostname()
+	if hostname == "" || err != nil {
+		hostname = "localhost"
+	}
+	var buf [12]byte
+	var b64 string
+	for len(b64) < 10 {
+		rand.Read(buf[:])
+		b64 = base64.StdEncoding.EncodeToString(buf[:])
+		b64 = strings.NewReplacer("+", "", "/", "").Replace(b64)
+	}
+
+	prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10])
+}
+
+// RequestID is a middleware that injects a request ID into the context of each
+// request. A request ID is a string of the form "host.example.com/random-0001",
+// where "random" is a base62 random string that uniquely identifies this go
+// process, and where the last number is an atomically incremented request
+// counter.
+func RequestID(next jrpc.Handler) jrpc.Handler {
+	fn := func(w jrpc.ResponseWriter, r *jrpc.Request) {
+		ctx := r.Context()
+		myid := atomic.AddUint64(&reqid, 1)
+		requestID := fmt.Sprintf("%s-%06d", prefix, myid)
+		ctx = context.WithValue(ctx, RequestIDKey, requestID)
+		next.ServeRPC(w, r.WithContext(ctx))
+	}
+	return jrpc.HandlerFunc(fn)
+}
+
+// GetReqID returns a request ID from the given context if one is present.
+// Returns the empty string if a request ID cannot be found.
+func GetReqID(ctx context.Context) string {
+	if ctx == nil {
+		return ""
+	}
+	if reqID, ok := ctx.Value(RequestIDKey).(string); ok {
+		return reqID
+	}
+	return ""
+}
+
+// NextRequestID generates the next request ID in the sequence.
+func NextRequestID() uint64 {
+	return atomic.AddUint64(&reqid, 1)
+}
diff --git a/middleware/terminal.go b/middleware/terminal.go
new file mode 100644
index 0000000000000000000000000000000000000000..477e67cbfbe4a2c55274e4fb97c4fef17cf06340
--- /dev/null
+++ b/middleware/terminal.go
@@ -0,0 +1,64 @@
+package middleware
+
+// Ported from Goji's middleware, source:
+// https://github.com/zenazn/goji/tree/master/web/middleware
+// https://github.com/go-chi/chi
+
+import (
+	"fmt"
+	"io"
+	"os"
+)
+
+var (
+	// Normal colors
+	nBlack   = []byte{'\033', '[', '3', '0', 'm'}
+	nRed     = []byte{'\033', '[', '3', '1', 'm'}
+	nGreen   = []byte{'\033', '[', '3', '2', 'm'}
+	nYellow  = []byte{'\033', '[', '3', '3', 'm'}
+	nBlue    = []byte{'\033', '[', '3', '4', 'm'}
+	nMagenta = []byte{'\033', '[', '3', '5', 'm'}
+	nCyan    = []byte{'\033', '[', '3', '6', 'm'}
+	nWhite   = []byte{'\033', '[', '3', '7', 'm'}
+	// Bright colors
+	bBlack   = []byte{'\033', '[', '3', '0', ';', '1', 'm'}
+	bRed     = []byte{'\033', '[', '3', '1', ';', '1', 'm'}
+	bGreen   = []byte{'\033', '[', '3', '2', ';', '1', 'm'}
+	bYellow  = []byte{'\033', '[', '3', '3', ';', '1', 'm'}
+	bBlue    = []byte{'\033', '[', '3', '4', ';', '1', 'm'}
+	bMagenta = []byte{'\033', '[', '3', '5', ';', '1', 'm'}
+	bCyan    = []byte{'\033', '[', '3', '6', ';', '1', 'm'}
+	bWhite   = []byte{'\033', '[', '3', '7', ';', '1', 'm'}
+
+	reset = []byte{'\033', '[', '0', 'm'}
+)
+
+var IsTTY bool
+
+func init() {
+	// This is sort of cheating: if stdout is a character device, we assume
+	// that means it's a TTY. Unfortunately, there are many non-TTY
+	// character devices, but fortunately stdout is rarely set to any of
+	// them.
+	//
+	// We could solve this properly by pulling in a dependency on
+	// code.google.com/p/go.crypto/ssh/terminal, for instance, but as a
+	// heuristic for whether to print in color or in black-and-white, I'd
+	// really rather not.
+	fi, err := os.Stdout.Stat()
+	if err == nil {
+		m := os.ModeDevice | os.ModeCharDevice
+		IsTTY = fi.Mode()&m == m
+	}
+}
+
+// colorWrite
+func cW(w io.Writer, useColor bool, color []byte, s string, args ...interface{}) {
+	if IsTTY && useColor {
+		w.Write(color)
+	}
+	fmt.Fprintf(w, s, args...)
+	if IsTTY && useColor {
+		w.Write(reset)
+	}
+}
diff --git a/server.go b/server.go
index 0c777fb7aabf94dacfc792373a4df7896111488a..5608478d031b5bae54deb1b4d391f66e755a6796 100644
--- a/server.go
+++ b/server.go
@@ -9,8 +9,10 @@ import (
 	mapset "github.com/deckarep/golang-set"
 )
 
-const MetadataApi = "rpc"
-const EngineApi = "engine"
+const (
+	MetadataApi = "rpc"
+	EngineApi   = "engine"
+)
 
 // CodecOption specifies which type of messages a codec supports.
 
@@ -39,12 +41,16 @@ func NewServer(r ...Router) *Server {
 	return server
 }
 
+func (s *Server) Router() Router {
+	return s.services
+}
+
 // RegisterName creates a service for the given receiver type under the given name. When no
 // methods on the given receiver match the criteria to be either a RPC method or a
 // subscription an error is returned. Otherwise a new service is created and added to the
 // service collection this server provides to clients.
 func (s *Server) RegisterName(name string, receiver any) error {
-	return registerStruct(s.services, name, receiver)
+	return RegisterStruct(s.services, name, receiver)
 }
 
 // ServeCodec reads incoming requests from codec, calls the appropriate callback and writes
diff --git a/service.go b/service.go
index 0f5e210347f5771a1f3ea318a5f1348652572c32..42c2824f98c70bbb786934c678fed6119ab134df 100644
--- a/service.go
+++ b/service.go
@@ -33,9 +33,9 @@ var (
 	stringType  = reflect.TypeOf("")
 )
 
-// Supported for legacy reasons.
-//TODO: we should redo our tests such that we no longer need this function.
-func registerStruct(r Router, name string, rcvr any) error {
+// A helper function that mimics the behavior of the handlers in the go-ethereum rpc package
+// if you don't know how to use this, just use the chi-like interface instead.
+func RegisterStruct(r Router, name string, rcvr any) error {
 	rcvrVal := reflect.ValueOf(rcvr)
 	if name == "" {
 		return fmt.Errorf("no service name for type %s", rcvrVal.Type().String())
diff --git a/types.go b/types.go
index a5e939b6ba741ba141b40cbb8f1ce3e2c1babdec..b2c54f5378c4ad8e461dbf0e25d63129239d4059 100644
--- a/types.go
+++ b/types.go
@@ -18,13 +18,9 @@ package jrpc
 
 import (
 	"context"
-	"encoding/json"
-	"fmt"
-	"math"
 	"strconv"
 	"strings"
 
-	"github.com/ethereum/go-ethereum/common"
 	"github.com/ethereum/go-ethereum/common/hexutil"
 )
 
@@ -49,187 +45,6 @@ type jsonWriter interface {
 	remoteAddr() string
 }
 
-type BlockNumber int64
-
-const (
-	SafeBlockNumber      = BlockNumber(-4)
-	FinalizedBlockNumber = BlockNumber(-3)
-	PendingBlockNumber   = BlockNumber(-2)
-	LatestBlockNumber    = BlockNumber(-1)
-	EarliestBlockNumber  = BlockNumber(0)
-)
-
-// UnmarshalJSON parses the given JSON fragment into a BlockNumber. It supports:
-// - "latest", "earliest" or "pending" as string arguments
-// - the block number
-// Returned errors:
-// - an invalid block number error when the given argument isn't a known strings
-// - an out of range error when the given block number is either too little or too large
-func (bn *BlockNumber) UnmarshalJSON(data []byte) error {
-	input := trimQuotes(strings.ToLower(strings.TrimSpace(string(data))))
-
-	switch input {
-	case "earliest":
-		*bn = EarliestBlockNumber
-		return nil
-	case "latest":
-		*bn = LatestBlockNumber
-		return nil
-	case "pending":
-		*bn = PendingBlockNumber
-		return nil
-	case "finalized":
-		*bn = FinalizedBlockNumber
-		return nil
-	case "safe":
-		*bn = SafeBlockNumber
-		return nil
-	}
-
-	blckNum, err := hexutil.DecodeUint64(input)
-	if err != nil {
-		return err
-	}
-	if blckNum > math.MaxInt64 {
-		return fmt.Errorf("block number larger than int64")
-	}
-	*bn = BlockNumber(blckNum)
-	return nil
-}
-
-// MarshalText implements encoding.TextMarshaler. It marshals:
-// - "latest", "earliest" or "pending" as strings
-// - other numbers as hex
-func (bn BlockNumber) MarshalText() ([]byte, error) {
-	switch bn {
-	case EarliestBlockNumber:
-		return []byte("earliest"), nil
-	case LatestBlockNumber:
-		return []byte("latest"), nil
-	case PendingBlockNumber:
-		return []byte("pending"), nil
-	case FinalizedBlockNumber:
-		return []byte("finalized"), nil
-	case SafeBlockNumber:
-		return []byte("safe"), nil
-	default:
-		return hexutil.Uint64(bn).MarshalText()
-	}
-}
-
-func (bn BlockNumber) Int64() int64 {
-	return (int64)(bn)
-}
-
-type BlockNumberOrHash struct {
-	BlockNumber      *BlockNumber `json:"blockNumber,omitempty"`
-	BlockHash        *common.Hash `json:"blockHash,omitempty"`
-	RequireCanonical bool         `json:"requireCanonical,omitempty"`
-}
-
-func (bnh *BlockNumberOrHash) UnmarshalJSON(data []byte) error {
-	type erased BlockNumberOrHash
-	e := erased{}
-	err := json.Unmarshal(data, &e)
-	if err == nil {
-		if e.BlockNumber != nil && e.BlockHash != nil {
-			return fmt.Errorf("cannot specify both BlockHash and BlockNumber, choose one or the other")
-		}
-		bnh.BlockNumber = e.BlockNumber
-		bnh.BlockHash = e.BlockHash
-		bnh.RequireCanonical = e.RequireCanonical
-		return nil
-	}
-	var input string
-	err = json.Unmarshal(data, &input)
-	if err != nil {
-		return err
-	}
-	switch input {
-	case "earliest":
-		bn := EarliestBlockNumber
-		bnh.BlockNumber = &bn
-		return nil
-	case "latest":
-		bn := LatestBlockNumber
-		bnh.BlockNumber = &bn
-		return nil
-	case "pending":
-		bn := PendingBlockNumber
-		bnh.BlockNumber = &bn
-		return nil
-	case "finalized":
-		bn := FinalizedBlockNumber
-		bnh.BlockNumber = &bn
-		return nil
-	case "safe":
-		bn := SafeBlockNumber
-		bnh.BlockNumber = &bn
-		return nil
-	default:
-		if len(input) == 66 {
-			hash := common.Hash{}
-			err := hash.UnmarshalText([]byte(input))
-			if err != nil {
-				return err
-			}
-			bnh.BlockHash = &hash
-			return nil
-		} else {
-			blckNum, err := hexutil.DecodeUint64(input)
-			if err != nil {
-				return err
-			}
-			if blckNum > math.MaxInt64 {
-				return fmt.Errorf("blocknumber too high")
-			}
-			bn := BlockNumber(blckNum)
-			bnh.BlockNumber = &bn
-			return nil
-		}
-	}
-}
-
-func (bnh *BlockNumberOrHash) Number() (BlockNumber, bool) {
-	if bnh.BlockNumber != nil {
-		return *bnh.BlockNumber, true
-	}
-	return BlockNumber(0), false
-}
-
-func (bnh *BlockNumberOrHash) String() string {
-	if bnh.BlockNumber != nil {
-		return strconv.Itoa(int(*bnh.BlockNumber))
-	}
-	if bnh.BlockHash != nil {
-		return bnh.BlockHash.String()
-	}
-	return "nil"
-}
-
-func (bnh *BlockNumberOrHash) Hash() (common.Hash, bool) {
-	if bnh.BlockHash != nil {
-		return *bnh.BlockHash, true
-	}
-	return common.Hash{}, false
-}
-
-func BlockNumberOrHashWithNumber(blockNr BlockNumber) BlockNumberOrHash {
-	return BlockNumberOrHash{
-		BlockNumber:      &blockNr,
-		BlockHash:        nil,
-		RequireCanonical: false,
-	}
-}
-
-func BlockNumberOrHashWithHash(hash common.Hash, canonical bool) BlockNumberOrHash {
-	return BlockNumberOrHash{
-		BlockNumber:      nil,
-		BlockHash:        &hash,
-		RequireCanonical: canonical,
-	}
-}
-
 // DecimalOrHex unmarshals a non-negative decimal or hex parameter into a uint64.
 type DecimalOrHex uint64
 
diff --git a/types_test.go b/types_test.go
deleted file mode 100644
index 03cb91b102064bc349869058468ce926ea79e55f..0000000000000000000000000000000000000000
--- a/types_test.go
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright 2015 The go-ethereum Authors
-// This file is part of the go-ethereum library.
-//
-// The go-ethereum library is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Lesser General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// The go-ethereum library is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Lesser General Public License for more details.
-//
-// You should have received a copy of the GNU Lesser General Public License
-// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
-
-package jrpc
-
-import (
-	"encoding/json"
-	"reflect"
-	"testing"
-
-	"github.com/ethereum/go-ethereum/common"
-	"github.com/ethereum/go-ethereum/common/math"
-)
-
-func TestBlockNumberJSONUnmarshal(t *testing.T) {
-	tests := []struct {
-		input    string
-		mustFail bool
-		expected BlockNumber
-	}{
-		0:  {`"0x"`, true, BlockNumber(0)},
-		1:  {`"0x0"`, false, BlockNumber(0)},
-		2:  {`"0X1"`, false, BlockNumber(1)},
-		3:  {`"0x00"`, true, BlockNumber(0)},
-		4:  {`"0x01"`, true, BlockNumber(0)},
-		5:  {`"0x1"`, false, BlockNumber(1)},
-		6:  {`"0x12"`, false, BlockNumber(18)},
-		7:  {`"0x7fffffffffffffff"`, false, BlockNumber(math.MaxInt64)},
-		8:  {`"0x8000000000000000"`, true, BlockNumber(0)},
-		9:  {"0", true, BlockNumber(0)},
-		10: {`"ff"`, true, BlockNumber(0)},
-		11: {`"pending"`, false, PendingBlockNumber},
-		12: {`"latest"`, false, LatestBlockNumber},
-		13: {`"earliest"`, false, EarliestBlockNumber},
-		14: {`someString`, true, BlockNumber(0)},
-		15: {`""`, true, BlockNumber(0)},
-		16: {``, true, BlockNumber(0)},
-	}
-
-	for i, test := range tests {
-		var num BlockNumber
-		err := json.Unmarshal([]byte(test.input), &num)
-		if test.mustFail && err == nil {
-			t.Errorf("Test %d should fail", i)
-			continue
-		}
-		if !test.mustFail && err != nil {
-			t.Errorf("Test %d should pass but got err: %v", i, err)
-			continue
-		}
-		if num != test.expected {
-			t.Errorf("Test %d got unexpected value, want %d, got %d", i, test.expected, num)
-		}
-	}
-}
-
-func TestBlockNumberOrHash_UnmarshalJSON(t *testing.T) {
-	tests := []struct {
-		input    string
-		mustFail bool
-		expected BlockNumberOrHash
-	}{
-		0:  {`"0x"`, true, BlockNumberOrHash{}},
-		1:  {`"0x0"`, false, BlockNumberOrHashWithNumber(0)},
-		2:  {`"0X1"`, false, BlockNumberOrHashWithNumber(1)},
-		3:  {`"0x00"`, true, BlockNumberOrHash{}},
-		4:  {`"0x01"`, true, BlockNumberOrHash{}},
-		5:  {`"0x1"`, false, BlockNumberOrHashWithNumber(1)},
-		6:  {`"0x12"`, false, BlockNumberOrHashWithNumber(18)},
-		7:  {`"0x7fffffffffffffff"`, false, BlockNumberOrHashWithNumber(math.MaxInt64)},
-		8:  {`"0x8000000000000000"`, true, BlockNumberOrHash{}},
-		9:  {"0", true, BlockNumberOrHash{}},
-		10: {`"ff"`, true, BlockNumberOrHash{}},
-		11: {`"pending"`, false, BlockNumberOrHashWithNumber(PendingBlockNumber)},
-		12: {`"latest"`, false, BlockNumberOrHashWithNumber(LatestBlockNumber)},
-		13: {`"earliest"`, false, BlockNumberOrHashWithNumber(EarliestBlockNumber)},
-		14: {`someString`, true, BlockNumberOrHash{}},
-		15: {`""`, true, BlockNumberOrHash{}},
-		16: {``, true, BlockNumberOrHash{}},
-		17: {`"0x0000000000000000000000000000000000000000000000000000000000000000"`, false, BlockNumberOrHashWithHash(common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"), false)},
-		18: {`{"blockHash":"0x0000000000000000000000000000000000000000000000000000000000000000"}`, false, BlockNumberOrHashWithHash(common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"), false)},
-		19: {`{"blockHash":"0x0000000000000000000000000000000000000000000000000000000000000000","requireCanonical":false}`, false, BlockNumberOrHashWithHash(common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"), false)},
-		20: {`{"blockHash":"0x0000000000000000000000000000000000000000000000000000000000000000","requireCanonical":true}`, false, BlockNumberOrHashWithHash(common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"), true)},
-		21: {`{"blockNumber":"0x1"}`, false, BlockNumberOrHashWithNumber(1)},
-		22: {`{"blockNumber":"pending"}`, false, BlockNumberOrHashWithNumber(PendingBlockNumber)},
-		23: {`{"blockNumber":"latest"}`, false, BlockNumberOrHashWithNumber(LatestBlockNumber)},
-		24: {`{"blockNumber":"earliest"}`, false, BlockNumberOrHashWithNumber(EarliestBlockNumber)},
-		25: {`{"blockNumber":"0x1", "blockHash":"0x0000000000000000000000000000000000000000000000000000000000000000"}`, true, BlockNumberOrHash{}},
-	}
-
-	for i, test := range tests {
-		var bnh BlockNumberOrHash
-		err := json.Unmarshal([]byte(test.input), &bnh)
-		if test.mustFail && err == nil {
-			t.Errorf("Test %d should fail", i)
-			continue
-		}
-		if !test.mustFail && err != nil {
-			t.Errorf("Test %d should pass but got err: %v", i, err)
-			continue
-		}
-		hash, hashOk := bnh.Hash()
-		expectedHash, expectedHashOk := test.expected.Hash()
-		num, numOk := bnh.Number()
-		expectedNum, expectedNumOk := test.expected.Number()
-		if bnh.RequireCanonical != test.expected.RequireCanonical ||
-			hash != expectedHash || hashOk != expectedHashOk ||
-			num != expectedNum || numOk != expectedNumOk {
-			t.Errorf("Test %d got unexpected value, want %v, got %v", i, test.expected, bnh)
-		}
-	}
-}
-
-func TestBlockNumberOrHash_WithNumber_MarshalAndUnmarshal(t *testing.T) {
-	tests := []struct {
-		name   string
-		number int64
-	}{
-		{"max", math.MaxInt64},
-		{"pending", int64(PendingBlockNumber)},
-		{"latest", int64(LatestBlockNumber)},
-		{"earliest", int64(EarliestBlockNumber)},
-	}
-	for _, test := range tests {
-		test := test
-		t.Run(test.name, func(t *testing.T) {
-			bnh := BlockNumberOrHashWithNumber(BlockNumber(test.number))
-			marshalled, err := json.Marshal(bnh)
-			if err != nil {
-				t.Fatal("cannot marshal:", err)
-			}
-			var unmarshalled BlockNumberOrHash
-			err = json.Unmarshal(marshalled, &unmarshalled)
-			if err != nil {
-				t.Fatal("cannot unmarshal:", err)
-			}
-			if !reflect.DeepEqual(bnh, unmarshalled) {
-				t.Fatalf("wrong result: expected %v, got %v", bnh, unmarshalled)
-			}
-		})
-	}
-}