Skip to content

Commit 9ad5583

Browse files
authored
Post/cross node bus (#53)
* post: Cross Node Bus * dependency updates * cross node bus post
1 parent 6b5a7eb commit 9ad5583

7 files changed

Lines changed: 1733 additions & 1811 deletions

File tree

.yarn/releases/yarn-4.14.1.cjs

Lines changed: 0 additions & 940 deletions
This file was deleted.

.yarn/releases/yarn-4.16.0.cjs

Lines changed: 944 additions & 0 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
nodeLinker: node-modules
22

3-
yarnPath: .yarn/releases/yarn-4.14.1.cjs
3+
yarnPath: .yarn/releases/yarn-4.16.0.cjs

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,26 @@
2424
"engines": {
2525
"node": ">=22.0.0"
2626
},
27-
"packageManager": "yarn@4.14.1",
27+
"packageManager": "yarn@4.16.0",
2828
"dependencies": {
29-
"@astrojs/check": "^0.9.8",
29+
"@astrojs/check": "^0.9.9",
3030
"@astrojs/rss": "^4.0.18",
31-
"@astrojs/sitemap": "^3.7.2",
32-
"astro": "^6.1.9",
33-
"date-fns": "^4.1.0",
31+
"@astrojs/sitemap": "^3.7.3",
32+
"astro": "^6.4.4",
33+
"date-fns": "^4.4.0",
3434
"remark-smartypants": "^3.0.2",
3535
"sharp": "^0.34.5"
3636
},
3737
"devDependencies": {
3838
"@eslint/js": "^10.0.1",
39-
"@types/node": "^25.6.0",
40-
"eslint": "^10.2.1",
39+
"@types/node": "^25.9.2",
40+
"eslint": "^10.4.1",
4141
"eslint-plugin-astro": "^1.7.0",
4242
"husky": "^9.1.7",
43-
"lint-staged": "^16.4.0",
43+
"lint-staged": "^17.0.7",
4444
"prettier": "^3.8.3",
4545
"prettier-plugin-astro": "^0.14.1",
4646
"typescript": "^6.0.3",
47-
"typescript-eslint": "^8.59.0"
47+
"typescript-eslint": "^8.60.1"
4848
}
4949
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
---
2+
title: 'Cross-node bus: finally, pub/sub that actually crosses nodes'
3+
author: [gallayl]
4+
tags:
5+
[
6+
'Architecture',
7+
'distributed-systems',
8+
'redis',
9+
'entity-sync',
10+
'rest-service',
11+
'identity',
12+
'cross-node-bus',
13+
]
14+
date: '2026-06-09T12:00:00.000Z'
15+
draft: false
16+
image: img/021-cross-node-bus.png
17+
excerpt: 'FuryStack now ships a real cross-node event bus and a Redis Streams adapter, so identity invalidation and entity sync finally work across pods instead of just within one lonely process.'
18+
---
19+
20+
If your FuryStack app has ever been deployed as more than one pod, you already know the classic bug:
21+
22+
- user logs out on Pod A
23+
- Pod B keeps serving stale identity until the cache TTL expires
24+
- the whole system acts like the other nodes are imaginary friends
25+
26+
That illusion is over.
27+
28+
## What shipped
29+
30+
The new release adds a tiny public bus abstraction in `@furystack/cross-node-bus` plus a real Redis Streams adapter in `@furystack/redis-cross-node-bus`.
31+
32+
That means the framework now has one shared, transport-agnostic primitive for:
33+
34+
- identity events (`IdentityEventBus`)
35+
- entity change broadcasts (`EntityChangeBus`)
36+
- app-defined typed facades over the bus
37+
38+
The implementation is intentionally straightforward, because a distributed event bus should be predictable and reliable before it tries to be clever.
39+
40+
## Why this matters
41+
42+
Before this, FuryStack's internal `EventHub` and cache invalidations were strictly process-local. The app could run on N pods behind a load balancer, and every cross-cutting channel still behaved like a single-node app.
43+
44+
The new bus changes that:
45+
46+
- logout and session invalidation events now propagate across nodes
47+
- entity change notifications now fan out from one write node to every sibling
48+
- adapters can assign sequence ids, support replay, and let reconnecting consumers catch up without guessing
49+
50+
In other words: the system now treats the fleet as one cooperative actor instead of N independent silos.
51+
52+
## The simple API that makes this work
53+
54+
The core package defines one shared token: `CrossNodeBus`.
55+
56+
That token exposes a small interface:
57+
58+
- `publish(topic, payload)`
59+
- `subscribe(topic, handler)`
60+
- `subscribeRemoteOnly(topic, handler)`
61+
- `subscribeForeign(prefix, topic, handler)`
62+
- `replay(topic, fromSeq)`
63+
64+
And yes, it also exposes a stable `nodeId` and a capability descriptor so the framework can refuse to start if the bound adapter does not support the features it needs.
65+
66+
That last bit is important. `EntityChangeBus` requires `replay` and `assignsSequence`, and we now fail loudly at startup instead of letting a non-replaying adapter serve stale data forever.
67+
68+
## What the packages do
69+
70+
### `@furystack/cross-node-bus`
71+
72+
This is the abstraction. It includes:
73+
74+
- the `CrossNodeBus` token
75+
- `InProcessCrossNodeBus` default factory for single-node apps
76+
- a shared `BusMessage` envelope with `originId`, `emittedAt`, optional `seq`, and version pinning
77+
- a lightweight in-process replay ring buffer for reconnecting subscribers
78+
- a testing harness for multi-instance in-process simulations
79+
80+
The default adapter is intentionally unexciting. If you do not bind a transport adapter, the bus still works in one process exactly like before.
81+
82+
### `@furystack/redis-cross-node-bus`
83+
84+
This is the production-grade adapter that actually talks to Redis Streams.
85+
86+
It supports:
87+
88+
- persistence
89+
- server-assigned sequence ids
90+
- replay from an arbitrary stream position
91+
- prefix-based multi-service isolation
92+
- explicit cross-prefix subscription via `subscribeForeign`
93+
94+
The Redis adapter also duplicates the Redis client for the read loop, so every subscriber can boot without requiring consumer groups or orphaned group state.
95+
96+
## Identity and entity sync now live on the same shared bus
97+
98+
The two framework facades that landed with this release are the ones where the difference is most visible:
99+
100+
- `IdentityEventBus` in `@furystack/rest-service` now publishes logout/invalidation events to the bus and invalidates cached user resolution across nodes.
101+
- `EntityChangeBus` in `@furystack/entity-sync-service` now publishes model-level deltas on the bus and uses replay to restore reconnecting clients.
102+
103+
That means a logout on one node invalidates the same cache entry everywhere, and a model update on one node is no longer invisible to the rest of the cluster.
104+
105+
## What “cross-node” actually looks like
106+
107+
The implementation is deliberately opinionated about one thing: the bus is a broadcast notification layer, not a distributed state store.
108+
109+
So:
110+
111+
- the bus carries events, not entire object graphs
112+
- receivers still re-read their local store when they need the canonical state
113+
- duplicates are fine, because the system is already designed for them
114+
- ordering is only guaranteed per-publisher, not globally across the fleet
115+
116+
That is the practical design FuryStack needs right now: a broadcast notification layer, not a distributed state machine.
117+
118+
## How apps should use it
119+
120+
App authors do not need to use low-level Redis or wire-format details. The recommended pattern is:
121+
122+
- bind `CrossNodeBus` to a transport adapter
123+
- define a typed facade over the bus
124+
- publish typed domain events on that facade
125+
- subscribe using the facade's local event API
126+
127+
This is the same pattern the framework facades already use, and it means custom app buses can be built with the same DX as `EventHub<T>` while still getting cross-node delivery.
128+
129+
Example sketch:
130+
131+
```ts
132+
const AppEventBus = defineService({
133+
name: 'my-app/AppEventBus',
134+
lifetime: 'singleton',
135+
factory: ({ inject, onDispose }) => {
136+
const bus = inject(CrossNodeBus);
137+
const local = new EventHub<{ event: AppEvent }>();
138+
139+
const handle = bus.subscribe('app/events', message => {
140+
local.emit('event', message.payload as AppEvent);
141+
});
142+
143+
onDispose(() => handle[Symbol.dispose]());
144+
145+
return {
146+
subscribe: (handler: (e: AppEvent) => void) => local.subscribe('event', handler),
147+
publish: (event: AppEvent) => bus.publish('app/events', event),
148+
};
149+
},
150+
});
151+
```
152+
153+
This release ships that pattern in the framework and makes it the recommended extension point for app-defined coordination.
154+
155+
## So what changed for release users?
156+
157+
If you are already on FuryStack 2026, the new bus means:
158+
159+
- single-node apps keep working as before
160+
- multi-node apps can bind `@furystack/redis-cross-node-bus`
161+
- identity invalidation becomes fleet-wide instead of pod-local
162+
- entity sync uses replay and sequence numbers instead of fragile local changelogs
163+
164+
If your app needs cross-service isolation, `topicPrefix` gives each service its own wire namespace, and `subscribeForeign` makes cross-service eavesdrop explicit.
165+
166+
## Final note
167+
168+
This is the kind of feature that is boring to write and exciting to use.
169+
170+
You do not need a new framework API for every cross-node event you want to publish. You only need a small shared bus, a typed facade, and a shipping-quality adapter.
171+
172+
`@furystack/cross-node-bus` is that shared bus. `@furystack/redis-cross-node-bus` is the adapter that makes it real.
173+
174+
If your app runs on more than one pod, go try it. If your app does not, congrats — no upgrade urgency, but the package still ships with a really clean in-process default.
2.15 MB
Loading

0 commit comments

Comments
 (0)