From 5e461042bae38e3fd4b21269f1e29497cefa4217 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 10:25:13 -0700 Subject: [PATCH 01/17] v1.12.0 changelog, docs, and blog post - Add v1.12.0 changelog covering channel storage, umd updates, memberUpdated/connectionUpdated events, getChannelInfo, and admin APIs - Add blog post for v1.12.0 release - Add channel storage docs page with usage guide and examples - Add server-side channels API docs (GET /channels/{name} and storage) - Add getInfo channel directive, channelStorageList, getChannelInfo, channelStorageGet, channelStorageSet client HTTP API endpoints - Add emitPubSubEvent storage directive and channelStorageUpdated event docs - Add permissions section to client HTTP API docs - Improve overview, standard, and presence channel pages - Remove emojis from doc page titles --- blog/2026-04-02-new-in-v1.12.mdx | 154 +++++++++++++ docs/channels/overview.mdx | 38 ++-- docs/channels/presence.mdx | 123 ++++++++--- docs/channels/standard.mdx | 50 +++-- docs/channels/storage.mdx | 150 +++++++++++++ docs/connections/authentication.mdx | 2 +- docs/connections/claims.mdx | 276 +++++++++++++++++++++++- docs/connections/client-http-api.mdx | 263 +++++++++++++++++++++- docs/installation/changelog.mdx | 15 ++ docs/installation/initial-setup.mdx | 2 +- docs/installation/region-support.mdx | 2 +- docs/installation/uninstallation.mdx | 2 +- docs/installation/updates.mdx | 2 +- docs/introduction.mdx | 1 + docs/performance/limits-and-scaling.mdx | 2 +- docs/server-api/channels.mdx | 112 ++++++++++ docs/server-api/events.mdx | 106 +++++++++ docs/server-api/logging.mdx | 2 +- docs/server-api/publish-messages.mdx | 10 +- docs/server-api/web-console.mdx | 2 +- docusaurus.config.js | 1 + src/components/HomepageBanner.js | 8 +- src/sidebar-docs.js | 2 + 23 files changed, 1249 insertions(+), 76 deletions(-) create mode 100644 blog/2026-04-02-new-in-v1.12.mdx create mode 100644 docs/channels/storage.mdx create mode 100644 docs/server-api/channels.mdx diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx new file mode 100644 index 0000000..25380db --- /dev/null +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -0,0 +1,154 @@ +--- +slug: channel-storage-and-umd-updates +title: Channel storage and live user metadata updates +authors: [james] +tags: [releases, features, channels, presence] +description: Hotsock v1.12 adds per-key persistent channel storage with real-time sync, live user metadata updates without reconnecting, and new presence events for metadata changes. +--- + +Hotsock v1.12 introduces **channel storage** for persistent per-key state on channels and **live user metadata updates** so clients can change their `umd` without reconnecting. Both features are designed to reduce the amount of state your backend needs to manage and push more real-time coordination into Hotsock itself. + +{/* truncate */} + +### Channel storage + +Channels can now have persistent key-value storage entries that subscribers can read, write, and observe in real-time. Storage entries have independent TTLs for flexible data retention and are automatically delivered to observers when they subscribe. + +This is useful for any state that should be available to current and future subscribers of a channel — things like configuration, feature flags, room settings, shared cursors, or game state. Instead of fetching this from your backend after subscribing, the client gets it immediately as part of the subscribe flow. + +#### Setting up permissions + +Storage permissions are configured per-key using the new [`storage`](/docs/connections/claims/#channels.storage) directive inside channel claims. Keys support wildcards and regex patterns, just like channel names and event names. + +```json +{ + "exp": 1743580800, + "scope": "connect", + "channels": { + "game.lobby": { + "subscribe": true, + // highlight-start + "storage": { + "settings": { + "observe": true + }, + "player.123": { + "observe": true, + "set": true, + "store": 86400, + // highlight-next-line + "emitPubSubEvent": true + } + } + // highlight-end + } + } +} +``` + +Each storage key directive supports these options: + +- **`observe`** — receive `hotsock.channelStorageUpdated` messages in real-time when the value changes, and get the current value automatically on subscribe +- **`get`** — fetch the current value on demand with `hotsock.channelStorageGet` +- **`set`** — write values with `hotsock.channelStorageSet` +- **`store`** — TTL in seconds for entries written by this connection (defaults to `-1`, forever) +- **`emitPubSubEvent`** — trigger a [`hotsock.channelStorageUpdated`](/docs/server-api/events/#hotsock.channelStorageUpdated) pub/sub event to SNS/EventBridge when a value changes + +#### Writing and reading storage + +Clients interact with storage using `hotsock.channelStorageSet` and `hotsock.channelStorageGet` messages on the WebSocket, or via the [Client HTTP API](/docs/connections/client-http-api/#connection/channelStorageGet): + +``` +> {"event":"hotsock.channelStorageSet", "channel":"game.lobby", "key":"settings", "data":{"maxPlayers":4}} +``` + +``` +> {"event":"hotsock.channelStorageGet", "channel":"game.lobby", "key":"settings"} +< {"event":"hotsock.channelStorageData","channel":"game.lobby","key":"settings","data":{"maxPlayers":4},"meta":{"uid":"host","umd":null}} +``` + +Writes that don't change the value are detected automatically and skip subscriber fan-out entirely, so you don't need to worry about deduplication on the client side. Setting `data` to `null` or `{}` clears the entry. + +Storage entries also track the `uid` and `umd` of the last writer in `meta`, so observers always know who last set a value. + +#### Real-time sync on subscribe + +When a connection subscribes to a channel, all storage entries matching their `observe` patterns are delivered as `hotsock.channelStorageUpdated` messages immediately after the `hotsock.subscribed` message. This means a new subscriber gets the current state without any additional requests — they're caught up the moment they join. + +#### Server-side storage writes + +Storage entries can also be set from the server side using the existing Lambda or HTTP publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a [`key`](/docs/server-api/publish-messages/#message-format.key) field. Server-side writes bypass client permission checks, so they're always authorized. This is handy for initializing channel state from your backend before any clients connect. + +### Live user metadata updates + +Previously, the only way to change a connection's `umd` was to disconnect and reconnect with a new token, or unsubscribe and resubscribe with a new subscribe token. Now clients can update their `umd` in place using `hotsock.umdUpdate`. + +This works at two levels: + +#### Per-channel updates + +Update `umd` on a specific channel subscription by sending `hotsock.umdUpdate` with a channel and a [`umd`-scoped](/docs/connections/claims/#scope) token that has the [`umdUpdate`](/docs/connections/claims/#channels.umdUpdate) channel directive: + +```json +{ + "exp": 1743580800, + "scope": "umd", + "uid": "Dwight", + // highlight-next-line + "umd": { "status": "away" }, + "channels": { + "presence.chat": { + // highlight-next-line + "umdUpdate": true + } + } +} +``` + +Then send the signed token on the WebSocket: + +``` +> {"event":"hotsock.umdUpdate", "channel":"presence.chat", "data":{"token":"eyJ..."}} +``` + +On presence channels, this triggers a new [`hotsock.memberUpdated`](/docs/channels/presence/#member-updated) event delivered to all members (including the initiator), so everyone sees the change immediately: + +``` +< {"event":"hotsock.memberUpdated","channel":"presence.chat","data":{"member":{"uid":"Dwight","umd":{"status":"away"}},"members":[{"uid":"Jim","umd":null},{"uid":"Dwight","umd":{"status":"away"}}]}} +``` + +This is great for status indicators, typing states, or any per-user metadata that changes during a session. + +#### Connection-level updates + +Update the connection's default `umd` by sending `hotsock.umdUpdate` without a channel. This requires the top-level [`umdUpdate`](/docs/connections/claims/#umdUpdate) claim. When [`umdPropagate`](/docs/connections/claims/#umdPropagate) is also enabled, the updated metadata is propagated to all existing subscriptions on the connection, triggering `hotsock.memberUpdated` on any presence channels. + +```json +{ + "exp": 1743580800, + "scope": "umd", + "uid": "Dwight", + // highlight-next-line + "umd": { "status": "away" }, + // highlight-next-line + "umdUpdate": true, + // highlight-next-line + "umdPropagate": true +} +``` + +``` +> {"event":"hotsock.umdUpdate", "data":{"token":"eyJ..."}} +``` + +Future subscriptions that are authorized by the connect token (implicit subscriptions) will automatically inherit the updated metadata from the connection, so you don't need to include the new `umd` in every subsequent subscribe token. + +Both paths are also available via the Client HTTP API at [`connection/umdUpdate`](/docs/connections/client-http-api/#connection/umdUpdate). + +#### New pub/sub event + +A new [`hotsock.connectionUpdated`](/docs/server-api/events/#hotsock.connectionUpdated) pub/sub event is emitted to SNS/EventBridge whenever a connection's user metadata changes. This event is only sent when `UserMetadata` actually changes, not on heartbeat-only updates, so you can use it to track user state changes on your backend without noise. + +### Wrapping up + +Existing installations with auto-update enabled are already running v1.12 and have access to these features today. Other installations can be [manually updated](/docs/installation/updates/#manually-update-installation) at any time. A [full changelog](/docs/installation/changelog/#v1.12.0) is available with the complete list of changes included in this release. diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index abb0213..81fa0e2 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -4,23 +4,28 @@ sidebar_label: Overview # Channels Overview -Your applications can have one or more channels and each of your clients can choose which channels to subscribe to. If a client is subscribed to a particular channel, they will receive all messages sent on that channel. +Channels are how you organize and control access to real-time data in Hotsock. Clients subscribe to channels to receive messages, and your backend publishes messages to channels to reach those subscribers. -**Each channel subscription _must_ be authorized by a claim in the token**. This allows you to use channels to control access to different streams of information. +**Every channel subscription must be authorized by a claim in the JWT token.** This is how you control which clients can access which data — permissions are baked into the token at signing time, not configured globally. -For example, you may have a "leaderboard" channel that publishes messages that everyone in the application can see. At the same time, you may have a "game.123" channel that publishes all the events that occur in game ID 123, where only people playing or viewing that particular game can see those messages. +There is no limit to the number of channels your application can use, and channels do not need to be declared ahead of time. When a message is published to a channel, every currently subscribed client receives a copy. -There is no limit to the number of channels your application can have and channels do not need to be declared ahead of time. When any message is published to a channel, any currently subscribed listeners receive a copy of that message. +## Naming -Channel names can be any string between 1 to 256 characters and must not contain asterisks (`*`), hashes (`#`), commas (`,`), spaces, or newline characters. In JWT claims, channel keys support wildcards (`*`) and regex patterns (`#regex:`) for flexible permission matching. +Channel names can be any string between 1 and 256 characters. The following characters are **not allowed** in channel names: -## Events +- Asterisks (`*`) +- Number signs (`#`) +- Commas (`,`) +- Spaces and newline characters + +In JWT claims, channel keys support wildcards (`*`) and [regex patterns](../connections/claims.mdx#channels--regex) (`#regex:`) for flexible permission matching across multiple channels. -**Each message published to a channel _must_ contain an event name**. Events allow for logically grouping types of messages sent to the same channel. +## Events -For example, if you have a "game.123" channel, perhaps you'd have "move" and "chat" events. In your handler code for these events, different events on the same channel allow you to maintain predictable message shape for different circumstances in the same context. +Every message published to a channel must include an **event name**. Events let you group different types of messages on the same channel so your client code can handle them separately. -In the end, messages received by a subscriber look something like this. The message `id` is Hotsock-generated. +For example, a `game.123` channel might have `move` and `chat` events: ```json { @@ -40,16 +45,19 @@ In the end, messages received by a subscriber look something like this. The mess } ``` -Your application will receive these messages on the same channel, but can handle "chat" messages differently from "move" messages without a separate channel subscription. +Both arrive on the same channel, but your application can route them to different handlers based on the event name. The message `id` is generated by Hotsock. :::tip When should you use a different channel instead of a different event? -Use channels to filter the data a client needs. All events published to a channel are received by each subscriber, regardless of whether or not the client cares about every event. +All events published to a channel are delivered to every subscriber, regardless of whether the client cares about that event. If you find yourself sending many events that most subscribers ignore, consider splitting those into a separate channel. -Avoid scenarios where you're sending lots of events to clients where most clients are ignoring those events. - -In these cases, it may be best to move those events to a separate channel and have clients that care about those events subscribe an additional channel. +Use **channels** to filter what data a client receives. Use **events** to categorize different message types within the same context. ::: ## Channel Types -There are 2 types of channels: [standard](./standard.mdx) and [presence](./presence.mdx). +| Type | Use case | Member awareness | +| -------------------------- | ----------------------------------------------------------------------------------- | ---------------- | +| [Standard](./standard.mdx) | Most situations — delivering real-time updates to clients | No | +| [Presence](./presence.mdx) | Chat rooms, collaboration, multiplayer — clients need to know who else is connected | Yes | + +Both channel types support the same messaging features ([publishing](../server-api/publish-messages.mdx), [client messages](./client-messages.mdx), [storage](./storage.mdx), [message history](../connections/claims.mdx#channels.historyStart), etc.). Presence channels add member tracking on top. diff --git a/docs/channels/presence.mdx b/docs/channels/presence.mdx index 85f80cf..f685524 100644 --- a/docs/channels/presence.mdx +++ b/docs/channels/presence.mdx @@ -1,10 +1,13 @@ # Presence Channels -Presence channels build on [standard channels](./standard.mdx) by allowing all subscribers to know information (ID and a custom data payload) about all other subscribers as they join and leave the channel. +Presence channels build on [standard channels](./standard.mdx) by letting every subscriber know who else is in the channel. When someone joins, leaves, or updates their metadata, all members are notified with the full, current member list. -It's great for things like chat rooms and document collaboration, where everyone there can be made aware of who else is present. +This is useful for chat rooms, collaborative documents, multiplayer games, or any feature where users need awareness of one another. -Each member in a presence channel has a `uid` field to identify the user and a `umd` field to supply additional data specific to this user. These attributes are supplied by authentication or subscription token claims ([`uid`](../connections/claims.mdx#uid) and [`umd`](../connections/claims.mdx#umd)) and shared with all other members of the channel. +Each member has two fields: + +- **`uid`** — a unique user identifier, set via the [`uid`](../connections/claims.mdx#uid) token claim (required for presence channels) +- **`umd`** — optional user metadata (name, avatar, status, etc.), set via the [`umd`](../connections/claims.mdx#umd) token claim :::tip Presence channel names must have a `presence.` prefix. @@ -12,54 +15,126 @@ Presence channel names must have a `presence.` prefix. ## Limits -Whenever someone leaves or joins a presence channel, all members of the channel receive a message containing the `uid` and `umd` of all other members. +Every time a member joins, leaves, or updates their metadata, all members receive the full member list. Because of this, there is a size constraint on the channel. + +**The sum total size of all member payloads (`{"uid":"...","umd":"..."}`) must not exceed 100KB.** There is no fixed user count limit — capacity depends on how much data each member carries. For example: -**There is no fixed user count limit on presence channels, but the sum total size of all subscribed member payloads (`{"uid":"...","umd":"..."}`) must not exceed 100KB.** Attempting to subscribe to a presence channel that would put the subscriber over this limit will fail with a `PRESENCE_CHANNEL_CAPACITY_REACHED` WebSocket error. +- ~100 members with 1KB of `umd` each +- ~800 members with minimal `umd` (~128 bytes each) -For example, a presence channel could hold ~100 users with 1KB of user data each or ~800 users with 200 bytes of user data each. +Attempting to subscribe when the channel is at capacity returns a `PRESENCE_CHANNEL_CAPACITY_REACHED` error. ## Subscribe -Once connected to the WebSocket with a token authorizing subscribe on the `presence.live-chat` channel, subscribe by sending a message on the WebSocket. +Once connected with a token authorizing subscribe on a presence channel, subscribe by sending a message on the WebSocket: ``` > {"event":"hotsock.subscribe", "channel":"presence.live-chat"} ``` -You'll immediately receive confirmation of the subscription with a `hotsock.subscribed` message. The `data` attribute will indicate that you're the only subscriber. - +You'll receive a `hotsock.subscribed` message with the current `members` list in `data` and your own `uid`/`umd` in `meta`: + +```json +{ + "event": "hotsock.subscribed", + "channel": "presence.live-chat", + "data": { + "members": [{ "uid": "Jim", "umd": null }] + }, + "meta": { "uid": "Jim", "umd": null } +} ``` -< {"event":"hotsock.subscribed","channel":"presence.live-chat","data":{"members":[{"uid":"Jim","umd":null}]},"meta":{"uid":"Jim","umd":null}} + +### When another member joins + +When Dwight subscribes from another connection, he receives his own `hotsock.subscribed` message with both members listed. You receive a `hotsock.memberAdded` message: + +```json +{ + "event": "hotsock.memberAdded", + "channel": "presence.live-chat", + "data": { + "member": { "uid": "Dwight", "umd": null }, + "members": [ + { "uid": "Jim", "umd": null }, + { "uid": "Dwight", "umd": null } + ] + } +} ``` -Let's say Dwight joins the channel from another connection. Dwight will receive a `hotsock.subscribed` message similar to the one you received above, but it will indicate that both you and him are present in the channel. +- `member` — the member who just joined +- `members` — the full, current member list (including the new member) -You will receive a `hotsock.memberAdded` message. The `data` attribute contains the `member` who was just added (Dwight) and a list (array) of all current members (you and Dwight). +### Duplicate connections with the same `uid` -``` -< {"event":"hotsock.memberAdded","channel":"presence.live-chat","data":{"member":{"uid":"Dwight","umd":null},"members":[{"uid":"Jim","umd":null},{"uid":"Dwight","umd":null}]}} -``` +If a user connects from a second device or browser tab with the same `uid`, the `hotsock.memberAdded` event is suppressed since they're already in the member list. However, if the new connection has different `umd`, a [`hotsock.memberUpdated`](#member-updated) event is sent so other members learn about the metadata change. If the `umd` is the same, no event is sent. -Here the `member` who was added was Dwight and `members` now contains both of you. +## Member Updated {#member-updated} -If Dwight unsubscribes from the channel, you'll receive a `hotsock.memberRemoved` message. +When a member's `umd` changes, all members (including the one who made the change) receive a `hotsock.memberUpdated` message: +```json +{ + "event": "hotsock.memberUpdated", + "channel": "presence.live-chat", + "data": { + "member": { "uid": "Dwight", "umd": { "status": "away" } }, + "members": [ + { "uid": "Jim", "umd": null }, + { "uid": "Dwight", "umd": { "status": "away" } } + ] + } +} ``` -{"event":"hotsock.memberRemoved","channel":"presence.live-chat","data":{"member":{"uid":"Dwight","umd":null},"members":[{"uid":"Jim","umd":null}]}} -``` -Here the `member` who was removed was Dwight and `members` no longer includes him. +- `member` — the member whose metadata changed +- `members` — the full, current member list reflecting the update + +This event is triggered in two scenarios: + +1. **Explicit update** — a client sends a `hotsock.umdUpdate` command targeting a presence channel subscription. This requires the [`umdUpdate`](../connections/claims.mdx#channels.umdUpdate) channel directive in a [`umd`-scoped](../connections/claims.mdx#scope) token. +2. **Duplicate `uid` with different `umd`** — a second connection subscribes with the same `uid` but different `umd` (see [above](#duplicate-connections-with-the-same-uid)). -If you're subscribing and responding to messages using client-side JS, take a look at [hotsock-js](https://www.github.com/hotsock/hotsock-js) where all of the above is handled by the library. +This is useful for status indicators, typing notifications, or any per-user state that changes during a session without requiring a reconnect. ## Unsubscribe -To unsubscribe from a channel, send a `hotsock.unsubscribe` message on the WebSocket. +To leave a presence channel, send a `hotsock.unsubscribe` message: ``` > {"event":"hotsock.unsubscribe", "channel":"presence.live-chat"} ``` -Other members of the presence channel will receive a `hotsock.memberRemoved` message indicating that you left, if anyone else is still present. +Remaining members receive a `hotsock.memberRemoved` message: + +```json +{ + "event": "hotsock.memberRemoved", + "channel": "presence.live-chat", + "data": { + "member": { "uid": "Dwight", "umd": null }, + "members": [{ "uid": "Jim", "umd": null }] + } +} +``` + +- `member` — the member who left +- `members` — the remaining member list + +When a WebSocket connection disconnects, `hotsock.unsubscribe` is called automatically for all subscribed channels, so remaining members are always notified. + +## Event Summary -When disconnecting the WebSocket connection, `hotsock.unsubscribe` is called automatically on your behalf for any subscribed channels, notifying anyone still present that you've left. +| Event | When | Delivered to | +| ----------------------- | ------------------------------ | ------------------------------------- | +| `hotsock.subscribed` | You subscribe to the channel | You only | +| `hotsock.memberAdded` | A new `uid` joins | All existing members (not the joiner) | +| `hotsock.memberUpdated` | A member's `umd` changes | All members (including the updater) | +| `hotsock.memberRemoved` | A member leaves or disconnects | All remaining members | + +All `hotsock.memberAdded`, `hotsock.memberUpdated`, and `hotsock.memberRemoved` messages include both the affected `member` and the full current `members` list. You can always replace your local member list with the `members` array from these events to stay in sync. + +:::tip +If you're building with JavaScript, [hotsock-js](https://www.github.com/hotsock/hotsock-js) handles all of the above automatically, including maintaining a local member list that stays in sync with the channel. +::: diff --git a/docs/channels/standard.mdx b/docs/channels/standard.mdx index 8f6ae90..8b0df66 100644 --- a/docs/channels/standard.mdx +++ b/docs/channels/standard.mdx @@ -1,39 +1,63 @@ # Standard Channels -Standard channels allow an unlimited number of subscribers to join to receive and exchange messages. This channel type can be used for the majority of situations, where clients or devices receiving real-time updates need the data but not the awareness of one another. For that awareness, take a look at [presence channels](./presence.mdx). +Standard channels allow an unlimited number of subscribers to receive and exchange messages in real time. This is the default channel type and covers the majority of use cases — live dashboards, notifications, collaborative editing, IoT telemetry, and anything else where clients need real-time data but don't need awareness of one another. + +For channels where subscribers need to know who else is connected, use [presence channels](./presence.mdx). ## Subscribe -Once connected to the WebSocket with a token authorizing subscribe on the `live-updates` channel, subscribe by sending a message on the WebSocket. +Once connected to the WebSocket with a token authorizing subscribe on a channel, subscribe by sending a `hotsock.subscribe` message: ``` > {"event":"hotsock.subscribe", "channel":"live-updates"} ``` -You'll immediately receive confirmation of the subscription with a `hotsock.subscribed` message. +You'll receive a `hotsock.subscribed` confirmation: -``` -> {"event":"hotsock.subscribed","channel":"live-updates","data":{},"meta":{"uid":null,"umd":null}} +```json +{ + "event": "hotsock.subscribed", + "channel": "live-updates", + "data": {}, + "meta": { "uid": null, "umd": null } +} ``` -At that point, any updates published by your systems to this channel will be received by this and any other clients subscribed to this channel. +From this point on, any messages published to this channel (by your backend or by other clients) are delivered to this connection: -``` -< {"id":"01J66BCPSDHYZQ38BW5M357YX5","event":"ticker-values","channel":"live-updates","data":{"AAPL":"226.84","MSFT":"416.79"}} +```json +{ + "id": "01J66BCPSDHYZQ38BW5M357YX5", + "event": "ticker-values", + "channel": "live-updates", + "data": { "AAPL": "226.84", "MSFT": "416.79" } +} ``` -If you're subscribing and responding to messages using client-side JS, take a look at [hotsock-js](https://www.github.com/hotsock/hotsock-js) where all of the above is handled by the library. +:::tip +You can skip the manual subscribe step entirely by setting [`autoSubscribe`](../connections/claims.mdx#channels.autoSubscribe) to `true` in your token claims. The channel is subscribed automatically when the connection is established. +::: ## Unsubscribe -To unsubscribe from a channel, send a `hotsock.unsubscribe` message on the WebSocket. +To stop receiving messages from a channel, send a `hotsock.unsubscribe` message: ``` > {"event":"hotsock.unsubscribe", "channel":"live-updates"} ``` -You'll receive a confirmation message and will immediately stop receiving messages from this channel. +You'll receive a confirmation and immediately stop receiving messages from this channel: +```json +{ + "event": "hotsock.unsubscribed", + "channel": "live-updates", + "data": {} +} ``` -< {"event":"hotsock.unsubscribed","channel":"live-updates","data":{}} -``` + +When a WebSocket connection disconnects, all active subscriptions are automatically cleaned up. + +:::tip +If you're building with JavaScript, [hotsock-js](https://www.github.com/hotsock/hotsock-js) provides an easy to use interface for subscribing, unsubscribing, and message handling. +::: diff --git a/docs/channels/storage.mdx b/docs/channels/storage.mdx new file mode 100644 index 0000000..69e37bf --- /dev/null +++ b/docs/channels/storage.mdx @@ -0,0 +1,150 @@ +# Channel Storage + +Channel storage provides persistent, per-key state on any channel. Storage entries are key-value pairs that live alongside the channel, surviving disconnects and reconnects. This gives clients a way to read, write, and observe shared state without round-tripping through your backend. + +## When to use storage + +Storage is a good fit for state that should be immediately available to anyone who joins a channel: + +- **Room or session settings** — max players, game mode, room name +- **Shared cursors or selections** — which cell a user is editing in a spreadsheet +- **Feature flags or configuration** — per-channel toggles that take effect in real-time +- **User status** — online/away/busy indicators tied to a channel +- **Last known state** — the most recent value of something that changes over time (current score, latest price, active speaker) + +Storage is not a general-purpose database. Each entry is a single key with a JSON value, scoped to a channel. For ordered, append-only data (like chat history), use [message storage](../connections/claims.mdx#channels.messages.store) instead. + +## How it works + +Each channel can have any number of storage entries. Each entry has a **key** (a string up to 128 characters) and a **data** value (any valid JSON, up to 32 KiB). Entries also track `meta` — the `uid` and `umd` of the last writer — so observers always know who set a value. + +### Permissions + +Storage access is controlled per-key in [JWT claims](../connections/claims.mdx#channels.storage) using the `storage` directive inside channel entries. Keys support wildcards (`*`) and regex patterns (`#regex:`) for flexible matching. + +```json +{ + "exp": 1743580800, + "scope": "connect", + "channels": { + "game.lobby": { + "subscribe": true, + // highlight-start + "storage": { + "settings": { + "observe": true, + "get": true + }, + "player.*": { + "observe": true, + "get": true, + "set": true, + "store": 86400 + } + } + // highlight-end + } + } +} +``` + +Five directives control what a connection can do with matching keys: + +| Directive | Purpose | +|---|---| +| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | +| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | +| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | +| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | +| [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) | Trigger a backend [pub/sub event](../server-api/events.mdx#hotsock.channelStorageUpdated) when a value changes | + +:::info +Storage operations do not require an active channel subscription. Connect token permissions alone are sufficient for `get` and `set`. +::: + +## Writing storage entries + +Clients write storage entries by sending a `hotsock.channelStorageSet` message on the WebSocket: + +``` +> {"event":"hotsock.channelStorageSet", "channel":"game.lobby", "key":"settings", "data":{"maxPlayers":4}} +``` + +Writes that don't change the value are detected automatically and skip subscriber fan-out, so clients don't need to deduplicate on their end. Setting `data` to `null` or `{}` clears the entry. + +Also available via the Client HTTP API at [`connection/channelStorageSet`](../connections/client-http-api.mdx#connection/channelStorageSet). + +### Server-side writes + +Storage entries can be set from your backend using the [Lambda](../server-api/publish-messages.mdx#publish-with-lambda) or [HTTP](../server-api/publish-messages.mdx#publish-with-http-url) publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a [`key`](../server-api/publish-messages.mdx#message-format.key) field. Server-side writes bypass client permission checks, making them useful for initializing channel state before any clients connect. + +```json +{ + "channel": "game.lobby", + "event": "hotsock.channelStorageSet", + "key": "settings", + "data": { "maxPlayers": 4 } +} +``` + +## Reading storage entries + +Clients read a single entry by sending a `hotsock.channelStorageGet` message: + +``` +> {"event":"hotsock.channelStorageGet", "channel":"game.lobby", "key":"settings"} +``` + +The response is a `hotsock.channelStorageData` message: + +```json +{ + "event": "hotsock.channelStorageData", + "channel": "game.lobby", + "key": "settings", + "data": { "maxPlayers": 4 }, + "meta": { "uid": "host", "umd": null } +} +``` + +If no value has been set for the key, `data` and `meta` are `null`. + +Also available via the Client HTTP API at [`connection/channelStorageGet`](../connections/client-http-api.mdx#connection/channelStorageGet). To list multiple entries at once, use [`connection/channelStorageList`](../connections/client-http-api.mdx#connection/channelStorageList). + +## Observing storage entries + +When a key has `observe` enabled, two things happen: + +1. **On subscribe** — all storage entries matching the connection's `observe` patterns are delivered as `hotsock.channelStorageUpdated` messages immediately after the `hotsock.subscribed` message. New subscribers are caught up the moment they join. + +2. **On change** — whenever an observed key's value changes, all observers receive a `hotsock.channelStorageUpdated` message in real-time: + +```json +{ + "event": "hotsock.channelStorageUpdated", + "channel": "game.lobby", + "key": "settings", + "data": { "maxPlayers": 6 }, + "meta": { "uid": "host", "umd": null } +} +``` + +:::info +Unlike message echo behavior, `hotsock.channelStorageUpdated` is always delivered back to the connection that set the value (when that connection observes the key). Storage updates are state notifications, not message echoes. +::: + +## TTL and expiration + +By default, storage entries are retained forever. The [`store`](../connections/claims.mdx#channels.storage.store) directive sets a TTL in seconds for entries written by a connection. Server-side writes can also specify a `store` value to control TTL. + +When an entry expires, it is removed from DynamoDB. WebSocket observers are **not** notified of TTL expirations — expired entries simply stop appearing in `observe` deliveries on subscribe and in `get` responses. If you need to react to expirations on your backend, enable [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) — the [`hotsock.channelStorageUpdated`](../server-api/events.mdx#hotsock.channelStorageUpdated) pub/sub event includes an `expired` flag that distinguishes TTL deletions from explicit deletes. + +## Backend events + +When the [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) directive is enabled, storage value changes emit a [`hotsock.channelStorageUpdated`](../server-api/events.mdx#hotsock.channelStorageUpdated) event to SNS/EventBridge. This includes creates, updates, deletes, and TTL expirations. The event includes the current value, the previous value, and expiration metadata. + +## Server-side access + +The admin API provides read access to channel storage: + +- [`GET /channels/{name}/storage`](../server-api/channels.mdx#get-channel-storage) — list all storage entries for a channel, with pagination and specific key retrieval diff --git a/docs/connections/authentication.mdx b/docs/connections/authentication.mdx index bba4b94..26c4f94 100644 --- a/docs/connections/authentication.mdx +++ b/docs/connections/authentication.mdx @@ -3,7 +3,7 @@ sidebar_label: Authentication description: Secure WebSocket authentication with JWT tokens. Learn how Hotsock uses HMAC, RSA, ECDSA, and EdDSA signing methods to authenticate connections and authorize channel access. --- -# Authentication 🔐 +# Authentication Hotsock uses [JSON Web Tokens (JWT)](https://jwt.io/) to authenticate WebSocket connections and authorize permissions once connected. This way, your existing application can sign customer-specific tokens and your client applications can pass that token to Hotsock without it needing to know anything about your application. diff --git a/docs/connections/claims.mdx b/docs/connections/claims.mdx index 56e7ced..a3ca707 100644 --- a/docs/connections/claims.mdx +++ b/docs/connections/claims.mdx @@ -3,7 +3,7 @@ sidebar_label: Claims toc_max_heading_level: 4 --- -# Claims ✉️ +# Claims When issuing a JWT, you're allowing the recipient of the token to connect to the WebSocket, access channels and perform operations on the WebSocket, or both. By design, Hotsock has minimal global configuration and allows as much as possible to be specified per-token using signed JWT claims. @@ -95,7 +95,7 @@ Regex patterns are compiled and validated when the token is parsed. Invalid patt Regex patterns cannot have an [`alias`](#channels.alias) or [`autoSubscribe`](#channels.autoSubscribe) defined on them, just like wildcard patterns. ::: -Each object inside the channels object accepts [`alias`](#channels.alias), [`autoSubscribe`](#channels.autoSubscribe), [`historyStart`](#channels.historyStart), [`messages`](#channels.messages), [`omitFromSubCount`](#channels.omitFromSubCount), and [`subscribe`](#channels.subscribe) attributes. +Each object inside the channels object accepts [`alias`](#channels.alias), [`autoSubscribe`](#channels.autoSubscribe), [`getInfo`](#channels.getInfo), [`historyStart`](#channels.historyStart), [`messages`](#channels.messages), [`omitFromSubCount`](#channels.omitFromSubCount), [`storage`](#channels.storage), [`subscribe`](#channels.subscribe), and [`umdUpdate`](#channels.umdUpdate) attributes. ### `alias` {#channels.alias} @@ -187,6 +187,21 @@ Some situations where this is particularly useful: If using `autoSubscribe` on a [presence](../channels/presence.mdx) channel, be sure to include a [`uid`](#uid) claim (required for presence), otherwise the connection will receive a `PRESENCE_CHANNEL_USER_ID_REQUIRED` subscribe error message once connected. ::: +### `getInfo` {#channels.getInfo} + +`Boolean` (optional) - Controls whether this connection can retrieve channel info (subscription count and, for presence channels, the deduplicated member list) via the Client HTTP API [`connection/getChannelInfo`](./client-http-api.mdx#connection/getChannelInfo) endpoint. An active channel subscription is not required. Default is `false`. + +```json +{ + "channels": { + "presence.chat": { + // highlight-next-line + "getInfo": true + } + } +} +``` + ### `historyStart` {#channels.historyStart} `NumericDate` (optional) - Allows the connection to make HTTP API requests to load past messages stored on the channel prior to the time when the current connection's channel subscription was created, where the specified time is the oldest retrievable message. If supplied, the history start time must be expressed as a Unix timestamp - the number of seconds since the Unix epoch. By default, stored message retreval is limited to messages that were published during the lifetime of the active channel subscription. @@ -437,6 +452,203 @@ The following allows sending ephemeral "is-typing" events and saved "chat" event } ``` +### `storage` {#channels.storage} + +`Object` (optional) - Manages the permissions and directives for per-key persistent storage on the channel(s). Each object key is the name of a storage key and can include asterisks (\*) anywhere in the string to denote wildcards. Keys can also use the `#regex:` prefix to specify a regular expression pattern for matching storage key names. Each object value is another object with the settings for that storage key or key pattern. + +Storage keys must not be blank, must be no more than 128 characters, and must not contain spaces, asterisks (`*`), number signs (`#`), or commas (`,`). + +Each object inside the storage object accepts [`emitPubSubEvent`](#channels.storage.emitPubSubEvent), [`get`](#channels.storage.get), [`observe`](#channels.storage.observe), [`set`](#channels.storage.set), and [`store`](#channels.storage.store) attributes. + +The following grants observe on the `settings` storage key storage key with the `settings.` prefix and `observe`, `get`, and `set` access to a key named `status` on the "mychannel" channel. + +```json +{ + "channels": { + "mychannel": { + "subscribe": true, + // highlight-start + "storage": { + "settings": { + "observe": true + }, + "status": { + "observe": true, + "get": true, + "set": true + } + } + // highlight-end + } + } +} +``` + +#### `emitPubSubEvent` {#channels.storage.emitPubSubEvent} + +`Boolean` (optional) - If [`set`](#channels.storage.set) is permitted for matching storage keys, the `emitPubSubEvent` attribute specifies whether or not storage writes from this connection will trigger a [`hotsock.channelStorageUpdated`](../server-api/events.mdx#hotsock.channelStorageUpdated) [backend pub/sub event to SNS/EventBridge](../server-api/events.mdx). Default is `false`. Has no effect if `set` resolves to `false`. Also has no effect if both SNS and EventBridge events are disabled globally. + +```json +{ + "channels": { + "mychannel": { + "subscribe": true, + "storage": { + "status": { + "set": true, + // highlight-next-line + "emitPubSubEvent": true + } + } + } + } +} +``` + +#### `get` {#channels.storage.get} + +`Boolean` (optional) - Controls whether this connection can read the current value of matching storage keys on the channel using a `hotsock.channelStorageGet` message. The response is a `hotsock.channelStorageData` message containing the key, value, and metadata of the last writer. An active channel subscription is not required — connect token permissions alone are sufficient. Default is `false`. + +```json +{ + "channels": { + "mychannel": { + "subscribe": true, + "storage": { + "config": { + // highlight-next-line + "get": true + } + } + } + } +} +``` + +Sending a `hotsock.channelStorageGet` message on the WebSocket: + +``` +> {"event":"hotsock.channelStorageGet", "channel":"mychannel", "key":"config"} +``` + +The response is a `hotsock.channelStorageData` message: + +``` +< {"event":"hotsock.channelStorageData","channel":"mychannel","key":"config","data":{"theme":"dark"},"meta":{"uid":"12345","umd":null}} +``` + +Also available via the Client HTTP API at [`connection/channelStorageGet`](./client-http-api.mdx#connection/channelStorageGet). + +If no value has been set for the key, `data` and `meta` are `null`. + +#### `observe` {#channels.storage.observe} + +`Boolean` (optional) - Controls whether this connection receives real-time `hotsock.channelStorageUpdated` messages when matching storage keys are modified on the channel. When a connection subscribes to a channel, all storage entries matching observed key patterns are delivered as `hotsock.channelStorageUpdated` messages immediately after the `hotsock.subscribed` message. Default is `false`. + +```json +{ + "channels": { + "mychannel": { + "subscribe": true, + "storage": { + "status": { + // highlight-next-line + "observe": true + } + } + } + } +} +``` + +When a storage value is set or updated by any client, observers receive: + +``` +< {"event":"hotsock.channelStorageUpdated","channel":"mychannel","key":"status","data":"online","meta":{"uid":"12345","umd":null}} +``` + +:::info +Unlike message echo behavior, `hotsock.channelStorageUpdated` is always delivered back to the connection that set the value (when that connection observes the key). Storage updates are state notifications, not message echoes. +::: + +#### `set` {#channels.storage.set} + +`Boolean` (optional) - Controls whether this connection can write values to matching storage keys on the channel using a `hotsock.channelStorageSet` message. Writes that don't change the value skip subscriber fan-out entirely. Setting `data` to `null` or `{}` clears the storage entry. An active channel subscription is not required — connect token permissions alone are sufficient. Default is `false`. + +```json +{ + "channels": { + "mychannel": { + "subscribe": true, + "storage": { + "status": { + // highlight-next-line + "set": true, + "observe": true + } + } + } + } +} +``` + +Sending a `hotsock.channelStorageSet` message on the WebSocket: + +``` +> {"event":"hotsock.channelStorageSet", "channel":"mychannel", "key":"status", "data":"online"} +``` + +Also available via the Client HTTP API at [`connection/channelStorageSet`](./client-http-api.mdx#connection/channelStorageSet). + +The `hotsock.channelStorageSet` event can also be published via the server-side [Lambda](../server-api/publish-messages.mdx#publish-with-lambda) and [HTTP URL](../server-api/publish-messages.mdx#publish-with-http-url) publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a `"key"` field. When published via the server API, `set` permissions are not checked — the server is always authorized. + +#### `store` {#channels.storage.store} + +`Integer` (optional) - The retention duration in seconds for storage entries written by this connection to matching keys. `-1` keeps entries forever (the default when `set` is allowed but `store` is not explicitly configured). `0` is not valid for storage entries. The highest number you can specify is `3155695200`, roughly 100 years. + +```json +{ + "channels": { + "mychannel": { + "subscribe": true, + "storage": { + "session.*": { + "set": true, + // highlight-next-line + "store": 86400 + } + } + } + } +} +``` + +### `umdUpdate` {#channels.umdUpdate} + +`Boolean` (optional) - Controls whether this connection can update its user metadata (`umd`) on a per-channel subscription using the `hotsock.umdUpdate` client command. This allows a connected client to change its `umd` on a specific channel without needing to unsubscribe and resubscribe. Requires a [`umd`-scoped](#scope) token. Default is `false`. + +For presence channels, updating `umd` triggers a [`hotsock.memberUpdated`](../channels/presence.mdx#member-updated) event delivered to all members of the channel, including the connection that made the update. + +```json +{ + "channels": { + "presence.chat": { + // highlight-next-line + "umdUpdate": true + } + }, + "scope": "umd", + "uid": "12345", + "umd": { "name": "Dwight", "status": "available" } +} +``` + +To update `umd` on a channel, send a `hotsock.umdUpdate` message on the WebSocket with a `umd`-scoped token in the `data` payload: + +``` +> {"event":"hotsock.umdUpdate", "channel":"presence.chat", "data":{"token":"eyJ..."}} +``` + ### `omitFromSubCount` {#channels.omitFromSubCount} `Boolean` (optional) - By default, subscribing to a channel increments an attribute that tracks the number of connections subscribed to a channel, and unsubscribing decrements that number. Those increment/decrement operations also publish [hotsock.channelUpdated](../server-api/events.mdx#hotsock.channelUpdated) pub/sub events. Many use cases have no need for tracking subscriber counts on a channel, so you can set `omitFromSubCount` to `true` to reduces costs and contention for DynamoDB writes, Lambda invocations for DynamoDB Stream processing, and SNS/EventBridge events. Default is `false`. @@ -456,7 +668,7 @@ The following sets `omitFromSubCount` to `true` on "mychannel". ``` :::info -Setting `omitFromSubCount` to `true` _does not_ prevent [`hotsock.memberAdded`](../channels/presence.mdx#subscribe) or [`hotsock.memberRemoved`](../channels/presence.mdx#unsubscribe) events from firing on presence channels. It also does not prevent [`hotsock.subscribed`](../server-api/events.mdx#hotsock.subscribed) or [`hotsock.unsubscribed`](../server-api/events.mdx#hotsock.unsubscribed) pub/sub events from firing. +Setting `omitFromSubCount` to `true` _does not_ prevent [`hotsock.memberAdded`](../channels/presence.mdx#subscribe), [`hotsock.memberUpdated`](../channels/presence.mdx#member-updated), or [`hotsock.memberRemoved`](../channels/presence.mdx#unsubscribe) events from firing on presence channels. It also does not prevent [`hotsock.subscribed`](../server-api/events.mdx#hotsock.subscribed) or [`hotsock.unsubscribed`](../server-api/events.mdx#hotsock.unsubscribed) pub/sub events from firing. ::: :::warning @@ -565,9 +777,9 @@ You can also explicitly set `subscribe` to `false`, preventing subscriptions to ## `scope` -`String` (required) - Space-separated list of scopes for when and where the token can be used. `connect` and `subscribe` are currently supported. +`String` (required) - Space-separated list of scopes for when and where the token can be used. `connect`, `subscribe`, and `umd` are currently supported. -To use a token when initiating a WebSocket connection, the `connect` scope is required. When using a token that provides additional channel subscription permissions not included in the connection token to subscribe to a channel, the `subscribe` scope is required. +To use a token when initiating a WebSocket connection, the `connect` scope is required. When using a token that provides additional channel subscription permissions not included in the connection token to subscribe to a channel, the `subscribe` scope is required. When using a token to update user metadata on a connection or channel subscription, the `umd` scope is required. :::info You can [subscribe to channels](./connect-and-subscribe.mdx#subscribe-to-a-channel) authorized by a `connect`-scoped token once you're connected to the WebSocket. The `subscribe` scope is only required if you're issuing a token with _additional_ permissions that were not included in the `connect` token. @@ -579,6 +791,23 @@ You can [subscribe to channels](./connect-and-subscribe.mdx#subscribe-to-a-chann } ``` +A `umd`-scoped token is used with `hotsock.umdUpdate` to update user metadata on a specific channel subscription or on the connection itself. It requires either a [`connectionId`](#connectionId) or [`uid`](#uid), and includes [`channels`](#channels) with [`umdUpdate`](#channels.umdUpdate) directives for per-channel updates and/or [`umdUpdate`](#umdUpdate) and [`umdPropagate`](#umdPropagate) for connection-level updates. + +```json +{ + "scope": "umd", + "uid": "12345", + "umd": { "name": "Dwight", "status": "away" }, + "umdUpdate": true, + "umdPropagate": true, + "channels": { + "presence.chat": { + "umdUpdate": true + } + } +} +``` + :::warning Although Hotsock supports granting multiple scopes, you likely don't want to authorize both `connect` and `subscribe` in the same token. @@ -596,6 +825,43 @@ Say you issue a token with `{"scope":"connect subscribe","uid":"jim","channels": } ``` +## `umdPropagate` {#umdPropagate} + +`Boolean` (optional) - When set to `true` alongside [`umdUpdate`](#umdUpdate), a connection-level `hotsock.umdUpdate` (without a channel) will also propagate the updated user metadata to all existing subscriptions on the connection. This causes presence channels to emit [`hotsock.memberUpdated`](../channels/presence.mdx#member-updated) events for each affected subscription. Only effective when `umdUpdate` is also `true`. Default is `false`. + +```json +{ + "scope": "umd", + "uid": "12345", + "umd": { "name": "Dwight", "status": "away" }, + "umdUpdate": true, + // highlight-next-line + "umdPropagate": true +} +``` + +## `umdUpdate` {#umdUpdate} + +`Boolean` (optional) - When set to `true`, permits updating the connection's default user metadata without reconnecting by sending a `hotsock.umdUpdate` command without a channel. The connection's stored `UserMetadata` is updated so that future implicit subscriptions (subscriptions authorized by the connect token rather than a separate subscribe token) inherit the updated value. Requires a [`umd`-scoped](#scope) token. Default is `false`. + +This is distinct from the per-channel [`umdUpdate`](#channels.umdUpdate) directive, which updates `umd` on a specific existing subscription. The top-level `umdUpdate` claim updates the connection's default metadata. + +```json +{ + "scope": "umd", + "uid": "12345", + "umd": { "name": "Dwight", "status": "away" }, + // highlight-next-line + "umdUpdate": true +} +``` + +To update the connection's default user metadata, send a `hotsock.umdUpdate` message on the WebSocket _without_ a channel: + +``` +> {"event":"hotsock.umdUpdate", "data":{"token":"eyJ..."}} +``` + ## `uid` - User ID {#uid} `String` (optional) - The user ID claim identifies the connected user in any channels where they subscribe. It is available to all other subscribers when joining presence channels and included in all published messages that are initated by this connection. Must not exceed 128 characters. diff --git a/docs/connections/client-http-api.mdx b/docs/connections/client-http-api.mdx index 13c19cf..f255efe 100644 --- a/docs/connections/client-http-api.mdx +++ b/docs/connections/client-http-api.mdx @@ -5,7 +5,7 @@ toc_max_heading_level: 4 # Client HTTP API -In addition to WebSocket interactions, connected clients can also make use of an HTTP API for publishing messages and listing message history for channels. All requests **_must_ be `POST` requests** and **_must_ have URL-encoded `connectionId` and `connectionSecret`** query parameters set. +In addition to WebSocket interactions, connected clients can also make use of an HTTP API for publishing messages, listing message history, reading and writing channel storage, and updating user metadata. All requests **_must_ be `POST` requests** and **_must_ have URL-encoded `connectionId` and `connectionSecret`** query parameters set. ## Determine your API URL @@ -45,6 +45,12 @@ https://r6zcm2.lambda-url.us-east-1.on.aws/connection/publishMessage?connectionI Notice the **%3d** in `JCnTZd_KoAMCF-A%3d`, replacing the **=** in the original connection ID `JCnTZd_KoAMCF-A=`. Connection IDs typically have an equals sign as their suffix, so this value must be URL encoded for the URL to be parsed correctly. ::: +### Permissions + +HTTP API requests are authorized using the permissions from the connection's connect token. If the connection also has an active channel subscription created via WebSocket (optionally with a [subscribe token](./claims.mdx#scope) that expanded permissions), those additional directives are honored by the HTTP API as well. + +There is no way to pass a subscribe token directly to the HTTP API — permissions can only be expanded by subscribing via the WebSocket first. + ## `connection/listMessages` {#connection/listMessages} List the message history for a channel. By default, a connection can only list messages for channels that they are subscribed to, limited to messages that were published during the timeframe of the active subscription on the current WebSocket connection. If you subscribed to a channel 2 minutes ago, you'll only be able to list message history on that channel for the past 2 minutes. @@ -89,7 +95,7 @@ fetch( { method: "POST", body: JSON.stringify({ channel: "my-channel", reverse: true }), - } + }, ) ``` @@ -213,7 +219,7 @@ fetch( event: "my-event", data: "Hi 👋", }), - } + }, ) ``` @@ -248,7 +254,7 @@ fetch( scheduleExpressionTimezone: "America/New_York", // highlight-end }), - } + }, ) ``` @@ -263,3 +269,252 @@ Expect a `202 Accepted` status code for successful scheduled message requests, w "scheduleArn": "arn:aws:scheduler:us-east-1:111111111111:schedule/Hotsock-Scheduler-1CXU99B3NT-ScheduleGroup-17VG1F14Z7SM6/01K0WF560Z91WTKMSC892S799W" } ``` + +## `connection/channelStorageGet` {#connection/channelStorageGet} + +Read a channel storage entry value. An active channel subscription is not required — connect token [`get`](./claims.mdx#channels.storage.get) permissions are sufficient. + +#### `channel` {#connection/channelStorageGet.channel} + +`String` (required) - The name of the channel to read the storage entry from. + +#### `key` {#connection/channelStorageGet.key} + +`String` (required) - The storage key to read. + +### Example {#connection/channelStorageGet--example} + +#### Request {#connection/channelStorageGet--example-request} + +```javascript +fetch( + "https://r6zcm2.lambda-url.us-east-1.on.aws/connection/channelStorageGet?connectionId=fjlb_eHLIAMCKRg%3d&connectionSecret=SZy32Etv0KIbe4Jod6KH", + { + method: "POST", + body: JSON.stringify({ channel: "mychannel", key: "settings" }), + }, +) +``` + +#### Response {#connection/channelStorageGet--example-response} + +Expect a `200 OK` status code for successful reads. + +```json +{ + "event": "hotsock.channelStorageData", + "channel": "mychannel", + "key": "settings", + "data": { "theme": "dark" }, + "meta": { "uid": "12345", "umd": null } +} +``` + +If no value has been set for the key, `data` and `meta` are `null`. + +## `connection/channelStorageSet` {#connection/channelStorageSet} + +Write a channel storage entry value. An active channel subscription is not required — connect token [`set`](./claims.mdx#channels.storage.set) permissions are sufficient. + +#### `channel` {#connection/channelStorageSet.channel} + +`String` (required) - The name of the channel where the storage entry will be written. + +#### `key` {#connection/channelStorageSet.key} + +`String` (required) - The storage key to write. + +#### `data` {#connection/channelStorageSet.data} + +`JSON` (optional) - The value to store. Setting `data` to `null` or `{}` clears the storage entry. + +### Example {#connection/channelStorageSet--example} + +#### Request {#connection/channelStorageSet--example-request} + +```javascript +fetch( + "https://r6zcm2.lambda-url.us-east-1.on.aws/connection/channelStorageSet?connectionId=fjlb_eHLIAMCKRg%3d&connectionSecret=SZy32Etv0KIbe4Jod6KH", + { + method: "POST", + body: JSON.stringify({ + channel: "mychannel", + key: "settings", + data: { theme: "dark" }, + }), + }, +) +``` + +#### Response {#connection/channelStorageSet--example-response} + +Expect a `202 Accepted` status code for successful writes. + +```json +{ + "channel": "mychannel", + "event": "hotsock.channelStorageSet" +} +``` + +## `connection/channelStorageList` {#connection/channelStorageList} + +List channel storage entries. Returns entries filtered by the connection's per-key [`get`](./claims.mdx#channels.storage.get) permissions. An active channel subscription is not required — connect token permissions are sufficient. + +#### `channel` {#connection/channelStorageList.channel} + +`String` (required) - The name of the channel to list storage entries from. + +#### `keys` {#connection/channelStorageList.keys} + +`Array[String]` (optional) - Specific storage keys to retrieve. If provided, only entries matching these keys (and permitted by `get` permissions) are returned. Maximum of 100 keys per request. + +#### `after` {#connection/channelStorageList.after} + +`String` (optional) - For pagination, the storage key to start after. Returns entries with keys that sort after this value. + +### Example {#connection/channelStorageList--example} + +#### Request {#connection/channelStorageList--example-request} + +```javascript +fetch( + "https://r6zcm2.lambda-url.us-east-1.on.aws/connection/channelStorageList?connectionId=fjlb_eHLIAMCKRg%3d&connectionSecret=SZy32Etv0KIbe4Jod6KH", + { + method: "POST", + body: JSON.stringify({ channel: "mychannel" }), + }, +) +``` + +#### Response {#connection/channelStorageList--example-response} + +Expect a `200 OK` status code for successful reads. + +```json +{ + "items": [ + { + "key": "settings", + "data": { "theme": "dark" }, + "meta": { "uid": "12345", "umd": null } + }, + { + "key": "status", + "data": "online", + "meta": { "uid": "12345", "umd": null } + } + ] +} +``` + +If there are no entries or no entries the connection has `get` permission for, `items` is an empty array. + +## `connection/getChannelInfo` {#connection/getChannelInfo} + +Retrieve channel info without requiring an active subscription. Returns the subscription count and, for presence channels, the deduplicated member list. Controlled by the [`getInfo`](./claims.mdx#channels.getInfo) channel directive. + +#### `channel` {#connection/getChannelInfo.channel} + +`String` (required) - The name of the channel to get info for. + +### Example {#connection/getChannelInfo--example} + +#### Request {#connection/getChannelInfo--example-request} + +```javascript +fetch( + "https://r6zcm2.lambda-url.us-east-1.on.aws/connection/getChannelInfo?connectionId=fjlb_eHLIAMCKRg%3d&connectionSecret=SZy32Etv0KIbe4Jod6KH", + { + method: "POST", + body: JSON.stringify({ channel: "presence.chat" }), + }, +) +``` + +#### Response {#connection/getChannelInfo--example-response} + +Expect a `200 OK` status code for successful reads. + +```json +{ + "channel": "presence.chat", + "subscriptionsCount": 4, + "members": [ + { "uid": "Jim", "umd": null }, + { "uid": "Dwight", "umd": { "status": "available" } }, + { "uid": "Pam", "umd": null } + ] +} +``` + +:::note +`subscriptionsCount` reflects the total number of subscriptions on the channel, while `members` is deduplicated by `uid`. If a user is connected from multiple devices, they appear once in `members` but count as multiple subscriptions. +::: + +For non-presence channels, the `members` field is omitted from the response. + +## `connection/umdUpdate` {#connection/umdUpdate} + +Update user metadata (`umd`) on a specific channel subscription or on the connection itself. This provides an HTTP alternative to sending the `hotsock.umdUpdate` command over the WebSocket. + +#### `channel` {#connection/umdUpdate.channel} + +`String` (optional) - The name of the channel where the subscription's `umd` should be updated. If omitted, the connection's default user metadata is updated instead (requires the [`umdUpdate`](./claims.mdx#umdUpdate) top-level claim in the token). + +#### `token` {#connection/umdUpdate.token} + +`String` (required) - A signed JWT with [`umd` scope](./claims.mdx#scope). The token must include either a `connectionId` matching the authenticated connection or a `uid` matching the connection's user ID. For per-channel updates, the token must include [`channels`](./claims.mdx#channels) with the [`umdUpdate`](./claims.mdx#channels.umdUpdate) directive for the target channel. The new `umd` value is taken from the token's [`umd`](./claims.mdx#umd) claim. + +### Per-Channel Example {#connection/umdUpdate--channel-example} + +Update user metadata on a specific presence channel subscription. + +#### Request {#connection/umdUpdate--channel-example-request} + +```javascript +fetch( + "https://r6zcm2.lambda-url.us-east-1.on.aws/connection/umdUpdate?connectionId=fjlb_eHLIAMCKRg%3d&connectionSecret=SZy32Etv0KIbe4Jod6KH", + { + method: "POST", + body: JSON.stringify({ + channel: "presence.chat", + token: "eyJ...", + }), + }, +) +``` + +#### Response {#connection/umdUpdate--channel-example-response} + +Expect a `200 OK` status code for successful updates. + +```json +{} +``` + +### Connection-Level Example {#connection/umdUpdate--connection-example} + +Update the connection's default user metadata. Omit the `channel` field. + +#### Request {#connection/umdUpdate--connection-example-request} + +```javascript +fetch( + "https://r6zcm2.lambda-url.us-east-1.on.aws/connection/umdUpdate?connectionId=fjlb_eHLIAMCKRg%3d&connectionSecret=SZy32Etv0KIbe4Jod6KH", + { + method: "POST", + body: JSON.stringify({ + token: "eyJ...", + }), + }, +) +``` + +#### Response {#connection/umdUpdate--connection-example-response} + +Expect a `200 OK` status code for successful updates. + +```json +{} +``` diff --git a/docs/installation/changelog.mdx b/docs/installation/changelog.mdx index e756601..ef97299 100644 --- a/docs/installation/changelog.mdx +++ b/docs/installation/changelog.mdx @@ -1,5 +1,20 @@ # Changelog +## v1.12.0 - April 8, 2026 {#v1.12.0} + +- Add [channel storage](../connections/claims.mdx#channels.storage), a per-key persistent key-value store on channels. Storage permissions are configured per-key with wildcard and regex pattern support. Entries have independent TTLs, are delivered to [`observe`](../connections/claims.mdx#channels.storage.observe) subscribers on join, and skip fan-out when the value hasn't changed. Clients interact with storage via [`hotsock.channelStorageSet`](../connections/claims.mdx#channels.storage.set) / [`hotsock.channelStorageGet`](../connections/claims.mdx#channels.storage.get) on the WebSocket or the [`connection/channelStorageGet`](../connections/client-http-api.mdx#connection/channelStorageGet), [`connection/channelStorageSet`](../connections/client-http-api.mdx#connection/channelStorageSet), and [`connection/channelStorageList`](../connections/client-http-api.mdx#connection/channelStorageList) Client HTTP API endpoints. Storage operations do not require an active channel subscription. Server-side writes are available via the Lambda and HTTP publish APIs using `"event": "hotsock.channelStorageSet"` with a `key` field. +- Add [`hotsock.channelStorageUpdated`](../server-api/events.mdx#hotsock.channelStorageUpdated) pub/sub event to SNS/EventBridge when storage entry values change, gated by the [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) storage key directive for client writes or the `emitPubSubEvent` field for server writes. +- Add admin [`GET /channels/{name}`](../server-api/channels.mdx#get-channel) and [`GET /channels/{name}/storage`](../server-api/channels.mdx#get-channel-storage) API endpoints for retrieving channel info and listing channel storage entries. +- Add [`connection/getChannelInfo`](../connections/client-http-api.mdx#connection/getChannelInfo) Client HTTP API endpoint to retrieve channel info (subscription count and, for presence channels, the member list) without requiring an active subscription. Controlled by the new [`getInfo`](../connections/claims.mdx#channels.getInfo) channel directive. +- Add [`hotsock.umdUpdate`](../connections/claims.mdx#channels.umdUpdate) client command to update user metadata (`umd`) on a per-channel subscription without needing to unsubscribe and resubscribe. Requires a [`umd`-scoped](../connections/claims.mdx#scope) token with the [`umdUpdate`](../connections/claims.mdx#channels.umdUpdate) channel directive. Also available via the Client HTTP API at [`connection/umdUpdate`](../connections/client-http-api.mdx#connection/umdUpdate). +- Add connection-level `hotsock.umdUpdate` (without a channel) to update the connection's default user metadata without reconnecting. Controlled by top-level [`umdUpdate`](../connections/claims.mdx#umdUpdate) and [`umdPropagate`](../connections/claims.mdx#umdPropagate) claims. When `umdPropagate` is enabled, the updated metadata is propagated to all existing subscriptions on the connection. +- Add [`hotsock.memberUpdated`](../channels/presence.mdx#member-updated) presence event, delivered to all channel members when a member's `umd` changes. Also triggered when a duplicate `uid` subscribes with a different `umd`. +- Add [`hotsock.connectionUpdated`](../server-api/events.mdx#hotsock.connectionUpdated) pub/sub event to SNS/EventBridge when a connection's user metadata changes via `hotsock.umdUpdate`. +- Update the [Web Console](../server-api/web-console.mdx) with support for channel storage. +- Fix channel subscription item deletion losing entries when multiple connections unsubscribed from the same channel in a single stream batch. +- Build with Go 1.26.2. +- Update all aws-sdk-go-v2 SDK modules to their latest versions (as of [2026-04-02](https://github.com/aws/aws-sdk-go-v2/releases/tag/release-2026-04-02)). + ## v1.11.0 - March 19, 2026 {#v1.11.0} - Add [regex pattern support](../connections/claims.mdx#channels--regex) for channel name patterns in JWT claims. Channel keys can now use the `#regex:` prefix for [Go regular expression](https://pkg.go.dev/regexp/syntax) matching, enabling fine-grained permission control for dynamically-named channels where wildcards are not precise enough. diff --git a/docs/installation/initial-setup.mdx b/docs/installation/initial-setup.mdx index b81983d..7d5537f 100644 --- a/docs/installation/initial-setup.mdx +++ b/docs/installation/initial-setup.mdx @@ -3,7 +3,7 @@ sidebar_label: Initial Setup description: Install Hotsock in your AWS account a few minutes using CloudFormation. Step-by-step guide with AWS CLI and Console options for deploying production-ready infrastructure. --- -# Initial Installation & Setup 🪄 +# Initial Installation & Setup Assuming you already have an AWS account ready to go, installing and configuring your stack should take less than 20 minutes. diff --git a/docs/installation/region-support.mdx b/docs/installation/region-support.mdx index e48fe41..440edf9 100644 --- a/docs/installation/region-support.mdx +++ b/docs/installation/region-support.mdx @@ -2,7 +2,7 @@ sidebar_label: Region Support --- -# Region Support 🌎 +# Region Support Choose a nearby AWS region. All of your server-initiated messages will be routed through endpoints in this region and all clients will connect to a WebSocket endpoint in this region. diff --git a/docs/installation/uninstallation.mdx b/docs/installation/uninstallation.mdx index 5796c70..400c97e 100644 --- a/docs/installation/uninstallation.mdx +++ b/docs/installation/uninstallation.mdx @@ -2,7 +2,7 @@ sidebar_label: Uninstallation --- -# Uninstall Hotsock 🗑️ +# Uninstall Hotsock Want to remove your Hotsock installation? No problem. Doing so takes about 5 minutes. diff --git a/docs/installation/updates.mdx b/docs/installation/updates.mdx index 5e1ceda..7a6adee 100644 --- a/docs/installation/updates.mdx +++ b/docs/installation/updates.mdx @@ -2,7 +2,7 @@ sidebar_label: Updates --- -# Update Your Installation ⬆️ +# Update Your Installation You can update your existing Hotsock CloudFormation stack to the latest Hotsock version in-place at any time without affecting currently-connected clients. You can generally [expect full compatibility](./versioning.mdx) with your existing applications, but consider testing updates against an isolated pre-production installation to ensure there aren't any issues for critical systems. diff --git a/docs/introduction.mdx b/docs/introduction.mdx index fe34567..d951f31 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,6 +32,7 @@ Whether you're a hobbyist, start-up, small business, or enterprise, all licenses | [Presence Channels](./channels/presence.mdx) | ✅ | ✅ | | Unlimited Channels | ✅ | ✅ | | WebSocket Messages | 1 million per month | Unlimited | +| [Channel Key-Value Persistent Storage](./channels/storage.mdx) | ✅ | ✅ | | [Scheduled Messages](/blog/message-scheduler-and-web-console-improvements/) | ✅ | ✅ | | [Custom Domains](./installation/custom-domains.mdx) | ✅ | ✅ | | [Pub/Sub Events with SNS and EventBridge](./server-api/events.mdx) | ✅ | ✅ | diff --git a/docs/performance/limits-and-scaling.mdx b/docs/performance/limits-and-scaling.mdx index f4ea098..9559928 100644 --- a/docs/performance/limits-and-scaling.mdx +++ b/docs/performance/limits-and-scaling.mdx @@ -2,7 +2,7 @@ sidebar_label: Limits & Scaling --- -# Limits & Scaling 📈 +# Limits & Scaling Hotsock is designed to scale infinitely. Whether you have 10, 100K, or 1M simultaneous connections, one or more installations can accommodate your needs. On the road to 100K concurrent connections though, there are a few things you may need to know. diff --git a/docs/server-api/channels.mdx b/docs/server-api/channels.mdx new file mode 100644 index 0000000..f50db29 --- /dev/null +++ b/docs/server-api/channels.mdx @@ -0,0 +1,112 @@ +--- +sidebar_label: Channels +--- + +# Channels API + +The Channels API provides server-side read access to channel metadata and storage entries. These endpoints are authenticated with the same API keys used for [HTTP message publishing](./publish-messages.mdx#publish-with-http-url). + +## Authentication + +All requests **must include an `Authorization` header** with a Bearer token set to one of your installation's API keys, available at [HttpApiSecretKey1ConsoleUrl](../installation/initial-setup.mdx#HttpApiSecretKey1ConsoleUrl) or [HttpApiSecretKey2ConsoleUrl](../installation/initial-setup.mdx#HttpApiSecretKey2ConsoleUrl). + +Your base URL is available in your installation's [HttpApiUrl](../installation/initial-setup.mdx#HttpApiUrl) stack output. + +``` +Authorization: Bearer YOUR_API_KEY +``` + +## `GET /channels/{name}` {#get-channel} + +Retrieve metadata for a channel, including the current subscription count. + +### Example {#get-channel--example} + +#### Request {#get-channel--example-request} + +```bash +curl "https://r6zcm2.lambda-url.us-east-1.on.aws/channels/my-channel" \ + -H 'Authorization: Bearer JAnzQFqRXsBgV0kKvd2DYhJMk77IhL8j9J2sLi5b' +``` + +#### Response {#get-channel--example-response} + +```json +{ + "name": "my-channel", + "subscriptionsCount": 12 +} +``` + +For presence channels, the response includes a deduplicated `members` array: + +```json +{ + "name": "presence.chat", + "subscriptionsCount": 4, + "members": [ + { "uid": "Jim", "umd": null }, + { "uid": "Dwight", "umd": { "status": "available" } }, + { "uid": "Pam", "umd": null } + ] +} +``` + +:::note +`subscriptionsCount` reflects the total number of subscriptions on the channel, while `members` is deduplicated by `uid`. If a user is connected from multiple devices, they appear once in `members` but count as multiple subscriptions. +::: + +If the channel has no active subscriptions or has never been used, `subscriptionsCount` is `0`. For presence channels with no members, `members` is an empty array. + +## `GET /channels/{name}/storage` {#get-channel-storage} + +List storage entries for a channel. Returns a paginated list of all storage entries, or specific entries when keys are requested. + +### Query parameters {#get-channel-storage--params} + +#### `after` {#get-channel-storage.after} + +`String` (optional) - For pagination, the storage key to start after. Returns entries with keys that sort after this value. + +#### `keys` {#get-channel-storage.keys} + +`String` (optional) - Comma-separated list of specific storage keys to retrieve. Maximum of 100 keys per request. + +### Example {#get-channel-storage--example} + +#### Request {#get-channel-storage--example-request} + +List all storage entries for a channel: + +```bash +curl "https://r6zcm2.lambda-url.us-east-1.on.aws/channels/my-channel/storage" \ + -H 'Authorization: Bearer JAnzQFqRXsBgV0kKvd2DYhJMk77IhL8j9J2sLi5b' +``` + +Fetch specific keys: + +```bash +curl "https://r6zcm2.lambda-url.us-east-1.on.aws/channels/my-channel/storage?keys=settings,status" \ + -H 'Authorization: Bearer JAnzQFqRXsBgV0kKvd2DYhJMk77IhL8j9J2sLi5b' +``` + +#### Response {#get-channel-storage--example-response} + +```json +{ + "items": [ + { + "key": "settings", + "data": { "theme": "dark" }, + "meta": { "uid": "12345", "umd": null } + }, + { + "key": "status", + "data": "online", + "meta": { "uid": "12345", "umd": null } + } + ] +} +``` + +If there are no storage entries, `items` is an empty array. diff --git a/docs/server-api/events.mdx b/docs/server-api/events.mdx index 569ac07..ed238c9 100644 --- a/docs/server-api/events.mdx +++ b/docs/server-api/events.mdx @@ -400,3 +400,109 @@ This is always `hotsock.unsubscribed`. ### `dataType` {#hotsock.unsubscribed--dataType} The data object type for this event is `subscription`. + +## `hotsock.connectionUpdated` {#hotsock.connectionUpdated} + +This event is sent whenever a connection's user metadata changes via a connection-level [`umdUpdate`](../connections/claims.mdx#umdUpdate). This event is only emitted when `UserMetadata` actually changes, not on heartbeat-only updates. + +### `type` {#hotsock.connectionUpdated--type} + +This is always `hotsock.connectionUpdated`. + +### `metadata` {#hotsock.connectionUpdated--metadata} + +- `hotsockVersion` (String): The version of the Hotsock installation that generated this event. + +```json +{ + "hotsockVersion": "1.12.0" +} +``` + +### `data` {#hotsock.connectionUpdated--data} + +- `id` (String): The identifier for this connection. +- `connectedAt` (String): The ISO3339 timestamp for when the connection was established. +- `disconnectedAt` (String | null): The ISO3339 timestamp for when the connection was terminated. +- `keepAlive` (Boolean): Whether or not the [`keepAlive` claim](../connections/claims.mdx#keepAlive) is enabled for this connection. +- `sourceIp` (String | null): The source IP address that was used by the client when the connection was established. +- `uid` (String | null): The user ID specified in the [`uid` claim](../connections/claims.mdx#uid) for this connection. +- `umd` (JSON | null): The user metadata specified in the [`umd` claim](../connections/claims.mdx#umd) for this connection, reflecting the updated value. +- `userAgent` (String | null): The value of the User-Agent header that was used when the client established the connection. + +```json +{ + "id": "IDnAdd9kIAMCEsQ=", + "connectedAt": "2024-05-31T15:47:45.231141792Z", + "disconnectedAt": null, + "keepAlive": true, + "sourceIp": "12.34.56.78", + "uid": "12345", + "umd": { "name": "Dwight", "status": "away" }, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0" +} +``` + +### `dataType` {#hotsock.connectionUpdated--dataType} + +The data object type for this event is `connection`. + +## `hotsock.channelStorageUpdated` {#hotsock.channelStorageUpdated} + +This event is sent whenever a [channel storage](../connections/claims.mdx#channels.storage) entry value changes and the [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) storage key directive is enabled. This event is only emitted when the stored value actually changes — writes that set the same value are suppressed. Clearing a storage entry (setting `data` to `null` or `{}`) and TTL-based expiration also trigger this event. + +For client-initiated storage writes, the `emitPubSubEvent` directive must be set to `true` on the matching storage key pattern in the token claims. For server-initiated writes (Lambda or HTTP API), set `emitPubSubEvent` to `true` in the publish payload. + +### `type` {#hotsock.channelStorageUpdated--type} + +This is always `hotsock.channelStorageUpdated`. + +### `metadata` {#hotsock.channelStorageUpdated--metadata} + +- `channel` (String): The name of the channel where the storage entry was updated. +- `connectionId` (String): The identifier for the connection that updated the storage entry. This field is only present for updates initiated by a WebSocket client or Client HTTP API. +- `hotsockVersion` (String): The version of the Hotsock installation that generated this event. +- `key` (String): The storage key that was updated. +- `sourceIp` (String): The IP address of the client that updated the storage entry. This field is only present if an IP address is known. +- `trigger` (String): The kind of update initiator, set to one of `client.websocket`, `client.http`, `server.lambda`, or `server.http`. +- `userAgent` (String): The User-Agent of the client that updated the storage entry. This field is only present if a user agent is known. + +SNS filter attributes are limited to `channel`, `key`, and `trigger`. + +```json +{ + "channel": "my-channel", + "connectionId": "IDnAdd9kIAMCEsQ=", + "hotsockVersion": "1.12.0", + "key": "status", + "sourceIp": "12.34.56.78", + "trigger": "client.websocket", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0" +} +``` + +### `data` {#hotsock.channelStorageUpdated--data} + +- `channel` (String): The name of the channel where the storage entry was updated. +- `key` (String): The storage key that was updated. +- `data` (JSON): The new value of the storage entry. `null` for delete events. +- `dataPrevious` (JSON): The previous value of the storage entry. `null` for create events (first write to a key). +- `expired` (Boolean): Whether the entry was deleted by TTL expiration. `true` for TTL-based deletes, `false` for all other changes. +- `expiresAt` (String | null): The RFC3339 timestamp when the entry expires, or `null` if the entry has no TTL. +- `meta` (Object): Metadata about the last writer, including `uid` and `umd`. + +```json +{ + "channel": "my-channel", + "key": "status", + "data": "online", + "dataPrevious": "away", + "expired": false, + "expiresAt": "2026-04-03T12:00:00Z", + "meta": { "uid": "12345", "umd": null } +} +``` + +### `dataType` {#hotsock.channelStorageUpdated--dataType} + +The data object type for this event is `channelStorage`. diff --git a/docs/server-api/logging.mdx b/docs/server-api/logging.mdx index 07fa899..e44b626 100644 --- a/docs/server-api/logging.mdx +++ b/docs/server-api/logging.mdx @@ -2,7 +2,7 @@ sidebar_label: Logging --- -# Logging 🪵 +# Logging Hotsock logs are written to [CloudWatch Logs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html). By default, it writes informational info, warnings, and errors to logs. However, you have full control over the log volume, verbosity, and retention settings in your installation's settings. diff --git a/docs/server-api/publish-messages.mdx b/docs/server-api/publish-messages.mdx index 8a09ebb..8f5d2ea 100644 --- a/docs/server-api/publish-messages.mdx +++ b/docs/server-api/publish-messages.mdx @@ -5,7 +5,7 @@ sidebar_label: Publish Messages import Tabs from "@theme/Tabs" import TabItem from "@theme/TabItem" -# Publish Messages 📝 +# Publish Messages Sending server-side messages to subscribers on Hotsock channels is the most common way to enable real-time features in your applications. Hotsock allows you to publish messages from your backend by invoking a Lambda function or by making an authenticated HTTP request. Connected clients can also send [client-initiated messages](../channels/client-messages.mdx). @@ -44,7 +44,7 @@ lambdaClient.send( event: "my-event", data: "👋", }), - }) + }), ) ``` @@ -263,7 +263,11 @@ Deduplication is handled [internally by SQS](https://docs.aws.amazon.com/AWSSimp ### `event` {#message-format.event} -`String` (required) - The name of the event to publish to the channel. This can be any string up to 128 characters, but must not begin with `hotsock.` or `#`, and must not contain any asterisk (`*`) characters. +`String` (required) - The name of the event to publish to the channel. This can be any string up to 128 characters, but must not begin with `hotsock.` or `#`, and must not contain any asterisk (`*`) characters. The one exception is `hotsock.channelStorageSet`, which can be used to set [channel storage](../connections/claims.mdx#channels.storage) entries from the server side (requires the [`key`](#message-format.key) field). + +### `key` {#message-format.key} + +`String` (conditional) - The storage key to set when publishing a `hotsock.channelStorageSet` event. Required when `event` is `hotsock.channelStorageSet`, ignored otherwise. Storage keys must not be blank, must be no more than 128 characters, and must not contain spaces, asterisks (`*`), number signs (`#`), or commas (`,`). When `store` is not specified, it defaults to `-1` (forever) for storage set operations. ### `scheduleExpression` {#message-format.scheduleExpression} diff --git a/docs/server-api/web-console.mdx b/docs/server-api/web-console.mdx index e342557..d111ff3 100644 --- a/docs/server-api/web-console.mdx +++ b/docs/server-api/web-console.mdx @@ -40,4 +40,4 @@ You'll see an outgoing WebSocket message that sends the message on the channel. ## Other actions -You can also use the web console to unsubscribe from channels, send pings and heartbeats, test disconnect/reconnect, and test sending your own raw messages on the WebSocket. Use the dropdown menu at the bottom to select the action you need. +You can also use the web console to unsubscribe from channels, send pings and heartbeats, test disconnect/reconnect, get/set channel storage, and test sending your own raw messages on the WebSocket. Use the dropdown menu at the bottom to select the action you need. diff --git a/docusaurus.config.js b/docusaurus.config.js index 15a3c44..e3248dc 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -46,6 +46,7 @@ const config = { }, blog: { blogSidebarTitle: "All our posts", + blogSidebarCount: "ALL", showReadingTime: true, }, theme: { diff --git a/src/components/HomepageBanner.js b/src/components/HomepageBanner.js index 9e60ed2..0eef2e8 100644 --- a/src/components/HomepageBanner.js +++ b/src/components/HomepageBanner.js @@ -8,17 +8,17 @@ export default function HomepageBanner() {

- New in v1.10: channel aliases and large channel fan-out! + New in v1.12: channel storage and live user metadata updates! - New in v1.10: channel aliases for friendly client-side names - and automatic fan-out for large channels! + New in v1.12: persistent channel storage with real-time sync + and live user metadata updates without reconnecting!

Learn more diff --git a/src/sidebar-docs.js b/src/sidebar-docs.js index 0be098d..c327bae 100644 --- a/src/sidebar-docs.js +++ b/src/sidebar-docs.js @@ -49,6 +49,7 @@ const sidebars = { "channels/overview", "channels/standard", "channels/presence", + "channels/storage", "channels/client-messages", ], collapsible: false, @@ -57,6 +58,7 @@ const sidebars = { type: "category", label: "Server API", items: [ + "server-api/channels", "server-api/logging", "server-api/publish-messages", "server-api/events", From e54f99f7c09d786303a62291366043f2c285e4e6 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 10:29:47 -0700 Subject: [PATCH 02/17] Fix admonition syntax to use bracket notation Change :::type Title to :::type[Title] for proper Docusaurus markdown admonition syntax. --- docs/channels/overview.mdx | 2 +- docs/installation/initial-setup.mdx | 32 ++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 81fa0e2..8e37f8f 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -47,7 +47,7 @@ For example, a `game.123` channel might have `move` and `chat` events: Both arrive on the same channel, but your application can route them to different handlers based on the event name. The message `id` is generated by Hotsock. -:::tip When should you use a different channel instead of a different event? +:::tip[When should you use a different channel instead of a different event?] All events published to a channel are delivered to every subscriber, regardless of whether the client cares about that event. If you find yourself sending many events that most subscribers ignore, consider splitting those into a separate channel. Use **channels** to filter what data a client receives. Use **events** to categorize different message types within the same context. diff --git a/docs/installation/initial-setup.mdx b/docs/installation/initial-setup.mdx index 7d5537f..11b4986 100644 --- a/docs/installation/initial-setup.mdx +++ b/docs/installation/initial-setup.mdx @@ -215,7 +215,7 @@ Once the stack creation completes, installation was successful. Next you'll like Upon successful creation of your Hotsock stack, the root CloudFormation stack has an "Outputs" tab with a list of variables specific to your installation. -:::info Example Stack Outputs Values +:::info[Example Stack Outputs Values] ![Outputs tab items](../../static/img/screenshots/initial-setup-outputs-tab.png) ::: @@ -223,7 +223,7 @@ Upon successful creation of your Hotsock stack, the root CloudFormation stack ha This is the URL where you can find the primary secret key value for API calls to the `PublishHttpApiUrl`. To access the secret value, go to this URL and click the "Show" link by the "Value" label, which will reveal a 40-character secret key that must be used as a Bearer token in the Authorization header of all HTTP API requests. -:::info Example Output Value +:::info[Example Output Value] ![HttpApiSecretKey1ConsoleUrl example output](../../static/img/screenshots/initial-setup-outputs-HttpApiSecretKey1ConsoleUrl.png) ::: @@ -233,7 +233,7 @@ This is the URL where you can find the secondary secret key value for API calls This secondary key exists to allow for zero-downtime secret key rotation. You can always use either key. If you want to allow access from more API keys, you can add additional keys to Parameter Store in the `/hotsock/hotsock/90592770-5584-11ee-b651-0ad7a5b44125/http-api-secret-keys` namespace. -:::info Example Output Value +:::info[Example Output Value] ![HttpApiSecretKey2ConsoleUrl example output](../../static/img/screenshots/initial-setup-outputs-HttpApiSecretKey2ConsoleUrl.png) ::: @@ -241,7 +241,7 @@ This secondary key exists to allow for zero-downtime secret key rotation. You ca This is the base URL for all HTTP API endpoints. -:::info Example Output Value +:::info[Example Output Value] ![HttpApiUrl example output](../../static/img/screenshots/initial-setup-outputs-HttpApiUrl.png) ::: @@ -249,7 +249,7 @@ This is the base URL for all HTTP API endpoints. This is the version of this installation. -:::info Example Output Value +:::info[Example Output Value] ![InstallationVersion example output](../../static/img/screenshots/initial-setup-outputs-InstallationVersion.png) ::: @@ -259,7 +259,7 @@ Installations are initially configured with [Free Tier](../licensing/pricing.mdx If this URL's value is blank, the installation is already adopted by a Hotsock account holder but a direct management URL is not available. -:::info Example Output Value +:::info[Example Output Value] ![LicensingUrl example output](../../static/img/screenshots/initial-setup-outputs-LicensingUrl.png) ::: @@ -267,7 +267,7 @@ If this URL's value is blank, the installation is already adopted by a Hotsock a The URL of a CloudWatch Dashboard that combines important metrics about your installation in a single place. -:::info Example Output Value +:::info[Example Output Value] ![LicensingUrl example output](../../static/img/screenshots/initial-setup-outputs-LicensingUrl.png) ::: @@ -275,7 +275,7 @@ The URL of a CloudWatch Dashboard that combines important metrics about your ins This is the Arn of the Lambda function that your backend systems can use to publish messages using the AWS SDK or from other AWS services (such as EventBridge). This is the recommended way to publish messages from your backend systems. -:::info Example Output Value +:::info[Example Output Value] ![PublishFunctionArn example output](../../static/img/screenshots/initial-setup-outputs-PublishFunctionArn.png) ::: @@ -283,7 +283,7 @@ This is the Arn of the Lambda function that your backend systems can use to publ This is the URL that your backend systems uses to publish messages if using HTTP with static API keys. -:::info Example Output Value +:::info[Example Output Value] ![PublishHttpApiUrl example output](../../static/img/screenshots/initial-setup-outputs-PublishHttpApiUrl.png) ::: @@ -291,7 +291,7 @@ This is the URL that your backend systems uses to publish messages if using HTTP This is the Amazon Resource Name (Arn) for the EventBridge event bus that is used for messages published via [pub/sub](../server-api/events.mdx). -:::info Example Output Value +:::info[Example Output Value] ![PubSubBusArn example output](../../static/img/screenshots/initial-setup-outputs-PubSubBusArn.png) ::: @@ -299,7 +299,7 @@ This is the Amazon Resource Name (Arn) for the EventBridge event bus that is use This is the Amazon Resource Name (Arn) for the SNS topic used for messages published via [pub/sub](../server-api/events.mdx). -:::info Example Output Value +:::info[Example Output Value] ![PubSubTopicArn example output](../../static/img/screenshots/initial-setup-outputs-PubSubTopicArn.png) ::: @@ -309,7 +309,7 @@ This is a link to your installation's web console where you can debug and test W This URL is public by design. It's a static HTML page that contains no resources that need protection. The `wssUrl` parameter in the URL tells the page where to make WebSocket connections. -:::info Example Output Value +:::info[Example Output Value] ![WebConsoleHttpUrl example output](../../static/img/screenshots/initial-setup-outputs-WebConsoleHttpUrl.png) ::: @@ -317,7 +317,7 @@ This URL is public by design. It's a static HTML page that contains no resources This is the URL that your clients will use when making WebSocket connections. This URL remains functional regardless of custom domain settings. -:::info Example Output Value +:::info[Example Output Value] ![WebSocketsAwsWssUrl example output](../../static/img/screenshots/initial-setup-outputs-WebSocketsAwsWssUrl.png) ::: @@ -327,7 +327,7 @@ If you've enabled a custom domain for your WebSockets endpoint, this is the valu This value is blank if a custom domain is not configured. -:::info Example Output Value +:::info[Example Output Value] ![WebSocketsCustomDomainRegionalDomainName example output](../../static/img/screenshots/initial-setup-outputs-WebSocketsCustomDomainRegionalDomainName.png) ::: @@ -337,7 +337,7 @@ If you've enabled a custom domain for your WebSockets endpoint, this is the AWS This value is blank if a custom domain is not configured. -:::info Example Output Value +:::info[Example Output Value] ![WebSocketsCustomDomainRegionalHostedZoneId example output](../../static/img/screenshots/initial-setup-outputs-WebSocketsCustomDomainRegionalHostedZoneId.png) ::: @@ -347,7 +347,7 @@ If you've enabled a custom domain for your WebSockets endpoint, this is the URL This value is blank if a custom domain is not configured. -:::info Example Output Value +:::info[Example Output Value] ![WebSocketsCustomDomainWssUrl example output](../../static/img/screenshots/initial-setup-outputs-WebSocketsCustomDomainWssUrl.png) ::: From 9795e808938125e45912e42a853a43cd08394a20 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 10:51:30 -0700 Subject: [PATCH 03/17] Clarify storage TTL expiration behavior TTL expirations do not notify WebSocket observers because DynamoDB TTL cleanup can lag up to 48 hours, making it unsuitable for real-time notifications. Recommend scheduled messages or explicit nullification instead. --- docs/channels/storage.mdx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/channels/storage.mdx b/docs/channels/storage.mdx index 69e37bf..6641cb1 100644 --- a/docs/channels/storage.mdx +++ b/docs/channels/storage.mdx @@ -50,12 +50,12 @@ Storage access is controlled per-key in [JWT claims](../connections/claims.mdx#c Five directives control what a connection can do with matching keys: -| Directive | Purpose | -|---|---| -| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | -| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | -| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | -| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | +| Directive | Purpose | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | +| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | +| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | +| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | | [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) | Trigger a backend [pub/sub event](../server-api/events.mdx#hotsock.channelStorageUpdated) when a value changes | :::info @@ -137,7 +137,9 @@ Unlike message echo behavior, `hotsock.channelStorageUpdated` is always delivere By default, storage entries are retained forever. The [`store`](../connections/claims.mdx#channels.storage.store) directive sets a TTL in seconds for entries written by a connection. Server-side writes can also specify a `store` value to control TTL. -When an entry expires, it is removed from DynamoDB. WebSocket observers are **not** notified of TTL expirations — expired entries simply stop appearing in `observe` deliveries on subscribe and in `get` responses. If you need to react to expirations on your backend, enable [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) — the [`hotsock.channelStorageUpdated`](../server-api/events.mdx#hotsock.channelStorageUpdated) pub/sub event includes an `expired` flag that distinguishes TTL deletions from explicit deletes. +When an entry expires, it is removed from storage. WebSocket observers are **not** notified of TTL expirations — expired entries simply stop appearing in `observe` deliveries on subscribe and in `get` responses. This is because DynamoDB TTL cleanup is a background process that can lag up to 48 hours behind the actual expiration time, making it unsuitable as a notification trigger for a real-time service. + +If you need observers to be notified when a value expires, explicitly set the storage entry to `null` at the desired time — either by publishing a [scheduled message](../server-api/publish-messages.mdx#message-format.scheduleExpression) or by invoking a `hotsock.channelStorageSet` from your backend when the expiration should take effect. ## Backend events From 0f6f66e014fc80ca48ce252f9fe4c976dd6b135f Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:21:51 -0700 Subject: [PATCH 04/17] Add scheduleBefore support for channel storage writes Document the new scheduleBefore storage key directive for scheduling storage writes for future delivery via WebSocket, Client HTTP API, and server-side publish APIs. --- docs/channels/storage.mdx | 27 ++++++++++++++++++--------- docs/connections/claims.mdx | 24 +++++++++++++++++++++++- docs/connections/client-http-api.mdx | 8 ++++++++ docs/installation/changelog.mdx | 1 + 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/channels/storage.mdx b/docs/channels/storage.mdx index 6641cb1..fd01e07 100644 --- a/docs/channels/storage.mdx +++ b/docs/channels/storage.mdx @@ -48,15 +48,16 @@ Storage access is controlled per-key in [JWT claims](../connections/claims.mdx#c } ``` -Five directives control what a connection can do with matching keys: +Six directives control what a connection can do with matching keys: -| Directive | Purpose | -| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | -| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | -| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | -| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | -| [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) | Trigger a backend [pub/sub event](../server-api/events.mdx#hotsock.channelStorageUpdated) when a value changes | +| Directive | Purpose | +| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | +| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | +| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | +| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | +| [`scheduleBefore`](../connections/claims.mdx#channels.storage.scheduleBefore) | The furthest future time this connection can schedule storage writes for | +| [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) | Trigger a backend [pub/sub event](../server-api/events.mdx#hotsock.channelStorageUpdated) when a value changes | :::info Storage operations do not require an active channel subscription. Connect token permissions alone are sufficient for `get` and `set`. @@ -87,6 +88,14 @@ Storage entries can be set from your backend using the [Lambda](../server-api/pu } ``` +### Scheduled writes + +Storage writes can be scheduled for future delivery using `scheduleExpression`, just like [scheduled messages](../server-api/publish-messages.mdx#message-format.scheduleExpression). This works on the WebSocket, the [Client HTTP API](../connections/client-http-api.mdx#connection/channelStorageSet.scheduleExpression), and the server-side [Lambda](../server-api/publish-messages.mdx#publish-with-lambda) and [HTTP](../server-api/publish-messages.mdx#publish-with-http-url) publish APIs. Client-initiated scheduled storage writes require the [`scheduleBefore`](../connections/claims.mdx#channels.storage.scheduleBefore) permission on the matching storage key. + +``` +> {"event":"hotsock.channelStorageSet", "channel":"game.lobby", "key":"round-state", "data":"finished", "scheduleExpression":"at(2026-04-08T15:00:00)"} +``` + ## Reading storage entries Clients read a single entry by sending a `hotsock.channelStorageGet` message: @@ -139,7 +148,7 @@ By default, storage entries are retained forever. The [`store`](../connections/c When an entry expires, it is removed from storage. WebSocket observers are **not** notified of TTL expirations — expired entries simply stop appearing in `observe` deliveries on subscribe and in `get` responses. This is because DynamoDB TTL cleanup is a background process that can lag up to 48 hours behind the actual expiration time, making it unsuitable as a notification trigger for a real-time service. -If you need observers to be notified when a value expires, explicitly set the storage entry to `null` at the desired time — either by publishing a [scheduled message](../server-api/publish-messages.mdx#message-format.scheduleExpression) or by invoking a `hotsock.channelStorageSet` from your backend when the expiration should take effect. +If you need observers to be notified when a value expires, explicitly set the storage entry to `null` at the desired time — either by publishing a [scheduled storage write](#scheduled-writes) or by invoking a `hotsock.channelStorageSet` from your backend when the expiration should take effect. ## Backend events diff --git a/docs/connections/claims.mdx b/docs/connections/claims.mdx index a3ca707..d027da6 100644 --- a/docs/connections/claims.mdx +++ b/docs/connections/claims.mdx @@ -458,7 +458,7 @@ The following allows sending ephemeral "is-typing" events and saved "chat" event Storage keys must not be blank, must be no more than 128 characters, and must not contain spaces, asterisks (`*`), number signs (`#`), or commas (`,`). -Each object inside the storage object accepts [`emitPubSubEvent`](#channels.storage.emitPubSubEvent), [`get`](#channels.storage.get), [`observe`](#channels.storage.observe), [`set`](#channels.storage.set), and [`store`](#channels.storage.store) attributes. +Each object inside the storage object accepts [`emitPubSubEvent`](#channels.storage.emitPubSubEvent), [`get`](#channels.storage.get), [`observe`](#channels.storage.observe), [`scheduleBefore`](#channels.storage.scheduleBefore), [`set`](#channels.storage.set), and [`store`](#channels.storage.store) attributes. The following grants observe on the `settings` storage key storage key with the `settings.` prefix and `observe`, `get`, and `set` access to a key named `status` on the "mychannel" channel. @@ -571,6 +571,28 @@ When a storage value is set or updated by any client, observers receive: Unlike message echo behavior, `hotsock.channelStorageUpdated` is always delivered back to the connection that set the value (when that connection observes the key). Storage updates are state notifications, not message echoes. ::: +#### `scheduleBefore` {#channels.storage.scheduleBefore} + +`NumericDate` (optional) - If [`set`](#channels.storage.set) is permitted for matching storage keys, the `scheduleBefore` attribute specifies the furthest out future time that this connection can schedule storage writes for. If supplied, the schedule before time must be expressed as a Unix timestamp — the number of seconds since the Unix epoch. By default, or if `scheduleBefore` is provided as `0` or any timestamp in the past, scheduled storage writes are not permitted. + +The following allows setting `session.*` storage keys on "mychannel" with scheduling permitted up to `2026-12-31 23:59:59 UTC` (which is `1798761599` as a Unix timestamp). + +```json +{ + "channels": { + "mychannel": { + "storage": { + "session.*": { + "set": true, + // highlight-next-line + "scheduleBefore": 1798761599 + } + } + } + } +} +``` + #### `set` {#channels.storage.set} `Boolean` (optional) - Controls whether this connection can write values to matching storage keys on the channel using a `hotsock.channelStorageSet` message. Writes that don't change the value skip subscriber fan-out entirely. Setting `data` to `null` or `{}` clears the storage entry. An active channel subscription is not required — connect token permissions alone are sufficient. Default is `false`. diff --git a/docs/connections/client-http-api.mdx b/docs/connections/client-http-api.mdx index f255efe..d6ed24d 100644 --- a/docs/connections/client-http-api.mdx +++ b/docs/connections/client-http-api.mdx @@ -328,6 +328,14 @@ Write a channel storage entry value. An active channel subscription is not requi `JSON` (optional) - The value to store. Setting `data` to `null` or `{}` clears the storage entry. +#### `scheduleExpression` {#connection/channelStorageSet.scheduleExpression} + +`String` (optional) - Schedule the storage write for future delivery using [EventBridge Scheduler one-time `at()` syntax](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#one-time). The connection must have the [`scheduleBefore`](./claims.mdx#channels.storage.scheduleBefore) permission for the matching storage key. If specified, the storage entry will be written at the scheduled time rather than immediately. + +#### `scheduleExpressionTimezone` {#connection/channelStorageSet.scheduleExpressionTimezone} + +`String` (optional) - The timezone for the `scheduleExpression`. Must be a valid [IANA timezone identifier](https://www.iana.org/time-zones). If not specified, the schedule expression is interpreted as UTC. Has no effect if `scheduleExpression` is not provided. + ### Example {#connection/channelStorageSet--example} #### Request {#connection/channelStorageSet--example-request} diff --git a/docs/installation/changelog.mdx b/docs/installation/changelog.mdx index ef97299..da10b46 100644 --- a/docs/installation/changelog.mdx +++ b/docs/installation/changelog.mdx @@ -4,6 +4,7 @@ - Add [channel storage](../connections/claims.mdx#channels.storage), a per-key persistent key-value store on channels. Storage permissions are configured per-key with wildcard and regex pattern support. Entries have independent TTLs, are delivered to [`observe`](../connections/claims.mdx#channels.storage.observe) subscribers on join, and skip fan-out when the value hasn't changed. Clients interact with storage via [`hotsock.channelStorageSet`](../connections/claims.mdx#channels.storage.set) / [`hotsock.channelStorageGet`](../connections/claims.mdx#channels.storage.get) on the WebSocket or the [`connection/channelStorageGet`](../connections/client-http-api.mdx#connection/channelStorageGet), [`connection/channelStorageSet`](../connections/client-http-api.mdx#connection/channelStorageSet), and [`connection/channelStorageList`](../connections/client-http-api.mdx#connection/channelStorageList) Client HTTP API endpoints. Storage operations do not require an active channel subscription. Server-side writes are available via the Lambda and HTTP publish APIs using `"event": "hotsock.channelStorageSet"` with a `key` field. - Add [`hotsock.channelStorageUpdated`](../server-api/events.mdx#hotsock.channelStorageUpdated) pub/sub event to SNS/EventBridge when storage entry values change, gated by the [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) storage key directive for client writes or the `emitPubSubEvent` field for server writes. +- Add [`scheduleBefore`](../connections/claims.mdx#channels.storage.scheduleBefore) storage key directive for scheduling storage writes for future delivery using `scheduleExpression`, supported on both WebSocket and Client HTTP API paths. - Add admin [`GET /channels/{name}`](../server-api/channels.mdx#get-channel) and [`GET /channels/{name}/storage`](../server-api/channels.mdx#get-channel-storage) API endpoints for retrieving channel info and listing channel storage entries. - Add [`connection/getChannelInfo`](../connections/client-http-api.mdx#connection/getChannelInfo) Client HTTP API endpoint to retrieve channel info (subscription count and, for presence channels, the member list) without requiring an active subscription. Controlled by the new [`getInfo`](../connections/claims.mdx#channels.getInfo) channel directive. - Add [`hotsock.umdUpdate`](../connections/claims.mdx#channels.umdUpdate) client command to update user metadata (`umd`) on a per-channel subscription without needing to unsubscribe and resubscribe. Requires a [`umd`-scoped](../connections/claims.mdx#scope) token with the [`umdUpdate`](../connections/claims.mdx#channels.umdUpdate) channel directive. Also available via the Client HTTP API at [`connection/umdUpdate`](../connections/client-http-api.mdx#connection/umdUpdate). From 2485ad278c024c2de286e2d941dba4afd0c58b49 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:22:55 -0700 Subject: [PATCH 05/17] Add scheduled storage writes to v1.12 blog post --- blog/2026-04-02-new-in-v1.12.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index 25380db..21792e2 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -75,6 +75,10 @@ Storage entries also track the `uid` and `umd` of the last writer in `meta`, so When a connection subscribes to a channel, all storage entries matching their `observe` patterns are delivered as `hotsock.channelStorageUpdated` messages immediately after the `hotsock.subscribed` message. This means a new subscriber gets the current state without any additional requests — they're caught up the moment they join. +#### Scheduled storage writes + +Storage writes support [scheduling for future delivery](/docs/channels/storage/#scheduled-writes) using `scheduleExpression`, just like scheduled messages. This is useful for things like expiring a game round, resetting a status after a timeout, or setting up state transitions at known times. Client-initiated scheduled writes require the [`scheduleBefore`](/docs/connections/claims/#channels.storage.scheduleBefore) permission on the matching storage key. + #### Server-side storage writes Storage entries can also be set from the server side using the existing Lambda or HTTP publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a [`key`](/docs/server-api/publish-messages/#message-format.key) field. Server-side writes bypass client permission checks, so they're always authorized. This is handy for initializing channel state from your backend before any clients connect. From c84713e61d11043cfa156e19c0cede23a07a5c86 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:23:32 -0700 Subject: [PATCH 06/17] Add link to storage docs from v1.12 blog post --- blog/2026-04-02-new-in-v1.12.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index 21792e2..8589e59 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -83,6 +83,8 @@ Storage writes support [scheduling for future delivery](/docs/channels/storage/# Storage entries can also be set from the server side using the existing Lambda or HTTP publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a [`key`](/docs/server-api/publish-messages/#message-format.key) field. Server-side writes bypass client permission checks, so they're always authorized. This is handy for initializing channel state from your backend before any clients connect. +For a full walkthrough of permissions, TTL behavior, and all the ways to interact with storage, see the [channel storage documentation](/docs/channels/storage/). + ### Live user metadata updates Previously, the only way to change a connection's `umd` was to disconnect and reconnect with a new token, or unsubscribe and resubscribe with a new subscribe token. Now clients can update their `umd` in place using `hotsock.umdUpdate`. From 596eb7e39b4c4cff778161ffce677250a2177bc9 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:25:10 -0700 Subject: [PATCH 07/17] Improve cross-linking in v1.12 blog post --- blog/2026-04-02-new-in-v1.12.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index 8589e59..860eb46 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -48,11 +48,11 @@ Storage permissions are configured per-key using the new [`storage`](/docs/conne Each storage key directive supports these options: -- **`observe`** — receive `hotsock.channelStorageUpdated` messages in real-time when the value changes, and get the current value automatically on subscribe -- **`get`** — fetch the current value on demand with `hotsock.channelStorageGet` -- **`set`** — write values with `hotsock.channelStorageSet` -- **`store`** — TTL in seconds for entries written by this connection (defaults to `-1`, forever) -- **`emitPubSubEvent`** — trigger a [`hotsock.channelStorageUpdated`](/docs/server-api/events/#hotsock.channelStorageUpdated) pub/sub event to SNS/EventBridge when a value changes +- [**`observe`**](/docs/connections/claims/#channels.storage.observe) — receive `hotsock.channelStorageUpdated` messages in real-time when the value changes, and get current values automatically on subscribe +- [**`get`**](/docs/connections/claims/#channels.storage.get) — fetch the current value on demand with `hotsock.channelStorageGet` +- [**`set`**](/docs/connections/claims/#channels.storage.set) — write values with `hotsock.channelStorageSet` +- [**`store`**](/docs/connections/claims/#channels.storage.store) — TTL in seconds for entries written by this connection (defaults to `-1`, forever) +- [**`emitPubSubEvent`**](/docs/connections/claims/#channels.storage.emitPubSubEvent) — trigger a [`hotsock.channelStorageUpdated`](/docs/server-api/events/#hotsock.channelStorageUpdated) pub/sub event to SNS/EventBridge when a value changes #### Writing and reading storage @@ -69,7 +69,7 @@ Clients interact with storage using `hotsock.channelStorageSet` and `hotsock.cha Writes that don't change the value are detected automatically and skip subscriber fan-out entirely, so you don't need to worry about deduplication on the client side. Setting `data` to `null` or `{}` clears the entry. -Storage entries also track the `uid` and `umd` of the last writer in `meta`, so observers always know who last set a value. +Storage entries also track the [`uid`](/docs/connections/claims/#uid) and [`umd`](/docs/connections/claims/#umd) of the last writer in `meta`, so observers always know who last set a value. #### Real-time sync on subscribe @@ -81,13 +81,13 @@ Storage writes support [scheduling for future delivery](/docs/channels/storage/# #### Server-side storage writes -Storage entries can also be set from the server side using the existing Lambda or HTTP publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a [`key`](/docs/server-api/publish-messages/#message-format.key) field. Server-side writes bypass client permission checks, so they're always authorized. This is handy for initializing channel state from your backend before any clients connect. +Storage entries can also be set from the server side using the existing [Lambda](/docs/server-api/publish-messages/#publish-with-lambda) or [HTTP](/docs/server-api/publish-messages/#publish-with-http-url) publish APIs by specifying `"event": "hotsock.channelStorageSet"` with a [`key`](/docs/server-api/publish-messages/#message-format.key) field. Server-side writes bypass client permission checks, so they're always authorized. This is handy for initializing channel state from your backend before any clients connect. For a full walkthrough of permissions, TTL behavior, and all the ways to interact with storage, see the [channel storage documentation](/docs/channels/storage/). ### Live user metadata updates -Previously, the only way to change a connection's `umd` was to disconnect and reconnect with a new token, or unsubscribe and resubscribe with a new subscribe token. Now clients can update their `umd` in place using `hotsock.umdUpdate`. +Previously, the only way to change a connection's [`umd`](/docs/connections/claims/#umd) was to disconnect and reconnect with a new token, or unsubscribe and resubscribe with a new subscribe token. Now clients can update their `umd` in place using `hotsock.umdUpdate`. This works at two levels: @@ -117,7 +117,7 @@ Then send the signed token on the WebSocket: > {"event":"hotsock.umdUpdate", "channel":"presence.chat", "data":{"token":"eyJ..."}} ``` -On presence channels, this triggers a new [`hotsock.memberUpdated`](/docs/channels/presence/#member-updated) event delivered to all members (including the initiator), so everyone sees the change immediately: +On [presence channels](/docs/channels/presence/), this triggers a new [`hotsock.memberUpdated`](/docs/channels/presence/#member-updated) event delivered to all members (including the initiator), so everyone sees the change immediately: ``` < {"event":"hotsock.memberUpdated","channel":"presence.chat","data":{"member":{"uid":"Dwight","umd":{"status":"away"}},"members":[{"uid":"Jim","umd":null},{"uid":"Dwight","umd":{"status":"away"}}]}} @@ -127,7 +127,7 @@ This is great for status indicators, typing states, or any per-user metadata tha #### Connection-level updates -Update the connection's default `umd` by sending `hotsock.umdUpdate` without a channel. This requires the top-level [`umdUpdate`](/docs/connections/claims/#umdUpdate) claim. When [`umdPropagate`](/docs/connections/claims/#umdPropagate) is also enabled, the updated metadata is propagated to all existing subscriptions on the connection, triggering `hotsock.memberUpdated` on any presence channels. +Update the connection's default `umd` by sending `hotsock.umdUpdate` without a channel. This requires the top-level [`umdUpdate`](/docs/connections/claims/#umdUpdate) claim. When [`umdPropagate`](/docs/connections/claims/#umdPropagate) is also enabled, the updated metadata is propagated to all existing subscriptions on the connection, triggering [`hotsock.memberUpdated`](/docs/channels/presence/#member-updated) on any presence channels. ```json { From 238577f98194b1c2d45e6b5e321bbf582abacf1a Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:26:03 -0700 Subject: [PATCH 08/17] Clarify umd abbreviation in blog post intro --- blog/2026-04-02-new-in-v1.12.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index 860eb46..6fef250 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -6,7 +6,7 @@ tags: [releases, features, channels, presence] description: Hotsock v1.12 adds per-key persistent channel storage with real-time sync, live user metadata updates without reconnecting, and new presence events for metadata changes. --- -Hotsock v1.12 introduces **channel storage** for persistent per-key state on channels and **live user metadata updates** so clients can change their `umd` without reconnecting. Both features are designed to reduce the amount of state your backend needs to manage and push more real-time coordination into Hotsock itself. +Hotsock v1.12 introduces **channel storage** for persistent per-key state on channels and **live user metadata updates** so clients can change their user metadata (`umd`) without reconnecting. Both features are designed to reduce the amount of state your backend needs to manage and push more real-time coordination into Hotsock itself. {/* truncate */} From 47355f77dd99c637b46066d2b19f05461caa9d51 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:26:27 -0700 Subject: [PATCH 09/17] Link umd to claims doc in blog post intro --- blog/2026-04-02-new-in-v1.12.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index 6fef250..f9803b0 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -6,7 +6,7 @@ tags: [releases, features, channels, presence] description: Hotsock v1.12 adds per-key persistent channel storage with real-time sync, live user metadata updates without reconnecting, and new presence events for metadata changes. --- -Hotsock v1.12 introduces **channel storage** for persistent per-key state on channels and **live user metadata updates** so clients can change their user metadata (`umd`) without reconnecting. Both features are designed to reduce the amount of state your backend needs to manage and push more real-time coordination into Hotsock itself. +Hotsock v1.12 introduces **channel storage** for persistent per-key state on channels and **live user metadata updates** so clients can change their user metadata ([`umd`](/docs/connections/claims/#umd)) without reconnecting. Both features are designed to reduce the amount of state your backend needs to manage and push more real-time coordination into Hotsock itself. {/* truncate */} From 455cec960c66b815ecf4af4eec3cbca75812f537 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:27:22 -0700 Subject: [PATCH 10/17] Simplify store description in blog post --- blog/2026-04-02-new-in-v1.12.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index f9803b0..7cf9a00 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -51,7 +51,7 @@ Each storage key directive supports these options: - [**`observe`**](/docs/connections/claims/#channels.storage.observe) — receive `hotsock.channelStorageUpdated` messages in real-time when the value changes, and get current values automatically on subscribe - [**`get`**](/docs/connections/claims/#channels.storage.get) — fetch the current value on demand with `hotsock.channelStorageGet` - [**`set`**](/docs/connections/claims/#channels.storage.set) — write values with `hotsock.channelStorageSet` -- [**`store`**](/docs/connections/claims/#channels.storage.store) — TTL in seconds for entries written by this connection (defaults to `-1`, forever) +- [**`store`**](/docs/connections/claims/#channels.storage.store) — TTL in seconds for entries written by this connection - [**`emitPubSubEvent`**](/docs/connections/claims/#channels.storage.emitPubSubEvent) — trigger a [`hotsock.channelStorageUpdated`](/docs/server-api/events/#hotsock.channelStorageUpdated) pub/sub event to SNS/EventBridge when a value changes #### Writing and reading storage From 949926afe37fa67ef34d754230421e0485e45a97 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:29:17 -0700 Subject: [PATCH 11/17] Clarify storage meta is for client-initiated writes --- blog/2026-04-02-new-in-v1.12.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index 7cf9a00..c7db021 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -69,7 +69,7 @@ Clients interact with storage using `hotsock.channelStorageSet` and `hotsock.cha Writes that don't change the value are detected automatically and skip subscriber fan-out entirely, so you don't need to worry about deduplication on the client side. Setting `data` to `null` or `{}` clears the entry. -Storage entries also track the [`uid`](/docs/connections/claims/#uid) and [`umd`](/docs/connections/claims/#umd) of the last writer in `meta`, so observers always know who last set a value. +For client-initiated writes, storage entries track the [`uid`](/docs/connections/claims/#uid) and [`umd`](/docs/connections/claims/#umd) of the last writer in `meta`, so observers know who set a value. #### Real-time sync on subscribe From bdd09a4af3b7fb27735ba54cab8d6628aa49aba4 Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:32:23 -0700 Subject: [PATCH 12/17] Simplify connectionUpdated description in blog post --- blog/2026-04-02-new-in-v1.12.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/2026-04-02-new-in-v1.12.mdx b/blog/2026-04-02-new-in-v1.12.mdx index c7db021..4ed7f6e 100644 --- a/blog/2026-04-02-new-in-v1.12.mdx +++ b/blog/2026-04-02-new-in-v1.12.mdx @@ -153,7 +153,7 @@ Both paths are also available via the Client HTTP API at [`connection/umdUpdate` #### New pub/sub event -A new [`hotsock.connectionUpdated`](/docs/server-api/events/#hotsock.connectionUpdated) pub/sub event is emitted to SNS/EventBridge whenever a connection's user metadata changes. This event is only sent when `UserMetadata` actually changes, not on heartbeat-only updates, so you can use it to track user state changes on your backend without noise. +A new [`hotsock.connectionUpdated`](/docs/server-api/events/#hotsock.connectionUpdated) pub/sub event is emitted to SNS/EventBridge when a connection's user metadata actually changes, so you can track user state changes on your backend. ### Wrapping up From 560b4b18aed5935a9621a8930900f9b167eaa1eb Mon Sep 17 00:00:00 2001 From: James Miller Date: Wed, 8 Apr 2026 12:33:03 -0700 Subject: [PATCH 13/17] Run prettier across project --- babel.config.js | 4 +- blog/2024-10-14-new-in-v1.3.mdx | 4 +- docs/channels/storage.mdx | 16 +- docs/installation/custom-domains.mdx | 1 - examples/collaborative-todo/eslint.config.js | 32 +- examples/collaborative-todo/src/App.jsx | 30 +- examples/live-dashboard/eslint.config.js | 32 +- examples/live-dashboard/src/App.jsx | 124 +- examples/notification-feed/eslint.config.js | 32 +- examples/notification-feed/src/App.jsx | 8 +- examples/real-time-chat/eslint.config.js | 32 +- examples/real-time-chat/src/App.jsx | 34 +- src/components/ExampleIframe.js | 2 +- src/components/HomepageBanner.js | 4 +- src/components/landing/CodeBlock.mdx | 2 +- src/components/landing/Features.js | 2 +- src/components/landing/FeaturesNew.js | 2 +- src/components/landing/Hero.js | 4 +- src/components/landing/Pricing.js | 6 +- src/components/landing/TabsSection.js | 14 +- src/css/custom.css | 48 +- src/examples/collaborative-todo.mdx | 14 +- src/examples/live-dashboard.mdx | 16 +- src/examples/notification-feed.mdx | 1 + src/examples/overview.mdx | 12 +- src/examples/real-time-chat.mdx | 8 +- src/lib/utils.js | 8 +- src/pages/legal/terms.mdx | 1 - .../real-time-chat/assets/index-BTCuFWzj.js | 12832 +++++++++++++++- .../real-time-chat/assets/index-DzAAk6Qu.css | 543 +- tailwind.config.js | 5 +- 31 files changed, 13643 insertions(+), 230 deletions(-) diff --git a/babel.config.js b/babel.config.js index e00595d..cf4260b 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,3 @@ module.exports = { - presets: [require.resolve('@docusaurus/core/lib/babel/preset')], -}; + presets: [require.resolve("@docusaurus/core/lib/babel/preset")], +} diff --git a/blog/2024-10-14-new-in-v1.3.mdx b/blog/2024-10-14-new-in-v1.3.mdx index 6754839..a73776b 100644 --- a/blog/2024-10-14-new-in-v1.3.mdx +++ b/blog/2024-10-14-new-in-v1.3.mdx @@ -64,7 +64,7 @@ fetch( { method: "POST", body: JSON.stringify({ channel: "my-channel" }), - } + }, ) ``` @@ -133,7 +133,7 @@ fetch( { method: "POST", body: JSON.stringify({ channel: "my-channel", event: "my-event" }), - } + }, ) ``` diff --git a/docs/channels/storage.mdx b/docs/channels/storage.mdx index fd01e07..b1c49cb 100644 --- a/docs/channels/storage.mdx +++ b/docs/channels/storage.mdx @@ -50,14 +50,14 @@ Storage access is controlled per-key in [JWT claims](../connections/claims.mdx#c Six directives control what a connection can do with matching keys: -| Directive | Purpose | -| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | -| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | -| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | -| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | -| [`scheduleBefore`](../connections/claims.mdx#channels.storage.scheduleBefore) | The furthest future time this connection can schedule storage writes for | -| [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) | Trigger a backend [pub/sub event](../server-api/events.mdx#hotsock.channelStorageUpdated) when a value changes | +| Directive | Purpose | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| [`observe`](../connections/claims.mdx#channels.storage.observe) | Receive real-time updates when the value changes, and get current values on subscribe | +| [`get`](../connections/claims.mdx#channels.storage.get) | Read the current value on demand | +| [`set`](../connections/claims.mdx#channels.storage.set) | Write values | +| [`store`](../connections/claims.mdx#channels.storage.store) | TTL in seconds for entries written by this connection (defaults to forever) | +| [`scheduleBefore`](../connections/claims.mdx#channels.storage.scheduleBefore) | The furthest future time this connection can schedule storage writes for | +| [`emitPubSubEvent`](../connections/claims.mdx#channels.storage.emitPubSubEvent) | Trigger a backend [pub/sub event](../server-api/events.mdx#hotsock.channelStorageUpdated) when a value changes | :::info Storage operations do not require an active channel subscription. Connect token permissions alone are sufficient for `get` and `set`. diff --git a/docs/installation/custom-domains.mdx b/docs/installation/custom-domains.mdx index 32376c8..37a08fb 100644 --- a/docs/installation/custom-domains.mdx +++ b/docs/installation/custom-domains.mdx @@ -72,7 +72,6 @@ After updating your Hotsock installation, you need to configure DNS to use your 1. Log into the DNS management console for your domain. 2. Navigate to the DNS settings or DNS management page for your domain. 3. Create a new CNAME record with the following settings: - - **Name**: Enter the subdomain you specified earlier, e.g., `real-time`. - **Type**: Choose `CNAME`. - **Value**: Enter the WebSocket API regional domain name (e.g., `d-zv7jqttrkl.execute-api.us-east-1.amazonaws.com`). diff --git a/examples/collaborative-todo/eslint.config.js b/examples/collaborative-todo/eslint.config.js index 964a299..e668663 100644 --- a/examples/collaborative-todo/eslint.config.js +++ b/examples/collaborative-todo/eslint.config.js @@ -1,36 +1,36 @@ -import js from '@eslint/js' -import globals from 'globals' -import react from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import js from "@eslint/js" +import globals from "globals" +import react from "eslint-plugin-react" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" export default [ { - files: ['**/*.{js,jsx}'], - ignores: ['dist'], + files: ["**/*.{js,jsx}"], + ignores: ["dist"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parserOptions: { - ecmaVersion: 'latest', + ecmaVersion: "latest", ecmaFeatures: { jsx: true }, - sourceType: 'module', + sourceType: "module", }, }, - settings: { react: { version: '18.3' } }, + settings: { react: { version: "18.3" } }, plugins: { react, - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...js.configs.recommended.rules, ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, + ...react.configs["jsx-runtime"].rules, ...reactHooks.configs.recommended.rules, - 'react/jsx-no-target-blank': 'off', - 'react-refresh/only-export-components': [ - 'warn', + "react/jsx-no-target-blank": "off", + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], }, diff --git a/examples/collaborative-todo/src/App.jsx b/examples/collaborative-todo/src/App.jsx index 15282e8..7b06778 100644 --- a/examples/collaborative-todo/src/App.jsx +++ b/examples/collaborative-todo/src/App.jsx @@ -43,8 +43,10 @@ const connectTokenFn = async () => { headers: { "content-type": "application/json", }, - body: JSON.stringify(initialSessionId ? { sessionId: initialSessionId } : {}), - } + body: JSON.stringify( + initialSessionId ? { sessionId: initialSessionId } : {}, + ), + }, ) .then((resp) => resp.json()) .then((data) => { @@ -83,14 +85,14 @@ function App() { new HotsockClient(wssUrl, { connectTokenFn: aliceConnectTokenFn, logLevel: "debug", - }) + }), ) const [bobClient] = useState( () => new HotsockClient(wssUrl, { connectTokenFn: bobConnectTokenFn, logLevel: "debug", - }) + }), ) useEffect(() => { @@ -134,7 +136,9 @@ function App() { > {copied ? "Copied!" : "Copy link"} - + + • +
- - + +
) @@ -207,7 +205,7 @@ function TodoPanel({ hotsockClient, channelName }) { { method: "POST", body: JSON.stringify(body), - } + }, ) const { messages } = await resp.json() if (messages && messages.length > 0) { @@ -247,7 +245,7 @@ function TodoPanel({ hotsockClient, channelName }) { } setLoading(false) }, - [channelName] + [channelName], ) useEffect(() => { @@ -267,7 +265,7 @@ function TodoPanel({ hotsockClient, channelName }) { if (connectionInfo.current) { loadHistory( connectionInfo.current.connectionId, - connectionInfo.current.connectionSecret + connectionInfo.current.connectionSecret, ) } else { setLoading(false) diff --git a/examples/live-dashboard/eslint.config.js b/examples/live-dashboard/eslint.config.js index 964a299..e668663 100644 --- a/examples/live-dashboard/eslint.config.js +++ b/examples/live-dashboard/eslint.config.js @@ -1,36 +1,36 @@ -import js from '@eslint/js' -import globals from 'globals' -import react from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import js from "@eslint/js" +import globals from "globals" +import react from "eslint-plugin-react" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" export default [ { - files: ['**/*.{js,jsx}'], - ignores: ['dist'], + files: ["**/*.{js,jsx}"], + ignores: ["dist"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parserOptions: { - ecmaVersion: 'latest', + ecmaVersion: "latest", ecmaFeatures: { jsx: true }, - sourceType: 'module', + sourceType: "module", }, }, - settings: { react: { version: '18.3' } }, + settings: { react: { version: "18.3" } }, plugins: { react, - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...js.configs.recommended.rules, ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, + ...react.configs["jsx-runtime"].rules, ...reactHooks.configs.recommended.rules, - 'react/jsx-no-target-blank': 'off', - 'react-refresh/only-export-components': [ - 'warn', + "react/jsx-no-target-blank": "off", + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], }, diff --git a/examples/live-dashboard/src/App.jsx b/examples/live-dashboard/src/App.jsx index 752277e..fb72d68 100644 --- a/examples/live-dashboard/src/App.jsx +++ b/examples/live-dashboard/src/App.jsx @@ -21,7 +21,7 @@ const connectTokenFn = async () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), - } + }, ) .then((resp) => resp.json()) .then((data) => { @@ -45,55 +45,97 @@ const tokenFn = async () => { // Icons as simple SVG components function PersonIcon() { return ( - - + + ) } function ActivityIcon() { return ( - - + + ) } function ErrorIcon() { return ( - - + + ) } function LatencyIcon() { return ( - - + + ) } function TrendArrow({ current, previous }) { if (previous === null || previous === undefined || current === previous) { - return - } - if (current > previous) { return ( - + + – + ) } - return ( - - ) + if (current > previous) { + return + } + return } function StatCard({ label, value, icon, color, previous }) { return (
- {label} + + {label} + {icon}
@@ -103,7 +145,10 @@ function StatCard({ label, value, icon, color, previous }) { > {value} - +
) @@ -136,7 +181,8 @@ function BarChart({ data }) { function AlertFeed({ alerts }) { const levelStyles = { info: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300", - warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + warning: + "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", error: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", } @@ -147,15 +193,23 @@ function AlertFeed({ alerts }) {
{alerts.length === 0 && ( -
No alerts yet
+
+ No alerts yet +
)} {alerts.map((alert, i) => (
- + {alert.level} - {alert.message} - {alert.time} + + {alert.message} + + + {alert.time} +
))}
@@ -215,15 +269,29 @@ function App() { channel.bind("chart-data", (message) => { setChartData((prev) => { - const next = [...prev, { timestamp: message.data.timestamp, requests: message.data.requests, errors: message.data.errors }] + const next = [ + ...prev, + { + timestamp: message.data.timestamp, + requests: message.data.requests, + errors: message.data.errors, + }, + ] return next.slice(-20) }) }) channel.bind("alert", (message) => { - const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + const time = new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) setAlerts((prev) => { - const next = [...prev, { level: message.data.level, message: message.data.message, time }] + const next = [ + ...prev, + { level: message.data.level, message: message.data.message, time }, + ] return next.slice(-5) }) }) @@ -284,7 +352,9 @@ function App() { value={`${(stats.errorRate * 100).toFixed(1)}%`} icon={} color={errorRateColor} - previous={prevStats.errorRate !== null ? prevStats.errorRate * 100 : null} + previous={ + prevStats.errorRate !== null ? prevStats.errorRate * 100 : null + } /> { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), - } + }, ) .then((resp) => resp.json()) .then((data) => { @@ -218,7 +218,7 @@ function App() { const markOneRead = useCallback((id) => { setNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, read: true } : n)) + prev.map((n) => (n.id === id ? { ...n, read: true } : n)), ) }, []) @@ -286,9 +286,7 @@ function App() { key={n.id} onClick={() => markOneRead(n.id)} className={`w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors border-b border-gray-50 dark:border-slate-700/50 ${ - !n.read - ? "bg-blue-50/50 dark:bg-blue-900/10" - : "" + !n.read ? "bg-blue-50/50 dark:bg-blue-900/10" : "" }`} > diff --git a/examples/real-time-chat/eslint.config.js b/examples/real-time-chat/eslint.config.js index 964a299..e668663 100644 --- a/examples/real-time-chat/eslint.config.js +++ b/examples/real-time-chat/eslint.config.js @@ -1,36 +1,36 @@ -import js from '@eslint/js' -import globals from 'globals' -import react from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import js from "@eslint/js" +import globals from "globals" +import react from "eslint-plugin-react" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" export default [ { - files: ['**/*.{js,jsx}'], - ignores: ['dist'], + files: ["**/*.{js,jsx}"], + ignores: ["dist"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parserOptions: { - ecmaVersion: 'latest', + ecmaVersion: "latest", ecmaFeatures: { jsx: true }, - sourceType: 'module', + sourceType: "module", }, }, - settings: { react: { version: '18.3' } }, + settings: { react: { version: "18.3" } }, plugins: { react, - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...js.configs.recommended.rules, ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, + ...react.configs["jsx-runtime"].rules, ...reactHooks.configs.recommended.rules, - 'react/jsx-no-target-blank': 'off', - 'react-refresh/only-export-components': [ - 'warn', + "react/jsx-no-target-blank": "off", + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], }, diff --git a/examples/real-time-chat/src/App.jsx b/examples/real-time-chat/src/App.jsx index ee1064b..97398b4 100644 --- a/examples/real-time-chat/src/App.jsx +++ b/examples/real-time-chat/src/App.jsx @@ -44,9 +44,9 @@ const connectTokenFn = async () => { "content-type": "application/json", }, body: JSON.stringify( - initialSessionId ? { sessionId: initialSessionId } : {} + initialSessionId ? { sessionId: initialSessionId } : {}, ), - } + }, ) .then((resp) => resp.json()) .then((data) => { @@ -91,14 +91,14 @@ function App() { new HotsockClient(wssUrl, { connectTokenFn: jimConnectTokenFn, logLevel: "debug", - }) + }), ) const [pamClient] = useState( () => new HotsockClient(wssUrl, { connectTokenFn: pamConnectTokenFn, logLevel: "debug", - }) + }), ) useEffect(() => { @@ -142,7 +142,9 @@ function App() { > {copied ? "Copied!" : "Copy link"} - + + • +