Help us shape the future of Go! Take the 2026 Go Developer Survey

push

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package push provides portable outbound push submission, not delivery. A Sender validates an immutable Message, resolves retention policy, and calls one Driver exactly once. Applications own registration storage, authorization, retries, reconciliation, and cleanup of registrations confirmed invalid.

Construct messages with a value Subscription or Token and a nonempty title:

message := push.NewMessage(push.Token("device-registration"), "Order ready",
	func(notification *push.MessageBuilder) {
		notification.Body("Your order is ready.").Data(map[string]string{"order": "42"})
	})
if err := message.Err(); err != nil {
	return err
}
receipt, err := push.New(driver).Send(ctx, message, push.TTL(time.Hour))
if err != nil {
	return err
}
_ = receipt.ID // Optional provider acceptance ID, not a delivery confirmation.

Native tokens are interpreted only by the configured driver; this package does not route between providers. Subscription validation is structural and performs no I/O. Drivers must separately protect endpoint requests against SSRF, including redirects, private DNS results, and application allowlists. Driver-specific payload limits and reserved metadata keys belong in drivers.

DefaultTTL is applied only by Sender. Raw SendConfig.TTL is already resolved: zero means no storage and drivers never apply a default. No-store does not prevent immediate delivery. Positive retention is not a delivery guarantee.

Sender and published messages are safe for concurrent use with a conforming Driver. Construction drafts are not. Message containers are copied on input, publication, and inspection; callbacks and live services are not cloned.

Each instrumented Send emits a "push send" client span and the arkoren.push.operations and arkoren.push.operation.duration metrics. Only bounded operation and error-class labels are recorded. Messages, tokens, endpoints, keys, metadata, receipts, and error text are never recorded. Nil telemetry follows telemetry.Default on every call; disabled telemetry is inert. ErrOutcomeUnknown takes precedence over joined cancellation or provider causes in telemetry classification. Send preserves all joined causes for errors.Is; uncertain acceptance is never converted into proof of failed delivery.

Builder.Events opts into best-effort in-process Accepted and Failed submission notifications; nil disables them. Preflight failures emit none. Payloads hold only duration and bounded error classes, never message or recipient data. outcome_unknown means acceptance may have occurred, not failed delivery. Notifications are not durable and must not drive delivery guarantees.

Example
package main

import (
	"context"
	"fmt"
	"time"

	"arkoren.dev/core/service/push"
	"arkoren.dev/core/service/push/pushtest"
)

func main() {
	fake := pushtest.New()
	sender := push.New(fake, func(delivery *push.Builder) {
		delivery.TTL(time.Hour)
	})

	message := push.NewMessage(push.Token("device-registration"), "Order ready",
		func(notification *push.MessageBuilder) {
			notification.Body("Collect your order.").Data(map[string]string{"order": "42"})
		})

	receipt, err := sender.Send(context.Background(), message, push.TTL(-1))

	if err != nil {
		fmt.Println("submission failed")

		return
	}

	fmt.Println(receipt.ID)
	fmt.Println(fake.Sent()[0].Config.TTL)
}
Output:
fake-1
0s

Index

Examples

Constants

View Source
const DefaultTTL = 24 * time.Hour

DefaultTTL is the Sender's initial retention period, applied only at the facade boundary. Raw drivers must not substitute it for SendConfig.TTL zero.

Example:

sender := push.New(driver, func(delivery *push.Builder) {
	delivery.TTL(push.DefaultTTL)
})
_ = sender

Variables

View Source
var (
	// ErrInvalidMessage identifies invalid portable input or send configuration.
	// Details are secret-safe; drivers may wrap this class for payload limits.
	//
	// Example:
	//
	//	if errors.Is(err, push.ErrInvalidMessage) { return err }
	ErrInvalidMessage = errors.New("invalid push message")

	// ErrUnsupported means the driver cannot submit this recipient or feature.
	// It never means a registration is expired and must not trigger deletion.
	//
	// Example:
	//
	//	if errors.Is(err, push.ErrUnsupported) { return err }
	ErrUnsupported = errors.New("unsupported push operation")

	// ErrNotFound means the provider confirmed this registration is invalid.
	// Authentication, project mismatch, malformed payloads, and generic HTTP 404
	// responses are not sufficient evidence. False classification can cause an
	// application to delete a live registration permanently.
	//
	// Example:
	//
	//	if errors.Is(err, push.ErrNotFound) { removeConfirmedRegistration() }
	ErrNotFound = errors.New("push registration not found")

	// ErrRateLimited means the provider rejected submission due to throttling.
	// Applications choose any retry policy; Sender never retries automatically.
	//
	// Example:
	//
	//	if errors.Is(err, push.ErrRateLimited) { return err }
	ErrRateLimited = errors.New("push provider rate limited")

	// ErrUnavailable means submission failed due to a provider availability issue.
	// It does not imply that a registration should be deleted.
	//
	// Example:
	//
	//	if errors.Is(err, push.ErrUnavailable) { return err }
	ErrUnavailable = errors.New("push provider unavailable")

	// ErrOutcomeUnknown means provider acceptance cannot be determined after a
	// request may have left the process. Retrying can deliver duplicate messages.
	// Drivers preserve any context or native cause alongside this classification.
	//
	// Example:
	//
	//	if errors.Is(err, push.ErrOutcomeUnknown) { return err /* reconcile */ }
	ErrOutcomeUnknown = errors.New("push outcome unknown")
)
View Source
var Accepted = event.NewTopic[SubmissionEvent]("push.submission.accepted")

Accepted reports a successful driver submission once, after Send returns from the driver. It contains no receipt ID and proves neither delivery nor display. Events are best effort and in process, never a durable audit trail.

Example:

_, err := events.Listen(push.Accepted, func(ctx context.Context, observed push.SubmissionEvent) error {
	return nil
})
if err != nil { return err }
View Source
var Failed = event.NewTopic[SubmissionEvent]("push.submission.failed")

Failed reports a driver submission error, not proof of failed delivery. outcome_unknown explicitly means acceptance could have occurred; application reconciliation and retry policy remain necessary. Preflight failures emit none.

Example:

_, err := events.Listen(push.Failed, func(ctx context.Context, observed push.SubmissionEvent) error {
	log.Print(observed.ErrorClass)
	return nil
})
if err != nil { return err }

Functions

func CheckSend

func CheckSend(ctx context.Context, message Message, config SendConfig) error

CheckSend is shared direct-driver preflight without I/O. Nil contexts fail with ErrInvalidMessage; done contexts preserve ctx.Err before declaration errors. Raw negative TTL is invalid, while zero explicitly requests no-store. This imposes no provider-specific bounds or whole-second requirement.

Example:

if err := push.CheckSend(ctx, message, push.SendConfig{TTL: time.Hour}); err != nil {
	return push.Receipt{}, err
}
Example
package main

import (
	"context"
	"fmt"

	"arkoren.dev/core/service/push"
)

func main() {
	message := push.NewMessage(push.Token("native"), "Ready")

	err := push.CheckSend(context.Background(), message, push.SendConfig{})
	fmt.Println(err)
}
Output:
<nil>

Types

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder is a construction-only Sender draft. New copies completed settings; retaining it cannot reconfigure a published sender. Live telemetry is shared.

Example:

sender := push.New(driver, func(delivery *push.Builder) {
	delivery.TTL(time.Hour).Telemetry(nil)
})
_ = sender.Driver()

func (*Builder) Events

func (delivery *Builder) Events(events *event.Bus) *Builder

Events selects best-effort, in-process submission notifications and returns the same draft. Nil disables them. Listeners cannot change Send's result; notifications never establish delivery or display on a device.

Example:

sender := push.New(driver, func(delivery *push.Builder) {
	delivery.Events(event.New())
})
_ = sender

func (*Builder) TTL

func (delivery *Builder) TTL(ttl time.Duration) *Builder

TTL replaces the default retention policy and returns the same draft. Zero resets to DefaultTTL, positive durations set retention, and negative durations disable provider storage. Drivers may reject unsupported policies.

Example:

sender := push.New(driver, func(delivery *push.Builder) {
	delivery.TTL(-1)
})
_ = sender

func (*Builder) Telemetry

func (delivery *Builder) Telemetry(instrumentation *telemetry.Telemetry) *Builder

Telemetry selects a shared observability service and returns the same draft. Nil follows telemetry.Default on every call, including later default changes.

Example:

sender := push.New(driver, func(delivery *push.Builder) {
	delivery.Telemetry(nil)
})
_ = sender

type BuilderFunc

type BuilderFunc = func(*Builder)

BuilderFunc aliases the synchronous construction callback type. Callbacks share one fresh draft; nil entries panic and empty expansions omit edits.

Example:

policies := []push.BuilderFunc{
	func(delivery *push.Builder) {
		delivery.TTL(time.Hour)
	},
}
sender := push.New(driver, policies...)
_ = sender.Driver()

type Driver

type Driver interface {
	// Send submits once with resolved retention and returns optional acceptance
	// metadata. Call CheckSend before I/O and return a zero receipt on errors.
	//
	// Example:
	//
	//	receipt, err := driver.Send(ctx, message, push.SendConfig{})
	//	if err != nil { return err }
	//	_ = receipt.ID
	Send(ctx context.Context, message Message, config SendConfig) (Receipt, error)
}

Driver submits notifications to one push provider and is safe for concurrent use. Send must call CheckSend before any I/O, reject unsupported recipients with ErrUnsupported, and apply no defaults to the resolved SendConfig. Drivers must not mutate input or retain caller-owned mutable state. Immutable Message values may be retained; Data returns a detached map for encoding.

A successful receipt means acceptance, not device delivery; its ID may be empty. Every error returns a zero Receipt. Preserve context cancellation and native error causes with wrapping. ErrOutcomeUnknown marks ambiguous acceptance; ErrNotFound is reserved for confirmed invalid registrations, not authentication failures, mismatched projects, or generic endpoint errors. Drivers and Sender never retry automatically. Any lifecycle belongs to the concrete driver, not this contract. Endpoint security belongs to the driver.

Example:

var backend push.Driver = driver
receipt, err := backend.Send(ctx, message, push.SendConfig{TTL: time.Hour})
if err != nil { return err }
_ = receipt.ID

type Message

type Message struct {
	// contains filtered or unexported fields
}

Message is an immutable notification declaration with a required recipient and title. The zero value is invalid. Err preserves the first invalid input, even when a later callback replaces it with a valid value.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready",
	func(notification *push.MessageBuilder) {
		notification.Body("Collect your order.")
	})
if err := message.Validate(); err != nil { return err }

func NewMessage

func NewMessage(to Recipient, title string, configure ...MessageBuilderFunc) Message

NewMessage publishes an immutable notification without I/O. Recipient and title are mandatory and cannot be replaced by callbacks. Only Subscription and Token values are accepted; pointer forms are rejected without retention. Callbacks run directly once in order on one draft, even for invalid required inputs. Callback panics and nil-entry misuse propagate. Maps are copied at capture and publication, while strings and canonical recipients are values.

Example:

message := push.NewMessage(push.Token("device-registration"), "Order ready")
if err := message.Err(); err != nil { return err }
Example
package main

import (
	"fmt"

	"arkoren.dev/core/service/push"
)

func main() {
	data := map[string]string{"order": "42"}
	message := push.NewMessage(push.Token("native"), "Ready", func(notification *push.MessageBuilder) {
		notification.Body("Collect your order.").Data(data)
	})

	if err := message.Err(); err != nil {
		fmt.Println("invalid message")

		return
	}

	data["order"] = "caller mutation"
	fmt.Println(message.Title(), message.Data()["order"])
}
Output:
Ready 42

func (Message) Body

func (message Message) Body() string

Body returns optional notification text; omission returns the empty string. Inspecting it does not alter or revalidate the immutable declaration.

Example:

body := push.NewMessage(push.Token("device-registration"), "Ready").Body()
_ = body

func (Message) Data

func (message Message) Data() map[string]string

Data returns an independent metadata map, or nil when none was declared. Mutating it cannot change this message or another caller's inspection.

Example:

data := push.NewMessage(push.Token("device-registration"), "Ready").Data()
_ = data

func (Message) Err

func (message Message) Err() error

Err reports the first declaration error, wrapping ErrInvalidMessage. The zero Message is invalid; successful construction makes this check cheap because only the constructor and draft can modify the validated fields.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready")
if err := message.Err(); err != nil { return err }

func (Message) Title

func (message Message) Title() string

Title returns the declared title, including invalid text for an invalid declaration. It contains application content and must not enter telemetry.

Example:

title := push.NewMessage(push.Token("device-registration"), "Ready").Title()
_ = title

func (Message) To

func (message Message) To() Recipient

To returns a value recipient without mutable references. Invalid messages may return nil; callers should check Err before inspecting or sending them.

Example:

recipient := push.NewMessage(push.Token("device-registration"), "Ready").To()
_ = recipient

func (Message) Validate

func (message Message) Validate() error

Validate reports the same portable declaration error as Err without I/O. Drivers add their own capability and encoded-payload checks after CheckSend.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready")
if err := message.Validate(); err != nil { return err }

type MessageBuilder

type MessageBuilder struct {
	// contains filtered or unexported fields
}

MessageBuilder is a mutable construction-only draft for optional content. NewMessage detaches its map at publication. Retained drafts cannot modify a published Message, and drafts are not safe for concurrent mutation.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready",
	func(notification *push.MessageBuilder) {
		notification.Body("Collect your order.").Data(map[string]string{"order": "42"})
	})
if err := message.Err(); err != nil { return err }

func (*MessageBuilder) Body

func (notification *MessageBuilder) Body(body string) *MessageBuilder

Body replaces optional UTF-8 body text and returns the same draft. Empty text is valid. Invalid input remains an error even after replacement.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready",
	func(notification *push.MessageBuilder) {
		notification.Body("Collect your order.")
	})
if err := message.Err(); err != nil { return err }

func (*MessageBuilder) Data

func (notification *MessageBuilder) Data(data map[string]string) *MessageBuilder

Data replaces metadata with a copy, never merging with earlier settings. Nil clears metadata. Keys and values must be valid UTF-8; driver-specific reserved keys and encoded size limits are checked by the configured driver.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready",
	func(notification *push.MessageBuilder) {
		notification.Data(map[string]string{"order": "42"})
	})
if err := message.Err(); err != nil { return err }

type MessageBuilderFunc

type MessageBuilderFunc = func(*MessageBuilder)

MessageBuilderFunc is an ordinary function alias for declaration callbacks. Callbacks run once, synchronously, in order, even after invalid input; nil entries panic. An expanded nil or empty slice means no optional settings.

Example:

policies := []push.MessageBuilderFunc{
	func(notification *push.MessageBuilder) {
		notification.Body("Ready now.")
	},
}
message := push.NewMessage(push.Token("device-registration"), "Ready", policies...)
if err := message.Err(); err != nil { return err }

type Receipt

type Receipt struct {
	// ID is optional opaque provider acceptance metadata. It may be empty and
	// must not enter telemetry or be interpreted as proof of device delivery.
	//
	// Example:
	//
	//	receipt, err := sender.Send(ctx, message)
	//	if err != nil { return err }
	//	_ = receipt.ID
	ID string
}

Receipt describes provider acceptance only. A successful receipt may be zero; it is neither delivery confirmation nor evidence that a device displayed it.

Example:

receipt, err := sender.Send(ctx, message)
if err != nil { return err }
_ = receipt.ID

type Recipient

type Recipient interface {
	// contains filtered or unexported methods
}

Recipient is a sealed push destination: use a Subscription or Token value. Pointers, including typed nil pointers, and embedded foreign variants are rejected by NewMessage. Value forms keep published messages independent of caller mutation. Recipients contain secrets and must not enter telemetry.

Example:

var recipient push.Recipient = push.Token("device-registration")
message := push.NewMessage(recipient, "Ready")
if err := message.Err(); err != nil { return err }

type SendConfig

type SendConfig struct {
	// TTL is requested retention as a nonnegative duration. Zero requests no
	// storage; a negative raw value is invalid. Drivers never apply defaults
	// and must not silently increase retention. Provider bounds and precision
	// constraints belong in the driver, not in shared message validation.
	//
	// Example:
	//
	//	_, err := driver.Send(ctx, message, push.SendConfig{TTL: 0})
	//	if err != nil { return err }
	TTL time.Duration
}

SendConfig is the fully resolved raw-driver configuration. Its zero value means explicit no-store; defaults are exclusively a Sender responsibility.

Example:

_, err := driver.Send(ctx, message, push.SendConfig{TTL: time.Hour})
if err != nil { return err }

type SendOption

type SendOption func(*sendOptions)

SendOption edits a private per-call draft. Use TTL to select retention; supplied nil options panic. Options never reconfigure the Sender.

Example:

options := []push.SendOption{push.TTL(time.Hour)}
_, err := sender.Send(ctx, message, options...)
if err != nil { return err }

func TTL

func TTL(ttl time.Duration) SendOption

TTL selects retention for one Send. Zero or omission inherits the Sender's configured default, positive overrides it, and negative requests no-store. Later options replace earlier values; no global maximum or precision limit is imposed here. A driver may reject a policy it cannot represent safely.

Example:

_, err := sender.Send(ctx, message, push.TTL(-1))
if err != nil { return err }

type Sender

type Sender struct {
	// contains filtered or unexported fields
}

Sender validates and instruments a Driver without adding lifecycle, delivery state, or automatic retries. Settings are immutable after New and calls may run concurrently when the underlying Driver honors its contract.

Example:

sender := push.New(driver)
_, err := sender.Send(ctx, push.NewMessage(push.Token("device-registration"), "Ready"))
if err != nil { return err }

func New

func New(driver Driver, configure ...BuilderFunc) *Sender

New constructs a Sender without I/O. Callbacks run directly once, synchronously, in order on a fresh draft. Nil entries and callback panics propagate; expanded nil or empty slices omit edits. Publication detaches the draft, not the driver, telemetry service, or event bus. Nil drivers fail on Send.

Example:

sender := push.New(driver, func(delivery *push.Builder) {
	delivery.TTL(time.Hour)
})
_ = sender.Driver()

func (*Sender) Driver

func (sender *Sender) Driver() Driver

Driver returns the originally supplied backend, including wrapping drivers. Direct use bypasses default resolution, telemetry, and events, not validation.

Example:

backend := push.New(driver).Driver()
_ = backend

func (*Sender) DriverAs

func (sender *Sender) DriverAs[T any]() (T, bool)

DriverAs finds the first backend of type T through Unwrap() Driver wrappers. It returns T's zero value and false when no matching backend is present.

Example:

backend, ok := push.New(driver).DriverAs[push.Driver]()
if ok { _ = backend }

func (*Sender) Send

func (sender *Sender) Send(ctx context.Context, message Message, opts ...SendOption) (receipt Receipt, err error)

Send resolves per-call retention, validates portable input, and submits once. Errors preserve native and context identity and always return a zero receipt. A nil context or driver fails with ErrInvalidMessage before instrumentation. Only bounded operation/error classes enter telemetry, never error text. ErrOutcomeUnknown outranks joined context or provider failures in telemetry; the returned error still preserves every original cause for errors.Is. When Events is configured, one Accepted or Failed notification observes the driver call; preflight failures emit none. Listener failures do not change the returned result, and unknown acceptance never proves failed delivery. Driver panics propagate without an outcome notification. A nil event bus performs no event-related clock reads.

Example:

receipt, err := sender.Send(ctx, message, push.TTL(time.Hour))
if err != nil { return err }
_ = receipt.ID

type SubmissionEvent

type SubmissionEvent struct {
	// Duration measures the observed driver call, not device delivery latency.
	// For example: log.Print(observed.Duration).
	Duration time.Duration

	// ErrorClass is empty for acceptance, outcome_unknown for uncertain provider
	// acceptance, a push sentinel name, canceled, deadline_exceeded, or error.
	// No error text is retained. For example: log.Print(observed.ErrorClass).
	ErrorClass string
}

SubmissionEvent describes one observed driver submission, without recipient, message, receipt, or native error data. Acceptance is not delivery or display.

Example:

_, err := events.Listen(push.Accepted, func(ctx context.Context, observed push.SubmissionEvent) error {
	log.Printf("submission took %s", observed.Duration)
	return nil
})
if err != nil { return err }

type Subscription

type Subscription struct {
	// Endpoint is the sensitive absolute HTTPS push-service URL. Credentials
	// and fragments, including an empty trailing #, are rejected. Encoded %23
	// in a path or query is data; paths and query strings are retained exactly.
	//
	// Example:
	//
	//	registration := push.Subscription{Endpoint: "https://push.example/send/123", Keys: keys}
	//	_ = registration
	Endpoint string `json:"endpoint"`

	// Keys holds the browser-generated encryption material, not VAPID keys.
	// These strings must not be logged or exposed through error messages.
	//
	// Example:
	//
	//	registration := push.Subscription{Endpoint: endpoint, Keys: keys}
	//	_ = registration
	Keys SubscriptionKeys `json:"keys"`
}

Subscription is a browser's Web Push registration, passed by value. Validation requires HTTPS and well-formed encryption keys, not a reachable endpoint. Drivers own request security and supported endpoint policy.

Example:

registration := push.Subscription{Endpoint: endpoint, Keys: keys}
message := push.NewMessage(registration, "Ready")
if err := message.Err(); err != nil { return err }

type SubscriptionKeys

type SubscriptionKeys struct {
	// P256DH encodes a valid 65-byte uncompressed P-256 public point.
	// Compressed points, padding, and noncanonical base64 are invalid.
	//
	// Example:
	//
	//	keys := push.SubscriptionKeys{P256DH: browserPublicKey, Auth: browserAuthSecret}
	//	_ = keys
	P256DH string `json:"p256dh"`

	// Auth encodes exactly 16 bytes of authentication secret without padding.
	// Treat it as a credential even though it accompanies a public key.
	//
	// Example:
	//
	//	keys := push.SubscriptionKeys{P256DH: browserPublicKey, Auth: browserAuthSecret}
	//	_ = keys
	Auth string `json:"auth"`
}

SubscriptionKeys contains unpadded, URL-safe base64 encryption material. P256DH is the browser's public key; Auth is a secret authentication value. Neither field is a sender's VAPID signing key.

Example:

keys := push.SubscriptionKeys{P256DH: browserPublicKey, Auth: browserAuthSecret}
message := push.NewMessage(push.Subscription{Endpoint: endpoint, Keys: keys}, "Ready")
if err := message.Err(); err != nil { return err }

type Token

type Token string

Token is a nonempty, valid UTF-8 native device registration token. Its opaque value is interpreted by the configured Driver, not by a global provider registry. Do not log it or use it as a telemetry label.

Example:

message := push.NewMessage(push.Token("device-registration"), "Ready")
if err := message.Err(); err != nil { return err }

Directories

Path Synopsis
Package pushtest provides an outbound push fake and Driver conformance suite.
Package pushtest provides an outbound push fake and Driver conformance suite.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL