diff --git a/app.go b/app.go
index 32d90ef402d..f5cfbf8ae89 100644
--- a/app.go
+++ b/app.go
@@ -90,6 +90,8 @@ type App struct {
mountFields *mountFields
// state management
state *State
+ // shared state management (prefork-safe, storage-backed)
+ sharedState *SharedState
// Route stack divided by HTTP methods
stack [][]*Route
// customConstraints is a list of external constraints
@@ -279,6 +281,19 @@ type Config struct { //nolint:govet // Aligning the struct fields is not necessa
// Default: nil
AppName string `json:"app_name"`
+ // SharedStorage configures storage-backed shared state that can be used
+ // safely across prefork workers and processes.
+ //
+ // Default: nil
+ SharedStorage Storage `json:"-"`
+
+ // SharedStatePrefix customizes the namespace prefix for keys written to
+ // SharedStorage. If empty, Fiber derives a prefixed namespace using
+ // AppName (when set) or an internal default.
+ //
+ // Default: ""
+ SharedStatePrefix string `json:"shared_state_prefix"`
+
// StreamRequestBody enables request body streaming,
// and calls the handler sooner when given body is
// larger than the current limit.
@@ -638,6 +653,8 @@ func New(config ...Config) *App {
if app.config.XMLDecoder == nil {
app.config.XMLDecoder = xml.Unmarshal
}
+
+ app.sharedState = newSharedState(&app.config)
if len(app.config.RequestMethods) == 0 {
app.config.RequestMethods = DefaultMethods
}
@@ -1172,11 +1189,18 @@ func (app *App) Hooks() *Hooks {
return app.hooks
}
-// State returns the state struct to store global data in order to share it between handlers.
+// State returns the in-process state struct to store global data between handlers.
+// State is process-local and is not shared across prefork workers.
func (app *App) State() *State {
return app.state
}
+// SharedState returns storage-backed shared state.
+// SharedState is prefork-safe when Config.SharedStorage is configured.
+func (app *App) SharedState() *SharedState {
+ return app.sharedState
+}
+
var ErrTestGotEmptyResponse = errors.New("test: got empty response")
// TestConfig is a struct holding Test settings
@@ -1375,6 +1399,11 @@ func (app *App) init() *App {
if err := app.shutdownServices(app.servicesShutdownCtx()); err != nil {
log.Errorf("failed to shutdown services: %v", err)
}
+ if app.sharedState != nil && app.sharedState.storage != nil {
+ if err := app.sharedState.Close(); err != nil {
+ log.Errorf("failed to close sharedState: %v", err)
+ }
+ }
return nil
})
diff --git a/docs/api/app.md b/docs/api/app.md
index a10283a8f35..c595aae8574 100644
--- a/docs/api/app.md
+++ b/docs/api/app.md
@@ -80,6 +80,18 @@ func main() {
}
```
+### State / SharedState
+
+`State()` returns in-process state (local to the current process).
+`SharedState()` returns storage-backed state intended for prefork/multi-process sharing.
+
+```go title="Signature"
+func (app *App) State() *State
+func (app *App) SharedState() *SharedState
+```
+
+See [State Management](./state.md) for usage and examples.
+
### MountPath
The `MountPath` property contains one or more path patterns on which a sub-app was mounted.
diff --git a/docs/api/state.md b/docs/api/state.md
index 36be5f67b89..a761f770ca5 100644
--- a/docs/api/state.md
+++ b/docs/api/state.md
@@ -10,6 +10,128 @@ State management provides a global key–value store for application dependencie
When prefork is enabled, each worker process has an independent state store, meaning state is not shared between them.
:::
+## SharedState (Prefork-safe)
+
+For data that must be shared across prefork workers or multiple app processes, use `app.SharedState()` backed by `fiber.Storage`.
+
+Configure storage in `fiber.Config`:
+
+```go
+app := fiber.New(fiber.Config{
+ AppName: "billing-api",
+ SharedStorage: redisStorage, // any implementation of fiber.Storage
+ SharedStatePrefix: "billing-shared-", // optional
+})
+```
+
+If `SharedStatePrefix` is empty, Fiber derives a default namespace and includes `AppName` (when set) to reduce collisions between apps/services.
+
+MsgPack and CBOR helpers require the corresponding `Config` encoders/decoders to be configured. If they are unavailable, the helper methods return an error instead of panicking.
+
+:::warning Memory storage caveat
+`SharedState` is only cross-worker / cross-process when the configured `SharedStorage` backend is shared.
+
+If you use an in-memory backend (for example memory storage), data remains process-local. In prefork mode, each worker process has its own independent in-memory store.
+:::
+
+### SharedState Methods
+
+```go title="Signature"
+func (app *App) SharedState() *SharedState
+
+func (s *SharedState) Set(key string, val []byte, ttl time.Duration) error
+func (s *SharedState) SetWithContext(ctx context.Context, key string, val []byte, ttl time.Duration) error
+
+func (s *SharedState) Get(key string) (val []byte, found bool, err error)
+func (s *SharedState) GetWithContext(ctx context.Context, key string) (val []byte, found bool, err error)
+
+func (s *SharedState) SetJSON(key string, v any, ttl time.Duration) error
+func (s *SharedState) SetJSONWithContext(ctx context.Context, key string, v any, ttl time.Duration) error
+
+func (s *SharedState) GetJSON(key string, out any) (raw []byte, found bool, err error)
+func (s *SharedState) GetJSONWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)
+
+func (s *SharedState) SetMsgPack(key string, v any, ttl time.Duration) error
+func (s *SharedState) SetMsgPackWithContext(ctx context.Context, key string, v any, ttl time.Duration) error
+
+func (s *SharedState) GetMsgPack(key string, out any) (raw []byte, found bool, err error)
+func (s *SharedState) GetMsgPackWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)
+
+func (s *SharedState) SetCBOR(key string, v any, ttl time.Duration) error
+func (s *SharedState) SetCBORWithContext(ctx context.Context, key string, v any, ttl time.Duration) error
+
+func (s *SharedState) GetCBOR(key string, out any) (raw []byte, found bool, err error)
+func (s *SharedState) GetCBORWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)
+
+func (s *SharedState) SetXML(key string, v any, ttl time.Duration) error
+func (s *SharedState) SetXMLWithContext(ctx context.Context, key string, v any, ttl time.Duration) error
+
+func (s *SharedState) GetXML(key string, out any) (raw []byte, found bool, err error)
+func (s *SharedState) GetXMLWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)
+
+func (s *SharedState) Delete(key string) error
+func (s *SharedState) DeleteWithContext(ctx context.Context, key string) error
+
+func (s *SharedState) Has(key string) (bool, error)
+func (s *SharedState) HasWithContext(ctx context.Context, key string) (bool, error)
+
+func (s *SharedState) Reset() error
+func (s *SharedState) ResetWithContext(ctx context.Context) error
+func (s *SharedState) Close() error
+```
+
+### SharedState Example
+
+```go
+type SessionSnapshot struct {
+ UserID string `json:"user_id"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+app.Post("/sessions/:id", func(c fiber.Ctx) error {
+ key := "session:" + c.Params("id")
+ value := SessionSnapshot{
+ UserID: c.Params("id"),
+ UpdatedAt: time.Now().UTC(),
+ }
+
+ if err := app.SharedState().SetJSON(key, value, 30*time.Minute); err != nil {
+ return err
+ }
+
+ return c.SendStatus(fiber.StatusAccepted)
+})
+
+app.Get("/sessions/:id", func(c fiber.Ctx) error {
+ key := "session:" + c.Params("id")
+ var snapshot SessionSnapshot
+
+ _, found, err := app.SharedState().GetJSON(key, &snapshot)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return c.SendStatus(fiber.StatusNotFound)
+ }
+
+ return c.JSON(snapshot)
+})
+```
+
+### SharedState with Context (timeouts/cancellation)
+
+```go
+ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+defer cancel()
+
+err := app.SharedState().SetJSONWithContext(ctx, "job:42", fiber.Map{
+ "status": "queued",
+}, 2*time.Minute)
+if err != nil {
+ // timeout, cancellation, storage error, or JSON serialization error
+}
+```
+
## State Type
`State` is a key–value store built on top of `sync.Map` to ensure safe concurrent access. It allows storage and retrieval of dependencies and configurations in a Fiber application as well as thread–safe access to runtime data.
diff --git a/docs/whats_new.md b/docs/whats_new.md
index eb50b3f82ee..f0877c5d287 100644
--- a/docs/whats_new.md
+++ b/docs/whats_new.md
@@ -85,6 +85,7 @@ We have made several changes to the Fiber app, including:
- **RegisterCustomConstraint**: Allows for the registration of custom constraints.
- **NewWithCustomCtx**: Initialize an app with a custom context in one step.
- **State**: Provides a global state for the application, which can be used to store and retrieve data across the application. Check out the [State](./api/state) method for further details.
+- **SharedState**: Introduces storage-backed app state for prefork-safe/multi-process coordination via `Config.SharedStorage`, with optional `Config.SharedStatePrefix` namespacing, codec-aware helpers (`SetJSON`, `SetMsgPack`, `SetCBOR`, `SetXML`, matching getters, and `WithContext` variants), empty-key no-op handling, and `Reset`/`Close` passthrough helpers.
- **NewErrorf**: Allows variadic parameters when creating formatted errors.
- **GetBytes / GetString**: Helpers that detach values only when `Immutable` is enabled and the data still references request or response buffers. Access via `c.App().GetString` and `c.App().GetBytes`.
- **ReloadViews**: Lets you re-run the configured view engine's `Load()` logic at runtime, including guard rails for missing or nil view engines so development hot-reload hooks can refresh templates safely.
diff --git a/services_test.go b/services_test.go
index 5e29063bb95..f03db3333e0 100644
--- a/services_test.go
+++ b/services_test.go
@@ -5,7 +5,11 @@ import (
"context"
"errors"
"fmt"
+ "io"
+ stdlog "log" //nolint:depguard // Test needs the concrete stdlib logger type to restore the previous writer.
"strings"
+ "sync"
+ "sync/atomic"
"testing"
"time"
@@ -30,6 +34,75 @@ type mockService struct {
terminateDelay time.Duration
}
+type shutdownHookStorage struct {
+ closeErr error
+ closeCalled atomic.Bool
+}
+
+type stringsLogger struct {
+ strings.Builder
+}
+
+var servicesTestLogOutputMu sync.Mutex
+
+func withCapturedLogOutput(t *testing.T, writer io.Writer) {
+ t.Helper()
+
+ servicesTestLogOutputMu.Lock()
+ cleanupRegistered := false
+ defer func() {
+ if !cleanupRegistered {
+ servicesTestLogOutputMu.Unlock()
+ }
+ }()
+
+ currentOutput := log.DefaultLogger[*stdlog.Logger]().Logger().Writer()
+ t.Cleanup(func() {
+ log.SetOutput(currentOutput)
+ servicesTestLogOutputMu.Unlock()
+ })
+ cleanupRegistered = true
+
+ log.SetOutput(writer)
+}
+
+func (*shutdownHookStorage) GetWithContext(context.Context, string) ([]byte, error) {
+ return nil, nil
+}
+
+func (*shutdownHookStorage) Get(string) ([]byte, error) {
+ return nil, nil
+}
+
+func (*shutdownHookStorage) SetWithContext(context.Context, string, []byte, time.Duration) error {
+ return nil
+}
+
+func (*shutdownHookStorage) Set(string, []byte, time.Duration) error {
+ return nil
+}
+
+func (*shutdownHookStorage) DeleteWithContext(context.Context, string) error {
+ return nil
+}
+
+func (*shutdownHookStorage) Delete(string) error {
+ return nil
+}
+
+func (*shutdownHookStorage) ResetWithContext(context.Context) error {
+ return nil
+}
+
+func (*shutdownHookStorage) Reset() error {
+ return nil
+}
+
+func (s *shutdownHookStorage) Close() error {
+ s.closeCalled.Store(true)
+ return s.closeErr
+}
+
func (m *mockService) Start(ctx context.Context) error {
select {
case <-ctx.Done():
@@ -127,6 +200,8 @@ func Test_HasConfiguredServices(t *testing.T) {
}
func Test_InitServices(t *testing.T) {
+ t.Parallel()
+
t.Run("no-services", func(t *testing.T) {
app := &App{configured: Config{}}
require.NotPanics(t, app.initServices)
@@ -178,10 +253,6 @@ func Test_InitServices(t *testing.T) {
require.NotPanics(t, app.initServices)
- type stringsLogger struct {
- strings.Builder
- }
-
var buf stringsLogger
log.SetOutput(&buf)
@@ -202,10 +273,6 @@ func Test_InitServices(t *testing.T) {
require.NotPanics(t, app.initServices)
- type stringsLogger struct {
- strings.Builder
- }
-
var buf stringsLogger
log.SetOutput(&buf)
@@ -213,6 +280,50 @@ func Test_InitServices(t *testing.T) {
require.Contains(t, buf.String(), "failed to shutdown services: service dep2 terminate: terminate error 2")
})
+
+ t.Run("shutdown-hooks/close-shared-state", func(t *testing.T) {
+ t.Parallel()
+
+ storage := &shutdownHookStorage{}
+ app := New(Config{
+ Services: []Service{&mockService{name: "dep1"}},
+ SharedStorage: storage,
+ })
+
+ require.NotPanics(t, app.initServices)
+
+ var buf stringsLogger
+ withCapturedLogOutput(t, &buf)
+
+ app.Hooks().executeOnPostShutdownHooks(nil)
+
+ require.True(t, storage.closeCalled.Load())
+ require.NotContains(t, buf.String(), "failed to close sharedState:")
+ })
+
+ t.Run("shutdown-hooks/close-shared-state-after-service-error", func(t *testing.T) {
+ t.Parallel()
+
+ storage := &shutdownHookStorage{closeErr: errors.New("close error")}
+ app := New(Config{
+ Services: []Service{
+ &mockService{name: "dep1"},
+ &mockService{name: "dep2", terminateError: errors.New(terminateErrorMessage + " 2")},
+ },
+ SharedStorage: storage,
+ })
+
+ require.NotPanics(t, app.initServices)
+
+ var buf stringsLogger
+ withCapturedLogOutput(t, &buf)
+
+ app.Hooks().executeOnPostShutdownHooks(nil)
+
+ require.True(t, storage.closeCalled.Load())
+ require.Contains(t, buf.String(), "failed to shutdown services: service dep2 terminate: terminate error 2")
+ require.Contains(t, buf.String(), "failed to close sharedState: close error")
+ })
}
func Test_StartServices(t *testing.T) {
diff --git a/shared_state.go b/shared_state.go
new file mode 100644
index 00000000000..2f06fb50a64
--- /dev/null
+++ b/shared_state.go
@@ -0,0 +1,416 @@
+package fiber
+
+import (
+ "context"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/gofiber/utils/v2"
+)
+
+const defaultSharedStatePrefix = "gofiber-shared-state-"
+
+var ErrSharedStorageNotConfigured = errors.New("fiber: shared storage is not configured")
+
+type SharedState struct {
+ storage Storage
+ jsonEncoder utils.JSONMarshal
+ jsonDecoder utils.JSONUnmarshal
+ msgPackEncoder utils.MsgPackMarshal
+ msgPackDecoder utils.MsgPackUnmarshal
+ cborEncoder utils.CBORMarshal
+ cborDecoder utils.CBORUnmarshal
+ xmlEncoder utils.XMLMarshal
+ xmlDecoder utils.XMLUnmarshal
+ prefix string
+}
+
+func newSharedState(cfg *Config) *SharedState {
+ if cfg == nil {
+ cfg = &Config{}
+ }
+
+ prefix := cfg.SharedStatePrefix
+ if prefix == "" {
+ prefix = defaultSharedStatePrefix
+ if cfg.AppName != "" {
+ prefix += cfg.AppName + "-"
+ }
+ }
+
+ jsonEncoder := cfg.JSONEncoder
+ if jsonEncoder == nil {
+ jsonEncoder = json.Marshal
+ }
+ jsonDecoder := cfg.JSONDecoder
+ if jsonDecoder == nil {
+ jsonDecoder = json.Unmarshal
+ }
+ xmlEncoder := cfg.XMLEncoder
+ if xmlEncoder == nil {
+ xmlEncoder = xml.Marshal
+ }
+ xmlDecoder := cfg.XMLDecoder
+ if xmlDecoder == nil {
+ xmlDecoder = xml.Unmarshal
+ }
+
+ return &SharedState{
+ storage: cfg.SharedStorage,
+ jsonEncoder: jsonEncoder,
+ jsonDecoder: jsonDecoder,
+ msgPackEncoder: cfg.MsgPackEncoder,
+ msgPackDecoder: cfg.MsgPackDecoder,
+ cborEncoder: cfg.CBOREncoder,
+ cborDecoder: cfg.CBORDecoder,
+ xmlEncoder: xmlEncoder,
+ xmlDecoder: xmlDecoder,
+ prefix: prefix,
+ }
+}
+
+func (s *SharedState) Set(key string, val []byte, ttl time.Duration) error {
+ return s.SetWithContext(context.Background(), key, val, ttl)
+}
+
+func (s *SharedState) SetWithContext(ctx context.Context, key string, val []byte, ttl time.Duration) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ storageKey, ok := s.storageKey(key)
+ if !ok {
+ return nil
+ }
+
+ return s.storage.SetWithContext(ctx, storageKey, val, ttl)
+}
+
+func (s *SharedState) Get(key string) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ return s.GetWithContext(context.Background(), key)
+}
+
+func (s *SharedState) GetWithContext(ctx context.Context, key string) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ if err := s.ensureStorage(); err != nil {
+ return nil, false, err
+ }
+
+ storageKey, ok := s.storageKey(key)
+ if !ok {
+ return nil, false, nil
+ }
+
+ data, err := s.storage.GetWithContext(ctx, storageKey)
+ if err != nil {
+ return nil, false, err
+ }
+ if data == nil {
+ return nil, false, nil
+ }
+
+ return append([]byte(nil), data...), true, nil
+}
+
+func (s *SharedState) SetJSON(key string, v any, ttl time.Duration) error {
+ return s.SetJSONWithContext(context.Background(), key, v, ttl)
+}
+
+func (s *SharedState) SetJSONWithContext(ctx context.Context, key string, v any, ttl time.Duration) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ return s.setEncodedWithContext(ctx, key, v, ttl, s.jsonEncoder, "json")
+}
+
+func (s *SharedState) GetJSON(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ return s.GetJSONWithContext(context.Background(), key, out)
+}
+
+func (s *SharedState) GetJSONWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ if err := s.ensureStorage(); err != nil {
+ return nil, false, err
+ }
+
+ return s.getEncodedWithContext(ctx, key, out, s.jsonDecoder, "json")
+}
+
+func (s *SharedState) SetMsgPack(key string, v any, ttl time.Duration) error {
+ return s.SetMsgPackWithContext(context.Background(), key, v, ttl)
+}
+
+func (s *SharedState) SetMsgPackWithContext(ctx context.Context, key string, v any, ttl time.Duration) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ return s.setEncodedWithContext(ctx, key, v, ttl, s.msgPackEncoder, "msgpack")
+}
+
+func (s *SharedState) GetMsgPack(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ return s.GetMsgPackWithContext(context.Background(), key, out)
+}
+
+func (s *SharedState) GetMsgPackWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ if err := s.ensureStorage(); err != nil {
+ return nil, false, err
+ }
+
+ return s.getEncodedWithContext(ctx, key, out, s.msgPackDecoder, "msgpack")
+}
+
+func (s *SharedState) SetCBOR(key string, v any, ttl time.Duration) error {
+ return s.SetCBORWithContext(context.Background(), key, v, ttl)
+}
+
+func (s *SharedState) SetCBORWithContext(ctx context.Context, key string, v any, ttl time.Duration) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ return s.setEncodedWithContext(ctx, key, v, ttl, s.cborEncoder, "cbor")
+}
+
+func (s *SharedState) GetCBOR(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ return s.GetCBORWithContext(context.Background(), key, out)
+}
+
+func (s *SharedState) GetCBORWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ if err := s.ensureStorage(); err != nil {
+ return nil, false, err
+ }
+
+ return s.getEncodedWithContext(ctx, key, out, s.cborDecoder, "cbor")
+}
+
+func (s *SharedState) SetXML(key string, v any, ttl time.Duration) error {
+ return s.SetXMLWithContext(context.Background(), key, v, ttl)
+}
+
+func (s *SharedState) SetXMLWithContext(ctx context.Context, key string, v any, ttl time.Duration) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ return s.setEncodedWithContext(ctx, key, v, ttl, s.xmlEncoder, "xml")
+}
+
+func (s *SharedState) GetXML(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ return s.GetXMLWithContext(context.Background(), key, out)
+}
+
+func (s *SharedState) GetXMLWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity.
+ if err := s.ensureStorage(); err != nil {
+ return nil, false, err
+ }
+
+ return s.getEncodedWithContext(ctx, key, out, s.xmlDecoder, "xml")
+}
+
+func (s *SharedState) Delete(key string) error {
+ return s.DeleteWithContext(context.Background(), key)
+}
+
+func (s *SharedState) DeleteWithContext(ctx context.Context, key string) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ storageKey, ok := s.storageKey(key)
+ if !ok {
+ return nil
+ }
+
+ return s.storage.DeleteWithContext(ctx, storageKey)
+}
+
+func (s *SharedState) Has(key string) (bool, error) {
+ return s.HasWithContext(context.Background(), key)
+}
+
+func (s *SharedState) HasWithContext(ctx context.Context, key string) (bool, error) {
+ if err := s.ensureStorage(); err != nil {
+ return false, err
+ }
+
+ storageKey, ok := s.storageKey(key)
+ if !ok {
+ return false, nil
+ }
+
+ data, err := s.storage.GetWithContext(ctx, storageKey)
+ if err != nil {
+ return false, err
+ }
+
+ return data != nil, nil
+}
+
+func (s *SharedState) Reset() error {
+ return s.ResetWithContext(context.Background())
+}
+
+func (s *SharedState) ResetWithContext(ctx context.Context) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ return s.storage.ResetWithContext(ctx)
+}
+
+func (s *SharedState) Close() error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ return s.storage.Close()
+}
+
+func (s *SharedState) ensureStorage() error {
+ if s == nil || s.storage == nil {
+ return ErrSharedStorageNotConfigured
+ }
+
+ return nil
+}
+
+func (s *SharedState) setEncodedWithContext(
+ ctx context.Context,
+ key string,
+ v any,
+ ttl time.Duration,
+ encoder func(any) ([]byte, error),
+ format string,
+) error {
+ if err := s.ensureStorage(); err != nil {
+ return err
+ }
+
+ storageKey, ok := s.storageKey(key)
+ if !ok {
+ return nil
+ }
+
+ encoded, err := encodeSharedStateValue(v, encoder, format)
+ if err != nil {
+ return err
+ }
+
+ return s.storage.SetWithContext(ctx, storageKey, encoded, ttl)
+}
+
+//nolint:gocritic // Keep unnamed returns for clarity.
+func (s *SharedState) getEncodedWithContext(
+ ctx context.Context,
+ key string,
+ out any,
+ decoder func([]byte, any) error,
+ format string,
+) ([]byte, bool, error) {
+ if err := s.ensureStorage(); err != nil {
+ return nil, false, err
+ }
+
+ storageKey, ok := s.storageKey(key)
+ if !ok {
+ return nil, false, nil
+ }
+
+ data, err := s.storage.GetWithContext(ctx, storageKey)
+ if err != nil {
+ return nil, false, err
+ }
+ if data == nil {
+ return nil, false, nil
+ }
+
+ if err := decodeSharedStateValue(data, out, decoder, format); err != nil {
+ return nil, false, err
+ }
+
+ return append([]byte(nil), data...), true, nil
+}
+
+func encodeSharedStateValue(v any, encoder func(any) ([]byte, error), format string) ([]byte, error) {
+ if encoder == nil {
+ return nil, sharedStateCodecNotConfiguredError(format, "encoder")
+ }
+
+ var (
+ encoded []byte
+ err error
+ recovered any
+ )
+ func() {
+ // App-configured codecs may be nil or may still use Fiber's
+ // binder.Unimplemented* placeholders, which panic instead of returning an
+ // error, so recover here and surface a regular error.
+ defer func() {
+ recovered = recover()
+ }()
+
+ encoded, err = encoder(v)
+ }()
+
+ if recovered != nil {
+ return nil, sharedStateCodecPanicError("encode", format, recovered)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("fiber: failed to encode shared state %s value: %w", format, err)
+ }
+
+ return encoded, nil
+}
+
+func decodeSharedStateValue(data []byte, out any, decoder func([]byte, any) error, format string) error {
+ if decoder == nil {
+ return sharedStateCodecNotConfiguredError(format, "decoder")
+ }
+
+ var (
+ err error
+ recovered any
+ )
+ func() {
+ // App-configured codecs may be nil or may still use Fiber's
+ // binder.Unimplemented* placeholders, which panic instead of returning an
+ // error, so recover here and surface a regular error.
+ defer func() {
+ recovered = recover()
+ }()
+
+ err = decoder(data, out)
+ }()
+
+ if recovered != nil {
+ return sharedStateCodecPanicError("decode", format, recovered)
+ }
+ if err != nil {
+ return fmt.Errorf("fiber: failed to decode shared state %s value: %w", format, err)
+ }
+
+ return nil
+}
+
+func sharedStateCodecNotConfiguredError(format, direction string) error {
+ return fmt.Errorf("fiber: shared state %s %s is not configured", format, direction)
+}
+
+func sharedStateCodecPanicError(operation, format string, recovered any) error {
+ if err, ok := recovered.(error); ok {
+ return fmt.Errorf("fiber: failed to %s shared state %s value: %w", operation, format, err)
+ }
+
+ return fmt.Errorf("fiber: failed to %s shared state %s value: %v", operation, format, recovered)
+}
+
+func (s *SharedState) storageKey(key string) (string, bool) {
+ if key == "" {
+ return "", false
+ }
+
+ return s.prefix + key, true
+}
diff --git a/shared_state_test.go b/shared_state_test.go
new file mode 100644
index 00000000000..0fe0437694b
--- /dev/null
+++ b/shared_state_test.go
@@ -0,0 +1,737 @@
+package fiber
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ storagememory "github.com/gofiber/fiber/v3/internal/storage/memory"
+ "github.com/stretchr/testify/require"
+)
+
+func newSharedStateMemoryStorage(t *testing.T) *storagememory.Storage {
+ t.Helper()
+
+ store := storagememory.New()
+ t.Cleanup(func() {
+ require.NoError(t, store.Close())
+ })
+
+ return store
+}
+
+type contextCheckingStorage struct {
+ base Storage
+ ctxKey any
+}
+
+type errorStorage struct {
+ err error
+ closeErr error
+}
+
+func (s *errorStorage) GetWithContext(context.Context, string) ([]byte, error) {
+ return nil, s.err
+}
+
+func (s *errorStorage) Get(string) ([]byte, error) {
+ return nil, s.err
+}
+
+func (s *errorStorage) SetWithContext(context.Context, string, []byte, time.Duration) error {
+ return s.err
+}
+
+func (s *errorStorage) Set(string, []byte, time.Duration) error {
+ return s.err
+}
+
+func (s *errorStorage) DeleteWithContext(context.Context, string) error {
+ return s.err
+}
+
+func (s *errorStorage) Delete(string) error {
+ return s.err
+}
+
+func (s *errorStorage) ResetWithContext(context.Context) error {
+ return s.err
+}
+
+func (s *errorStorage) Reset() error {
+ return s.err
+}
+
+func (s *errorStorage) Close() error {
+ return s.closeErr
+}
+
+func (s *contextCheckingStorage) SetWithContext(ctx context.Context, key string, val []byte, exp time.Duration) error {
+ if ctx.Value(s.ctxKey) == nil {
+ return errors.New("context value not found")
+ }
+ return s.base.SetWithContext(ctx, key, val, exp)
+}
+
+func (s *contextCheckingStorage) GetWithContext(ctx context.Context, key string) ([]byte, error) {
+ if ctx.Value(s.ctxKey) == nil {
+ return nil, errors.New("context value not found")
+ }
+ return s.base.GetWithContext(ctx, key)
+}
+
+func (s *contextCheckingStorage) DeleteWithContext(ctx context.Context, key string) error {
+ if ctx.Value(s.ctxKey) == nil {
+ return errors.New("context value not found")
+ }
+ return s.base.DeleteWithContext(ctx, key)
+}
+
+func (s *contextCheckingStorage) Get(key string) ([]byte, error) {
+ return s.GetWithContext(context.Background(), key)
+}
+
+func (s *contextCheckingStorage) Set(key string, val []byte, exp time.Duration) error {
+ return s.SetWithContext(context.Background(), key, val, exp)
+}
+
+func (s *contextCheckingStorage) Delete(key string) error {
+ return s.DeleteWithContext(context.Background(), key)
+}
+
+func (s *contextCheckingStorage) ResetWithContext(ctx context.Context) error {
+ return s.base.ResetWithContext(ctx)
+}
+
+func (s *contextCheckingStorage) Reset() error {
+ return s.base.Reset()
+}
+
+func (s *contextCheckingStorage) Close() error {
+ return s.base.Close()
+}
+
+func TestSharedState_NotConfigured(t *testing.T) {
+ t.Parallel()
+
+ app := New()
+
+ err := app.SharedState().Set("raw-key", []byte("raw"), time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ raw, found, err := app.SharedState().Get("raw-key")
+ require.Nil(t, raw)
+ require.False(t, found)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = app.SharedState().SetJSON("key", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ err = app.SharedState().SetMsgPack("key", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ err = app.SharedState().SetCBOR("key", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ err = app.SharedState().SetXML("key", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ _, found, err = app.SharedState().GetJSON("key", &Map{})
+ require.False(t, found)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ _, found, err = app.SharedState().GetMsgPack("key", &Map{})
+ require.False(t, found)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ _, found, err = app.SharedState().GetCBOR("key", &Map{})
+ require.False(t, found)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ _, found, err = app.SharedState().GetXML("key", &Map{})
+ require.False(t, found)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = app.SharedState().Delete("key")
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ has, err := app.SharedState().Has("key")
+ require.False(t, has)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = app.SharedState().Reset()
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = app.SharedState().Close()
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+}
+
+func TestSharedState_PreforkSafeWithSharedStorage(t *testing.T) {
+ t.Parallel()
+
+ store := newSharedStateMemoryStorage(t)
+ workerA := New(Config{AppName: "prefork-app", SharedStorage: store})
+ workerB := New(Config{AppName: "prefork-app", SharedStorage: store})
+
+ workerA.State().Set("process-only", "from-worker-a")
+ _, ok := workerB.State().Get("process-only")
+ require.False(t, ok)
+
+ payload := Map{"worker": "a", "version": 3}
+ err := workerA.SharedState().SetJSON("cluster-key", payload, time.Minute)
+ require.NoError(t, err)
+
+ err = workerA.SharedState().Set("raw-cluster-key", []byte("raw-value"), time.Minute)
+ require.NoError(t, err)
+
+ var out map[string]any
+ rawJSON, found, err := workerB.SharedState().GetJSON("cluster-key", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.NotNil(t, rawJSON)
+ require.Equal(t, "a", out["worker"])
+ require.EqualValues(t, 3, out["version"])
+
+ raw, found, err := workerB.SharedState().Get("raw-cluster-key")
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("raw-value"), raw)
+
+ has, err := workerB.SharedState().Has("cluster-key")
+ require.NoError(t, err)
+ require.True(t, has)
+
+ err = workerB.SharedState().Delete("cluster-key")
+ require.NoError(t, err)
+
+ has, err = workerA.SharedState().Has("cluster-key")
+ require.NoError(t, err)
+ require.False(t, has)
+
+ require.NoError(t, workerA.SharedState().Delete("raw-cluster-key"))
+ raw, found, err = workerB.SharedState().Get("raw-cluster-key")
+ require.NoError(t, err)
+ require.Nil(t, raw)
+ require.False(t, found)
+}
+
+func TestSharedState_ExplicitSerializationError(t *testing.T) {
+ t.Parallel()
+
+ app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)})
+ err := app.SharedState().SetJSON("invalid", make(chan int), time.Second)
+ require.Error(t, err)
+}
+
+func TestSharedState_ContextAwareVariants(t *testing.T) {
+ t.Parallel()
+
+ type testContextKey string
+
+ ctxKey := testContextKey("tenant")
+ store := &contextCheckingStorage{ctxKey: ctxKey, base: newSharedStateMemoryStorage(t)}
+ app := New(Config{SharedStorage: store})
+
+ t.Run("missing context", func(t *testing.T) {
+ t.Parallel()
+
+ err := app.SharedState().SetJSONWithContext(context.Background(), "key", Map{"ok": true}, time.Second)
+ require.Error(t, err)
+ })
+
+ t.Run("context propagation", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.WithValue(context.Background(), ctxKey, "value")
+ err := app.SharedState().SetJSONWithContext(ctx, "key", Map{"ok": true}, time.Second)
+ require.NoError(t, err)
+
+ var out map[string]bool
+ _, found, err := app.SharedState().GetJSONWithContext(ctx, "key", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.True(t, out["ok"])
+
+ has, err := app.SharedState().HasWithContext(ctx, "key")
+ require.NoError(t, err)
+ require.True(t, has)
+
+ err = app.SharedState().DeleteWithContext(ctx, "key")
+ require.NoError(t, err)
+ })
+}
+
+func TestSharedState_KeyNamespacing(t *testing.T) {
+ t.Parallel()
+
+ store := newSharedStateMemoryStorage(t)
+ appOne := New(Config{AppName: "app-one", SharedStorage: store})
+ appTwo := New(Config{AppName: "app-two", SharedStorage: store})
+
+ err := appOne.SharedState().SetJSON("same-key", Map{"app": 1}, time.Minute)
+ require.NoError(t, err)
+ err = appTwo.SharedState().SetJSON("same-key", Map{"app": 2}, time.Minute)
+ require.NoError(t, err)
+
+ var outOne map[string]int
+ _, found, err := appOne.SharedState().GetJSON("same-key", &outOne)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, 1, outOne["app"])
+
+ var outTwo map[string]int
+ _, found, err = appTwo.SharedState().GetJSON("same-key", &outTwo)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, 2, outTwo["app"])
+}
+
+func TestSharedState_StorageErrorsArePropagated(t *testing.T) {
+ t.Parallel()
+
+ expectedErr := errors.New("storage failed")
+ closeErr := errors.New("close failed")
+ app := New(Config{
+ SharedStorage: &errorStorage{err: expectedErr, closeErr: closeErr},
+ MsgPackEncoder: func(any) ([]byte, error) {
+ return []byte("msgpack"), nil
+ },
+ MsgPackDecoder: func([]byte, any) error {
+ return nil
+ },
+ CBOREncoder: func(any) ([]byte, error) {
+ return []byte("cbor"), nil
+ },
+ CBORDecoder: func([]byte, any) error {
+ return nil
+ },
+ XMLEncoder: func(any) ([]byte, error) {
+ return []byte(""), nil
+ },
+ XMLDecoder: func([]byte, any) error {
+ return nil
+ },
+ })
+
+ err := app.SharedState().SetJSON("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, expectedErr)
+ err = app.SharedState().SetMsgPack("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, expectedErr)
+ err = app.SharedState().SetCBOR("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, expectedErr)
+ err = app.SharedState().SetXML("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, expectedErr)
+
+ err = app.SharedState().Set("k", []byte("v"), time.Second)
+ require.ErrorIs(t, err, expectedErr)
+
+ _, _, err = app.SharedState().Get("k")
+ require.ErrorIs(t, err, expectedErr)
+
+ _, _, err = app.SharedState().GetJSON("k", &Map{})
+ require.ErrorIs(t, err, expectedErr)
+ _, _, err = app.SharedState().GetMsgPack("k", &Map{})
+ require.ErrorIs(t, err, expectedErr)
+ _, _, err = app.SharedState().GetCBOR("k", &Map{})
+ require.ErrorIs(t, err, expectedErr)
+ _, _, err = app.SharedState().GetXML("k", &Map{})
+ require.ErrorIs(t, err, expectedErr)
+
+ _, err = app.SharedState().Has("k")
+ require.ErrorIs(t, err, expectedErr)
+
+ err = app.SharedState().Delete("k")
+ require.ErrorIs(t, err, expectedErr)
+
+ err = app.SharedState().Reset()
+ require.ErrorIs(t, err, expectedErr)
+
+ err = app.SharedState().Close()
+ require.ErrorIs(t, err, closeErr)
+}
+
+func TestSharedState_NilReceiver(t *testing.T) {
+ t.Parallel()
+
+ var state *SharedState
+
+ err := state.SetJSON("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ err = state.SetMsgPack("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ err = state.SetCBOR("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ err = state.SetXML("k", Map{"v": 1}, time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = state.Set("k", []byte("v"), time.Second)
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ _, _, err = state.Get("k")
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ _, _, err = state.GetJSON("k", &Map{})
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ _, _, err = state.GetMsgPack("k", &Map{})
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ _, _, err = state.GetCBOR("k", &Map{})
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+ _, _, err = state.GetXML("k", &Map{})
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = state.Delete("k")
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ _, err = state.Has("k")
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = state.Reset()
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+
+ err = state.Close()
+ require.ErrorIs(t, err, ErrSharedStorageNotConfigured)
+}
+
+func TestSharedState_DefaultPrefixFallback(t *testing.T) {
+ t.Parallel()
+
+ state := newSharedState(&Config{SharedStorage: newSharedStateMemoryStorage(t)})
+ require.Equal(t, defaultSharedStatePrefix, state.prefix)
+}
+
+func TestSharedState_NewAppDefaultPrefixIncludesAppName(t *testing.T) {
+ t.Parallel()
+
+ app := New(Config{AppName: "my-app", SharedStorage: newSharedStateMemoryStorage(t)})
+ require.Equal(t, defaultSharedStatePrefix+"my-app-", app.SharedState().prefix)
+}
+
+func TestSharedState_GetJSON_UnmarshalError(t *testing.T) {
+ t.Parallel()
+
+ store := newSharedStateMemoryStorage(t)
+ app := New(Config{SharedStorage: store})
+
+ storageKey, ok := app.SharedState().storageKey("broken")
+ require.True(t, ok)
+ require.NoError(t, store.Set(storageKey, []byte("{"), 0))
+
+ var out map[string]any
+ _, found, err := app.SharedState().GetJSON("broken", &out)
+ require.False(t, found)
+ require.Error(t, err)
+}
+
+func TestSharedState_Get_ReturnsCopy(t *testing.T) {
+ t.Parallel()
+
+ app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)})
+ require.NoError(t, app.SharedState().Set("raw", []byte("value"), time.Minute))
+
+ got, found, err := app.SharedState().Get("raw")
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("value"), got)
+
+ got[0] = 'X'
+
+ gotAgain, found, err := app.SharedState().Get("raw")
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("value"), gotAgain)
+}
+
+func TestSharedState_RawWithContextVariants(t *testing.T) {
+ t.Parallel()
+
+ type testContextKey string
+
+ ctxKey := testContextKey("tenant")
+ store := &contextCheckingStorage{ctxKey: ctxKey, base: newSharedStateMemoryStorage(t)}
+ app := New(Config{SharedStorage: store})
+
+ err := app.SharedState().SetWithContext(context.Background(), "raw", []byte("x"), time.Second)
+ require.Error(t, err)
+
+ ctx := context.WithValue(context.Background(), ctxKey, "value")
+ require.NoError(t, app.SharedState().SetWithContext(ctx, "raw", []byte("x"), time.Second))
+
+ raw, found, err := app.SharedState().GetWithContext(ctx, "raw")
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("x"), raw)
+}
+
+func TestSharedState_SetGet_RawDataKinds(t *testing.T) {
+ t.Parallel()
+
+ app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)})
+
+ testCases := []struct {
+ key string
+ value []byte
+ }{
+ {key: "plain", value: []byte("text")},
+ {key: "json", value: []byte(`{"id":42}`)},
+ {key: "binary", value: []byte{0x00, 0xFF, 0x10, 0x7F}},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.key, func(t *testing.T) {
+ t.Parallel()
+
+ require.NoError(t, app.SharedState().Set(tc.key, tc.value, time.Minute))
+
+ got, found, err := app.SharedState().Get(tc.key)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, tc.value, got)
+ })
+ }
+}
+
+func TestSharedState_SetGet_JSONDataKinds(t *testing.T) {
+ t.Parallel()
+
+ type sample struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+ }
+
+ app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)})
+
+ t.Run("map", func(t *testing.T) {
+ t.Parallel()
+
+ expected := map[string]any{
+ "name": "fiber",
+ "ok": true,
+ }
+ require.NoError(t, app.SharedState().SetJSON("map", expected, time.Minute))
+
+ var out map[string]any
+ raw, found, err := app.SharedState().GetJSON("map", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.NotEmpty(t, raw)
+ require.Equal(t, expected["name"], out["name"])
+ require.Equal(t, expected["ok"], out["ok"])
+ })
+
+ t.Run("slice", func(t *testing.T) {
+ t.Parallel()
+
+ expected := []int{1, 2, 3, 4}
+ require.NoError(t, app.SharedState().SetJSON("slice", expected, time.Minute))
+
+ var out []int
+ _, found, err := app.SharedState().GetJSON("slice", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, expected, out)
+ })
+
+ t.Run("struct", func(t *testing.T) {
+ t.Parallel()
+
+ expected := sample{Name: "shared", Count: 7}
+ require.NoError(t, app.SharedState().SetJSON("struct", expected, time.Minute))
+
+ var out sample
+ _, found, err := app.SharedState().GetJSON("struct", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, expected, out)
+ })
+}
+
+func TestSharedState_UsesAppJSONCodec(t *testing.T) {
+ t.Parallel()
+
+ encoderCalled := false
+ decoderCalled := false
+
+ app := New(Config{
+ SharedStorage: newSharedStateMemoryStorage(t),
+ JSONEncoder: func(_ any) ([]byte, error) {
+ encoderCalled = true
+ return json.Marshal(Map{"via": "custom-encoder"})
+ },
+ JSONDecoder: func(data []byte, out any) error {
+ decoderCalled = true
+ return json.Unmarshal(data, out)
+ },
+ })
+
+ require.NoError(t, app.SharedState().SetJSON("codec", Map{"ignored": true}, time.Minute))
+
+ var out map[string]string
+ _, found, err := app.SharedState().GetJSON("codec", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, "custom-encoder", out["via"])
+ require.True(t, encoderCalled)
+ require.True(t, decoderCalled)
+}
+
+func TestSharedState_UsesAppMsgPackCodec(t *testing.T) {
+ t.Parallel()
+
+ encoderCalled := false
+ decoderCalled := false
+
+ app := New(Config{
+ SharedStorage: newSharedStateMemoryStorage(t),
+ MsgPackEncoder: func(_ any) ([]byte, error) {
+ encoderCalled = true
+ return []byte("msgpack-payload"), nil
+ },
+ MsgPackDecoder: func(data []byte, out any) error {
+ decoderCalled = true
+ ptr, ok := out.(*string)
+ if ok {
+ *ptr = string(data)
+ }
+ return nil
+ },
+ })
+
+ require.NoError(t, app.SharedState().SetMsgPack("codec", Map{"ignored": true}, time.Minute))
+
+ var out string
+ raw, found, err := app.SharedState().GetMsgPack("codec", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("msgpack-payload"), raw)
+ require.Equal(t, "msgpack-payload", out)
+ require.True(t, encoderCalled)
+ require.True(t, decoderCalled)
+}
+
+func TestSharedState_UnconfiguredCodecsReturnErrorInsteadOfPanic(t *testing.T) {
+ t.Parallel()
+
+ app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)})
+
+ err := app.SharedState().SetMsgPack("codec", Map{"ignored": true}, time.Minute)
+ require.ErrorContains(t, err, "shared state msgpack")
+
+ require.NoError(t, app.SharedState().Set("msgpack-payload", []byte("payload"), time.Minute))
+
+ var out Map
+ _, found, err := app.SharedState().GetMsgPack("msgpack-payload", &out)
+ require.False(t, found)
+ require.ErrorContains(t, err, "shared state msgpack")
+
+ err = app.SharedState().SetCBOR("codec", Map{"ignored": true}, time.Minute)
+ require.ErrorContains(t, err, "shared state cbor")
+
+ require.NoError(t, app.SharedState().Set("cbor-payload", []byte("payload"), time.Minute))
+
+ _, found, err = app.SharedState().GetCBOR("cbor-payload", &out)
+ require.False(t, found)
+ require.ErrorContains(t, err, "shared state cbor")
+}
+
+func TestSharedState_UsesAppCBORCodec(t *testing.T) {
+ t.Parallel()
+
+ encoderCalled := false
+ decoderCalled := false
+
+ app := New(Config{
+ SharedStorage: newSharedStateMemoryStorage(t),
+ CBOREncoder: func(_ any) ([]byte, error) {
+ encoderCalled = true
+ return []byte("cbor-payload"), nil
+ },
+ CBORDecoder: func(data []byte, out any) error {
+ decoderCalled = true
+ ptr, ok := out.(*string)
+ if ok {
+ *ptr = string(data)
+ }
+ return nil
+ },
+ })
+
+ require.NoError(t, app.SharedState().SetCBOR("codec", Map{"ignored": true}, time.Minute))
+
+ var out string
+ raw, found, err := app.SharedState().GetCBOR("codec", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("cbor-payload"), raw)
+ require.Equal(t, "cbor-payload", out)
+ require.True(t, encoderCalled)
+ require.True(t, decoderCalled)
+}
+
+func TestSharedState_UsesAppXMLCodec(t *testing.T) {
+ t.Parallel()
+
+ encoderCalled := false
+ decoderCalled := false
+
+ app := New(Config{
+ SharedStorage: newSharedStateMemoryStorage(t),
+ XMLEncoder: func(_ any) ([]byte, error) {
+ encoderCalled = true
+ return []byte("xml-payload"), nil
+ },
+ XMLDecoder: func(data []byte, out any) error {
+ decoderCalled = true
+ ptr, ok := out.(*string)
+ if ok {
+ *ptr = string(data)
+ }
+ return nil
+ },
+ })
+
+ require.NoError(t, app.SharedState().SetXML("codec", Map{"ignored": true}, time.Minute))
+
+ var out string
+ raw, found, err := app.SharedState().GetXML("codec", &out)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, []byte("xml-payload"), raw)
+ require.Equal(t, "xml-payload", out)
+ require.True(t, encoderCalled)
+ require.True(t, decoderCalled)
+}
+
+func TestSharedState_EmptyKeyBehavior(t *testing.T) {
+ t.Parallel()
+
+ app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)})
+
+ require.NoError(t, app.SharedState().Set("", []byte("raw"), time.Minute))
+ require.NoError(t, app.SharedState().SetJSON("", Map{"v": 1}, time.Minute))
+ require.NoError(t, app.SharedState().SetMsgPack("", Map{"v": 1}, time.Minute))
+ require.NoError(t, app.SharedState().SetCBOR("", Map{"v": 1}, time.Minute))
+ require.NoError(t, app.SharedState().SetXML("", Map{"v": 1}, time.Minute))
+
+ raw, found, err := app.SharedState().Get("")
+ require.NoError(t, err)
+ require.Nil(t, raw)
+ require.False(t, found)
+
+ _, found, err = app.SharedState().GetJSON("", &Map{})
+ require.NoError(t, err)
+ require.False(t, found)
+
+ _, found, err = app.SharedState().GetMsgPack("", &Map{})
+ require.NoError(t, err)
+ require.False(t, found)
+
+ _, found, err = app.SharedState().GetCBOR("", &Map{})
+ require.NoError(t, err)
+ require.False(t, found)
+
+ _, found, err = app.SharedState().GetXML("", &Map{})
+ require.NoError(t, err)
+ require.False(t, found)
+
+ require.NoError(t, app.SharedState().Delete(""))
+
+ has, err := app.SharedState().Has("")
+ require.NoError(t, err)
+ require.False(t, has)
+}