Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hook

A small, generic, concurrency-safe event hook system for Go.

hook lets you define typed events, register handlers for them (with optional priorities and stable IDs), and trigger the handlers as a middleware-style chain where each handler decides whether the rest of the chain runs by calling e.Next().

The code is extracted from the excellent tools/hook package of PocketBase by @ganigeorgiev, published as a standalone module with no third-party dependencies.

Requirements

  • Go 1.27 or newer (the module uses the standard library uuid package for handler ID generation and has no external dependencies).

Installation

go get github.com/gowool/hook
import "github.com/gowool/hook"

Quick start

package main

import (
	"fmt"

	"github.com/gowool/hook"
)

// 1. Define an event. It must embed hook.Event.
type UserCreatedEvent struct {
	hook.Event

	UserID string
	Email  string
}

func main() {
	// 2. Create a hook typed on a pointer to your event.
	var onUserCreated hook.Hook[*UserCreatedEvent]

	// 3. Register handlers. Each handler must call e.Next() to let the
	//    remaining handlers run.
	onUserCreated.BindFunc(func(e *UserCreatedEvent) error {
		fmt.Println("sending welcome email to", e.Email)
		return e.Next()
	})

	onUserCreated.BindFunc(func(e *UserCreatedEvent) error {
		fmt.Println("recording audit log for", e.UserID)
		return e.Next()
	})

	// 4. Trigger the hook.
	err := onUserCreated.Trigger(&UserCreatedEvent{
		UserID: "u_123",
		Email:  "alice@example.com",
	})
	if err != nil {
		fmt.Println("hook failed:", err)
	}
}

Output:

sending welcome email to alice@example.com
recording audit log for u_123

Core concepts

Events and hook.Event

An event is any struct that embeds hook.Event. Embedding gives the struct the Next() method and the unexported plumbing that satisfies the hook.Resolver interface, which is the type constraint used by Hook[T].

type RequestEvent struct {
	hook.Event

	Path   string
	Status int
}

Because Next() has a pointer receiver, hooks are always typed on a pointer to the event: hook.Hook[*RequestEvent]. This also means every handler in the chain shares the same event instance and can read or mutate its fields.

The handler chain and e.Next()

Handlers run in a chain, similar to HTTP middleware. Each handler receives the event and must call e.Next() to continue the chain. This gives every handler full control over what happens before, after, or instead of the rest of the chain:

h.BindFunc(func(e *RequestEvent) error {
	// before the remaining handlers
	start := time.Now()

	err := e.Next() // run the rest of the chain

	// after the remaining handlers
	fmt.Println("took", time.Since(start))

	return err
})

Returning without calling e.Next() short-circuits the chain: the handlers that come after the current one are simply not executed.

h.BindFunc(func(e *RequestEvent) error {
	if e.Path == "/health" {
		return nil // stop here, skip everything after this handler
	}
	return e.Next()
})

An error returned by a handler is propagated up through the callers of e.Next() and finally returned by Trigger. Note that an error by itself does not stop the chain; only skipping e.Next() does. A handler is free to call e.Next() first and then return its own error, or to inspect and wrap the error returned by e.Next().

Registering handlers

There are two ways to register a handler.

BindFunc is the short form. It registers the function with priority 0 and an autogenerated ID, and returns that ID:

id := h.BindFunc(func(e *RequestEvent) error {
	return e.Next()
})

Bind takes a *hook.Handler and lets you set an explicit ID and/or priority:

id := h.Bind(&hook.Handler[*RequestEvent]{
	ID:       "auth",
	Priority: -100,
	Func: func(e *RequestEvent) error {
		return e.Next()
	},
})

hook.Handler has three fields:

Field Description
Func The handler function. Must call e.Next() to continue the chain.
ID Unique identifier. Autogenerated (UUID) if empty. Re-binding with an existing ID replaces the old handler.
Priority Execution order. Lower values run first. Handlers with equal priority keep their registration order. Default 0.

Priorities

Handlers are sorted by ascending Priority every time one is bound, using a stable sort. A handler with priority -10 runs before priority 0, which runs before priority 10. Handlers with the same priority run in the order they were registered.

h.BindFunc(func(e *RequestEvent) error { fmt.Print("B"); return e.Next() })
h.Bind(&hook.Handler[*RequestEvent]{
	Priority: -1,
	Func:     func(e *RequestEvent) error { fmt.Print("A"); return e.Next() },
})
h.Bind(&hook.Handler[*RequestEvent]{
	Priority: 1,
	Func:     func(e *RequestEvent) error { fmt.Print("C"); return e.Next() },
})

_ = h.Trigger(&RequestEvent{}) // prints "ABC"

Replacing a handler by ID

Binding a handler whose ID already exists replaces the previous handler in place. This is useful for letting users override a default behaviour:

h.Bind(&hook.Handler[*RequestEvent]{
	ID:   "logger",
	Func: defaultLogger,
})

// later, somewhere else:
h.Bind(&hook.Handler[*RequestEvent]{
	ID:   "logger",
	Func: customLogger, // replaces defaultLogger
})

Removing handlers

Unbind removes one or more handlers by ID. Unknown IDs are ignored. UnbindAll clears every handler.

id1 := h.BindFunc(...)
id2 := h.BindFunc(...)

h.Unbind(id1, id2)
h.Unbind("does-not-exist") // no-op

h.UnbindAll()

Length returns the number of currently registered handlers.

Triggering

Trigger runs the chain with the given event and returns the error that bubbles out of it, if any.

err := h.Trigger(&RequestEvent{Path: "/users"})

You can also pass one-off handlers that are appended to the end of the chain for this single call only. They are not stored on the hook. This is handy for running the "actual" operation after all the registered hooks have had a chance to intercept it:

err := h.Trigger(&RequestEvent{Path: "/users"}, func(e *RequestEvent) error {
	// runs last, after every registered handler called e.Next()
	e.Status = 200
	return e.Next()
})

A typical pattern is to build a "before"/"after" flow around a core action:

func (s *Service) CreateUser(u *User) error {
	return s.OnUserCreate.Trigger(&UserCreateEvent{User: u}, func(e *UserCreateEvent) error {
		// the core operation, executed only if no handler short-circuited the chain
		if err := s.db.Insert(e.User); err != nil {
			return err
		}
		return e.Next()
	})
}

Registered handlers then see the event before the insert (code placed before e.Next()) and after it (code placed after e.Next()), and can abort the whole operation by returning an error or not calling e.Next().

The same event value can be safely passed to Trigger more than once; the internal chain state is reset on every call.

Tagged hooks

TaggedHook is a proxy over a regular Hook whose handlers only run when the event carries a matching tag. This is useful when a single hook is shared by many logical groups (collections, tables, topics, routes, …) and you want to register handlers for a subset of them without creating a separate hook per group.

To use it, your event must also implement hook.Tagger, i.e. provide a Tags() []string method in addition to embedding hook.Event:

type RecordEvent struct {
	hook.Event

	Collection string
	Record     map[string]any
}

func (e *RecordEvent) Tags() []string {
	return []string{e.Collection}
}

Then create tagged views over one shared base hook:

var onRecordCreate hook.Hook[*RecordEvent]

// Runs for every collection.
onRecordCreate.BindFunc(func(e *RecordEvent) error {
	fmt.Println("any record created in", e.Collection)
	return e.Next()
})

// Runs only when e.Tags() contains "users" or "admins".
usersHook := hook.NewTaggedHook(&onRecordCreate, "users", "admins")
usersHook.BindFunc(func(e *RecordEvent) error {
	fmt.Println("user-like record created")
	return e.Next()
})

// Runs only for "posts".
postsHook := hook.NewTaggedHook(&onRecordCreate, "posts")
postsHook.BindFunc(func(e *RecordEvent) error {
	fmt.Println("post created")
	return e.Next()
})

_ = onRecordCreate.Trigger(&RecordEvent{Collection: "users"})
// any record created in users
// user-like record created

_ = onRecordCreate.Trigger(&RecordEvent{Collection: "posts"})
// any record created in posts
// post created

Notes on tagged hooks:

  • Handlers are always stored on, and triggered through, the base hook. Priorities, IDs, Unbind, UnbindAll and Length all refer to the shared base handler list.
  • A TaggedHook created with no tags matches every event, exactly like binding on the base hook directly.
  • When the event's tags do not match, the wrapped handler is skipped transparently: it calls e.Next() on your behalf so the rest of the chain still runs.
  • CanTriggerOn(tags) is exposed if you need to check matching manually.

Concurrency

All Hook methods are safe for concurrent use. Binding, unbinding and triggering can happen from multiple goroutines. Trigger takes a snapshot of the handler list under a read lock, so handlers added or removed while a trigger is in flight do not affect that in-flight chain.

The event instance itself is shared by all handlers in a single Trigger call and is not synchronised; do not trigger the same event value concurrently from multiple goroutines.

API summary

// event.go
type Resolver interface { Next() error /* + unexported methods */ }
type Event struct{ /* embed this in your events */ }
func (e *Event) Next() error

// hook.go
type Handler[T Resolver] struct {
	Func     func(T) error
	ID       string
	Priority int
}

type Hook[T Resolver] struct{ /* ... */ }
func (h *Hook[T]) Bind(handler *Handler[T]) string
func (h *Hook[T]) BindFunc(fn func(e T) error) string
func (h *Hook[T]) Unbind(idsToRemove ...string)
func (h *Hook[T]) UnbindAll()
func (h *Hook[T]) Length() int
func (h *Hook[T]) Trigger(event T, oneOffHandlerFuncs ...func(T) error) error

// tagged.go
type Tagger interface { Resolver; Tags() []string }
type TaggedHook[T Tagger] struct{ /* ... */ }
func NewTaggedHook[T Tagger](hook *Hook[T], tags ...string) *TaggedHook[T]
func (h *TaggedHook[T]) CanTriggerOn(tagsToCheck []string) bool
func (h *TaggedHook[T]) Bind(handler *Handler[T]) string
func (h *TaggedHook[T]) BindFunc(fn func(e T) error) string
// Unbind, UnbindAll, Length and Trigger are promoted from the base Hook.

Running the tests

go test ./...

Credits and license

The implementation is copied from pocketbase/pocketbase tools/hook, Copyright (c) 2022 - present, Gani Georgiev, and is distributed under the MIT License. The original license notice is preserved at the top of every source file.

About

Extracted from https://github.com/pocketbase/pocketbase/tree/master/tools/hook by @ganigeorgiev — hanks to the original author for the excellent implementation.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages