Forge has always fired signals internally. A post publishes, a doc page archives, a story goes live — the database reflects it, and the signal fires. Before v1.20.0, the only thing that could react was webhook delivery. Application code had no extension point. Cache invalidation, audit logs, SSE pushes — all had to be bolted on outside Forge or shimmed in through middleware.
That changes with the signal bus.
The new API
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
log.Printf("published: %s %s", ev.Type, ev.Slug)
return nil
})
One call. Any lifecycle signal. Multiple handlers per signal are supported and fire in registration order. All registrations must happen before app.Run — handlers registered after Run are silently ignored.
What the handler receives
Every handler gets a SignalEvent with the full context of the change:
type SignalEvent struct {
Type string // Go type name — "Post", "DocPage", etc.
Slug string
Title string
URL string // BaseURL + module prefix + "/" + slug
Timestamp time.Time
PreviousState string // status before the transition; "" on create
ActorRole string
ActorID string
}
PreviousState is the useful one. For AfterPublish, it tells you whether this was a first-time publish from draft or a re-publish from scheduled. For AfterCreate, it is empty. Transition-aware logic becomes one field check, not a second database query.
Webhooks as one subscriber
app.Webhooks(store) is now an OnSignal handler internally. When you call it, Forge registers OnSignal handlers for each event you configured on the webhook store. Your custom handlers and webhook delivery coexist on the same bus. One failing handler does not block the chain — errors are logged at Warn, and the next handler fires regardless.
Handler semantics
Handlers run asynchronously in a background goroutine. Each receives a context.Context with a 100 ms timeout, detached from the originating HTTP request via context.WithoutCancel. The HTTP response is already on the wire before any handler runs.
The 100 ms limit is deliberate. Long-running work — database writes, external API calls, queue enqueues — should return immediately and dispatch to a separate goroutine or queue. The bus is not a job runner.
Practical use cases
Four patterns that come up immediately:
Audit log:
app.OnSignal(forge.AfterCreate, func(ctx context.Context, ev forge.SignalEvent) error {
return auditDB.Record(ev.ActorID, "create", ev.Type, ev.Slug, ev.Timestamp)
})
Cache invalidation:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
return cache.Invalidate(ev.URL)
})
SSE broadcast:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
hub.Broadcast(ev.Slug)
return nil
})
Discord notification:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
msg := fmt.Sprintf("Published: [%s](%s)", ev.Title, ev.URL)
return discord.PostMessage(ctx, channelID, msg)
})
The full API reference — all seven signals, SignalEvent fields, module-level typed handlers — is in /docs/signal-bus.
The OutboundDelivery interface
For delivery engines that need retry-backed outbound HTTP, Forge now exposes OutboundDelivery:
type OutboundDelivery interface {
Enqueue(ctx context.Context, job OutboundJob) error
}
The webhook worker pool satisfies this interface. A custom engine — social posting, for example — can use the same job queue without reimplementing retry logic. This is the foundation for forge-social, which will use OutboundDelivery to push content to social platforms on publish.
What's next
The signal bus is the infrastructure that makes the next pieces possible. forge-social (content-to-social pipeline) and an in-process audit trail both depend on it. The bus ships in v1.20.0. Both are in the roadmap.