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 ¶
- Constants
- Variables
- func CheckSend(ctx context.Context, message Message, config SendConfig) error
- type Builder
- type BuilderFunc
- type Driver
- type Message
- type MessageBuilder
- type MessageBuilderFunc
- type Receipt
- type Recipient
- type SendConfig
- type SendOption
- type Sender
- type SubmissionEvent
- type Subscription
- type SubscriptionKeys
- type Token
Examples ¶
Constants ¶
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 ¶
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") // 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") )
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 }
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 ¶
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 ¶
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
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 }