create-fluxstack 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,11 +72,12 @@ export class LiveLocalCounter extends LiveComponent<typeof LiveLocalCounter.defa
72
72
  6. **Mandatory `publicActions`** - Components without it deny ALL remote actions (secure by default)
73
73
  7. **Client link** - `import type { Demo as _Client }` enables Ctrl+Click in IDE
74
74
 
75
- ### With Room Events (Advanced)
75
+ ### With Typed Rooms (Advanced)
76
76
 
77
77
  ```typescript
78
78
  // app/server/live/LiveCounter.ts
79
79
  import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
80
+ import { CounterRoom } from './rooms/CounterRoom'
80
81
 
81
82
  export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
82
83
  static componentName = 'LiveCounter'
@@ -86,9 +87,11 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState>
86
87
  lastUpdatedBy: null as string | null,
87
88
  connectedUsers: 0
88
89
  }
89
- protected roomType = 'counter'
90
90
 
91
- // Constructor needed for room event subscriptions
91
+ private roomId: string
92
+ private unsubscribeCounter: (() => void) | null = null
93
+ private unsubscribePresence: (() => void) | null = null
94
+
92
95
  constructor(
93
96
  initialState: Partial<typeof LiveCounter.defaultState> = {},
94
97
  ws: FluxStackWebSocket,
@@ -96,59 +99,90 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState>
96
99
  ) {
97
100
  super(initialState, ws, options)
98
101
 
99
- this.onRoomEvent<{ count: number; userId: string }>('COUNT_CHANGED', (data) => {
100
- this.setState({ count: data.count, lastUpdatedBy: data.userId })
101
- })
102
+ this.roomId = options?.room ?? 'default'
103
+ const room = this.$room(CounterRoom, this.roomId)
104
+ room.join()
102
105
 
103
- this.onRoomEvent<{ connectedUsers: number }>('USER_COUNT_CHANGED', (data) => {
104
- this.setState({ connectedUsers: data.connectedUsers })
106
+ // Load authoritative room state for new joiners.
107
+ this.setState({
108
+ count: room.state.count,
109
+ lastUpdatedBy: room.state.lastUpdatedBy,
110
+ connectedUsers: room.state.onlineCount
105
111
  })
106
112
 
107
- this.notifyUserJoined()
108
- }
113
+ this.unsubscribeCounter = room.on('counter:updated', (data) => {
114
+ this.setState({
115
+ count: data.count,
116
+ lastUpdatedBy: data.updatedBy
117
+ })
118
+ })
109
119
 
110
- private notifyUserJoined() {
111
- const newCount = this.state.connectedUsers + 1
112
- this.emitRoomEventWithState('USER_COUNT_CHANGED',
113
- { connectedUsers: newCount },
114
- { connectedUsers: newCount }
115
- )
120
+ this.unsubscribePresence = room.on('presence:changed', (data) => {
121
+ this.setState({ connectedUsers: data.onlineCount })
122
+ })
116
123
  }
117
124
 
118
125
  async increment() {
119
- const newCount = this.state.count + 1
120
- this.emitRoomEventWithState('COUNT_CHANGED',
121
- { count: newCount, userId: this.userId || 'anonymous' },
122
- { count: newCount, lastUpdatedBy: this.userId || 'anonymous' }
123
- )
124
- return { success: true, count: newCount }
126
+ const room = this.$room(CounterRoom, this.roomId)
127
+ const count = room.increment(this.userId || 'anonymous')
128
+ return { success: true, count }
125
129
  }
126
130
 
127
131
  async decrement() {
128
- const newCount = this.state.count - 1
129
- this.emitRoomEventWithState('COUNT_CHANGED',
130
- { count: newCount, userId: this.userId || 'anonymous' },
131
- { count: newCount, lastUpdatedBy: this.userId || 'anonymous' }
132
- )
133
- return { success: true, count: newCount }
132
+ const room = this.$room(CounterRoom, this.roomId)
133
+ const count = room.decrement(this.userId || 'anonymous')
134
+ return { success: true, count }
134
135
  }
135
136
 
136
137
  async reset() {
137
- this.emitRoomEventWithState('COUNT_CHANGED',
138
- { count: 0, userId: this.userId || 'anonymous' },
139
- { count: 0, lastUpdatedBy: this.userId || 'anonymous' }
140
- )
141
- return { success: true, count: 0 }
138
+ const room = this.$room(CounterRoom, this.roomId)
139
+ const count = room.reset(this.userId || 'anonymous')
140
+ return { success: true, count }
142
141
  }
143
142
 
144
143
  destroy() {
145
- const newCount = Math.max(0, this.state.connectedUsers - 1)
146
- this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
144
+ this.unsubscribeCounter?.()
145
+ this.unsubscribePresence?.()
147
146
  super.destroy()
148
147
  }
149
148
  }
150
149
  ```
151
150
 
151
+ Presence must be tracked by authoritative room state, not by incrementing each component instance's local `connectedUsers`. A new tab creates a new component instance with local state starting at `0`, so local arithmetic can publish the wrong count. Put membership accounting in the `LiveRoom` lifecycle:
152
+
153
+ ```typescript
154
+ // app/server/live/rooms/CounterRoom.ts
155
+ interface CounterEvents {
156
+ 'counter:updated': { count: number; updatedBy: string }
157
+ 'presence:changed': { onlineCount: number }
158
+ }
159
+
160
+ export class CounterRoom extends LiveRoom<CounterState, {}, CounterEvents> {
161
+ static roomName = 'counter'
162
+ static defaultState = { count: 0, lastUpdatedBy: null, onlineCount: 0 }
163
+ static defaultMeta = {}
164
+
165
+ onJoin() {
166
+ const onlineCount = this.state.onlineCount + 1
167
+ this.setState({ onlineCount })
168
+ this.emit('presence:changed', { onlineCount })
169
+ }
170
+
171
+ onLeave() {
172
+ const onlineCount = Math.max(0, this.state.onlineCount - 1)
173
+ this.setState({ onlineCount })
174
+ this.emit('presence:changed', { onlineCount })
175
+ }
176
+
177
+ increment(username: string) {
178
+ const count = this.state.count + 1
179
+ this.setState({ count, lastUpdatedBy: username })
180
+ this.emit('counter:updated', { count, updatedBy: username })
181
+ return count
182
+ }
183
+ }
184
+ ```
185
+
152
186
  ## Lifecycle Hooks
153
187
 
154
188
  The `@fluxstack/live` framework provides a full lifecycle hook system. All hooks are **optional** -- override only what you need. The example components in `app/server/live/` do not use all of them, but they are all available in the framework API.
@@ -274,7 +308,7 @@ Rehydration (reconnect with saved state):
274
308
 
275
309
  - All hooks are optional -- override only what you need
276
310
  - All hook errors are caught and logged -- they never break the system
277
- - Constructor is still needed ONLY for `this.onRoomEvent()` subscriptions
311
+ - Constructor is still needed ONLY for room setup/subscriptions (`this.$room(...).join()`, `room.on(...)`, or legacy `this.onRoomEvent()`)
278
312
  - All hooks are in BLOCKED_ACTIONS -- clients cannot call them remotely
279
313
 
280
314
  ## Custom ID Generator
@@ -597,6 +631,7 @@ export class LiveSharedCounter extends LiveComponent<typeof LiveSharedCounter.de
597
631
  }
598
632
 
599
633
  private counterUnsub: (() => void) | null = null
634
+ private presenceUnsub: (() => void) | null = null
600
635
 
601
636
  constructor(initialState: Partial<typeof LiveSharedCounter.defaultState> = {}, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
602
637
  super(initialState, ws, options)
@@ -615,6 +650,10 @@ export class LiveSharedCounter extends LiveComponent<typeof LiveSharedCounter.de
615
650
  this.counterUnsub = room.on('counter:updated', (data) => {
616
651
  this.setState({ count: data.count, lastUpdatedBy: data.updatedBy })
617
652
  })
653
+
654
+ this.presenceUnsub = room.on('presence:changed', (data) => {
655
+ this.setState({ onlineCount: data.onlineCount })
656
+ })
618
657
  }
619
658
 
620
659
  async increment() {
@@ -625,6 +664,7 @@ export class LiveSharedCounter extends LiveComponent<typeof LiveSharedCounter.de
625
664
 
626
665
  destroy() {
627
666
  this.counterUnsub?.()
667
+ this.presenceUnsub?.()
628
668
  super.destroy()
629
669
  }
630
670
  }
@@ -893,11 +933,11 @@ All components in same room receive events:
893
933
  ```typescript
894
934
  // User A increments
895
935
  await counter.increment()
896
- // Emits COUNT_CHANGED to room
936
+ // Calls CounterRoom.increment(), which updates room state and emits counter:updated
897
937
 
898
938
  // User B's component receives event
899
- this.onRoomEvent('COUNT_CHANGED', (data) => {
900
- this.setState({ count: data.count })
939
+ const unsub = room.on('counter:updated', (data) => {
940
+ this.setState({ count: data.count, lastUpdatedBy: data.updatedBy })
901
941
  })
902
942
  // User B sees updated count
903
943
  ```
@@ -907,25 +947,31 @@ this.onRoomEvent('COUNT_CHANGED', (data) => {
907
947
  Track connected users in room:
908
948
 
909
949
  ```typescript
910
- constructor(initialState, ws, options) {
911
- super(initialState, ws, options)
912
-
913
- // Notify room of new user
914
- const newCount = this.state.connectedUsers + 1
915
- this.emitRoomEventWithState('USER_COUNT_CHANGED',
916
- { connectedUsers: newCount },
917
- { connectedUsers: newCount }
918
- )
950
+ // In the typed room class, not in each component instance.
951
+ onJoin() {
952
+ const onlineCount = this.state.onlineCount + 1
953
+ this.setState({ onlineCount })
954
+ this.emit('presence:changed', { onlineCount })
919
955
  }
920
956
 
921
- destroy() {
922
- // Notify room of user leaving
923
- const newCount = Math.max(0, this.state.connectedUsers - 1)
924
- this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
925
- super.destroy()
957
+ onLeave() {
958
+ const onlineCount = Math.max(0, this.state.onlineCount - 1)
959
+ this.setState({ onlineCount })
960
+ this.emit('presence:changed', { onlineCount })
961
+ }
962
+
963
+ // In the component constructor.
964
+ const room = this.$room(CounterRoom, options?.room ?? 'default')
965
+ room.join()
966
+ this.setState({ connectedUsers: room.state.onlineCount })
967
+
968
+ const unsub = room.on('presence:changed', (data) => {
969
+ this.setState({ connectedUsers: data.onlineCount })
926
970
  }
927
971
  ```
928
972
 
973
+ Do not calculate presence with `this.state.connectedUsers + 1` inside each component. Each browser tab mounts a separate component instance, so local state starts from its own value and can publish stale counts. Use room state (`room.state.onlineCount`) as the source of truth.
974
+
929
975
  ## Error Handling
930
976
 
931
977
  ```typescript
@@ -968,8 +1014,8 @@ The app includes these live components in `app/server/live/`:
968
1014
  | Component | Description | Features |
969
1015
  |-----------|-------------|----------|
970
1016
  | `LiveLocalCounter` | Simple counter, no room events | Direct state access, `declare` |
971
- | `LiveCounter` | Shared counter with room events | `onRoomEvent`, `emitRoomEventWithState` |
972
- | `LiveSharedCounter` | Shared counter using typed `CounterRoom` | `$room(CounterRoom, 'global')` |
1017
+ | `LiveCounter` | Shared counter using typed `CounterRoom` | `$room(CounterRoom, roomId)`, `presence:changed` |
1018
+ | `LiveSharedCounter` | Shared counter using typed `CounterRoom` | `$room(CounterRoom, 'global')`, `presence:changed` |
973
1019
  | `LiveForm` | Reactive form with server validation | `setValue`, `validate`, `submit` |
974
1020
  | `LivePingPong` | Binary codec demo (msgpack) | Typed `PingRoom`, round-trip timing |
975
1021
  | `LiveRoomChat` | Multi-room chat with directory | `ChatRoom`, `DirectoryRoom`, password rooms |
@@ -981,7 +1027,7 @@ The app includes these live components in `app/server/live/`:
981
1027
 
982
1028
  ```
983
1029
  app/server/live/
984
- ├── LiveCounter.ts # Shared counter with room events
1030
+ ├── LiveCounter.ts # Shared counter using typed CounterRoom
985
1031
  ├── LiveLocalCounter.ts # Local counter (no room)
986
1032
  ├── LiveForm.ts # Reactive form
987
1033
  ├── LivePingPong.ts # Binary codec demo
@@ -1038,7 +1084,7 @@ export class MyComponent extends LiveComponent<typeof MyComponent.defaultState>
1038
1084
  - Use `declare` for each state property (TypeScript type hint)
1039
1085
  - Use `onMount()` for async initialization (rooms, auth, data fetching)
1040
1086
  - Use `onDestroy()` for cleanup (timers, connections) -- sync only
1041
- - Use `emitRoomEventWithState` for state changes in rooms
1087
+ - Prefer typed `LiveRoom` state/methods for shared state and presence; use `emitRoomEventWithState` only for simple legacy untyped-room events
1042
1088
  - Handle errors in actions (throw Error)
1043
1089
  - Add client link: `import type { Demo as _Client } from '@client/...'`
1044
1090
  - Use `$persistent` for data that should survive HMR reloads