Compare commits

..
12 Commits
Author SHA1 Message Date
Markk116 c84d3b4bcf add license 2026-05-26 23:31:41 +02:00
Markk116 4d05eebe00 doc: create readme 2026-05-26 23:29:29 +02:00
Markk116andGitHub 9bff0c6fdf Merge pull request #4 from Markk116/claude/refactor-connection-tasks-fF25i
Add streaming brotli compression and hot-reloadable config (livevue.toml)
2026-03-14 12:10:31 +01:00
Claude 1035fd179f Refactor fanout to per-connection tasks with debouncing and reconnection grace
- Add RenderTrigger enum to connection.rs (avoids circular imports)
- Replace ConnectionState.sse_sender with trigger_tx + pipe_tx channels
- Add get_trigger_sender and get_pipe_sender to ConnectionManager
- Extract render_and_send helper to eliminate duplicated render/SSE logic
- Implement connection_task: long-lived per-connection coroutine that owns
  the SSE pipe, debounces render triggers (2ms), and survives disconnects for
  a 30s grace period to allow clean reconnection
- Update sse_handler: spawns connection_task on fresh connect, hands new SSE
  pipe to existing task on reconnect; removes cleanup-on-disconnect spawn
- Update spawn_fanout: now a pure router — sends RenderTrigger::Invalidated
  to each subscribed connection's task; no render logic remains in fanout
- Bump broadcast channel capacity from 256 to 16384 in Store::new
- Merge brotli compression branch (config hot-reload, BrotliBody middleware)

https://claude.ai/code/session_01QiTaFtwXETxJDk79XcgfrN
2026-03-14 11:09:49 +00:00
Claude 6d91fd9c36 Add streaming brotli compression and hot-reloadable config (livevue.toml)
Key changes:

- **src/config.rs** – `LiveVueConfig` (server host/port, brotli quality +
  window size), loaded from `livevue.toml`. A notify-based file watcher
  reloads the config on any file change; a SIGHUP handler does the same on
  demand. Falls back to compiled-in defaults when the file is absent.

- **src/brotli_layer.rs** – `BrotliBody`, a custom `http_body::Body` wrapper
  that streams brotli-compressed data through a single persistent
  `brotli::CompressorWriter`. The encoder survives across SSE event flushes,
  so its sliding window accumulates context from all prior events —
  progressively better compression as the stream grows. Both quality (0–11)
  and window size (lgwin 10–24, i.e. 1 KB – 16 MB) are taken from config.
  `brotli_compression` is an axum `from_fn_with_state` middleware that
  activates only when the client sends `Accept-Encoding: br`.

- **src/server.rs** – `AppState` gains a `config: SharedConfig` field.
  `AppState::new` takes the config; `AppState::new_default` is a zero-config
  convenience constructor.

- **livevue.toml** – documented example config with inline comments
  explaining each field and the brotli window-size tradeoff table.

- **examples/todo** – loads config via `load_and_watch_config`, derives the
  bind address from `config.server`, and applies `brotli_compression` as a
  router layer.

https://claude.ai/code/session_01UnQQkkwts64FPUsSzFfdQb
2026-03-12 07:06:10 +00:00
Markk116andGitHub a09ac32bef Merge pull request #3 from Markk116/claude/fix-todo-query-lOZlM
Add mutation_exec! macro and PgQuery trait for database operations
2026-03-12 07:46:23 +01:00
Claude 9dcb0d6dc8 Refactor Query contract: uniform ergonomics for SQLite + PostgreSQL
- Add `publish_key()` default method to `Query` trait (returns `None`)
- Add `mutation_exec!` macro for SQLite INSERT/UPDATE/DELETE ops; overrides
  `publish_key()` so the mutation carries its own invalidation key
- Add `Store::exec()` which runs a mutation and auto-publishes its key —
  action handlers no longer need a separate `store.publish()` call
- Add `PgQuery` trait + `pg_query_one!` / `pg_query_all!` macros
  (feature-gated on `pg_replication`) mirroring the SQLite Query API
- Add `cx.run_pg(pool, query)` to `RenderContext` which executes a PgQuery
  and auto-subscribes its cache key — no more manual `cx.subscribe()` calls
- Update todo example: replace plain mutation fns with `mutation_exec!` structs
  and `store.exec()` calls; remove `action_then_publish` helper
- Update pg_replication example: add `ListMessages` via `pg_query_all!` and
  replace manual subscribe + raw query with `cx.run_pg()`

https://claude.ai/code/session_01HXe8rAZPweU9j2piBo9MdG
2026-03-11 22:37:33 +00:00
Markk116 23fab7a5ff make overview of pending todo's 2026-03-10 23:05:49 +01:00
Markk116 ae9064ee76 fix up pg replication example 2026-03-10 22:47:17 +01:00
Markk116andGitHub a1e2effe96 Merge pull request #2 from Markk116/claude/pg-replication-module-ATq5z
feat: add pg_replication module for reactive WAL-based cache invalidation
2026-03-09 12:08:20 +01:00
Claude b57dbd0fa0 feat: add pg_replication module for reactive WAL-based cache invalidation
Implements a PostgreSQL logical replication module that streams WAL
changes, buffers them per XID for transaction atomicity, and publishes
CacheKey invalidation events to the existing framework broadcast channel.

Key design choices:
- Transaction atomicity: changes are buffered until COMMIT so the fanout
  always receives a consistent view. A 1000-row bulk-insert emits one
  table-level CacheKey, not 1000 row events.
- Native protocol: uses a self-contained raw TCP + postgres-protocol
  implementation for the replication connection (tokio-postgres 0.7 does
  not expose copy_both_simple publicly).
- Corrected pgoutput v1 parser: original maybe_sql_integration code
  incorrectly read XID from DML messages; DML messages carry no XID in
  proto v1 — only Begin does.
- Clean integration: publishes CacheKey::Channel events to store.events
  so the existing fanout task picks them up with zero changes to the
  fanout loop.
- New cx.subscribe(key) API on RenderContext for manually registering
  subscription keys (needed when queries go through PgPool, not cx.run).

New files:
  src/pg_replication/mod.rs       — PgReplicationListener, key helpers
  src/pg_replication/wal_parser.rs — pgoutput v1 binary protocol parser
  src/pg_replication/emitter.rs   — WalEmitter (tx-buffering + CacheKey emit)
  src/pg_replication/proto.rs     — raw TCP PG wire-protocol connection
  examples/pg_replication/main.rs — live message board example
  docker-compose.yml              — PG 16 container with wal_level=logical

Run the example:
  docker compose up -d
  cargo run --example pg_replication --features pg_replication

https://claude.ai/code/session_01SLGgXeqKV2o7KTmCaQZZPg
2026-03-08 22:12:01 +00:00
Markk116 2358ca29c7 dump some extra files 2026-03-08 22:34:03 +01:00
26 changed files with 4029 additions and 271 deletions
Generated
+583 -22
View File
@@ -2,6 +2,30 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -134,6 +158,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -152,6 +182,27 @@ dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "4.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -244,6 +295,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -370,6 +430,23 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -402,6 +479,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -519,7 +605,19 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
@@ -530,7 +628,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -831,6 +929,26 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -847,6 +965,26 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -880,7 +1018,7 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
@@ -909,16 +1047,25 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"brotli",
"bytes",
"chrono",
"dashmap",
"futures",
"http-body",
"maud",
"notify",
"pin-project",
"postgres-protocol",
"serde",
"serde_json",
"sqlx",
"tokio",
"tokio-postgres",
"tokio-stream",
"toml",
"tracing",
"tracing-subscriber",
"uuid",
]
@@ -937,6 +1084,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
@@ -989,6 +1145,18 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -996,7 +1164,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
@@ -1011,7 +1207,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@@ -1046,6 +1242,24 @@ dependencies = [
"libm",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -1096,6 +1310,45 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1141,6 +1394,35 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postgres-protocol"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491"
dependencies = [
"base64",
"byteorder",
"bytes",
"fallible-iterator",
"hmac",
"md-5",
"memchr",
"rand 0.9.2",
"sha2",
"stringprep",
]
[[package]]
name = "postgres-types"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20"
dependencies = [
"bytes",
"fallible-iterator",
"postgres-protocol",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1210,6 +1492,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -1223,8 +1511,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -1234,7 +1532,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -1246,13 +1554,22 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.0",
]
[[package]]
@@ -1261,9 +1578,26 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags",
"bitflags 2.11.0",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rsa"
version = "0.9.10"
@@ -1277,7 +1611,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@@ -1296,6 +1630,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1362,6 +1705,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1396,6 +1748,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1419,9 +1780,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1559,7 +1926,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags",
"bitflags 2.11.0",
"byteorder",
"bytes",
"crc",
@@ -1580,7 +1947,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@@ -1590,7 +1957,7 @@ dependencies = [
"stringprep",
"thiserror",
"tracing",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -1601,7 +1968,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags",
"bitflags 2.11.0",
"byteorder",
"crc",
"dotenvy",
@@ -1618,7 +1985,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@@ -1627,7 +1994,7 @@ dependencies = [
"stringprep",
"thiserror",
"tracing",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -1725,6 +2092,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -1758,7 +2134,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.1.1",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -1778,6 +2154,32 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-postgres"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42"
dependencies = [
"async-trait",
"byteorder",
"bytes",
"fallible-iterator",
"futures-channel",
"futures-util",
"log",
"parking_lot",
"percent-encoding",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand 0.9.2",
"socket2",
"tokio",
"tokio-util",
"whoami 2.1.1",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -1789,6 +2191,60 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
@@ -1847,6 +2303,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -1918,6 +2404,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -1930,12 +2422,31 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@@ -1960,6 +2471,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@@ -2033,12 +2553,22 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "whoami"
version = "1.6.1"
@@ -2046,7 +2576,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"wasite 0.1.0",
]
[[package]]
name = "whoami"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite 1.0.2",
"web-sys",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
@@ -2257,6 +2809,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -2315,7 +2876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.0",
"indexmap",
"log",
"serde",
+30 -3
View File
@@ -3,14 +3,20 @@ name = "livevue-rs"
version = "0.1.0"
edition = "2021"
# Run the todo example with: cargo run --example todo
# Run the todo example with: cargo run --example todo
# Run the pg_replication example with: cargo run --example pg_replication --features pg_replication
[features]
# Opt-in feature: PostgreSQL logical replication for reactive cache invalidation.
# Adds tokio-postgres and bytes as dependencies.
pg_replication = ["dep:tokio-postgres", "dep:postgres-protocol"]
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres"] }
maud = { version = "0.26", features = ["axum"] }
uuid = { version = "1", features = ["v4", "serde"] }
dashmap = "6"
@@ -19,6 +25,27 @@ tokio-stream = "0.1"
tracing = "0.1.44"
futures = "0.3.32"
# Config file parsing
toml = "0.8"
# Brotli streaming compression (configurable window size + quality)
brotli = "6"
pin-project = "1"
bytes = "1"
http-body = "1"
# Config file watching (inotify on Linux, kqueue on macOS, FSEvents on macOS)
notify = "6"
# Optional: PostgreSQL logical replication
tokio-postgres = { version = "0.7", optional = true }
postgres-protocol = { version = "0.6", optional = true }
[dev-dependencies]
chrono = { version = "0.4", features = ["clock"] }
chrono = { version = "0.4", features = ["clock"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[[example]]
name = "pg_replication"
path = "examples/pg_replication/main.rs"
required-features = ["pg_replication"]
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 Markk116
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+341
View File
@@ -0,0 +1,341 @@
# livevue-rs
A reactive server-side rendering framework for Rust that powers live-updating UIs with real-time data synchronization via Server-Sent Events (SSE). Built with [Axum](https://github.com/tokio-rs/axum), [Tokio](https://tokio.rs/), and [Maud](https://maud.lambda.world/) for efficient, async HTML rendering.
**Status**: Early development (v0.1.0) — actively under development with frequent improvements.
## Key Features
- **Server-Driven Reactivity**: Stream UI updates directly from the server to clients via SSE
- **Reactive Signals**: Type-safe signal system with client-side synchronization
- **Query Caching & Invalidation**: Automatic cache key subscription during render, with smart fanout
- **PostgreSQL Logical Replication** (opt-in): Real-time cache invalidation via PostgreSQL's logical replication protocol
- **Hot-Reloadable Config**: `livevue.toml` configuration with live reload on file changes (via `notify` crate)
- **Streaming Brotli Compression**: Configurable compression for SSE streams with custom window sizes
- **SQLite & PostgreSQL Support**: Query any SQL database seamlessly
- **Per-Connection Task Handling**: Debounced rerenders with reconnection grace periods
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Client (Browser) │
│ • DataStar morphing │
│ • Signals (frontend state) │
│ • SSE listener │
└─────────────────────┬─────────────────────────────┘
│ SSE
┌─────────────────────▼─────────────────────────────┐
│ Axum Server (livevue-rs) │
├─────────────────────────────────────────────────┤
│ HTTP Routes: │
│ • GET /route?signals={json} → Initial HTML │
│ • POST /action → Signal updates │
│ • GET /sse?connId={uuid} → SSE stream │
├─────────────────────────────────────────────────┤
│ Internal Systems: │
│ • RenderContext: Tracks signal subscriptions │
│ • ConnectionManager: Per-connection state │
│ • Fanout Task: Publishes cache invalidations │
│ • Store: KV cache + DB pool │
└─────────────────────┬─────────────────────────────┘
┌─────────────┴──────────────┐
│ │
┌───▼───────┐ ┌──────▼─────┐
│ SQLite │ │ PostgreSQL │
│ DB │ │ (+ PgR) │
└───────────┘ └─────────────┘
```
## Quick Start
### 1. Create a Render Function
```rust
use livevue_rs::RenderContext;
use std::sync::Arc;
let render_fn: livevue_rs::RenderFn = Arc::new(|mut cx: RenderContext| {
Box::pin(async move {
// Run queries subscribed automatically to cache keys
let todos: Vec<Todo> = cx.run(MyQuery::list_todos()).await?;
// Render HTML (using Maud)
let html = html! {
ul {
@for todo in &todos {
li { (todo.title) }
}
}
};
let keys = cx.take_subscription_keys();
Ok((html, keys))
})
});
```
### 2. Set Up Application State
```rust
use livevue_rs::{AppState, Store, load_and_watch_config, spawn_fanout};
let db = sqlx::SqlitePool::connect("sqlite:app.db").await?;
let store = Store::new(db);
let config = load_and_watch_config("livevue.toml")?;
let state = AppState::new(store.clone(), render_fn, config.clone());
// Start the fanout task for server-initiated rerenders
spawn_fanout(state.clone());
```
### 3. Create Routes
```rust
use livevue_rs::sse_handler;
use axum::{Router, routing::{get, post}, middleware};
let app = Router::new()
.route("/", get(/* initial render handler */))
.route("/sse", get(sse_handler))
.route("/action", post(/* action handler */))
.with_state(state)
.layer(middleware::from_fn_with_state(
state.clone(),
brotli_compression,
));
```
### 4. Configure (livevue.toml)
```toml
[server]
bind_addr = "0.0.0.0"
bind_port = 3000
[compression]
enabled = true
window_bits = 22 # Window size (20-24)
quality = 11 # Compression level (0-11)
```
## Examples
### Todo App (SQLite)
```bash
cargo run --example todo
# Open http://localhost:3000
```
Features: Add/complete/delete todos, real-time list updates via SSE.
**Files**:
- `examples/todo/main.rs` — Server setup & database initialization
- `examples/todo/queries.rs` — Query definitions with cache keys
- `examples/todo/todo_list.rs` — Component rendering logic
### PostgreSQL Replication Example
```bash
cargo run --example pg_replication --features pg_replication
```
Demonstrates logical replication for real-time cache invalidation directly from the database.
## Core Concepts
### Signals
Type-safe reactive values synchronized between server and client.
```rust
use livevue_rs::{Signal, SignalOpts};
#[derive(Signal)]
#[signal(opts = "SignalOpts { mangle: true, ..Default::default() }")]
struct MySignal {
count: i32,
name: String,
}
```
Signals are automatically mangled for client safety and synced via SSE.
### Queries & Cache Keys
Queries register cache subscription keys during render:
```rust
pub struct MyQuery;
impl Query for MyQuery {
type Item = Todo;
async fn run(store: &Store) -> anyhow::Result<Vec<Self::Item>> {
sqlx::query_as::<_, Todo>("SELECT * FROM todos")
.fetch_all(&store.db)
.await
.map_err(Into::into)
}
fn cache_key() -> CacheKey {
CacheKey::Channel { key: "todos" }
}
}
```
When the cache key is invalidated, all subscribed connections are scheduled for rerender.
### Fanout & Invalidation
The fanout task:
1. Monitors `AppState::fanout_rx` for cache invalidation events
2. Looks up all connections subscribed to that key
3. Schedules a render for each connection
4. Publishes patches via SSE with debouncing (configurable grace period)
### Brotli Compression
Streams are compressed on-the-fly with configurable settings:
```rust
use livevue_rs::brotli_compression;
let app = app.layer(middleware::from_fn_with_state(
state.clone(),
brotli_compression,
));
```
Window size (20-24 bits) and quality (0-11) are configurable in `livevue.toml`.
## Configuration (livevue.toml)
```toml
[server]
bind_addr = "127.0.0.1" # Bind address
bind_port = 3000 # Bind port
[compression]
enabled = true # Enable brotli compression
window_bits = 22 # Window size (20-24, default 22)
quality = 11 # Compression level (0-11, default 11)
[connection]
rerender_debounce_ms = 100 # Debounce duration for rerenders
reconnect_grace_period_ms = 5000 # Grace period for reconnection
```
Hot-reloaded on file changes; trigger with `SIGHUP` to force reload.
## Features
### Default
- Basic server-side rendering with SQLite support
- Signal management and caching
- Brotli compression
### `pg_replication`
Enable PostgreSQL logical replication for real-time cache invalidation:
```toml
[dependencies]
livevue-rs = { features = ["pg_replication"] }
```
Use `cx.run_pg(pool, query)` in render functions to execute `PgQuery` objects with automatic replication-based invalidation.
## API Overview
### Key Exports
| Module | Purpose |
|--------|---------|
| `signal` | Signal trait & macros for reactive values |
| `query` | `Query` trait & `CacheKey` types |
| `store` | `Store` (DB pool + KV cache) & `KVStore` trait |
| `context` | `RenderContext` for render functions |
| `connection` | Connection manager & subscription registry |
| `server` | HTTP handlers, fanout, config |
| `config` | Config loading, hot-reload |
| `brotli_layer` | Compression middleware |
| `pg_replication` | PostgreSQL replication (feature-gated) |
| `pg_query` | PostgreSQL query macros (feature-gated) |
### Main Functions
- **`sse_handler()`** — Axum handler for SSE connections
- **`spawn_fanout()`** — Start the fanout task
- **`ingest_signals()`** — Update signals from action payloads
- **`load_and_watch_config()`** — Load config with hot-reload
- **`brotli_compression()`** — Middleware for compression
## Development
### Running Tests & Examples
```bash
# Run the todo example
cargo run --example todo
# Run with PostgreSQL replication
cargo run --example pg_replication --features pg_replication
# Build release
cargo build --release
```
### Development Environment (Nix)
```bash
nix-shell shell.nix
cargo build
```
### Dependencies
- **Runtime**: Axum, Tokio, Maud, SQLx, Serde, Dashmap, Brotli, Notify
- **Async**: Tokio, Tokio-stream, Futures
- **DB**: SQLx (SQLite + PostgreSQL), optional `tokio-postgres` for replication
- **Compression**: Brotli with configurable streaming
- **Config**: TOML parsing with hot-reload via `notify`
## Known Limitations & TODO
See `todo.md` for pending improvements:
- [ ] Refactor query contract for better pg_replication + SQLite differentiation
- [ ] Clean up SSE module (currently inlined in `server.rs`)
- [ ] Fix pg_replication example to defer signal delivery over SSE
- [ ] Implement cache-backed query examples
- [ ] Move `connId` to HTTP headers instead of query params
- [ ] Extract common example logic into framework
## Contributing
This is an early-stage project. Contributions welcome! Key areas:
1. **Testing** — Expand test coverage for fanout & compression
2. **Documentation** — Add more examples (auth, streaming uploads, etc.)
3. **Performance** — Optimize render scheduling, compression tuning
4. **PostgreSQL** — Refine replication reliability
## License
Check the repository for license details.
## Similar Projects
- **Phoenix LiveView** (Elixir) — Inspirational real-time rendering approach
- **Leptos** (Rust) — Full-stack reactive framework
- **Dioxus** (Rust) — Component-based with SSR support
- **HTMX** (JavaScript) — Client-side hypermedia enhancement
---
**Start building live-updating Rust UIs with livevue-rs!**
@@ -0,0 +1,32 @@
services:
postgres:
image: postgres:16-alpine
container_name: livevue_postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: livevue
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./pg_hba.conf:/etc/postgresql/pg_hba.conf
command:
- postgres
- -c
- wal_level=logical
- -c
- max_replication_slots=10
- -c
- max_wal_senders=10
- -c
- hba_file=/etc/postgresql/pg_hba.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d livevue"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
+394
View File
@@ -0,0 +1,394 @@
//! # pg_replication example
//!
//! A live message board backed by PostgreSQL.
//!
//! Run:
//! ```sh
//! docker compose up -d # start the PG container (see docker-compose.yml)
//! cargo run --example pg_replication --features pg_replication
//! ```
//! Then open http://localhost:3001 in two browser tabs and watch them stay in sync.
//!
//! ## How it works
//!
//! 1. The render function queries the `messages` table directly via `sqlx::PgPool`
//! and calls `cx.subscribe(pg_table_key("public", "messages"))` to tell the
//! framework "re-render this connection whenever `pg:public.messages` is
//! invalidated".
//!
//! 2. `PgReplicationListener` connects to PostgreSQL over the logical replication
//! protocol, buffers changes per transaction, and on each COMMIT publishes
//! `CacheKey::Channel { key: "pg:public.messages" }` to the store's broadcast
//! channel.
//!
//! 3. The fanout task picks up the event, finds all connections subscribed to that
//! key, and triggers a re-render for each — sending fresh HTML over SSE.
//!
//! No polling. No websocket boilerplate. Just PostgreSQL WAL + SSE.
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{Json, State},
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
Router,
};
use maud::{html, Markup, DOCTYPE};
use sqlx::FromRow;
use uuid::Uuid;
use livevue_rs::{
pg_replication::{pg_table_key, PgReplicationConfig, PgReplicationListener},
server::{ingest_signals, patch_signal, AppState, SharedState},
spawn_fanout, RenderContext, Store,
};
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/// Fetch all messages, newest first.
pub struct ListMessages;
livevue_rs::pg_query_all!(ListMessages {
sql: "SELECT id, body FROM messages ORDER BY id DESC",
params: [],
output: Message,
cache: |_| pg_table_key("public", "messages"),
});
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, FromRow)]
pub struct Message {
id: i64,
body: String,
}
// ---------------------------------------------------------------------------
// Database helpers
// ---------------------------------------------------------------------------
async fn setup_db(pool: &sqlx::PgPool) -> anyhow::Result<()> {
// Enable logical replication for this table (requires superuser or
// rds_superuser on managed PG).
sqlx::query(
"CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
body TEXT NOT NULL
)",
)
.execute(pool)
.await?;
// Create the publication if it doesn't exist.
// `IF NOT EXISTS` was added in PG 14 — fall back gracefully on older versions.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'livevue_pub')",
)
.fetch_one(pool)
.await?;
if !exists {
sqlx::query("CREATE PUBLICATION livevue_pub FOR TABLE messages")
.execute(pool)
.await?;
println!("Created publication livevue_pub");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Shared render state — anything the render function needs beyond the store.
#[derive(Clone)]
struct RenderState {
pg: sqlx::PgPool,
}
async fn render_page(cx: &mut RenderContext, rs: &RenderState) -> anyhow::Result<Markup> {
// Fetch messages and auto-subscribe to the WAL invalidation key.
let messages = cx.run_pg(&rs.pg, ListMessages).await?;
let new_msg = cx.signal("newMsg", livevue_rs::global());
let _conn_id_signal = cx.signal("connId", livevue_rs::global());
Ok(html! {
div #app {
h1 { "Live Message Board" }
p.subtitle {
"Messages are stored in PostgreSQL. "
"Add one and watch every open tab update in real time via WAL replication."
}
// ── New message form ─────────────────────────────────────────
div.compose {
input
type="text"
placeholder="Type a message…"
data-bind=(new_msg.name)
data-on:keydown="{if (event.key === 'Enter') @post('/action/post_message')}"
;
button
data-on:click="@post('/action/post_message')"
{ "Send" }
}
// ── Message list ─────────────────────────────────────────────
@if messages.is_empty() {
p.empty { "No messages yet. Be the first!" }
} @else {
ul.messages {
@for msg in &messages {
li {
span.id { "#" (msg.id) }
span.body { (msg.body) }
}
}
}
}
}
})
}
/// Full HTML shell for the initial page load.
fn page_shell(conn_id: Uuid, signals_json: &str, inner: &Markup) -> String {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "pg_replication example — livevue-rs" }
style {
(r#"
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: 0.25rem; }
.subtitle { color: #666; font-size: 0.9rem; margin-top: 0; }
.compose { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.compose input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
.compose button { padding: 0.5rem 1rem; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
.compose button:disabled { background: #aaa; cursor: default; }
ul.messages { list-style: none; padding: 0; margin: 0; }
ul.messages li { padding: 0.75rem; border-bottom: 1px solid #eee; display: flex; gap: 0.75rem; }
.id { color: #999; font-size: 0.85rem; min-width: 2.5rem; }
.empty { color: #999; }
"#)
}
// DataStar SDK (loaded from CDN for the example)
script
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js"
{}
}
body
data-signals=(signals_json)
data-init=(format!("@get('/sse?conn={conn_id}')"))
{
(inner)
}
}
}
.into_string()
}
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
async fn index_handler(State(state): State<(SharedState, RenderState)>) -> impl IntoResponse {
let (app_state, rs) = &state;
let conn_id = Uuid::new_v4();
let signals_json = &format!(r#"{{"newMsg":"","connId":"{}"}}"#, conn_id);
app_state.connections.insert(conn_id, {
// We need a sender for the connection, but the SSE handler will replace
// it. Use a dummy channel that immediately drops.
let (tx, _rx) = tokio::sync::mpsc::channel(1);
tx
});
app_state
.connections
.update_signals(&conn_id, signals_json.to_string());
let mut cx =
RenderContext::new(conn_id, livevue_rs::SignalStore::from_json(serde_json::json!({"newMsg": "", "connId": conn_id.to_string()})), Default::default(), app_state.store.clone());
let inner = render_page(&mut cx, rs).await.unwrap_or_else(|e| {
html! { p { "Render error: " (e) } }
});
Html(page_shell(conn_id, signals_json, &inner))
}
// SSE handler is provided by the framework; it needs SharedState only.
// We wrap it to adapt the two-tuple state.
async fn sse_adapter(
State(state): State<(SharedState, RenderState)>,
query: axum::extract::Query<livevue_rs::server::SseParams>,
) -> impl IntoResponse {
livevue_rs::sse_handler(State(state.0), query).await
}
async fn post_message_handler(
State(state): State<(SharedState, RenderState)>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let conn_id = body
.get("connId")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or_else(Uuid::new_v4);
let signals = ingest_signals(&state.0, conn_id, body);
let msg: String = signals
.get("newMsg")
.and_then(|v| v.as_str().map(|s| s.trim().to_string()))
.unwrap_or_default();
if msg.is_empty() {
return StatusCode::BAD_REQUEST;
}
if let Err(e) = sqlx::query("INSERT INTO messages (body) VALUES ($1)")
.bind(&msg)
.execute(&state.1.pg)
.await
{
tracing::error!("failed to insert message: {e}");
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Update the stored signals to reset the input field
patch_signal(&state.0, &conn_id, "newMsg", serde_json::json!(""));
// Note: we do NOT call store.publish() here — the PgReplicationListener
// will detect the INSERT through the WAL stream and fire the invalidation
// automatically. This demonstrates the key value of the module: mutations
// from *any* source (other services, psql, migrations) are reflected
// without any extra wiring.
StatusCode::OK
}
fn router(app_state: SharedState, render_state: RenderState) -> Router {
let combined = (app_state, render_state);
Router::new()
.route("/", get(index_handler))
.route("/sse", get(sse_adapter))
.route("/action/post_message", post(post_message_handler))
.with_state(combined)
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("livevue_rs=debug".parse().unwrap())
.add_directive("pg_replication=debug".parse().unwrap()),
)
.init();
// -----------------------------------------------------------------------
// Database setup
// -----------------------------------------------------------------------
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/livevue".into());
let pg = sqlx::PgPool::connect(&database_url).await?;
setup_db(&pg).await?;
// -----------------------------------------------------------------------
// Framework store (SQLite in-memory, unused for queries here)
// -----------------------------------------------------------------------
// The Store provides the broadcast channel and KV store that the framework
// needs. The embedded SQLite pool is not used in this example — queries go
// directly to PostgreSQL via the captured `pg` pool in the render closure.
let sqlite = sqlx::SqlitePool::connect("sqlite::memory:").await?;
let store = Store::new(sqlite);
// -----------------------------------------------------------------------
// Render function
// -----------------------------------------------------------------------
let render_state = RenderState { pg: pg.clone() };
let rs_clone = render_state.clone();
let render_fn: livevue_rs::RenderFn = Arc::new(move |mut cx| {
let rs = rs_clone.clone();
Box::pin(async move {
let signals_json = cx.signals.to_json_string();
let conn_id = cx.connection_id;
let inner = render_page(&mut cx, &rs).await?;
let keys = cx.take_subscription_keys();
// Wrap in the full page shell so DataStar can morph the entire document.
let html = page_shell(conn_id, &signals_json, &inner);
Ok((html, keys))
})
});
// -----------------------------------------------------------------------
// Application state + fanout
// -----------------------------------------------------------------------
let state = AppState::new(store.clone(), render_fn);
spawn_fanout(state.clone());
// -----------------------------------------------------------------------
// PG replication listener
// -----------------------------------------------------------------------
// The replication protocol requires a dedicated connection.
let replication_store = store.clone();
tokio::spawn(async move {
loop {
// Rebuild the config each iteration — PgReplicationConfig is not
// Clone, and start() takes ownership, so we reconstruct from the
// env-var values cached in the outer scope.
let c = PgReplicationConfig {
host: std::env::var("PG_HOST").unwrap_or_else(|_| "localhost".into()),
port: std::env::var("PG_PORT").ok()
.and_then(|p| p.parse().ok()).unwrap_or(5432),
user: std::env::var("PG_USER").unwrap_or_else(|_| "postgres".into()),
password: std::env::var("PG_PASSWORD").unwrap_or_else(|_| "postgres".into()),
database: std::env::var("PG_DATABASE").unwrap_or_else(|_| "livevue".into()),
slot_name: "livevue_slot".into(),
publication_name: "livevue_pub".into(),
start_lsn: None,
};
if let Err(e) = PgReplicationListener::new(c, replication_store.clone()).start().await {
tracing::error!("pg replication listener error: {e:#}");
}
tracing::info!("pg replication listener reconnecting in 5 s…");
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
// -----------------------------------------------------------------------
// HTTP server
// -----------------------------------------------------------------------
let app = router(state, render_state);
let listener_tcp = tokio::net::TcpListener::bind("0.0.0.0:3001").await?;
println!("pg_replication example listening on http://localhost:3001");
axum::serve(listener_tcp, app).await?;
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
# docker/pg_hba.conf
local all all trust
host all all 0.0.0.0/0 trust
host replication all 0.0.0.0/0 trust
+37 -54
View File
@@ -1,9 +1,10 @@
use livevue_rs::{
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
ingest_signals, sse_handler,
ComponentTree, RenderContext, SharedConfig, SharedState, SignalStore,
brotli_compression, ingest_signals, sse_handler,
};
use axum::extract::{Query as AxumQuery, State};
use axum::middleware;
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use axum::{Json, Router};
@@ -14,7 +15,7 @@ use uuid::Uuid;
// Router
// ---------------------------------------------------------------------------
pub fn router(state: SharedState) -> Router {
pub fn router(state: SharedState, config: SharedConfig) -> Router {
Router::new()
.route("/", get(initial_page_load))
.route("/about", get(initial_about_load))
@@ -22,6 +23,10 @@ pub fn router(state: SharedState) -> Router {
.route("/action/add_todo", post(action_add_todo))
.route("/action/toggle_todo", post(action_toggle_todo))
.route("/action/delete_todo", post(action_delete_todo))
.layer(middleware::from_fn_with_state(
config,
brotli_compression,
))
.with_state(state)
}
@@ -155,50 +160,30 @@ struct ActionIdParams {
id: i64,
}
/// Ingest signals, run a mutation, then publish an invalidation event so the
/// fanout task handles the rerender. Returns 202 Accepted.
async fn action_then_publish(
state: &AppState,
body: serde_json::Value,
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
invalidation_key: CacheKey,
) -> StatusCode {
let signals = ingest_signals(state, extract_conn_and_tree(&SignalStore::from_json(body.clone())).0, body);
let _ = signals; // signals are stored; mutation drives the rerender
if let Err(e) = mutation.await {
eprintln!("Action mutation error: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR;
}
state.store.publish(invalidation_key);
StatusCode::ACCEPTED
}
async fn action_add_todo(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
ingest_signals(&state, conn_id, body.clone());
let title = body
.get("newTodo")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let db = state.store.db.clone();
action_then_publish(
&state,
body,
async move {
if !title.is_empty() {
crate::queries::create_todo(&db, &title).await?;
}
Ok(())
},
CacheKey::Table { table: "todos" },
)
.await
if title.is_empty() {
return StatusCode::ACCEPTED;
}
if let Err(e) = state.store.exec(crate::queries::CreateTodo { title }).await {
eprintln!("action_add_todo error: {e}");
return StatusCode::INTERNAL_SERVER_ERROR;
}
StatusCode::ACCEPTED
}
async fn action_toggle_todo(
@@ -206,16 +191,15 @@ async fn action_toggle_todo(
AxumQuery(params): AxumQuery<ActionIdParams>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let db = state.store.db.clone();
let id = params.id;
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
ingest_signals(&state, conn_id, body);
action_then_publish(
&state,
body,
async move { crate::queries::toggle_todo(&db, id).await },
CacheKey::Table { table: "todos" },
)
.await
if let Err(e) = state.store.exec(crate::queries::ToggleTodo { id: params.id }).await {
eprintln!("action_toggle_todo error: {e}");
return StatusCode::INTERNAL_SERVER_ERROR;
}
StatusCode::ACCEPTED
}
async fn action_delete_todo(
@@ -223,14 +207,13 @@ async fn action_delete_todo(
AxumQuery(params): AxumQuery<ActionIdParams>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let db = state.store.db.clone();
let id = params.id;
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
ingest_signals(&state, conn_id, body);
action_then_publish(
&state,
body,
async move { crate::queries::delete_todo(&db, id).await },
CacheKey::Table { table: "todos" },
)
.await
if let Err(e) = state.store.exec(crate::queries::DeleteTodo { id: params.id }).await {
eprintln!("action_delete_todo error: {e}");
return StatusCode::INTERNAL_SERVER_ERROR;
}
StatusCode::ACCEPTED
}
+12 -5
View File
@@ -3,7 +3,7 @@ mod queries;
mod todo_list;
mod about;
use livevue_rs::{AppState, CacheKey, RenderContext, Store, spawn_fanout};
use livevue_rs::{AppState, RenderContext, Store, load_and_watch_config, spawn_fanout};
use crate::app::{app_wrapper, resolve_and_render};
use std::sync::Arc;
use std::time::Duration;
@@ -68,11 +68,17 @@ async fn main() -> anyhow::Result<()> {
})
});
// -----------------------------------------------------------------------
// Configuration (livevue.toml, hot-reloaded on change or SIGHUP)
// -----------------------------------------------------------------------
let config = load_and_watch_config("livevue.toml")?;
// -----------------------------------------------------------------------
// Application state
// -----------------------------------------------------------------------
let state = AppState::new(store.clone(), render_fn);
let state = AppState::new(store.clone(), render_fn, config.clone());
// -----------------------------------------------------------------------
// Fanout task
@@ -103,9 +109,10 @@ async fn main() -> anyhow::Result<()> {
// Server
// -----------------------------------------------------------------------
let app = app::router(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("Todo example listening on http://localhost:3000");
let bind_addr = config.read().await.server.bind_addr();
let app = app::router(state, config);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
println!("Todo example listening on http://{bind_addr}");
axum::serve(listener, app).await?;
Ok(())
+29 -23
View File
@@ -40,36 +40,42 @@ livevue_rs::query_one!(GetTodo {
});
// ---------------------------------------------------------------------------
// Write operations (plain sqlx, not through Query trait)
// Write operations (mutation structs via mutation_exec!)
// ---------------------------------------------------------------------------
//
// Mutations don't go through cx.run() because they're not reads and don't
// produce subscription keys. They're called directly by action handlers,
// which then trigger a full rerender.
// Each mutation struct implements Query with publish_key() set to the cache
// key it invalidates. Use Store::exec() in action handlers — it executes the
// mutation and publishes the key automatically.
/// Insert a new todo. Returns the new row's id.
pub async fn create_todo(db: &sqlx::SqlitePool, title: &str) -> anyhow::Result<i64> {
let result = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, false)")
.bind(title)
.execute(db)
.await?;
Ok(result.last_insert_rowid())
/// Insert a new todo.
pub struct CreateTodo {
pub title: String,
}
livevue_rs::mutation_exec!(CreateTodo {
sql: "INSERT INTO todos (title, completed) VALUES (?, false)",
params: [|s: &CreateTodo| s.title.clone()],
publish: |_| CacheKey::Table { table: "todos" },
});
/// Toggle a todo's completed status.
pub async fn toggle_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
sqlx::query("UPDATE todos SET completed = NOT completed WHERE id = ?")
.bind(id)
.execute(db)
.await?;
Ok(())
pub struct ToggleTodo {
pub id: i64,
}
livevue_rs::mutation_exec!(ToggleTodo {
sql: "UPDATE todos SET completed = NOT completed WHERE id = ?",
params: [|s: &ToggleTodo| s.id],
publish: |_| CacheKey::Table { table: "todos" },
});
/// Delete a todo by id.
pub async fn delete_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
sqlx::query("DELETE FROM todos WHERE id = ?")
.bind(id)
.execute(db)
.await?;
Ok(())
pub struct DeleteTodo {
pub id: i64,
}
livevue_rs::mutation_exec!(DeleteTodo {
sql: "DELETE FROM todos WHERE id = ?",
params: [|s: &DeleteTodo| s.id],
publish: |_| CacheKey::Table { table: "todos" },
});
+68
View File
@@ -0,0 +1,68 @@
# livevue.toml — livevue-rs server configuration
#
# This file is optional. The server starts with compiled-in defaults when it
# is absent. When present, it is loaded on startup and then watched for
# changes automatically (inotify on Linux, kqueue on macOS, FSEvents on macOS).
#
# To force a reload without restarting: kill -HUP <pid>
#
# Note: [server] changes (host / port) require a full restart because the TCP
# listener is bound once at startup. All brotli settings apply to new
# connections immediately after a reload.
# ---------------------------------------------------------------------------
# HTTP server
# ---------------------------------------------------------------------------
[server]
# Address to bind. "0.0.0.0" listens on all interfaces.
host = "0.0.0.0"
# TCP port.
port = 3000
# ---------------------------------------------------------------------------
# Brotli streaming compression
# ---------------------------------------------------------------------------
#
# Brotli is the compression algorithm of choice for Datastar SSE streams.
# Unlike gzip (which compresses each HTTP response independently), brotli
# maintains a *sliding window* across the entire SSE byte stream. All events
# on a connection share a single encoder context, so later events — which
# often contain similar HTML structure — get compressed with reference to
# everything that was sent earlier. Compression ratios improve progressively
# over the lifetime of a long-lived connection.
#
# The browser sends "Accept-Encoding: br" if it supports brotli. The
# middleware checks this and only activates compression for those clients.
[brotli]
# Set to false to disable brotli and send SSE events uncompressed.
enabled = true
# Encoder quality: 0 (fastest, worst ratio) … 11 (slowest, best ratio).
#
# For real-time SSE, 46 gives a good latency/ratio tradeoff. Values above 9
# incur significant CPU cost with diminishing returns for typical HTML
# payloads. Default: 5.
quality = 5
# Sliding window size as log₂ of bytes (brotli's `lgwin` parameter).
#
# The window is how much previously compressed data the encoder can
# reference. Larger ⇒ better compression, more memory per connection.
#
# lgwin │ Window size
# ──────┼────────────
# 10 │ 1 KB
# 16 │ 64 KB
# 20 │ 1 MB
# 22 │ 4 MB ← default
# 24 │ 16 MB
#
# For a server with many concurrent SSE connections, consider whether the
# memory cost (window_size bytes × number of connections) is acceptable.
# Reduce to 20 or 16 under memory pressure.
window_size = 22
+233
View File
@@ -0,0 +1,233 @@
//! Streaming brotli compression middleware for axum.
//!
//! # Why a custom encoder instead of `tower-http`'s `CompressionLayer`?
//!
//! `tower-http` does support brotli (via `async-compression`), but it does not
//! expose the `lgwin` (window size) parameter. For Datastar SSE streams the
//! window size is the key tuning knob: all SSE events on a connection travel
//! through a **single persistent brotli encoder**, so later events benefit from
//! the full history of earlier ones. A 4 MB window (`lgwin = 22`) means the
//! encoder can reference up to 4 MB of previously transmitted HTML when
//! compressing a new fragment — dramatically better ratios than per-chunk gzip.
//!
//! This module implements a [`BrotliBody`] wrapper and an axum
//! [`brotli_compression`] middleware that honour the [`BrotliConfig`] (quality
//! + window size) loaded from `livevue.toml`.
//!
//! # Behaviour
//!
//! - Only activates when the client sends `Accept-Encoding: br`.
//! - Reads config from `SharedConfig` on **each new request**, so a hot-reload
//! applies to the next connection without a restart.
//! - Calls `encoder.flush()` after every body frame so SSE clients receive
//! data promptly (brotli metablock boundary = decodable checkpoint).
//! - The encoder's sliding-window context is preserved across flushes, giving
//! progressively better compression as the SSE stream grows.
use crate::config::SharedConfig;
use axum::body::Body;
use axum::extract::State;
use axum::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH};
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::Response;
use bytes::Bytes;
use http_body::Body as HttpBody;
use pin_project::pin_project;
use std::io::Write as _;
use std::pin::Pin;
use std::task::{Context, Poll};
// ---------------------------------------------------------------------------
// DrainWriter — a `Write` impl whose output can be drained incrementally
// ---------------------------------------------------------------------------
/// A `std::io::Write` target that accumulates bytes in a `Vec<u8>`.
///
/// After flushing the brotli encoder we call [`DrainWriter::drain`] to collect
/// only the bytes written since the last drain. This lets us send one
/// compressed chunk per SSE event while keeping the encoder (and its sliding
/// window) alive across the entire connection.
struct DrainWriter(Vec<u8>);
impl std::io::Write for DrainWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.0.extend_from_slice(data);
Ok(data.len())
}
/// No-op: the actual flushing is driven by `CompressorWriter::flush()`.
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl DrainWriter {
fn drain(&mut self) -> Bytes {
Bytes::from(self.0.drain(..).collect::<Vec<_>>())
}
}
// ---------------------------------------------------------------------------
// BrotliBody — streaming Body wrapper
// ---------------------------------------------------------------------------
/// An `http_body::Body` that brotli-compresses a wrapped [`axum::body::Body`].
///
/// The encoder is constructed once at stream creation and persists for the
/// lifetime of the connection, maintaining its sliding-window context across
/// every SSE event. [`flush()`] is called after each data frame to create a
/// metablock boundary so the browser can decode partial data immediately.
///
/// [`flush()`]: std::io::Write::flush
#[pin_project]
pub struct BrotliBody {
#[pin]
inner: Body,
/// `None` once we have consumed the encoder to finalize the stream.
encoder: Option<brotli::CompressorWriter<DrainWriter>>,
}
impl BrotliBody {
pub fn new(inner: Body, quality: u32, lgwin: u32) -> Self {
let params = brotli::enc::BrotliEncoderParams {
quality: quality as i32,
lgwin: lgwin as i32,
..Default::default()
};
// Buffer size (4 KiB) is the encoder's internal I/O buffer — smaller
// means lower latency per flush, which matters for SSE.
let encoder =
brotli::CompressorWriter::with_params(DrainWriter(Vec::new()), 4096, &params);
Self {
inner,
encoder: Some(encoder),
}
}
}
impl HttpBody for BrotliBody {
type Data = Bytes;
type Error = axum::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
let this = self.project();
match this.inner.poll_frame(cx) {
// ----------------------------------------------------------------
// Got a data frame — compress it and yield immediately.
// ----------------------------------------------------------------
Poll::Ready(Some(Ok(frame))) => {
if let Ok(data) = frame.into_data() {
match this.encoder {
Some(enc) => {
if let Err(e) = enc.write_all(&data) {
return Poll::Ready(Some(Err(axum::Error::new(e))));
}
if let Err(e) = enc.flush() {
return Poll::Ready(Some(Err(axum::Error::new(e))));
}
let compressed = enc.get_mut().drain();
Poll::Ready(Some(Ok(http_body::Frame::data(compressed))))
}
None => Poll::Ready(None),
}
} else {
// Trailers / unknown frame type — pass through as-is.
// (Re-construct the original frame; trailers don't need compression.)
Poll::Ready(None)
}
}
// ----------------------------------------------------------------
// Inner body is exhausted — finalise the encoder.
// ----------------------------------------------------------------
Poll::Ready(None) => {
if let Some(enc) = this.encoder.take() {
// into_inner() flushes any pending bytes and writes the
// final brotli stream terminator, returning the inner
// DrainWriter directly (not wrapped in Result).
let mut drain = enc.into_inner();
let final_bytes = drain.drain();
if !final_bytes.is_empty() {
return Poll::Ready(Some(Ok(http_body::Frame::data(final_bytes))));
}
}
Poll::Ready(None)
}
// ----------------------------------------------------------------
// Pass-through.
// ----------------------------------------------------------------
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Pending => Poll::Pending,
}
}
}
// ---------------------------------------------------------------------------
// axum middleware
// ---------------------------------------------------------------------------
/// Axum middleware that wraps SSE (and any other) responses with streaming
/// brotli compression, obeying `Accept-Encoding: br`.
///
/// Reads [`BrotliConfig`] from [`SharedConfig`] on each new request, so
/// quality and window-size changes take effect for new connections immediately
/// after a config reload.
///
/// # Usage
///
/// ```rust,ignore
/// use axum::{middleware, Router};
/// use livevue_rs::brotli_layer::brotli_compression;
///
/// let app = Router::new()
/// /* ... routes ... */
/// .layer(middleware::from_fn_with_state(config.clone(), brotli_compression));
/// ```
pub async fn brotli_compression(
State(config): State<SharedConfig>,
request: axum::http::Request<Body>,
next: Next,
) -> Response {
// Check whether the client signals brotli support.
let accepts_brotli = request
.headers()
.get(ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("br"))
.unwrap_or(false);
let response = next.run(request).await;
if !accepts_brotli {
return response;
}
// Snapshot the brotli config; new values apply to future connections.
let (enabled, quality, window_size) = {
let cfg = config.read().await;
(cfg.brotli.enabled, cfg.brotli.quality, cfg.brotli.window_size)
};
if !enabled {
return response;
}
let (mut parts, body) = response.into_parts();
// Remove Content-Length — the compressed length will differ.
parts.headers.remove(CONTENT_LENGTH);
// Advertise brotli encoding.
parts
.headers
.insert(CONTENT_ENCODING, HeaderValue::from_static("br"));
let brotli_body = BrotliBody::new(body, quality, window_size);
Response::from_parts(parts, Body::new(brotli_body))
}
+254
View File
@@ -0,0 +1,254 @@
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
// ---------------------------------------------------------------------------
// Config types
// ---------------------------------------------------------------------------
/// Top-level configuration for a livevue-rs server.
///
/// Loaded from `livevue.toml` (or any TOML file). All sections are optional;
/// missing fields fall back to their documented defaults.
///
/// Reload at runtime by sending `SIGHUP` to the process, or simply save the
/// file — the embedded file watcher will pick it up automatically.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LiveVueConfig {
/// HTTP server bind settings.
pub server: ServerConfig,
/// Brotli streaming compression for SSE responses.
pub brotli: BrotliConfig,
}
impl Default for LiveVueConfig {
fn default() -> Self {
Self {
server: ServerConfig::default(),
brotli: BrotliConfig::default(),
}
}
}
/// HTTP server bind settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
/// Address to bind on. Default: `"0.0.0.0"`.
pub host: String,
/// TCP port to listen on. Default: `3000`.
pub port: u16,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".into(),
port: 3000,
}
}
}
impl ServerConfig {
/// Returns `"<host>:<port>"` suitable for `TcpListener::bind`.
pub fn bind_addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
/// Brotli streaming compression settings.
///
/// # Why brotli for SSE?
///
/// Brotli is stateful: its encoder keeps a sliding window of previously seen
/// data. Because SSE events for a single connection are a continuous byte
/// stream (not independent requests), later events benefit from the context
/// of all earlier ones. HTML fragments are highly repetitive, so compression
/// ratios improve dramatically over the lifetime of a connection — exactly
/// the opposite of per-request gzip.
///
/// The `window_size` parameter (`lgwin`) controls how much history the encoder
/// remembers. Larger values give better compression at the cost of more memory
/// per connection (2^lgwin bytes).
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct BrotliConfig {
/// Enable brotli compression on SSE responses. Default: `true`.
pub enabled: bool,
/// Encoder quality, 0 (fastest / worst ratio) to 11 (slowest / best).
///
/// Quality 46 is a good balance for real-time streaming. Default: `5`.
pub quality: u32,
/// Sliding window size as log₂ of bytes (`lgwin`), valid range 1024.
///
/// | lgwin | Window |
/// |-------|----------|
/// | 10 | 1 KB |
/// | 16 | 64 KB |
/// | 20 | 1 MB |
/// | 22 | 4 MB |
/// | 24 | 16 MB |
///
/// Larger windows give better compression ratios for long-lived SSE
/// streams but cost more memory per connection. Default: `22` (4 MB).
pub window_size: u32,
}
impl Default for BrotliConfig {
fn default() -> Self {
Self {
enabled: true,
quality: 5,
window_size: 22,
}
}
}
// ---------------------------------------------------------------------------
// Shared handle
// ---------------------------------------------------------------------------
/// Thread-safe, hot-reloadable config handle.
pub type SharedConfig = Arc<RwLock<LiveVueConfig>>;
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
/// Parse a `LiveVueConfig` from a TOML file.
///
/// Returns the default config (with a log message) if the file does not exist,
/// so the server starts without requiring a config file.
pub fn load_config(path: &Path) -> anyhow::Result<LiveVueConfig> {
match std::fs::read_to_string(path) {
Ok(content) => {
let cfg: LiveVueConfig = toml::from_str(&content)?;
tracing::info!("loaded config from {:?}", path);
Ok(cfg)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!(
"config file {:?} not found, using defaults",
path
);
Ok(LiveVueConfig::default())
}
Err(e) => Err(e.into()),
}
}
/// Load config and start background reload tasks.
///
/// Returns a `SharedConfig` (`Arc<RwLock<LiveVueConfig>>`). The config is
/// automatically reloaded whenever:
///
/// - The file is modified or replaced (via inotify / kqueue / FSEvents).
/// - The process receives `SIGHUP` (Unix only).
///
/// Changes to `server.port` / `server.host` require a restart to take effect
/// (the TCP listener is bound once at startup). All other settings — brotli
/// quality, window size, enabled flag — apply to **new connections** as soon
/// as the config is reloaded.
pub fn load_and_watch_config(path: impl AsRef<Path>) -> anyhow::Result<SharedConfig> {
let path = path.as_ref().to_path_buf();
let config = Arc::new(RwLock::new(load_config(&path)?));
spawn_file_watcher(path.clone(), config.clone());
spawn_sighup_reloader(path, config.clone());
Ok(config)
}
// ---------------------------------------------------------------------------
// Background reload tasks
// ---------------------------------------------------------------------------
fn spawn_file_watcher(path: PathBuf, config: SharedConfig) {
use notify::{RecursiveMode, Watcher};
let (tx, mut rx) =
tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(16);
let mut watcher = match notify::recommended_watcher(move |res| {
// blocking_send is fine here: we're inside the notify callback thread
let _ = tx.blocking_send(res);
}) {
Ok(w) => w,
Err(e) => {
tracing::warn!("config file watcher could not be created: {e}");
return;
}
};
// If the file doesn't exist yet, watch the parent directory so we catch
// it being created.
let watch_target = if path.exists() {
path.clone()
} else {
path.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
};
if let Err(e) = watcher.watch(&watch_target, RecursiveMode::NonRecursive) {
tracing::warn!("could not watch {:?}: {e}", watch_target);
return;
}
tokio::spawn(async move {
// Keep the watcher alive for the duration of this task.
let _watcher = watcher;
while let Some(event) = rx.recv().await {
match event {
Ok(evt) => {
let relevant = evt.paths.iter().any(|p| p == &path);
let modified =
evt.kind.is_modify() || evt.kind.is_create();
if relevant && modified {
reload_config(&path, &config).await;
}
}
Err(e) => tracing::warn!("config watcher error: {e}"),
}
}
});
}
fn spawn_sighup_reloader(path: PathBuf, config: SharedConfig) {
#[cfg(unix)]
tokio::spawn(async move {
use tokio::signal::unix::{signal, SignalKind};
let mut sighup = match signal(SignalKind::hangup()) {
Ok(s) => s,
Err(e) => {
tracing::warn!("could not register SIGHUP handler: {e}");
return;
}
};
loop {
sighup.recv().await;
tracing::info!(
"SIGHUP received — reloading config from {:?}",
path
);
reload_config(&path, &config).await;
}
});
}
async fn reload_config(path: &Path, config: &SharedConfig) {
match load_config(path) {
Ok(new_cfg) => {
*config.write().await = new_cfg;
tracing::info!("config reloaded from {:?}", path);
}
Err(e) => tracing::error!("config reload failed: {e}"),
}
}
+30 -26
View File
@@ -6,23 +6,26 @@ use std::sync::Arc;
use tokio::sync::mpsc;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// RenderTrigger
// ---------------------------------------------------------------------------
/// Signal sent to a connection task to request a rerender.
pub enum RenderTrigger {
Invalidated,
}
// ---------------------------------------------------------------------------
// Connection state
// ---------------------------------------------------------------------------
/// Per-connection state stored in the manager.
pub struct ConnectionState {
/// Sender half of the per-connection SSE channel.
///
/// The fanout task sends fully-constructed axum `Event`s here; the SSE
/// handler streams them directly to the client without re-wrapping.
pub sse_sender: mpsc::Sender<Event>,
/// The full signal JSON from the most recent render for this connection.
///
/// Stored so the fanout task can reconstruct a `SignalStore` and build a
/// `RenderContext` for server-initiated rerenders, without a client
/// request carrying the signals.
/// Sends render triggers to the connection task.
pub trigger_tx: mpsc::Sender<RenderTrigger>,
/// Sends new SSE pipe senders to the connection task on reconnect.
pub pipe_tx: mpsc::Sender<mpsc::Sender<Event>>,
/// Last known signals JSON, updated by action handlers.
pub last_signals_json: Option<String>,
}
@@ -35,8 +38,8 @@ pub struct ConnectionState {
/// Accessed from:
/// - `GET /sse` handler (insert)
/// - `POST /action` handler (update signals)
/// - SSE drop cleanup (remove)
/// - Fanout task (read sender + signals for rerender)
/// - Fanout task (get trigger sender for rerender)
/// - Connection task (cleanup on grace period expiry)
pub struct ConnectionManager {
connections: DashMap<Uuid, ConnectionState>,
}
@@ -48,26 +51,29 @@ impl ConnectionManager {
}
}
/// Register a new connection with its SSE sender.
pub fn insert(&self, id: Uuid, sender: mpsc::Sender<Event>) {
/// Register a new connection with its trigger and pipe senders.
pub fn insert(&self, id: Uuid, trigger_tx: mpsc::Sender<RenderTrigger>, pipe_tx: mpsc::Sender<mpsc::Sender<Event>>) {
self.connections.insert(
id,
ConnectionState {
sse_sender: sender,
trigger_tx,
pipe_tx,
last_signals_json: None,
},
);
}
/// Get the SSE sender for a connection.
pub fn get_sender(&self, id: &Uuid) -> Option<mpsc::Sender<Event>> {
self.connections.get(id).map(|c| c.sse_sender.clone())
/// Get the render trigger sender for a connection.
pub fn get_trigger_sender(&self, id: &Uuid) -> Option<mpsc::Sender<RenderTrigger>> {
self.connections.get(id).map(|c| c.trigger_tx.clone())
}
/// Get the pipe sender for a connection (used to hand off a new SSE pipe on reconnect).
pub fn get_pipe_sender(&self, id: &Uuid) -> Option<mpsc::Sender<mpsc::Sender<Event>>> {
self.connections.get(id).map(|c| c.pipe_tx.clone())
}
/// Get the last signals JSON for a connection.
///
/// Called by the fanout task to reconstruct signal state for a
/// server-initiated rerender.
pub fn get_signals(&self, id: &Uuid) -> Option<String> {
self.connections
.get(id)
@@ -75,15 +81,13 @@ impl ConnectionManager {
}
/// Update the stored signals for a connection.
///
/// Called by `POST /action` after ingesting the client's signal blob.
pub fn update_signals(&self, id: &Uuid, signals_json: String) {
if let Some(mut conn) = self.connections.get_mut(id) {
conn.last_signals_json = Some(signals_json);
}
}
/// Remove a connection (called on SSE pipe drop).
/// Remove a connection.
pub fn remove(&self, id: &Uuid) -> Option<(Uuid, ConnectionState)> {
self.connections.remove(id)
}
@@ -168,4 +172,4 @@ impl SubscriptionRegistry {
.map(|c| c.clone())
.unwrap_or_default()
}
}
}
+51
View File
@@ -197,6 +197,57 @@ impl RenderContext {
self.store.kv.get(&key)
}
// -----------------------------------------------------------------------
// PostgreSQL queries
// -----------------------------------------------------------------------
/// Execute a PostgreSQL query, automatically registering its subscription key.
///
/// This is the pg equivalent of `cx.run()`. The query's `cache_key()` is
/// pushed into the subscription set so the connection rerenders whenever
/// the WAL listener publishes that key.
///
/// ```ignore
/// let messages = cx.run_pg(&rs.pg, ListMessages).await?;
/// // No manual cx.subscribe() needed.
/// ```
#[cfg(feature = "pg_replication")]
pub async fn run_pg<Q: crate::pg_query::PgQuery>(
&mut self,
pool: &sqlx::PgPool,
query: Q,
) -> anyhow::Result<Q::Output> {
let key = query.cache_key();
if key != CacheKey::None {
self.subscription_keys.push(key);
}
query.execute(pool).await
}
// -----------------------------------------------------------------------
// Manual subscriptions
// -----------------------------------------------------------------------
/// Manually register a subscription key without executing a query.
///
/// Use this to subscribe to externally-sourced invalidation events that
/// don't go through `cx.run()` — for example, events published by the
/// PostgreSQL replication module:
///
/// ```ignore
/// use livevue_rs::pg_replication::pg_table_key;
///
/// cx.subscribe(pg_table_key("public", "posts"));
/// let posts = sqlx::query_as::<_, Post>("SELECT id, body FROM posts")
/// .fetch_all(&pg_pool)
/// .await?;
/// ```
pub fn subscribe(&mut self, key: CacheKey) {
if key != CacheKey::None {
self.subscription_keys.push(key);
}
}
// -----------------------------------------------------------------------
// Post-render access
// -----------------------------------------------------------------------
+24 -2
View File
@@ -5,11 +5,33 @@ pub mod context;
pub mod connection;
pub mod sse;
pub mod server;
pub mod config;
pub mod brotli_layer;
/// PostgreSQL logical replication module.
///
/// Enabled via the `pg_replication` Cargo feature:
/// ```toml
/// livevue-rs = { features = ["pg_replication"] }
/// ```
#[cfg(feature = "pg_replication")]
pub mod pg_replication;
/// PostgreSQL query trait and macros (`pg_query_one!`, `pg_query_all!`).
///
/// Use `cx.run_pg(pool, query)` in render functions to execute a `PgQuery`
/// and auto-subscribe to its cache key, exactly like `cx.run()` for SQLite.
#[cfg(feature = "pg_replication")]
pub mod pg_query;
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
pub use query::{CacheKey, Query};
pub use store::{Store, KVStore};
#[cfg(feature = "pg_replication")]
pub use pg_query::PgQuery;
pub use context::{RenderContext, ComponentTree, ComponentNode};
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry};
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry, RenderTrigger};
pub use sse::{format_patch_elements, format_patch_signals};
pub use server::{AppState, SharedState, RenderFn, sse_handler, spawn_fanout, ingest_signals, ActionParams, SseParams};
pub use server::{AppState, SharedState, RenderFn, sse_handler, spawn_fanout, ingest_signals, ActionParams, SseParams};
pub use config::{LiveVueConfig, ServerConfig, BrotliConfig, SharedConfig, load_config, load_and_watch_config};
pub use brotli_layer::brotli_compression;
+133
View File
@@ -0,0 +1,133 @@
use std::future::Future;
use std::pin::Pin;
use crate::query::CacheKey;
// ---------------------------------------------------------------------------
// PgQuery trait
// ---------------------------------------------------------------------------
/// The data-access abstraction for PostgreSQL-backed queries.
///
/// Mirrors the SQLite [`Query`](crate::Query) trait but binds to
/// `sqlx::PgPool`. Use `cx.run_pg(pool, query)` in render functions — it
/// calls `cache_key()` to register the subscription automatically, exactly
/// like `cx.run()` does for SQLite queries.
///
/// Pg mutations do **not** implement this trait and do **not** call
/// `publish_key()` — the WAL replication listener fires invalidation events
/// automatically for any write from any source.
///
/// The `pg_query_one!` / `pg_query_all!` macros generate impls of this trait.
pub trait PgQuery: Send + Sync {
type Output: Send;
fn cache_key(&self) -> CacheKey;
fn execute<'a>(
&'a self,
db: &'a sqlx::PgPool,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Self::Output>> + Send + 'a>>;
}
// ---------------------------------------------------------------------------
// pg_query_one! — fetch exactly one row from PostgreSQL
// ---------------------------------------------------------------------------
/// Generate a `PgQuery` impl that fetches a single row from PostgreSQL.
///
/// ```ignore
/// pub struct GetMessage { pub id: i64 }
///
/// pg_query_one!(GetMessage {
/// sql: "SELECT id, body FROM messages WHERE id = $1",
/// params: [|s: &GetMessage| s.id],
/// output: Message,
/// cache: |s: &GetMessage| pg_table_key("public", "messages"),
/// });
/// ```
#[cfg(feature = "pg_replication")]
#[macro_export]
macro_rules! pg_query_one {
($name:ident {
sql: $sql:expr,
params: [$($param:expr),* $(,)?],
output: $output:ty,
cache: $cache:expr $(,)?
}) => {
impl $crate::PgQuery for $name {
type Output = $output;
fn cache_key(&self) -> $crate::CacheKey {
($cache)(self)
}
fn execute<'a>(
&'a self,
db: &'a sqlx::PgPool,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
> {
let zelf = self;
Box::pin(async move {
let row = sqlx::query_as::<_, $output>($sql)
$(.bind(($param)(zelf)))*
.fetch_one(db)
.await?;
Ok(row)
})
}
}
};
}
// ---------------------------------------------------------------------------
// pg_query_all! — fetch zero or more rows from PostgreSQL
// ---------------------------------------------------------------------------
/// Generate a `PgQuery` impl that fetches multiple rows from PostgreSQL.
///
/// ```ignore
/// pub struct ListMessages;
///
/// pg_query_all!(ListMessages {
/// sql: "SELECT id, body FROM messages ORDER BY id DESC",
/// params: [],
/// output: Message,
/// cache: |_| pg_table_key("public", "messages"),
/// });
/// ```
#[cfg(feature = "pg_replication")]
#[macro_export]
macro_rules! pg_query_all {
($name:ident {
sql: $sql:expr,
params: [$($param:expr),* $(,)?],
output: $output:ty,
cache: $cache:expr $(,)?
}) => {
impl $crate::PgQuery for $name {
type Output = Vec<$output>;
fn cache_key(&self) -> $crate::CacheKey {
($cache)(self)
}
fn execute<'a>(
&'a self,
db: &'a sqlx::PgPool,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
> {
let zelf = self;
Box::pin(async move {
let rows = sqlx::query_as::<_, $output>($sql)
$(.bind(($param)(zelf)))*
.fetch_all(db)
.await?;
Ok(rows)
})
}
}
};
}
+311
View File
@@ -0,0 +1,311 @@
use super::wal_parser::{ColumnValue, LogicalMessage, MessageParser, RelationInfo, TupleData};
use crate::query::CacheKey;
use bytes::BytesMut;
use std::collections::HashSet;
use tokio::sync::broadcast;
// ---------------------------------------------------------------------------
// Per-transaction buffer
// ---------------------------------------------------------------------------
/// Accumulates the set of invalidation keys for one PostgreSQL transaction.
///
/// We use a `HashSet` for table-level keys so that a transaction inserting
/// 10 000 rows into one table still produces exactly **one**
/// `CacheKey::Channel { key: "pg:public.todos" }` event. Row-level keys are
/// collected separately for callers that need fine-grained precision.
struct TxBuffer {
xid: u32,
/// Deduplicated `(schema, table)` pairs touched in this transaction.
table_keys: HashSet<(String, String)>,
/// `(schema, table, id)` tuples for changes with a detectable integer PK.
row_keys: Vec<(String, String, i64)>,
}
impl TxBuffer {
fn new(xid: u32) -> Self {
Self {
xid,
table_keys: HashSet::new(),
row_keys: Vec::new(),
}
}
fn record_table(&mut self, schema: &str, table: &str) {
self.table_keys.insert((schema.to_owned(), table.to_owned()));
}
fn record_row(&mut self, schema: &str, table: &str, id: i64) {
self.row_keys.push((schema.to_owned(), table.to_owned(), id));
}
}
// ---------------------------------------------------------------------------
// WalEmitter
// ---------------------------------------------------------------------------
/// Processes pgoutput logical replication messages and publishes
/// [`CacheKey`] invalidation events to the framework's broadcast channel.
///
/// ## Transaction atomicity
///
/// Changes are buffered per XID. On `COMMIT`, all invalidation events for
/// that transaction are published in a tight loop so the fanout task receives
/// them as a burst. The transaction's WAL data is **never** partially visible:
/// the emitter waits for the `Commit` message before publishing anything.
///
/// ## Event granularity
///
/// For every transaction two kinds of events are emitted:
///
/// * **Table-level** (`"pg:{schema}.{table}"`) — deduplicated; at most one per
/// distinct table touched. Components that track whole-table queries should
/// subscribe with [`pg_table_key`](super::pg_table_key).
///
/// * **Row-level** (`"pg:{schema}.{table}:{id}"`) — emitted when the change
/// has a single-column integer replica-identity key. Components that track
/// individual rows can subscribe with [`pg_row_key`](super::pg_row_key).
///
/// ## Usage
///
/// ```ignore
/// let emitter = WalEmitter::new(store.events.clone());
/// // Inside the replication loop:
/// emitter.process_raw(&wal_bytes).await?;
/// ```
pub struct WalEmitter {
parser: MessageParser,
events: broadcast::Sender<CacheKey>,
/// XID of the currently open transaction (pgoutput v1 is sequential).
current_xid: Option<u32>,
/// Buffer for the current transaction; `None` when outside a transaction.
buffer: Option<TxBuffer>,
}
impl WalEmitter {
/// Create a new emitter that publishes to `events`.
///
/// Pass `store.events.clone()` so the emitter integrates directly with the
/// framework's fanout task.
pub fn new(events: broadcast::Sender<CacheKey>) -> Self {
Self {
parser: MessageParser::new(),
events,
current_xid: None,
buffer: None,
}
}
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
/// Parse and process all pgoutput messages in a raw XLogData payload.
///
/// Call this for every `XLogData` message received from the replication
/// stream. The method is `&mut self` because the parser maintains relation
/// metadata across calls.
pub async fn process_raw(&mut self, data: &[u8]) -> Result<(), String> {
let mut buf = BytesMut::from(data);
while !buf.is_empty() {
match self.parser.parse_message(&mut buf)? {
Some(msg) => self.process_message(msg)?,
None => break,
}
}
Ok(())
}
// -----------------------------------------------------------------------
// Message dispatch
// -----------------------------------------------------------------------
fn process_message(&mut self, msg: LogicalMessage) -> Result<(), String> {
match msg {
LogicalMessage::Begin { xid, .. } => {
self.current_xid = Some(xid);
self.buffer = Some(TxBuffer::new(xid));
}
LogicalMessage::Commit { .. } => {
if let Some(buf) = self.buffer.take() {
self.emit(buf);
}
self.current_xid = None;
}
LogicalMessage::Insert { rel_oid, new_tuple } => {
self.record_change(rel_oid, &new_tuple);
}
LogicalMessage::Update { rel_oid, new_tuple, key_tuple, .. } => {
// Prefer the key_tuple for row-level id extraction if available
// (it holds the *old* key when the PK is being changed).
let id_source = key_tuple.as_ref().unwrap_or(&new_tuple);
let id = self.extract_pk_id(rel_oid, id_source);
self.record_change_with_id(rel_oid, &new_tuple, id);
}
LogicalMessage::Delete { rel_oid, key_tuple, old_tuple } => {
let tuple = key_tuple.as_ref().or(old_tuple.as_ref());
let id = tuple.and_then(|t| self.extract_pk_id(rel_oid, t));
// We still need the relation name; use an empty TupleData as placeholder.
if let Some(info) = self.relation_info(rel_oid) {
let schema = info.0;
let table = info.1;
if let Some(buf) = &mut self.buffer {
buf.record_table(&schema, &table);
if let Some(id) = id {
buf.record_row(&schema, &table, id);
}
}
}
}
LogicalMessage::Truncate { rel_oids, .. } => {
let pairs: Vec<(String, String)> = rel_oids
.iter()
.filter_map(|&oid| self.relation_info(oid))
.collect();
if let Some(buf) = &mut self.buffer {
for (schema, table) in pairs {
buf.record_table(&schema, &table);
}
}
}
// Relation messages are consumed by the parser (internal state).
// Type and Message are not relevant for cache invalidation.
LogicalMessage::Relation(_)
| LogicalMessage::Type { .. }
| LogicalMessage::Message { .. } => {}
}
Ok(())
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// Record a table + optional row key for a DML change.
fn record_change(&mut self, rel_oid: u32, tuple: &TupleData) {
let id = self.extract_pk_id(rel_oid, tuple);
self.record_change_with_id(rel_oid, tuple, id);
}
fn record_change_with_id(&mut self, rel_oid: u32, _tuple: &TupleData, id: Option<i64>) {
if let Some(info) = self.relation_info(rel_oid) {
let (schema, table) = info;
if let Some(buf) = &mut self.buffer {
buf.record_table(&schema, &table);
if let Some(id) = id {
buf.record_row(&schema, &table, id);
}
}
}
}
/// Resolve a relation OID to `(schema, table)`. Clones so the parser
/// borrow is released before we mutably borrow `self.buffer`.
fn relation_info(&self, rel_oid: u32) -> Option<(String, String)> {
self.parser
.get_relation(rel_oid)
.map(|r| (r.namespace.clone(), r.name.clone()))
}
/// Try to extract a single-column integer primary key from `tuple`.
///
/// Returns `None` for composite keys, non-integer keys, or when the
/// relation has no replica-identity columns flagged (`flags & 1`).
fn extract_pk_id(&self, rel_oid: u32, tuple: &TupleData) -> Option<i64> {
let rel = self.parser.get_relation(rel_oid)?;
extract_single_int_pk(rel, tuple)
}
// -----------------------------------------------------------------------
// Emission
// -----------------------------------------------------------------------
/// Publish all invalidation events for a committed transaction.
fn emit(&self, buf: TxBuffer) {
tracing::debug!(
xid = buf.xid,
tables = buf.table_keys.len(),
rows = buf.row_keys.len(),
"pg_replication: emitting cache invalidation"
);
// Table-level events (deduplicated by HashSet).
for (schema, table) in &buf.table_keys {
self.events
.send(CacheKey::Channel {
key: format!("pg:{schema}.{table}"),
})
.ok(); // ok() = ignore when no receivers are subscribed
}
// Row-level events.
for (schema, table, id) in &buf.row_keys {
self.events
.send(CacheKey::Channel {
key: format!("pg:{schema}.{table}:{id}"),
})
.ok();
}
}
}
// ---------------------------------------------------------------------------
// Row-level PK extraction
// ---------------------------------------------------------------------------
/// Extract an integer PK only when the relation has exactly one column
/// flagged as part of the replica identity key and its value is parseable
/// as `i64`.
fn extract_single_int_pk(rel: &RelationInfo, tuple: &TupleData) -> Option<i64> {
let key_cols: Vec<usize> = rel
.columns
.iter()
.enumerate()
.filter(|(_, col)| col.flags & 1 != 0)
.map(|(i, _)| i)
.collect();
if key_cols.len() != 1 {
return None;
}
let idx = key_cols[0];
match tuple.values.get(idx)? {
ColumnValue::Text(s) => s.parse::<i64>().ok(),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_receivers_does_not_panic() {
let (tx, _rx) = broadcast::channel(16);
drop(_rx); // no receivers
let mut emitter = WalEmitter::new(tx);
// Publishing to a channel with no receivers should silently succeed.
emitter
.emit(TxBuffer { xid: 1, table_keys: Default::default(), row_keys: vec![] });
}
#[test]
fn outside_transaction_dml_is_ignored() {
let (tx, _rx) = broadcast::channel(16);
let mut emitter = WalEmitter::new(tx);
// No Begin sent → buffer is None → Insert is silently dropped.
assert!(emitter.buffer.is_none());
}
}
+265
View File
@@ -0,0 +1,265 @@
//! PostgreSQL logical replication module for livevue-rs.
//!
//! Connects to a PostgreSQL `pgoutput` logical replication stream, buffers
//! changes per transaction, and publishes [`CacheKey`] invalidation events
//! to the framework's broadcast channel on every committed transaction.
//!
//! # Quick start
//!
//! ```ignore
//! use livevue_rs::pg_replication::{PgReplicationConfig, PgReplicationListener, pg_table_key};
//!
//! let config = PgReplicationConfig {
//! host: "localhost".into(),
//! port: 5432,
//! user: "postgres".into(),
//! password: "postgres".into(),
//! database: "mydb".into(),
//! slot_name: "livevue_slot".into(),
//! publication_name: "livevue_pub".into(),
//! start_lsn: None,
//! };
//!
//! let listener = PgReplicationListener::new(config, store.clone());
//! tokio::spawn(async move {
//! if let Err(e) = listener.start().await {
//! tracing::error!("replication listener exited: {e}");
//! }
//! });
//! ```
//!
//! # CacheKey convention
//!
//! | Granularity | Channel key format | Example |
//! |-------------|------------------------------|-----------------------------|
//! | Table-level | `"pg:{schema}.{table}"` | `"pg:public.posts"` |
//! | Row-level | `"pg:{schema}.{table}:{id}"` | `"pg:public.posts:42"` |
//!
//! Use [`pg_table_key`] / [`pg_row_key`] in `cx.subscribe()` in render functions.
//!
//! # Feature flag
//!
//! Enable with `features = ["pg_replication"]` in your `Cargo.toml`.
mod wal_parser;
mod emitter;
mod proto;
pub use wal_parser::{
ColumnInfo, ColumnValue, LogicalMessage, MessageParser, RelationInfo, TupleData,
};
pub use emitter::WalEmitter;
use crate::query::CacheKey;
use crate::store::Store;
use bytes::{BufMut, Bytes, BytesMut};
use std::sync::Arc;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// Key helpers
// ---------------------------------------------------------------------------
/// [`CacheKey`] matching table-level events from the PG replication module.
///
/// Call `cx.subscribe(pg_table_key("public", "messages"))` in a render
/// function to re-render whenever any row in `messages` changes.
pub fn pg_table_key(schema: &str, table: &str) -> CacheKey {
CacheKey::Channel { key: format!("pg:{schema}.{table}") }
}
/// [`CacheKey`] matching row-level events from the PG replication module.
///
/// Row-level events are only emitted when the row has a single-column
/// integer replica-identity key.
pub fn pg_row_key(schema: &str, table: &str, id: i64) -> CacheKey {
CacheKey::Channel { key: format!("pg:{schema}.{table}:{id}") }
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// Configuration for a [`PgReplicationListener`].
pub struct PgReplicationConfig {
pub host: String,
pub port: u16,
pub user: String,
pub password: String,
pub database: String,
/// Logical replication slot name. Auto-created with the `pgoutput`
/// plugin if it does not already exist.
pub slot_name: String,
/// Publication name. Must be created in PostgreSQL first:
/// ```sql
/// CREATE PUBLICATION livevue_pub FOR ALL TABLES;
/// ```
pub publication_name: String,
/// LSN to start from. `None` = start from the slot's confirmed LSN
/// (only future changes).
pub start_lsn: Option<u64>,
}
// ---------------------------------------------------------------------------
// Listener
// ---------------------------------------------------------------------------
/// Streams PostgreSQL WAL changes and publishes [`CacheKey`] events to the
/// framework's broadcast channel.
///
/// Spawn as a long-running background task after calling `AppState::new` and
/// `spawn_fanout`. Wrap in a retry loop for automatic reconnection.
pub struct PgReplicationListener {
config: PgReplicationConfig,
store: Arc<Store>,
}
impl PgReplicationListener {
pub fn new(config: PgReplicationConfig, store: Arc<Store>) -> Self {
Self { config, store }
}
/// Connect and stream WAL until an error or stream close.
pub async fn start(self) -> anyhow::Result<()> {
use anyhow::Context as _;
// --- connect in replication mode ---
let mut conn = proto::RawPgConn::connect(
&self.config.host,
self.config.port,
&self.config.user,
&self.config.password,
&self.config.database,
)
.await
.context("connect to PostgreSQL (replication mode)")?;
// --- ensure replication slot exists ---
let slot = &self.config.slot_name;
let create_sql = format!(
"CREATE_REPLICATION_SLOT {slot} LOGICAL pgoutput NOEXPORT_SNAPSHOT"
);
match conn.simple_query(&create_sql).await {
Ok(_) => tracing::info!(slot, "pg_replication: created replication slot"),
Err(e) => {
let msg = e.to_string();
if msg.contains("42710") || msg.contains("already exists") {
tracing::debug!(slot, "pg_replication: slot already exists");
} else {
return Err(e).context("create replication slot");
}
}
}
// --- start logical replication ---
let lsn = self.config.start_lsn.unwrap_or(0);
let lsn_str = format!("{:X}/{:X}", (lsn >> 32) as u32, lsn as u32);
let pub_name = &self.config.publication_name;
let start_sql = format!(
"START_REPLICATION SLOT {slot} LOGICAL {lsn_str} \
(proto_version '1', publication_names '{pub_name}')"
);
conn.start_replication(&start_sql)
.await
.context("START_REPLICATION")?;
tracing::info!(
slot, publication = pub_name, lsn = lsn_str,
"pg_replication: replication stream started"
);
// --- stream processing loop ---
let mut emitter = WalEmitter::new(self.store.events.clone());
let mut last_lsn: u64 = lsn;
let mut last_status_at = Instant::now();
const STATUS_INTERVAL: Duration = Duration::from_secs(10);
loop {
let frame = tokio::time::timeout(
Duration::from_secs(1),
conn.read_copy_data(),
)
.await;
match frame {
Err(_timeout) => {}
Ok(Err(e)) => return Err(e).context("replication stream read"),
Ok(Ok(None)) => {
tracing::info!("pg_replication: replication stream ended");
break;
}
Ok(Ok(Some(data))) => {
if data.is_empty() { continue; }
match data[0] {
// XLogData: 'w' + u64 wal_start(8) + u64 wal_end(8) + i64 time(8) + WAL
b'w' if data.len() >= 25 => {
last_lsn = u64::from_be_bytes(
data[9..17].try_into().expect("8 byte slice"),
);
if let Err(e) = emitter.process_raw(&data[25..]).await {
tracing::warn!("pg_replication: WAL parse warning: {e}");
}
}
// PrimaryKeepalive: 'k' + u64 wal_end(8) + i64 time(8) + u8 reply_req
b'k' if data.len() >= 18 => {
if data[17] != 0 {
conn.send_standby_status(last_lsn)
.await
.context("send standby status (keepalive reply)")?;
last_status_at = Instant::now();
}
}
other => {
tracing::debug!(
"pg_replication: unknown replication msg 0x{:02x}", other
);
}
}
}
}
if last_status_at.elapsed() >= STATUS_INTERVAL {
conn.send_standby_status(last_lsn)
.await
.context("send periodic standby status")?;
last_status_at = Instant::now();
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// StandbyStatusUpdate payload for acknowledging processed WAL.
pub(crate) fn standby_status_payload(lsn: u64) -> Bytes {
let ack = lsn.saturating_add(1);
let mut buf = BytesMut::with_capacity(34);
buf.put_u8(b'r');
buf.put_u64(ack); // write LSN
buf.put_u64(ack); // flush LSN
buf.put_u64(ack); // apply LSN
buf.put_i64(pg_epoch_micros());
buf.put_u8(0); // no reply requested
buf.freeze()
}
/// Current time as microseconds since the PostgreSQL epoch (2000-01-01 UTC).
pub(crate) fn pg_epoch_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let micros = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as i64;
micros - 946_684_800_000_000
}
+341
View File
@@ -0,0 +1,341 @@
//! Raw PostgreSQL wire-protocol connection for logical replication.
//!
//! `tokio-postgres` 0.7 does not expose a `copy_both_simple` API suitable for
//! the replication stream. This module implements the minimum subset of the
//! PostgreSQL wire protocol needed to:
//!
//! 1. Connect with `replication=database` startup parameter.
//! 2. Perform MD5 or cleartext password authentication.
//! 3. Send simple queries (for `CREATE_REPLICATION_SLOT` and `START_REPLICATION`).
//! 4. Enter `COPY BOTH` mode and stream `CopyData` frames.
//! 5. Send `StandbyStatusUpdate` CopyData frames back to the server.
//!
//! `postgres-protocol` is used for building most frontend messages and for
//! MD5 hash computation. The backend parser is bypassed for `CopyBothResponse`
//! (tag `'W'` / 0x57) which is not present in that crate's public API.
use super::standby_status_payload;
use anyhow::{bail, Context as _};
use bytes::{Buf, Bytes, BytesMut};
use postgres_protocol::authentication::md5_hash;
use postgres_protocol::message::frontend;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
// ---------------------------------------------------------------------------
// RawPgConn
// ---------------------------------------------------------------------------
/// A raw PostgreSQL connection suitable for replication streaming.
pub struct RawPgConn {
stream: TcpStream,
/// Accumulates bytes read from the server until a complete frame arrives.
read_buf: BytesMut,
}
impl RawPgConn {
// -----------------------------------------------------------------------
// Constructor
// -----------------------------------------------------------------------
/// Connect to a PostgreSQL server in **logical replication** mode.
///
/// Sends a startup message with `replication=database` so the backend
/// enters walsender mode. Handles MD5 and cleartext password auth.
pub async fn connect(
host: &str,
port: u16,
user: &str,
password: &str,
database: &str,
) -> anyhow::Result<Self> {
let addr = format!("{host}:{port}");
let stream = TcpStream::connect(&addr)
.await
.with_context(|| format!("TCP connect to {addr}"))?;
let mut conn = RawPgConn {
stream,
read_buf: BytesMut::with_capacity(4096),
};
// --- Startup message -----------------------------------------------
// Parameters include `replication=database` which instructs PostgreSQL
// to accept replication commands on this connection.
let mut startup_buf = BytesMut::new();
frontend::startup_message(
[
("user", user),
("database", database),
("replication", "database"),
("application_name", "livevue-rs"),
]
.iter()
.copied(),
&mut startup_buf,
)?;
conn.stream.write_all(&startup_buf).await?;
conn.stream.flush().await?;
// --- Authentication loop -------------------------------------------
loop {
let (tag, body) = conn.read_frame().await?;
match tag {
b'R' => {
// Authentication request
conn.handle_auth_request(&body, user, password).await?;
}
b'K' => {
// BackendKeyData — ignore
}
b'S' => {
// ParameterStatus — ignore
}
b'Z' => {
// ReadyForQuery — handshake complete
break;
}
b'E' => {
let msg = parse_error_message(&body);
bail!("PostgreSQL startup error: {msg}");
}
b'N' => {
// NoticeResponse — ignore
}
other => {
tracing::debug!("startup: unexpected tag 0x{:02x}", other);
}
}
}
Ok(conn)
}
// -----------------------------------------------------------------------
// Simple query (used for CREATE_REPLICATION_SLOT etc.)
// -----------------------------------------------------------------------
/// Send a simple query and drain responses until `ReadyForQuery`.
///
/// Returns `Err` if the server sends an `ErrorResponse`.
pub async fn simple_query(&mut self, query: &str) -> anyhow::Result<()> {
let mut buf = BytesMut::new();
frontend::query(query, &mut buf)?;
self.stream.write_all(&buf).await?;
self.stream.flush().await?;
loop {
let (tag, body) = self.read_frame().await?;
match tag {
b'Z' => break, // ReadyForQuery
b'E' => {
let msg = parse_error_message(&body);
bail!("query error: {msg}");
}
_ => {} // CommandComplete, RowDescription, DataRow, NoticeResponse …
}
}
Ok(())
}
// -----------------------------------------------------------------------
// Enter COPY BOTH mode for replication streaming
// -----------------------------------------------------------------------
/// Send `START_REPLICATION` and absorb the `CopyBothResponse`.
///
/// After this call the connection is in COPY BOTH mode. Use
/// [`read_copy_data`] / [`send_standby_status`] to exchange messages.
pub async fn start_replication(&mut self, query: &str) -> anyhow::Result<()> {
let mut buf = BytesMut::new();
frontend::query(query, &mut buf)?;
self.stream.write_all(&buf).await?;
self.stream.flush().await?;
// Expect CopyBothResponse ('W') followed by the replication stream.
// postgres-protocol doesn't parse 'W', so we handle it manually.
loop {
let (tag, _body) = self.read_frame().await?;
match tag {
b'W' => {
// CopyBothResponse — body contains column-format info we
// don't need for the replication stream. Just break out.
break;
}
b'E' => {
let msg = parse_error_message(&_body);
bail!("START_REPLICATION error: {msg}");
}
b'N' => {} // NoticeResponse — skip
other => {
tracing::debug!("start_replication: unexpected tag 0x{:02x}", other);
}
}
}
Ok(())
}
// -----------------------------------------------------------------------
// Replication stream I/O
// -----------------------------------------------------------------------
/// Read one `CopyData` payload from the replication stream.
///
/// Returns:
/// - `Ok(Some(bytes))` — a CopyData payload (XLogData or PrimaryKeepalive).
/// - `Ok(None)` — the server sent `CopyDone` or `ErrorResponse` (stream end).
/// - `Err(_)` — I/O or protocol error.
pub async fn read_copy_data(&mut self) -> anyhow::Result<Option<Bytes>> {
let (tag, body) = self.read_frame().await?;
match tag {
b'd' => Ok(Some(body)),
b'c' => Ok(None), // CopyDone
b'E' => {
let msg = parse_error_message(&body);
bail!("replication error: {msg}");
}
other => {
tracing::debug!("copy stream: unexpected tag 0x{:02x}", other);
Ok(Some(Bytes::new()))
}
}
}
/// Send a `StandbyStatusUpdate` CopyData frame to acknowledge `lsn`.
pub async fn send_standby_status(&mut self, lsn: u64) -> anyhow::Result<()> {
let payload = standby_status_payload(lsn);
let mut buf = BytesMut::new();
frontend::CopyData::new(&payload[..])?.write(&mut buf);
self.stream.write_all(&buf).await?;
self.stream.flush().await?;
Ok(())
}
// -----------------------------------------------------------------------
// Low-level frame I/O
// -----------------------------------------------------------------------
/// Read one complete PostgreSQL message frame.
///
/// Frame format: `tag (u8) | length (i32, BE, includes itself) | body`.
/// Returns `(tag, body_bytes)`.
async fn read_frame(&mut self) -> anyhow::Result<(u8, Bytes)> {
// Ensure we have at least 5 bytes (1 tag + 4 length).
while self.read_buf.len() < 5 {
let n = self.stream.read_buf(&mut self.read_buf).await?;
if n == 0 {
bail!("server closed connection unexpectedly");
}
}
let tag = self.read_buf[0];
// Length field is i32 big-endian, includes the 4-length bytes but NOT
// the tag byte. Total frame length = 1 (tag) + length.
let body_len = (i32::from_be_bytes(
self.read_buf[1..5].try_into().expect("4 byte slice"),
) as usize)
.saturating_sub(4); // subtract the 4 length bytes themselves
let total = 5 + body_len; // tag(1) + length(4) + body
while self.read_buf.len() < total {
let n = self.stream.read_buf(&mut self.read_buf).await?;
if n == 0 {
bail!("server closed connection while reading message body");
}
}
self.read_buf.advance(5); // consume tag + length
let body = self.read_buf.split_to(body_len).freeze();
Ok((tag, body))
}
// -----------------------------------------------------------------------
// Authentication
// -----------------------------------------------------------------------
async fn handle_auth_request(
&mut self,
body: &[u8],
user: &str,
password: &str,
) -> anyhow::Result<()> {
if body.len() < 4 {
bail!("AuthenticationRequest body too short");
}
let auth_type = i32::from_be_bytes(body[0..4].try_into().expect("4 bytes"));
match auth_type {
0 => {
// AuthenticationOk — nothing to do
}
3 => {
// AuthenticationCleartextPassword
let mut buf = BytesMut::new();
frontend::password_message(password.as_bytes(), &mut buf)?;
self.stream.write_all(&buf).await?;
self.stream.flush().await?;
}
5 => {
// AuthenticationMD5Password — salt is bytes 4..8
if body.len() < 8 {
bail!("MD5 auth: body too short for salt");
}
let salt: [u8; 4] = body[4..8].try_into().expect("4 byte salt");
let hashed = md5_hash(user.as_bytes(), password.as_bytes(), salt);
let mut buf = BytesMut::new();
frontend::password_message(hashed.as_bytes(), &mut buf)?;
self.stream.write_all(&buf).await?;
self.stream.flush().await?;
}
10 => {
// AuthenticationSASL (SCRAM-SHA-256) — not implemented here.
// For Docker dev setups, use `md5` or `trust` in pg_hba.conf.
bail!(
"SCRAM-SHA-256 authentication is not supported by the \
replication module. Configure PostgreSQL to use `md5` \
or `trust` for this user in pg_hba.conf."
);
}
other => {
bail!("unsupported authentication type: {other}");
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extract a human-readable message from an `ErrorResponse` body.
///
/// Fields are null-terminated key-value pairs. We extract the 'M' (message)
/// and 'D' (detail) fields.
fn parse_error_message(body: &[u8]) -> String {
let mut parts = Vec::new();
let mut i = 0;
while i < body.len() {
let field_type = body[i];
i += 1;
if field_type == 0 {
break;
}
// Find the null terminator of the field value.
let start = i;
while i < body.len() && body[i] != 0 {
i += 1;
}
let value = String::from_utf8_lossy(&body[start..i]).into_owned();
i += 1; // skip null terminator
if matches!(field_type, b'M' | b'D' | b'C') {
parts.push(value);
}
}
if parts.is_empty() {
"unknown PostgreSQL error".into()
} else {
parts.join("")
}
}
+505
View File
@@ -0,0 +1,505 @@
use bytes::{Buf, BytesMut};
use std::collections::HashMap;
/// PostgreSQL logical replication binary protocol parser (pgoutput, version 1).
///
/// Parses the raw WAL bytes delivered by `START_REPLICATION … (proto_version '1')`.
/// In protocol version 1 the transaction ID (XID) is **only** present in `Begin`
/// messages; DML messages (Insert / Update / Delete / Truncate) carry no XID.
/// The [`WalEmitter`](super::emitter::WalEmitter) tracks the current XID via the
/// `Begin`/`Commit` boundary.
// ---------------------------------------------------------------------------
// Schema metadata
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct ColumnInfo {
/// Bit 0: column is part of the replica identity key.
pub flags: u8,
pub name: String,
pub type_oid: u32,
pub type_modifier: i32,
}
#[derive(Debug, Clone)]
pub struct RelationInfo {
pub oid: u32,
pub namespace: String,
pub name: String,
/// `d` = default (PK), `n` = nothing, `f` = full row, `i` = index.
pub replica_identity: u8,
pub columns: Vec<ColumnInfo>,
}
// ---------------------------------------------------------------------------
// Tuple data
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub enum ColumnValue {
Null,
UnchangedToasted,
Text(String),
Binary(Vec<u8>),
}
#[derive(Debug, Clone)]
pub struct TupleData {
pub values: Vec<ColumnValue>,
}
// ---------------------------------------------------------------------------
// Logical messages (pgoutput v1)
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum LogicalMessage {
/// Transaction start. `xid` identifies the transaction; DML messages that
/// follow do not carry their own XID.
Begin {
/// Final LSN of the transaction (equals the Commit LSN).
final_lsn: u64,
/// Commit timestamp (microseconds since PG epoch 2000-01-01).
timestamp: i64,
/// Transaction ID.
xid: u32,
},
/// Schema descriptor sent before the first DML that references a relation
/// in a session (and after schema changes). Stored internally by the parser.
Relation(RelationInfo),
/// Row inserted into `rel_oid`.
Insert {
rel_oid: u32,
new_tuple: TupleData,
},
/// Row updated in `rel_oid`.
///
/// `key_tuple` is present when REPLICA IDENTITY is the primary key
/// (default). `old_tuple` is present for REPLICA IDENTITY FULL.
Update {
rel_oid: u32,
key_tuple: Option<TupleData>,
old_tuple: Option<TupleData>,
new_tuple: TupleData,
},
/// Row deleted from `rel_oid`.
Delete {
rel_oid: u32,
key_tuple: Option<TupleData>,
old_tuple: Option<TupleData>,
},
/// Transaction committed successfully.
Commit {
flags: u8,
commit_lsn: u64,
end_lsn: u64,
timestamp: i64,
},
/// One or more tables truncated.
Truncate {
num_relations: u32,
options: u8,
rel_oids: Vec<u32>,
},
/// Custom data-type descriptor (pgoutput emits these before first use).
Type {
type_oid: u32,
namespace: String,
type_name: String,
},
/// Application-level logical message emitted via `pg_logical_emit_message`.
Message {
/// Bit 0: message is transactional.
flags: u8,
lsn: u64,
prefix: String,
content: Vec<u8>,
},
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
/// Stateful pgoutput v1 parser.
///
/// Maintains a cache of `RelationInfo` objects keyed by OID so that
/// Insert/Update/Delete messages can be decoded without extra round-trips.
pub struct MessageParser {
relations: HashMap<u32, RelationInfo>,
_types: HashMap<u32, (String, String)>,
}
impl MessageParser {
pub fn new() -> Self {
Self {
relations: HashMap::new(),
_types: HashMap::new(),
}
}
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
/// Parse the next logical message from `buf`.
///
/// `Relation` messages are absorbed into internal state and return `None`
/// so callers only see semantically meaningful messages.
pub fn parse_message(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
if buf.is_empty() {
return Ok(None);
}
match buf[0] as char {
'B' => self.parse_begin(buf),
'C' => self.parse_commit(buf),
'R' => {
self.parse_relation(buf)?;
Ok(None)
}
'I' => self.parse_insert(buf),
'U' => self.parse_update(buf),
'D' => self.parse_delete(buf),
'T' => self.parse_truncate(buf),
'Y' => self.parse_type(buf),
'M' => self.parse_message_msg(buf),
other => Err(format!(
"unknown pgoutput message type: '{}' (0x{:02x})",
other, buf[0]
)),
}
}
/// Look up a relation by OID (returns `None` if not yet seen).
pub fn get_relation(&self, oid: u32) -> Option<&RelationInfo> {
self.relations.get(&oid)
}
// -----------------------------------------------------------------------
// Low-level readers
// -----------------------------------------------------------------------
fn read_u8(buf: &mut BytesMut) -> Result<u8, String> {
if buf.is_empty() {
return Err("unexpected EOF reading u8".into());
}
Ok(buf.get_u8())
}
fn read_i16(buf: &mut BytesMut) -> Result<i16, String> {
if buf.len() < 2 {
return Err("unexpected EOF reading i16".into());
}
Ok(buf.get_i16())
}
fn read_u32(buf: &mut BytesMut) -> Result<u32, String> {
if buf.len() < 4 {
return Err("unexpected EOF reading u32".into());
}
Ok(buf.get_u32())
}
fn read_i32(buf: &mut BytesMut) -> Result<i32, String> {
if buf.len() < 4 {
return Err("unexpected EOF reading i32".into());
}
Ok(buf.get_i32())
}
fn read_u64(buf: &mut BytesMut) -> Result<u64, String> {
if buf.len() < 8 {
return Err("unexpected EOF reading u64".into());
}
Ok(buf.get_u64())
}
fn read_i64(buf: &mut BytesMut) -> Result<i64, String> {
if buf.len() < 8 {
return Err("unexpected EOF reading i64".into());
}
Ok(buf.get_i64())
}
/// Read a null-terminated UTF-8 string.
fn read_string(buf: &mut BytesMut) -> Result<String, String> {
let mut bytes = Vec::new();
loop {
if buf.is_empty() {
return Err("unexpected EOF reading null-terminated string".into());
}
let b = buf.get_u8();
if b == 0 {
break;
}
bytes.push(b);
}
String::from_utf8(bytes).map_err(|e| format!("invalid UTF-8 in string: {e}"))
}
// -----------------------------------------------------------------------
// Message parsers
// -----------------------------------------------------------------------
fn parse_begin(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'B'
let final_lsn = Self::read_u64(buf)?;
let timestamp = Self::read_i64(buf)?;
let xid = Self::read_u32(buf)?;
Ok(Some(LogicalMessage::Begin { final_lsn, timestamp, xid }))
}
fn parse_commit(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'C'
let flags = Self::read_u8(buf)?;
let commit_lsn = Self::read_u64(buf)?;
let end_lsn = Self::read_u64(buf)?;
let timestamp = Self::read_i64(buf)?;
Ok(Some(LogicalMessage::Commit { flags, commit_lsn, end_lsn, timestamp }))
}
fn parse_relation(&mut self, buf: &mut BytesMut) -> Result<(), String> {
buf.get_u8(); // 'R'
let rel_oid = Self::read_u32(buf)?;
let namespace = Self::read_string(buf)?;
let name = Self::read_string(buf)?;
let replica_identity = Self::read_u8(buf)?;
let num_columns = Self::read_i16(buf)? as usize;
let mut columns = Vec::with_capacity(num_columns);
for _ in 0..num_columns {
let flags = Self::read_u8(buf)?;
let col_name = Self::read_string(buf)?;
let type_oid = Self::read_u32(buf)?;
let type_modifier = Self::read_i32(buf)?;
columns.push(ColumnInfo { flags, name: col_name, type_oid, type_modifier });
}
self.relations.insert(
rel_oid,
RelationInfo { oid: rel_oid, namespace, name, replica_identity, columns },
);
Ok(())
}
fn parse_insert(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'I'
// pgoutput v1: Relation OID comes directly after type byte (no XID).
let rel_oid = Self::read_u32(buf)?;
let marker = Self::read_u8(buf)?;
if marker != b'N' {
return Err(format!("INSERT: expected 'N' marker, got '{}'", marker as char));
}
let new_tuple = self.parse_tuple_data(buf, rel_oid)?;
Ok(Some(LogicalMessage::Insert { rel_oid, new_tuple }))
}
fn parse_update(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'U'
// pgoutput v1: no XID in Update.
let rel_oid = Self::read_u32(buf)?;
let mut key_tuple = None;
let mut old_tuple = None;
if !buf.is_empty() {
match buf[0] as char {
'K' => {
buf.get_u8();
key_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
'O' => {
buf.get_u8();
old_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
_ => {} // no optional tuple, proceed to 'N'
}
}
let marker = Self::read_u8(buf)?;
if marker != b'N' {
return Err(format!("UPDATE: expected 'N' marker, got '{}'", marker as char));
}
let new_tuple = self.parse_tuple_data(buf, rel_oid)?;
Ok(Some(LogicalMessage::Update { rel_oid, key_tuple, old_tuple, new_tuple }))
}
fn parse_delete(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'D'
// pgoutput v1: no XID in Delete.
let rel_oid = Self::read_u32(buf)?;
let mut key_tuple = None;
let mut old_tuple = None;
if !buf.is_empty() {
match buf[0] as char {
'K' => {
buf.get_u8();
key_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
'O' => {
buf.get_u8();
old_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
other => {
return Err(format!("DELETE: expected 'K' or 'O', got '{}'", other));
}
}
}
Ok(Some(LogicalMessage::Delete { rel_oid, key_tuple, old_tuple }))
}
fn parse_truncate(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'T'
// pgoutput v1: no XID in Truncate.
let num_relations = Self::read_u32(buf)?;
let options = Self::read_u8(buf)?;
let mut rel_oids = Vec::with_capacity(num_relations as usize);
for _ in 0..num_relations {
rel_oids.push(Self::read_u32(buf)?);
}
Ok(Some(LogicalMessage::Truncate { num_relations, options, rel_oids }))
}
fn parse_type(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'Y'
// pgoutput v1: no XID in Type.
let type_oid = Self::read_u32(buf)?;
let namespace = Self::read_string(buf)?;
let type_name = Self::read_string(buf)?;
self._types.insert(type_oid, (namespace.clone(), type_name.clone()));
Ok(Some(LogicalMessage::Type { type_oid, namespace, type_name }))
}
fn parse_message_msg(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'M'
// pgoutput v1: no XID in Message.
let flags = Self::read_u8(buf)?;
let lsn = Self::read_u64(buf)?;
let prefix = Self::read_string(buf)?;
let content_len = Self::read_i32(buf)? as usize;
if buf.len() < content_len {
return Err(format!(
"Message: need {} bytes for content, have {}",
content_len,
buf.len()
));
}
let content = buf.split_to(content_len).to_vec();
Ok(Some(LogicalMessage::Message { flags, lsn, prefix, content }))
}
// -----------------------------------------------------------------------
// Tuple decoder
// -----------------------------------------------------------------------
fn parse_tuple_data(&self, buf: &mut BytesMut, rel_oid: u32) -> Result<TupleData, String> {
let num_columns = Self::read_i16(buf)? as usize;
let relation = self
.relations
.get(&rel_oid)
.ok_or_else(|| format!("unknown relation OID {rel_oid} — missing Relation message?"))?;
if num_columns != relation.columns.len() {
return Err(format!(
"tuple has {} columns but relation '{}' has {}",
num_columns,
relation.name,
relation.columns.len()
));
}
let mut values = Vec::with_capacity(num_columns);
for _ in 0..num_columns {
let marker = Self::read_u8(buf)?;
let value = match marker {
b'n' => ColumnValue::Null,
b'u' => ColumnValue::UnchangedToasted,
b't' => {
let len = Self::read_i32(buf)? as usize;
if buf.len() < len {
return Err(format!(
"text column: need {} bytes, have {}",
len,
buf.len()
));
}
let data = buf.split_to(len).to_vec();
let text = String::from_utf8(data)
.map_err(|e| format!("non-UTF-8 text column: {e}"))?;
ColumnValue::Text(text)
}
b'b' => {
let len = Self::read_i32(buf)? as usize;
if buf.len() < len {
return Err(format!(
"binary column: need {} bytes, have {}",
len,
buf.len()
));
}
ColumnValue::Binary(buf.split_to(len).to_vec())
}
other => {
return Err(format!(
"unknown column marker '{}'",
other as char
));
}
};
values.push(value);
}
Ok(TupleData { values })
}
}
impl Default for MessageParser {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_starts_empty() {
let p = MessageParser::new();
assert!(p.relations.is_empty());
}
#[test]
fn empty_buf_returns_none() {
let mut p = MessageParser::new();
let mut buf = BytesMut::new();
assert!(p.parse_message(&mut buf).unwrap().is_none());
}
}
+69
View File
@@ -52,6 +52,11 @@ pub enum CacheKey {
/// The trait uses `Pin<Box<dyn Future>>` instead of `async fn` to guarantee
/// `Send` bounds needed for `tokio::try_join!` in `cx.run_all()`. The
/// `query_one!` / `query_all!` macros hide this behind a clean declaration.
///
/// Mutation structs (generated by `mutation_exec!`) also implement this trait.
/// They override `publish_key()` to return the `CacheKey` that should be
/// invalidated after the write. `Store::exec()` calls `publish_key()` and
/// fires the broadcast automatically — no manual `store.publish()` needed.
pub trait Query: Send + Sync {
type Output: Send;
@@ -61,6 +66,14 @@ pub trait Query: Send + Sync {
&'a self,
db: &'a sqlx::SqlitePool,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Self::Output>> + Send + 'a>>;
/// The cache key to publish after a successful mutation.
///
/// Read queries return `None` (the default). Mutation structs generated
/// by `mutation_exec!` override this to return the key they invalidate.
fn publish_key(&self) -> Option<CacheKey> {
None
}
}
// ---------------------------------------------------------------------------
@@ -113,6 +126,62 @@ macro_rules! query_one {
};
}
// ---------------------------------------------------------------------------
// mutation_exec! — INSERT / UPDATE / DELETE (no RETURNING)
// ---------------------------------------------------------------------------
/// Generate a `Query` impl for write operations (INSERT / UPDATE / DELETE).
///
/// The struct returns `()` from `execute` and overrides `publish_key()` with
/// the provided closure. Use `Store::exec()` to run it — the key is published
/// automatically after a successful write.
///
/// ```ignore
/// pub struct CreateTodo { pub title: String }
///
/// mutation_exec!(CreateTodo {
/// sql: "INSERT INTO todos (title, completed) VALUES (?, false)",
/// params: [|s: &CreateTodo| s.title.as_str()],
/// publish: |_| CacheKey::Table { table: "todos" },
/// });
/// ```
#[macro_export]
macro_rules! mutation_exec {
($name:ident {
sql: $sql:expr,
params: [$($param:expr),* $(,)?],
publish: $publish:expr $(,)?
}) => {
impl $crate::Query for $name {
type Output = ();
fn cache_key(&self) -> $crate::CacheKey {
$crate::CacheKey::None
}
fn publish_key(&self) -> Option<$crate::CacheKey> {
Some(($publish)(self))
}
fn execute<'a>(
&'a self,
db: &'a sqlx::SqlitePool,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
> {
let zelf = self;
Box::pin(async move {
sqlx::query($sql)
$(.bind(($param)(zelf)))*
.execute(db)
.await?;
Ok(())
})
}
}
};
}
// ---------------------------------------------------------------------------
// query_all! — fetch zero or more rows
// ---------------------------------------------------------------------------
+233 -135
View File
@@ -1,8 +1,9 @@
use crate::connection::{ConnectionManager, SubscriptionRegistry};
use crate::config::{LiveVueConfig, SharedConfig};
use crate::connection::{ConnectionManager, RenderTrigger, SubscriptionRegistry};
use crate::context::{ComponentTree, RenderContext};
use crate::query::CacheKey;
use crate::signal::SignalStore;
use crate::store::Store;
use crate::query::CacheKey;
use axum::extract::{Query as AxumQuery, State};
use axum::response::sse::{Event, KeepAlive, Sse};
@@ -10,9 +11,14 @@ use futures::future::BoxFuture;
use std::convert::Infallible;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::sync::RwLock;
use tokio::time::{sleep, Duration};
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
const DEBOUNCE_MS: u64 = 2;
const RECONNECT_GRACE_SECS: u64 = 30;
// ---------------------------------------------------------------------------
// RenderFn
// ---------------------------------------------------------------------------
@@ -38,7 +44,11 @@ use uuid::Uuid;
/// Ok((html, keys))
/// }));
/// ```
pub type RenderFn = Arc<dyn Fn(RenderContext) -> BoxFuture<'static, anyhow::Result<(String, Vec<CacheKey>)>> + Send + Sync>;
pub type RenderFn = Arc<
dyn Fn(RenderContext) -> BoxFuture<'static, anyhow::Result<(String, Vec<CacheKey>)>>
+ Send
+ Sync,
>;
// ---------------------------------------------------------------------------
// AppState
@@ -46,26 +56,196 @@ pub type RenderFn = Arc<dyn Fn(RenderContext) -> BoxFuture<'static, anyhow::Resu
/// All shared framework + app state.
///
/// Construct with `AppState::new(store, render_fn)` and wrap in `Arc` to
/// produce a `SharedState` for axum's `State` extractor.
/// Construct with [`AppState::new`] and wrap in `Arc` to produce a
/// [`SharedState`] for axum's `State` extractor.
///
/// The `config` field holds the live [`LiveVueConfig`] and is updated in place
/// by the file-watcher / SIGHUP tasks started by
/// [`crate::config::load_and_watch_config`]. Handlers and middleware that need
/// config values (e.g. brotli quality) should clone or read from this field.
pub struct AppState {
pub store: Arc<Store>,
pub connections: ConnectionManager,
pub subscriptions: SubscriptionRegistry,
pub render_fn: RenderFn,
/// Live server configuration, hot-reloadable via SIGHUP or file watch.
pub config: SharedConfig,
}
pub type SharedState = Arc<AppState>;
impl AppState {
pub fn new(store: Arc<Store>, render_fn: RenderFn) -> SharedState {
/// Build a [`SharedState`] with the given store, render function, and config.
///
/// Typically called after [`crate::config::load_and_watch_config`]:
///
/// ```ignore
/// let config = load_and_watch_config("livevue.toml")?;
/// let state = AppState::new(store, render_fn, config);
/// spawn_fanout(state.clone());
/// ```
pub fn new(store: Arc<Store>, render_fn: RenderFn, config: SharedConfig) -> SharedState {
Arc::new(Self {
store,
connections: ConnectionManager::new(),
subscriptions: SubscriptionRegistry::new(),
render_fn,
config,
})
}
/// Convenience constructor that uses default configuration.
///
/// Suitable for examples and tests where `livevue.toml` is not needed.
pub fn new_default(store: Arc<Store>, render_fn: RenderFn) -> SharedState {
let config = Arc::new(RwLock::new(LiveVueConfig::default()));
Self::new(store, render_fn, config)
}
}
// ---------------------------------------------------------------------------
// render_and_send helper
// ---------------------------------------------------------------------------
/// Parse signals, render, and send SSE events for one connection.
///
/// On any error, logs and returns without panicking.
async fn render_and_send(
conn_id: Uuid,
signals_json: &str,
sse_tx: &mpsc::Sender<Event>,
state: &AppState,
) {
let signals = match serde_json::from_str::<serde_json::Value>(signals_json) {
Ok(v) => SignalStore::from_json(v),
Err(e) => {
tracing::error!("render_and_send: failed to parse signals for {}: {}", conn_id, e);
return;
}
};
let tree: ComponentTree = signals
.get("tree")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let cx = RenderContext::new(conn_id, signals, tree, state.store.clone());
match (state.render_fn)(cx).await {
Ok((html, keys)) => {
state.subscriptions.update(conn_id, keys);
let data_payload: String = html
.lines()
.map(|line| format!("elements {}", line))
.collect::<Vec<_>>()
.join("\n");
let html_event = Event::default()
.event("datastar-patch-elements")
.data(data_payload);
if sse_tx.send(html_event).await.is_err() {
tracing::debug!("render_and_send: SSE channel closed for {}", conn_id);
return;
}
let signals_event = Event::default()
.event("datastar-patch-signals")
.data("signals ".to_owned() + signals_json);
if sse_tx.send(signals_event).await.is_err() {
tracing::debug!("render_and_send: SSE channel closed for {}", conn_id);
}
}
Err(e) => {
tracing::error!("render_and_send: render error for {}: {}", conn_id, e);
}
}
}
// ---------------------------------------------------------------------------
// connection_task
// ---------------------------------------------------------------------------
/// Long-lived task that owns the SSE pipe for one logical connection.
///
/// Survives SSE disconnects for a grace period (RECONNECT_GRACE_SECS), allowing
/// the client to reconnect cleanly. Debounces render triggers by DEBOUNCE_MS.
async fn connection_task(
conn_id: Uuid,
mut inbox: mpsc::Receiver<RenderTrigger>,
mut pipe_rx: mpsc::Receiver<mpsc::Sender<Event>>,
initial_signals_json: String,
state: SharedState,
) {
let mut signals_json = initial_signals_json;
loop {
// ----------------------------------------------------------------
// Wait for first/next SSE pipe (with reconnect grace period).
// ----------------------------------------------------------------
let sse_tx: mpsc::Sender<Event> = tokio::select! {
maybe_pipe = pipe_rx.recv() => {
match maybe_pipe {
Some(tx) => tx,
None => {
// pipe channel closed — shut down
return;
}
}
}
_ = sleep(Duration::from_secs(RECONNECT_GRACE_SECS)) => {
tracing::debug!(
"connection_task: grace period expired for {}, cleaning up",
conn_id
);
state.connections.remove(&conn_id);
state.subscriptions.remove_connection(&conn_id);
return;
}
};
// Do an immediate render on pipe arrival.
render_and_send(conn_id, &signals_json, &sse_tx, &state).await;
// ----------------------------------------------------------------
// Main event loop for this pipe.
// ----------------------------------------------------------------
loop {
tokio::select! {
// SSE pipe dropped by the HTTP layer — wait for reconnect.
_ = sse_tx.closed() => {
tracing::debug!("connection_task: SSE pipe closed for {}, waiting for reconnect", conn_id);
break; // back to outer loop to wait for new pipe
}
// Trigger channel closed — task is being shut down.
maybe_trigger = inbox.recv() => {
match maybe_trigger {
None => {
// Trigger sender dropped — clean up and exit.
state.connections.remove(&conn_id);
state.subscriptions.remove_connection(&conn_id);
return;
}
Some(_) => {
// Debounce: drain any additional triggers within DEBOUNCE_MS.
sleep(Duration::from_millis(DEBOUNCE_MS)).await;
while inbox.try_recv().is_ok() {}
// Read fresh signals and render.
signals_json = state
.connections
.get_signals(&conn_id)
.unwrap_or_else(|| signals_json.clone());
render_and_send(conn_id, &signals_json, &sse_tx, &state).await;
}
}
}
}
}
}
}
// ---------------------------------------------------------------------------
@@ -77,16 +257,11 @@ impl AppState {
/// Must be called once at startup, after building `AppState`. The task owns
/// a `broadcast::Receiver<CacheKey>` and runs for the lifetime of the server.
///
/// On each invalidation event:
/// 1. Look up subscribed connections via `SubscriptionRegistry`.
/// 2. For each connection, fetch `last_signals_json` + SSE sender.
/// 3. Reconstruct `SignalStore` + `ComponentTree`, build `RenderContext`.
/// 4. Call `render_fn` to produce HTML.
/// 5. Frame as SSE and send down the connection's SSE channel.
/// 6. Update subscription keys and `last_signals_json` from the new render.
/// On each invalidation event, routes a `RenderTrigger::Invalidated` to each
/// subscribed connection's long-lived connection task.
///
/// ```ignore
/// let state = AppState::new(store.clone(), render_fn);
/// let state = AppState::new(store.clone(), render_fn, config);
/// spawn_fanout(state.clone());
/// ```
pub fn spawn_fanout(state: SharedState) {
@@ -109,64 +284,9 @@ pub fn spawn_fanout(state: SharedState) {
let conn_ids = state.subscriptions.get_connections_for_key(&key);
for conn_id in conn_ids {
let signals_json = state.connections.get_signals(&conn_id)
.unwrap_or_else(|| "{}".to_string());
let Some(sse_tx) = state.connections.get_sender(&conn_id) else {
continue;
};
let signals = match serde_json::from_str::<serde_json::Value>(&signals_json) {
Ok(v) => SignalStore::from_json(v),
Err(e) => {
tracing::error!("fanout: failed to parse signals for {}: {}", conn_id, e);
continue;
}
};
// Parse the component tree from the `tree` signal.
let tree = signals
.get("tree")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut cx = RenderContext::new(
conn_id,
signals,
tree,
state.store.clone(),
);
let render_fn = state.render_fn.clone();
let state_ref = state.clone();
tokio::spawn(async move {
match render_fn(cx).await {
Ok((html, keys)) => {
state_ref.subscriptions.update(conn_id, keys);
// Build a datastar-patch-elements SSE event.
// Each HTML line gets an `elements ` prefix; axum's
// Event::data() splits on \n and writes a `data:`
// line for each, which is correct SSE multiline format.
let data_payload: String = html
.lines()
.map(|line| format!("elements {}", line))
.collect::<Vec<_>>()
.join("\n");
let event = Event::default()
.event("datastar-patch-elements")
.data(data_payload);
if sse_tx.send(event).await.is_err() {
tracing::debug!("fanout: SSE channel closed for {}", conn_id);
}
}
Err(e) => {
tracing::error!("fanout: render error for {}: {}", conn_id, e);
}
}
});
if let Some(tx) = state.connections.get_trigger_sender(&conn_id) {
tx.send(RenderTrigger::Invalidated).await.ok();
}
}
}
});
@@ -184,75 +304,45 @@ pub struct SseParams {
/// `GET /sse?conn=<uuid>` — Long-lived SSE pipe for server-initiated push.
///
/// The conn_id is generated by `GET /` and pre-seeded with initial signals
/// in `ConnectionManager` before this handler is called. On connect, the
/// handler immediately triggers a render and pushes the result down the pipe,
/// so the client sees content without any further round-trip.
/// On first connect, spawns a `connection_task` that owns the lifecycle of the
/// connection. On reconnect with the same conn_id, hands a new SSE pipe to the
/// already-running task. The task survives disconnects for RECONNECT_GRACE_SECS.
pub async fn sse_handler(
State(state): State<SharedState>,
AxumQuery(params): AxumQuery<SseParams>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let conn_id = params.conn;
let (tx, rx) = mpsc::channel::<Event>(32);
let tx_watch = tx.clone();
state.connections.insert(conn_id, tx.clone());
let pipe_tx: mpsc::Sender<mpsc::Sender<Event>> = if !state.connections.contains(&conn_id) {
// Fresh connection — create channels and spawn the connection task.
let (trigger_tx, trigger_rx) = mpsc::channel::<RenderTrigger>(32);
let (pipe_tx, pipe_rx) = mpsc::channel::<mpsc::Sender<Event>>(4);
// Cleanup on disconnect
let state_cleanup = state.clone();
tokio::spawn(async move {
tx_watch.closed().await;
state_cleanup.connections.remove(&conn_id);
state_cleanup.subscriptions.remove_connection(&conn_id);
tracing::debug!("cleaned up connection {}", conn_id);
});
println!("SSE Handler called");
let initial_signals = state
.connections
.get_signals(&conn_id)
.unwrap_or_else(|| "{}".to_string());
// Trigger first render immediately using the pre-seeded signals.
let signals_json = state.connections.get_signals(&conn_id)
.unwrap_or_else(|| "{}".to_string());
state.connections.insert(conn_id, trigger_tx, pipe_tx.clone());
let render_fn = state.render_fn.clone();
let store = state.store.clone();
let subscriptions = state.subscriptions.clone();
let tx_init = tx.clone();
let state_task = state.clone();
tokio::spawn(async move {
connection_task(conn_id, trigger_rx, pipe_rx, initial_signals, state_task).await;
});
tokio::spawn(async move {
let signals = match serde_json::from_str::<serde_json::Value>(&signals_json) {
Ok(v) => SignalStore::from_json(v),
Err(e) => {
tracing::error!("sse_handler: failed to parse seeded signals: {}", e);
return;
}
};
let tree = signals
.get("tree")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
pipe_tx
} else {
// Reconnect — retrieve existing pipe sender.
state.connections.get_pipe_sender(&conn_id)
.expect("connection exists but has no pipe_tx")
};
let cx = RenderContext::new(conn_id, signals, tree, store);
match render_fn(cx).await {
Ok((html, keys)) => {
subscriptions.update(conn_id, keys);
let data_payload = html
.lines()
.map(|line| format!("elements {}", line))
.collect::<Vec<_>>()
.join("\n");
let event = Event::default()
.event("datastar-patch-elements")
.data(data_payload);
tx_init.send(event).await.ok();
}
Err(e) => tracing::error!("sse_handler: initial render failed: {}", e),
}
});
// Create a fresh SSE channel for this HTTP connection's lifetime.
let (sse_tx, sse_rx) = mpsc::channel::<Event>(32);
pipe_tx.send(sse_tx).await.ok();
let stream = ReceiverStream::new(rx);
let event_stream = tokio_stream::StreamExt::map(stream, |event| {
Ok::<Event, Infallible>(event)
});
let stream = ReceiverStream::new(sse_rx);
let event_stream = tokio_stream::StreamExt::map(stream, |event| Ok::<Event, Infallible>(event));
Sse::new(event_stream).keep_alive(KeepAlive::default())
}
@@ -294,14 +384,22 @@ pub struct ActionParams {
/// StatusCode::ACCEPTED
/// }
/// ```
pub fn ingest_signals(
state: &AppState,
conn_id: Uuid,
body: serde_json::Value,
) -> SignalStore {
pub fn ingest_signals(state: &AppState, conn_id: Uuid, body: serde_json::Value) -> SignalStore {
let store = SignalStore::from_json(body);
state
.connections
.update_signals(&conn_id, store.to_json_string());
store
}
}
pub fn patch_signal(state: &AppState, conn_id: &Uuid, key: &str, value: serde_json::Value) {
let current = state
.connections
.get_signals(conn_id)
.unwrap_or_else(|| "{}".to_string());
let mut store = SignalStore::from_json(serde_json::from_str(&current).unwrap_or_default());
store.insert(key.to_string(), value);
state
.connections
.update_signals(conn_id, store.to_json_string());
}
+1
View File
@@ -30,6 +30,7 @@ pub fn format_patch_elements(html: &str) -> String {
event
}
/// Format a `datastar-patch-signals` SSE event.
///
/// `signals_js` should be a JS object literal string, e.g. `{foo: '', bar: 0}`.
+18 -1
View File
@@ -108,7 +108,7 @@ impl Store {
/// that can be buffered before slow receivers start lagging. The fanout
/// task should be fast enough that this is never hit in practice.
pub fn new(db: sqlx::SqlitePool) -> Arc<Self> {
let (events_tx, _) = broadcast::channel(256);
let (events_tx, _) = broadcast::channel(16384);
let kv = Arc::new(KVStore::new(events_tx.clone()));
Arc::new(Self {
db,
@@ -137,4 +137,21 @@ impl Store {
pub fn subscribe(&self) -> broadcast::Receiver<CacheKey> {
self.events.subscribe()
}
/// Execute a mutation query and auto-publish its invalidation key.
///
/// Use this in action handlers instead of calling the plain async mutation
/// function and then `store.publish(key)` separately. The mutation's
/// `publish_key()` drives the fanout automatically.
///
/// ```ignore
/// state.store.exec(CreateTodo { title }).await?;
/// ```
pub async fn exec<Q: crate::query::Query>(&self, query: Q) -> anyhow::Result<Q::Output> {
let result = query.execute(&self.db).await?;
if let Some(key) = query.publish_key() {
self.publish(key);
}
Ok(result)
}
}
+13
View File
@@ -0,0 +1,13 @@
Here's a list of things I still want to get done
- [ ] refactor or change the query contract so for pg_queries we keep auto setting key but for sqlite we get the option to not only set a cache invalidation key, we also get the option to publish a cache invalidation event. so a create_todo query would automaitcally trigger a fanout when cx.run gets to it.
- [ ] the sse file is unsused and the function bodies are inlined in server.rs. It's also wrong and the content of server.rs are correct.
- [ ] fix the pg_replication example such that it does not pass the {inner} in the initial render but intead pushes it over the sse connection. This is exclusively such that the signals are not yet in scope when the sse initiates to keep the noisy signals out of the get parameters
- might accomplish this by including filterSignals in the `@get(/route, options = {filterSignals = })` and then I could put some regex to filter out literally everything.
- [ ] Make another example site, this one maybe demo'ing cache-backed queries
- [ ] Review example code and extract common logic to framework code
- [ ] consider moving connId to a header by passing the following in the @get options:
headers: {
'X-Conn-Id': 'some_uuid',
},
- [ ]