create-fluxstack 1.19.0 → 1.20.1
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.
- package/LLMD/INDEX.md +1 -1
- package/LLMD/MAINTENANCE.md +197 -197
- package/LLMD/MIGRATION.md +44 -1
- package/LLMD/agent.md +20 -7
- package/LLMD/config/declarative-system.md +268 -268
- package/LLMD/config/environment-vars.md +3 -6
- package/LLMD/config/runtime-reload.md +401 -401
- package/LLMD/core/build-system.md +599 -599
- package/LLMD/core/framework-lifecycle.md +249 -229
- package/LLMD/core/plugin-system.md +154 -100
- package/LLMD/patterns/anti-patterns.md +397 -397
- package/LLMD/patterns/project-structure.md +264 -264
- package/LLMD/patterns/type-safety.md +61 -5
- package/LLMD/reference/cli-commands.md +31 -7
- package/LLMD/reference/plugin-hooks.md +4 -2
- package/LLMD/reference/troubleshooting.md +364 -364
- package/LLMD/resources/controllers.md +465 -465
- package/LLMD/resources/live-auth.md +178 -1
- package/LLMD/resources/live-binary-delta.md +3 -1
- package/LLMD/resources/live-components.md +1192 -1041
- package/LLMD/resources/live-logging.md +3 -1
- package/LLMD/resources/live-rooms.md +1 -1
- package/LLMD/resources/live-upload.md +228 -181
- package/LLMD/resources/plugins-external.md +8 -7
- package/LLMD/resources/rest-auth.md +290 -290
- package/LLMD/resources/routes-eden.md +254 -254
- package/app/client/.live-stubs/LiveAdminPanel.js +15 -0
- package/app/client/.live-stubs/LiveCounter.js +9 -0
- package/app/client/.live-stubs/LiveForm.js +11 -0
- package/app/client/.live-stubs/LiveLocalCounter.js +8 -0
- package/app/client/.live-stubs/LivePingPong.js +10 -0
- package/app/client/.live-stubs/LiveRoomChat.js +11 -0
- package/app/client/.live-stubs/LiveSharedCounter.js +10 -0
- package/app/client/.live-stubs/LiveUpload.js +15 -0
- package/app/server/live/auto-generated-components.ts +1 -1
- package/core/utils/version.ts +6 -6
- package/package.json +108 -108
|
@@ -1,1041 +1,1192 @@
|
|
|
1
|
-
# Live Components
|
|
2
|
-
|
|
3
|
-
**Version:**
|
|
4
|
-
|
|
5
|
-
## Quick Facts
|
|
6
|
-
|
|
7
|
-
- Server-side state management with WebSocket sync
|
|
8
|
-
- **Direct state access** - `this.count++` auto-syncs
|
|
9
|
-
- **Lifecycle hooks** - `onMount()
|
|
10
|
-
- **HMR persistence** - `static persistent` + `this.$persistent` survives hot reloads
|
|
11
|
-
- **Singleton components** - `static singleton = true` for shared server-side instances
|
|
12
|
-
- **Mandatory `publicActions`** - Only whitelisted methods are callable from client (secure by default)
|
|
13
|
-
- **Helpful error messages** - Forgotten `publicActions` entries show exactly what to fix
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
static
|
|
34
|
-
|
|
35
|
-
static defaultState = {
|
|
36
|
-
count: 0
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.count
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
this.state.
|
|
112
|
-
this.emitRoomEventWithState('
|
|
113
|
-
{
|
|
114
|
-
{
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this
|
|
146
|
-
this
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Called
|
|
179
|
-
protected
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
-
|
|
277
|
-
-
|
|
278
|
-
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
this.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
```typescript
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
```typescript
|
|
586
|
-
import {
|
|
587
|
-
import {
|
|
588
|
-
|
|
589
|
-
export
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
```
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
```
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
-
|
|
1035
|
-
-
|
|
1036
|
-
-
|
|
1037
|
-
-
|
|
1038
|
-
-
|
|
1039
|
-
-
|
|
1040
|
-
-
|
|
1041
|
-
-
|
|
1
|
+
# Live Components
|
|
2
|
+
|
|
3
|
+
**Version:** @fluxstack/live 0.7.2 | **Updated:** 2026-04-14
|
|
4
|
+
|
|
5
|
+
## Quick Facts
|
|
6
|
+
|
|
7
|
+
- Server-side state management with WebSocket sync
|
|
8
|
+
- **Direct state access** - `this.count++` auto-syncs via reactive proxy
|
|
9
|
+
- **Lifecycle hooks** - `onMount()`, `onDestroy()`, `onConnect()`, `onDisconnect()`, and more (all optional)
|
|
10
|
+
- **HMR persistence** - `static persistent` + `this.$persistent` survives hot reloads
|
|
11
|
+
- **Singleton components** - `static singleton = true` for shared server-side instances
|
|
12
|
+
- **Mandatory `publicActions`** - Only whitelisted methods are callable from client (secure by default)
|
|
13
|
+
- **Helpful error messages** - Forgotten `publicActions` entries show exactly what to fix
|
|
14
|
+
- **Custom ID generator** - `LiveServerOptions.generateId` replaces default ID generation
|
|
15
|
+
- Automatic state persistence and re-hydration (with anti-replay nonces)
|
|
16
|
+
- Room-based event system for multi-user sync (typed `LiveRoom` support)
|
|
17
|
+
- Type-safe client-server communication (`FluxStackWebSocket`)
|
|
18
|
+
- Built-in connection management and recovery
|
|
19
|
+
- **Client component links** - Ctrl+Click navigation via `import type`
|
|
20
|
+
|
|
21
|
+
## LiveComponent Class Structure
|
|
22
|
+
|
|
23
|
+
Server-side component extends `LiveComponent` from `@fluxstack/live` with **static defaultState**:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// app/server/live/LiveLocalCounter.ts
|
|
27
|
+
import { LiveComponent } from '@core/types/types'
|
|
28
|
+
|
|
29
|
+
// Componente Cliente (Ctrl+Click para navegar)
|
|
30
|
+
import type { CounterDemo as _Client } from '@client/src/live/CounterDemo'
|
|
31
|
+
|
|
32
|
+
export class LiveLocalCounter extends LiveComponent<typeof LiveLocalCounter.defaultState> {
|
|
33
|
+
static componentName = 'LiveLocalCounter'
|
|
34
|
+
static publicActions = ['increment', 'decrement', 'reset'] as const // REQUIRED
|
|
35
|
+
static defaultState = {
|
|
36
|
+
count: 0,
|
|
37
|
+
clicks: 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Declarar propriedades do estado (TypeScript)
|
|
41
|
+
declare count: number
|
|
42
|
+
declare clicks: number
|
|
43
|
+
|
|
44
|
+
// Direct state access - auto-syncs with frontend
|
|
45
|
+
async increment() {
|
|
46
|
+
this.count++
|
|
47
|
+
this.clicks++
|
|
48
|
+
return { success: true, count: this.count }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async decrement() {
|
|
52
|
+
this.count--
|
|
53
|
+
this.clicks++
|
|
54
|
+
return { success: true, count: this.count }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async reset() {
|
|
58
|
+
this.count = 0
|
|
59
|
+
this.clicks++
|
|
60
|
+
return { success: true, count: 0 }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Key Patterns
|
|
66
|
+
|
|
67
|
+
1. **Direct state access** - `this.count++` instead of `this.state.count++`
|
|
68
|
+
2. **`declare` keyword** - TypeScript hint for dynamic state properties
|
|
69
|
+
3. **Static `defaultState`** inside the class - no external export needed
|
|
70
|
+
4. **Reactive Proxy** - `this.state.count++` or `this.count++` triggers sync automatically
|
|
71
|
+
5. **No constructor needed** - Base class handles `defaultState` merge (constructor only needed for room event subscriptions)
|
|
72
|
+
6. **Mandatory `publicActions`** - Components without it deny ALL remote actions (secure by default)
|
|
73
|
+
7. **Client link** - `import type { Demo as _Client }` enables Ctrl+Click in IDE
|
|
74
|
+
|
|
75
|
+
### With Room Events (Advanced)
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// app/server/live/LiveCounter.ts
|
|
79
|
+
import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
|
|
80
|
+
|
|
81
|
+
export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
|
|
82
|
+
static componentName = 'LiveCounter'
|
|
83
|
+
static publicActions = ['increment', 'decrement', 'reset'] as const
|
|
84
|
+
static defaultState = {
|
|
85
|
+
count: 0,
|
|
86
|
+
lastUpdatedBy: null as string | null,
|
|
87
|
+
connectedUsers: 0
|
|
88
|
+
}
|
|
89
|
+
protected roomType = 'counter'
|
|
90
|
+
|
|
91
|
+
// Constructor needed for room event subscriptions
|
|
92
|
+
constructor(
|
|
93
|
+
initialState: Partial<typeof LiveCounter.defaultState> = {},
|
|
94
|
+
ws: FluxStackWebSocket,
|
|
95
|
+
options?: { room?: string; userId?: string }
|
|
96
|
+
) {
|
|
97
|
+
super(initialState, ws, options)
|
|
98
|
+
|
|
99
|
+
this.onRoomEvent<{ count: number; userId: string }>('COUNT_CHANGED', (data) => {
|
|
100
|
+
this.setState({ count: data.count, lastUpdatedBy: data.userId })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
this.onRoomEvent<{ connectedUsers: number }>('USER_COUNT_CHANGED', (data) => {
|
|
104
|
+
this.setState({ connectedUsers: data.connectedUsers })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
this.notifyUserJoined()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private notifyUserJoined() {
|
|
111
|
+
const newCount = this.state.connectedUsers + 1
|
|
112
|
+
this.emitRoomEventWithState('USER_COUNT_CHANGED',
|
|
113
|
+
{ connectedUsers: newCount },
|
|
114
|
+
{ connectedUsers: newCount }
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
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 }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
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 }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
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 }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
destroy() {
|
|
145
|
+
const newCount = Math.max(0, this.state.connectedUsers - 1)
|
|
146
|
+
this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
|
|
147
|
+
super.destroy()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Lifecycle Hooks
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
|
|
158
|
+
static componentName = 'MyComponent'
|
|
159
|
+
static publicActions = ['doWork'] as const
|
|
160
|
+
static defaultState = { users: [] as string[], ready: false, currentRoom: '' }
|
|
161
|
+
|
|
162
|
+
private _pollTimer?: NodeJS.Timeout
|
|
163
|
+
|
|
164
|
+
// 1. Called when WebSocket connection is established (before onMount)
|
|
165
|
+
protected onConnect() {
|
|
166
|
+
console.log('WebSocket connected for this component')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. Called AFTER component is fully mounted (rooms, auth, injections ready)
|
|
170
|
+
// Can be async!
|
|
171
|
+
protected async onMount() {
|
|
172
|
+
this.$room('main').join()
|
|
173
|
+
const data = await fetchInitialData(this.$auth.session?.id)
|
|
174
|
+
this.state.ready = true
|
|
175
|
+
this._pollTimer = setInterval(() => this.poll(), 5000)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Called after state is restored from localStorage (rehydration)
|
|
179
|
+
protected onRehydrate(previousState: typeof MyComponent.defaultState) {
|
|
180
|
+
if (!previousState.ready) {
|
|
181
|
+
this.state.ready = false // Re-validate stale state
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Called after any state mutation (proxy or setState)
|
|
186
|
+
protected onStateChange(changes: Partial<typeof MyComponent.defaultState>) {
|
|
187
|
+
if ('users' in changes) {
|
|
188
|
+
console.log(`User count: ${this.state.users.length}`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Called when joining a room
|
|
193
|
+
protected onRoomJoin(roomId: string) {
|
|
194
|
+
this.state.currentRoom = roomId
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Called when leaving a room
|
|
198
|
+
protected onRoomLeave(roomId: string) {
|
|
199
|
+
if (this.state.currentRoom === roomId) this.state.currentRoom = ''
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Called before each action -- return false to cancel
|
|
203
|
+
protected onAction(action: string, payload: any) {
|
|
204
|
+
console.log(`[${this.id}] ${action}`, payload)
|
|
205
|
+
// return false // would cancel the action
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Called when a new client joins a singleton component
|
|
209
|
+
protected onClientJoin(connectionId: string, connectionCount: number) {
|
|
210
|
+
console.log(`Client ${connectionId} joined, total: ${connectionCount}`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Called when a client leaves a singleton component
|
|
214
|
+
protected onClientLeave(connectionId: string, connectionCount: number) {
|
|
215
|
+
console.log(`Client ${connectionId} left, total: ${connectionCount}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Called when WebSocket drops (NOT on intentional unmount)
|
|
219
|
+
protected onDisconnect() {
|
|
220
|
+
console.log('Connection lost -- saving recovery data')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Called BEFORE internal cleanup (sync only)
|
|
224
|
+
protected onDestroy() {
|
|
225
|
+
clearInterval(this._pollTimer)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async doWork() { /* ... */ }
|
|
229
|
+
private poll() { /* ... */ }
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
> **Note:** The example components in `app/server/live/` are intentionally simple and do not use most lifecycle hooks. This does not mean the hooks are unavailable -- they are all part of the `@fluxstack/live` framework API and can be used in any LiveComponent subclass.
|
|
234
|
+
|
|
235
|
+
### Lifecycle Order
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
WebSocket connects
|
|
239
|
+
-> onConnect()
|
|
240
|
+
-> onMount() <- async, rooms/auth ready
|
|
241
|
+
-> [component active]
|
|
242
|
+
|-> onAction(action, payload) <- before each action (return false to cancel)
|
|
243
|
+
|-> onStateChange(changes) <- after each state mutation
|
|
244
|
+
|-> onRoomJoin(roomId) <- when joining a room
|
|
245
|
+
|-> onRoomLeave(roomId) <- when leaving a room
|
|
246
|
+
|-> onClientJoin(connId, count) <- singleton: new client joined
|
|
247
|
+
-> onClientLeave(connId, count) <- singleton: client left
|
|
248
|
+
|
|
249
|
+
Connection drops:
|
|
250
|
+
-> onDisconnect() <- only on unexpected disconnect
|
|
251
|
+
-> onDestroy() <- sync, before internal cleanup
|
|
252
|
+
|
|
253
|
+
Rehydration (reconnect with saved state):
|
|
254
|
+
-> onConnect()
|
|
255
|
+
-> onRehydrate(previousState)
|
|
256
|
+
-> onMount()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Rules
|
|
260
|
+
|
|
261
|
+
| Hook | Async? | When |
|
|
262
|
+
|------|--------|------|
|
|
263
|
+
| `onConnect()` | No | WebSocket established, before mount |
|
|
264
|
+
| `onMount()` | **Yes** | After all setup (rooms, auth, DI) |
|
|
265
|
+
| `onRehydrate(prevState)` | No | After state restored from localStorage |
|
|
266
|
+
| `onStateChange(changes)` | No | After every state mutation |
|
|
267
|
+
| `onRoomJoin(roomId)` | No | After `$room.join()` |
|
|
268
|
+
| `onRoomLeave(roomId)` | No | After `$room.leave()` |
|
|
269
|
+
| `onAction(action, payload)` | **Yes** | Before action execution (return `false` to cancel) |
|
|
270
|
+
| `onClientJoin(connId, count)` | No | Singleton: new client connected |
|
|
271
|
+
| `onClientLeave(connId, count)` | No | Singleton: client disconnected |
|
|
272
|
+
| `onDisconnect()` | No | Connection lost (NOT intentional unmount) |
|
|
273
|
+
| `onDestroy()` | No | Before internal cleanup |
|
|
274
|
+
|
|
275
|
+
- All hooks are optional -- override only what you need
|
|
276
|
+
- All hook errors are caught and logged -- they never break the system
|
|
277
|
+
- Constructor is still needed ONLY for `this.onRoomEvent()` subscriptions
|
|
278
|
+
- All hooks are in BLOCKED_ACTIONS -- clients cannot call them remotely
|
|
279
|
+
|
|
280
|
+
## Custom ID Generator
|
|
281
|
+
|
|
282
|
+
The `LiveServer` accepts a `generateId` option that replaces the default ID generation for component IDs, connection IDs, and cluster singleton IDs:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import { LiveServer } from '@fluxstack/live'
|
|
286
|
+
import { nanoid } from 'nanoid'
|
|
287
|
+
|
|
288
|
+
const server = new LiveServer({
|
|
289
|
+
transport: elysiaAdapter,
|
|
290
|
+
generateId: () => nanoid(), // Custom ID generator
|
|
291
|
+
})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
When provided, the custom generator is used via the `LiveComponentContext` -- every `LiveComponent` instance calls it during construction. If not provided, the framework uses its built-in `generateId()` (crypto-based).
|
|
295
|
+
|
|
296
|
+
## HMR Persistence
|
|
297
|
+
|
|
298
|
+
Data in `static persistent` survives Hot Module Replacement reloads via `globalThis`:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
export class LiveMigration extends LiveComponent<typeof LiveMigration.defaultState> {
|
|
302
|
+
static componentName = 'LiveMigration'
|
|
303
|
+
static publicActions = ['runMigration'] as const
|
|
304
|
+
static defaultState = { status: 'idle', lastResult: '' }
|
|
305
|
+
|
|
306
|
+
// Define shape and defaults for persistent data
|
|
307
|
+
static persistent = {
|
|
308
|
+
cache: {} as Record<string, any>,
|
|
309
|
+
runCount: 0
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
protected onMount() {
|
|
313
|
+
this.$persistent.runCount++
|
|
314
|
+
console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR!
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async runMigration(payload: { key: string }) {
|
|
318
|
+
// Check HMR-safe cache
|
|
319
|
+
if (this.$persistent.cache[payload.key]) {
|
|
320
|
+
return { cached: true, result: this.$persistent.cache[payload.key] }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const result = await expensiveComputation(payload.key)
|
|
324
|
+
this.$persistent.cache[payload.key] = result
|
|
325
|
+
this.state.lastResult = result
|
|
326
|
+
return { cached: false, result }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Key facts:**
|
|
332
|
+
- `this.$persistent` reads from `globalThis.__fluxstack_persistent_{ComponentName}`
|
|
333
|
+
- Each component class has its own namespace
|
|
334
|
+
- Defaults come from `static persistent` -- initialized once, then persisted
|
|
335
|
+
- Not sent to client -- server-only
|
|
336
|
+
- `$persistent` is in BLOCKED_ACTIONS (can't be called from client)
|
|
337
|
+
|
|
338
|
+
## Singleton Components
|
|
339
|
+
|
|
340
|
+
When `static singleton = true`, only ONE server-side instance exists. All clients share the same state. This is a real feature of `@fluxstack/live` with cluster support via Redis.
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
export class LiveDashboard extends LiveComponent<typeof LiveDashboard.defaultState> {
|
|
344
|
+
static componentName = 'LiveDashboard'
|
|
345
|
+
static singleton = true // All clients share this instance
|
|
346
|
+
static publicActions = ['refresh', 'addAlert'] as const
|
|
347
|
+
static defaultState = {
|
|
348
|
+
visitors: 0,
|
|
349
|
+
alerts: [] as string[],
|
|
350
|
+
lastRefresh: ''
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
protected async onMount() {
|
|
354
|
+
this.state.visitors++
|
|
355
|
+
this.state.lastRefresh = new Date().toISOString()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Singleton-specific hooks (optional)
|
|
359
|
+
protected onClientJoin(connectionId: string, connectionCount: number) {
|
|
360
|
+
this.state.visitors = connectionCount
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
protected onClientLeave(connectionId: string, connectionCount: number) {
|
|
364
|
+
this.state.visitors = connectionCount
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async refresh() {
|
|
368
|
+
const data = await fetchDashboardData()
|
|
369
|
+
this.setState(data) // Broadcasts to ALL connected clients
|
|
370
|
+
return { success: true }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async addAlert(payload: { message: string }) {
|
|
374
|
+
this.state.alerts = [...this.state.alerts, payload.message]
|
|
375
|
+
// All clients see the new alert instantly
|
|
376
|
+
return { success: true }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**How it works:**
|
|
382
|
+
- First client to mount creates the singleton instance
|
|
383
|
+
- Subsequent clients join the existing instance and receive current state
|
|
384
|
+
- `emit` / `setState` / `this.state.x = y` broadcast to ALL connected WebSockets
|
|
385
|
+
- When a client disconnects, it's removed from the singleton's connections
|
|
386
|
+
- When the LAST client disconnects, the singleton is destroyed
|
|
387
|
+
- Stats visible at `/api/live/stats` (shows singleton connection counts)
|
|
388
|
+
- Cluster support: coordinated across server instances via `IClusterAdapter` (Redis)
|
|
389
|
+
|
|
390
|
+
**Use cases:** Shared dashboards, global migration state, admin panels, live counters
|
|
391
|
+
|
|
392
|
+
## State Management
|
|
393
|
+
|
|
394
|
+
### Reactive State Proxy (How It Works)
|
|
395
|
+
|
|
396
|
+
State mutations auto-sync with the frontend via two layers:
|
|
397
|
+
|
|
398
|
+
**Layer 1 -- Proxy** (`this.state`): A `Proxy` wraps the internal state object. Any `set` on `this.state` compares old vs new value and, if changed, emits `STATE_DELTA` to the client automatically.
|
|
399
|
+
|
|
400
|
+
**Layer 2 -- Direct Accessors** (`this.count`): On construction, `createDirectStateAccessors()` defines a getter/setter via `Object.defineProperty` for each key in `defaultState`. The setter delegates to the proxy, so it also triggers `STATE_DELTA`.
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
this.count++ -> accessor setter -> proxy set -> STATE_DELTA
|
|
404
|
+
this.state.count++ -> proxy set -> STATE_DELTA
|
|
405
|
+
this.setState({count: 1}) -> Object.assign + single STATE_DELTA (batch)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Direct State Access
|
|
409
|
+
|
|
410
|
+
State properties are accessible directly on `this`:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
// Declare properties for TypeScript
|
|
414
|
+
declare count: number
|
|
415
|
+
declare message: string
|
|
416
|
+
|
|
417
|
+
// Direct access - auto-syncs via proxy!
|
|
418
|
+
this.count++
|
|
419
|
+
this.message = 'Hello'
|
|
420
|
+
|
|
421
|
+
// Also works - same proxy underneath
|
|
422
|
+
this.state.count++
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
> **Performance note:** Each direct assignment emits one `STATE_DELTA`. For multiple properties at once, use `setState` (single emit).
|
|
426
|
+
|
|
427
|
+
### setState (Batch Updates)
|
|
428
|
+
|
|
429
|
+
Use `setState` for multiple properties at once (single emit):
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
// Batch update - one STATE_DELTA event
|
|
433
|
+
this.setState({
|
|
434
|
+
count: newCount,
|
|
435
|
+
lastUpdatedBy: userId,
|
|
436
|
+
updatedAt: new Date().toISOString()
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// Function updater (access previous state)
|
|
440
|
+
this.setState(prev => ({
|
|
441
|
+
count: prev.count + 1,
|
|
442
|
+
lastUpdatedBy: userId
|
|
443
|
+
}))
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
> `setState` writes directly to `_state` (bypasses proxy) and emits a single `STATE_DELTA` with all changed keys. More efficient than N individual assignments.
|
|
447
|
+
|
|
448
|
+
### setValue (Generic Action)
|
|
449
|
+
|
|
450
|
+
Built-in action to set any state key from the client. **Must be explicitly included in `publicActions` to be callable:**
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
// Server: opt-in to setValue
|
|
454
|
+
static publicActions = ['increment', 'setValue'] as const // Must include 'setValue'
|
|
455
|
+
|
|
456
|
+
// Client can then call:
|
|
457
|
+
await component.setValue({ key: 'count', value: 42 })
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
> **Security note:** `setValue` is powerful - it allows the client to set any state key. Only add it to `publicActions` if you trust the client to modify any state field.
|
|
461
|
+
|
|
462
|
+
### $private -- Server-Only State
|
|
463
|
+
|
|
464
|
+
`$private` is a key-value store that lives **exclusively on the server**. It is NEVER synchronized with the client -- no `STATE_UPDATE`, no `STATE_DELTA`, not included in `getSerializableState()`.
|
|
465
|
+
|
|
466
|
+
Use it for sensitive data like tokens, API keys, internal IDs, or any server-side bookkeeping:
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
export class Chat extends LiveComponent<typeof Chat.defaultState> {
|
|
470
|
+
static componentName = 'Chat'
|
|
471
|
+
static publicActions = ['connect', 'sendMessage'] as const
|
|
472
|
+
static defaultState = { messages: [] as string[] }
|
|
473
|
+
|
|
474
|
+
async connect(payload: { token: string }) {
|
|
475
|
+
// Stays on server -- never sent to client
|
|
476
|
+
this.$private.token = payload.token
|
|
477
|
+
this.$private.apiKey = await getApiKey()
|
|
478
|
+
|
|
479
|
+
// Only UI data goes to state (synced with client)
|
|
480
|
+
this.state.messages = await fetchMessages(this.$private.token)
|
|
481
|
+
return { success: true }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async sendMessage(payload: { text: string }) {
|
|
485
|
+
// Use $private data for server-side logic
|
|
486
|
+
await postToAPI(this.$private.apiKey, payload.text)
|
|
487
|
+
this.state.messages = [...this.state.messages, payload.text]
|
|
488
|
+
return { success: true }
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
#### Typed $private (optional)
|
|
494
|
+
|
|
495
|
+
Pass a second generic to get full autocomplete and type checking:
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
interface ChatPrivate {
|
|
499
|
+
token: string
|
|
500
|
+
apiKey: string
|
|
501
|
+
retryCount: number
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export class Chat extends LiveComponent<typeof Chat.defaultState, ChatPrivate> {
|
|
505
|
+
static componentName = 'Chat'
|
|
506
|
+
static publicActions = ['connect'] as const
|
|
507
|
+
static defaultState = { messages: [] as string[] }
|
|
508
|
+
|
|
509
|
+
async connect(payload: { token: string }) {
|
|
510
|
+
this.$private.token = payload.token // autocomplete
|
|
511
|
+
this.$private.retryCount = 0 // must be number
|
|
512
|
+
// this.$private.tokkken = 'x' // TypeScript error (typo)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
The second generic defaults to `Record<string, any>`, so existing components work without changes.
|
|
518
|
+
|
|
519
|
+
**Key facts:**
|
|
520
|
+
- Starts as an empty `{}` -- no static default needed
|
|
521
|
+
- Mutations do NOT trigger any WebSocket messages
|
|
522
|
+
- Cleared automatically on `destroy()`
|
|
523
|
+
- Lost on rehydration (re-populate in your action handlers)
|
|
524
|
+
- Blocked from remote access (`$private` and `_privateState` are in BLOCKED_ACTIONS)
|
|
525
|
+
- Optional `TPrivate` generic for full type safety
|
|
526
|
+
|
|
527
|
+
### getSerializableState
|
|
528
|
+
|
|
529
|
+
Get current state for serialization (does NOT include `$private`):
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
const currentState = this.getSerializableState()
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### State Persistence
|
|
536
|
+
|
|
537
|
+
State is automatically signed and persisted on client. On reconnection, state is re-hydrated:
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
// Automatic - no code needed
|
|
541
|
+
// Client stores signed state in localStorage
|
|
542
|
+
// On reconnect, sends signed state to server
|
|
543
|
+
// Server validates signature and restores component
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## Room Events System
|
|
547
|
+
|
|
548
|
+
### Subscribe to Room Events
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
constructor(initialState, ws, options) {
|
|
552
|
+
super(initialState, ws, options)
|
|
553
|
+
|
|
554
|
+
// Listen for room events
|
|
555
|
+
this.onRoomEvent<{ count: number }>('COUNT_CHANGED', (data) => {
|
|
556
|
+
this.setState({ count: data.count })
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
this.onRoomEvent<{ message: string }>('MESSAGE_SENT', (data) => {
|
|
560
|
+
// Handle message
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### Emit Room Events
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
// Emit event to all room members
|
|
569
|
+
this.emitRoomEvent('MESSAGE_SENT', {
|
|
570
|
+
message: 'Hello',
|
|
571
|
+
userId: this.userId
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// Emit event AND update local state
|
|
575
|
+
this.emitRoomEventWithState('COUNT_CHANGED',
|
|
576
|
+
{ count: newCount }, // Event data
|
|
577
|
+
{ count: newCount } // State update
|
|
578
|
+
)
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Typed Rooms ($room)
|
|
582
|
+
|
|
583
|
+
Components can use typed `LiveRoom` classes for structured room interactions:
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
|
|
587
|
+
import { CounterRoom } from './rooms/CounterRoom'
|
|
588
|
+
|
|
589
|
+
export class LiveSharedCounter extends LiveComponent<typeof LiveSharedCounter.defaultState> {
|
|
590
|
+
static componentName = 'LiveSharedCounter'
|
|
591
|
+
static publicActions = ['increment', 'decrement', 'reset'] as const
|
|
592
|
+
static defaultState = {
|
|
593
|
+
username: '',
|
|
594
|
+
count: 0,
|
|
595
|
+
lastUpdatedBy: null as string | null,
|
|
596
|
+
onlineCount: 0
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private counterUnsub: (() => void) | null = null
|
|
600
|
+
|
|
601
|
+
constructor(initialState: Partial<typeof LiveSharedCounter.defaultState> = {}, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
|
|
602
|
+
super(initialState, ws, options)
|
|
603
|
+
|
|
604
|
+
const room = this.$room(CounterRoom, 'global')
|
|
605
|
+
room.join()
|
|
606
|
+
|
|
607
|
+
// Load current state from room
|
|
608
|
+
this.setState({
|
|
609
|
+
count: room.state.count,
|
|
610
|
+
lastUpdatedBy: room.state.lastUpdatedBy,
|
|
611
|
+
onlineCount: room.state.onlineCount
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
// Listen for updates from other users
|
|
615
|
+
this.counterUnsub = room.on('counter:updated', (data) => {
|
|
616
|
+
this.setState({ count: data.count, lastUpdatedBy: data.updatedBy })
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async increment() {
|
|
621
|
+
const room = this.$room(CounterRoom, 'global')
|
|
622
|
+
const count = room.increment(this.state.username || 'Anonymous')
|
|
623
|
+
return { success: true, count }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
destroy() {
|
|
627
|
+
this.counterUnsub?.()
|
|
628
|
+
super.destroy()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
**$room API:**
|
|
634
|
+
- `this.$room(RoomClass, instanceId)` -- typed room handle with custom methods
|
|
635
|
+
- `this.$room('roomId')` -- untyped room handle (legacy)
|
|
636
|
+
- `this.$rooms` -- list of room IDs this component participates in
|
|
637
|
+
|
|
638
|
+
## Actions
|
|
639
|
+
|
|
640
|
+
Actions are methods callable from the client. **Only methods listed in `publicActions` can be called remotely.** Components without `publicActions` deny ALL remote actions.
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
// Server-side
|
|
644
|
+
export class LiveForm extends LiveComponent<typeof LiveForm.defaultState> {
|
|
645
|
+
static publicActions = ['submit', 'validate', 'reset', 'setValue'] as const
|
|
646
|
+
|
|
647
|
+
static defaultState = {
|
|
648
|
+
name: '',
|
|
649
|
+
email: '',
|
|
650
|
+
message: '',
|
|
651
|
+
submitted: false,
|
|
652
|
+
submittedAt: null as string | null
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async submit() {
|
|
656
|
+
const { name, email, message } = this.state
|
|
657
|
+
|
|
658
|
+
if (!name || !email) {
|
|
659
|
+
throw new Error('Nome e email sao obrigatorios')
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
this.setState({
|
|
663
|
+
submitted: true,
|
|
664
|
+
submittedAt: new Date().toISOString()
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
success: true,
|
|
669
|
+
data: { name, email, message },
|
|
670
|
+
submittedAt: this.state.submittedAt
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async validate() {
|
|
675
|
+
const errors: Record<string, string> = {}
|
|
676
|
+
|
|
677
|
+
if (!this.state.name) errors.name = 'Nome e obrigatorio'
|
|
678
|
+
if (!this.state.email) errors.email = 'Email e obrigatorio'
|
|
679
|
+
else if (!this.state.email.includes('@')) errors.email = 'Email invalido'
|
|
680
|
+
|
|
681
|
+
return { valid: Object.keys(errors).length === 0, errors }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
### Action Security Features (framework)
|
|
687
|
+
|
|
688
|
+
The `@fluxstack/live` framework provides additional action security features:
|
|
689
|
+
|
|
690
|
+
- **Zod validation** -- `static actionSchemas` for automatic payload validation before action execution
|
|
691
|
+
- **Rate limiting** -- `static actionRateLimit` to prevent clients from spamming actions
|
|
692
|
+
- **Per-action auth** -- `static actionAuth` with roles/permissions per action
|
|
693
|
+
|
|
694
|
+
```typescript
|
|
695
|
+
static actionSchemas = {
|
|
696
|
+
sendMessage: z.object({ text: z.string().max(500) }),
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
static actionRateLimit = { maxCalls: 10, windowMs: 1000, perAction: true }
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
## Authentication
|
|
703
|
+
|
|
704
|
+
Components can require authentication and define per-action permissions:
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
export class LiveAdminPanel extends LiveComponent<AdminPanelState> {
|
|
708
|
+
static componentName = 'LiveAdminPanel'
|
|
709
|
+
static publicActions = ['getAuthInfo', 'init', 'listUsers', 'addUser', 'deleteUser', 'clearAudit'] as const
|
|
710
|
+
|
|
711
|
+
// Component-level: requires auth + admin role
|
|
712
|
+
static auth: LiveComponentAuth = {
|
|
713
|
+
required: true,
|
|
714
|
+
roles: ['admin'],
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Per-action: granular permissions
|
|
718
|
+
static actionAuth: LiveActionAuthMap = {
|
|
719
|
+
deleteUser: { permissions: ['users.delete'] },
|
|
720
|
+
clearAudit: { roles: ['admin'] },
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async getAuthInfo() {
|
|
724
|
+
return {
|
|
725
|
+
authenticated: this.$auth.authenticated,
|
|
726
|
+
userId: this.$auth.session?.id,
|
|
727
|
+
roles: this.$auth.session?.roles || [],
|
|
728
|
+
isAdmin: this.$auth.hasRole('admin'),
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
**Auth levels:**
|
|
735
|
+
- `this.state` -- client reads AND writes (bidirectional)
|
|
736
|
+
- `this.$private` -- client NEVER sees (server-only)
|
|
737
|
+
- `this.$auth` -- set by framework, immutable (read-only)
|
|
738
|
+
|
|
739
|
+
## Client-Side Integration
|
|
740
|
+
|
|
741
|
+
### Provider Setup
|
|
742
|
+
|
|
743
|
+
Wrap app with LiveComponentsProvider:
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
// app/client/src/App.tsx
|
|
747
|
+
import { LiveComponentsProvider } from '@/core/client'
|
|
748
|
+
|
|
749
|
+
function App() {
|
|
750
|
+
return (
|
|
751
|
+
<LiveComponentsProvider
|
|
752
|
+
url="ws://localhost:3000"
|
|
753
|
+
autoConnect={true}
|
|
754
|
+
reconnectInterval={1000}
|
|
755
|
+
debug={true}
|
|
756
|
+
>
|
|
757
|
+
<AppContent />
|
|
758
|
+
</LiveComponentsProvider>
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Using Components
|
|
764
|
+
|
|
765
|
+
```typescript
|
|
766
|
+
import { Live } from '@/core/client'
|
|
767
|
+
import { LiveCounter } from '@server/live/LiveCounter'
|
|
768
|
+
|
|
769
|
+
export function CounterDemo() {
|
|
770
|
+
// Mount component with options
|
|
771
|
+
const counter = Live.use(LiveCounter, {
|
|
772
|
+
room: 'global-counter',
|
|
773
|
+
initialState: LiveCounter.defaultState
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
// Access state
|
|
777
|
+
const count = counter.$state.count
|
|
778
|
+
|
|
779
|
+
// Check connection status
|
|
780
|
+
const isConnected = counter.$connected
|
|
781
|
+
|
|
782
|
+
// Check loading state
|
|
783
|
+
const isLoading = counter.$loading
|
|
784
|
+
|
|
785
|
+
// Call actions
|
|
786
|
+
const handleIncrement = async () => {
|
|
787
|
+
await counter.increment()
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<div>
|
|
792
|
+
<p>Count: {count}</p>
|
|
793
|
+
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
|
|
794
|
+
<button onClick={handleIncrement} disabled={isLoading}>
|
|
795
|
+
Increment
|
|
796
|
+
</button>
|
|
797
|
+
</div>
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### Field Binding
|
|
803
|
+
|
|
804
|
+
For form components, use `$field` helper:
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
const form = Live.use(LiveForm)
|
|
808
|
+
|
|
809
|
+
// Sync on blur
|
|
810
|
+
<input {...form.$field('name', { syncOn: 'blur' })} />
|
|
811
|
+
|
|
812
|
+
// Sync on change with debounce
|
|
813
|
+
<input {...form.$field('email', { syncOn: 'change', debounce: 500 })} />
|
|
814
|
+
|
|
815
|
+
// Manual sync
|
|
816
|
+
await form.$sync()
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### Client API
|
|
820
|
+
|
|
821
|
+
```typescript
|
|
822
|
+
// State access
|
|
823
|
+
counter.$state.count
|
|
824
|
+
|
|
825
|
+
// Connection status
|
|
826
|
+
counter.$connected
|
|
827
|
+
|
|
828
|
+
// Loading state
|
|
829
|
+
counter.$loading
|
|
830
|
+
|
|
831
|
+
// Call action
|
|
832
|
+
await counter.increment()
|
|
833
|
+
|
|
834
|
+
// Field binding (forms)
|
|
835
|
+
form.$field('fieldName', options)
|
|
836
|
+
|
|
837
|
+
// Manual sync
|
|
838
|
+
await form.$sync()
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
## Component Registry
|
|
842
|
+
|
|
843
|
+
Components are auto-discovered from `app/server/live/`:
|
|
844
|
+
|
|
845
|
+
```typescript
|
|
846
|
+
// app/server/live/auto-generated-components.ts (auto-generated by @fluxstack/live)
|
|
847
|
+
import { LiveAdminPanel } from "./LiveAdminPanel"
|
|
848
|
+
import { LiveCounter } from "./LiveCounter"
|
|
849
|
+
import { LiveForm } from "./LiveForm"
|
|
850
|
+
// ... etc
|
|
851
|
+
|
|
852
|
+
export const liveComponentClasses = [
|
|
853
|
+
LiveAdminPanel,
|
|
854
|
+
LiveCounter,
|
|
855
|
+
LiveForm,
|
|
856
|
+
// ...
|
|
857
|
+
]
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
The `LiveServer` auto-discovers components via `componentsPath` option and generates this file. For production builds, pass `components: liveComponentClasses` to avoid dynamic imports.
|
|
861
|
+
|
|
862
|
+
## WebSocket Connection Handling
|
|
863
|
+
|
|
864
|
+
### Automatic Reconnection
|
|
865
|
+
|
|
866
|
+
Client automatically reconnects on disconnect:
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
<LiveComponentsProvider
|
|
870
|
+
reconnectInterval={1000} // Retry every 1 second
|
|
871
|
+
autoConnect={true}
|
|
872
|
+
>
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### State Re-hydration
|
|
876
|
+
|
|
877
|
+
On reconnect, components restore previous state:
|
|
878
|
+
|
|
879
|
+
1. Client stores signed state in localStorage
|
|
880
|
+
2. On reconnect, sends signed state to server
|
|
881
|
+
3. Server validates signature (HMAC-SHA256) and **anti-replay nonce**
|
|
882
|
+
4. Component re-hydrated with previous state
|
|
883
|
+
5. State expires after 24 hours (configurable)
|
|
884
|
+
|
|
885
|
+
No manual code needed - automatic. Each signed state includes a cryptographic nonce that is consumed on validation, preventing replay attacks.
|
|
886
|
+
|
|
887
|
+
## Multi-User Synchronization
|
|
888
|
+
|
|
889
|
+
### Room-Based Sync
|
|
890
|
+
|
|
891
|
+
All components in same room receive events:
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
// User A increments
|
|
895
|
+
await counter.increment()
|
|
896
|
+
// Emits COUNT_CHANGED to room
|
|
897
|
+
|
|
898
|
+
// User B's component receives event
|
|
899
|
+
this.onRoomEvent('COUNT_CHANGED', (data) => {
|
|
900
|
+
this.setState({ count: data.count })
|
|
901
|
+
})
|
|
902
|
+
// User B sees updated count
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
### User Tracking
|
|
906
|
+
|
|
907
|
+
Track connected users in room:
|
|
908
|
+
|
|
909
|
+
```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
|
+
)
|
|
919
|
+
}
|
|
920
|
+
|
|
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()
|
|
926
|
+
}
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
## Error Handling
|
|
930
|
+
|
|
931
|
+
```typescript
|
|
932
|
+
// Server-side - throw errors
|
|
933
|
+
async submit() {
|
|
934
|
+
if (!this.state.email) {
|
|
935
|
+
throw new Error('Email required')
|
|
936
|
+
}
|
|
937
|
+
// Process...
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Client-side - catch errors
|
|
941
|
+
try {
|
|
942
|
+
await form.submit()
|
|
943
|
+
} catch (error) {
|
|
944
|
+
alert(error.message)
|
|
945
|
+
}
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
## Performance Monitoring
|
|
949
|
+
|
|
950
|
+
Built-in performance tracking:
|
|
951
|
+
|
|
952
|
+
```typescript
|
|
953
|
+
// Automatic metrics collection
|
|
954
|
+
// - Render times
|
|
955
|
+
// - Action execution times
|
|
956
|
+
// - Error counts
|
|
957
|
+
// - Memory usage
|
|
958
|
+
|
|
959
|
+
// Access via registry
|
|
960
|
+
const health = componentRegistry.getComponentHealth(componentId)
|
|
961
|
+
// { status: 'healthy', metrics: {...} }
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
## Existing Components
|
|
965
|
+
|
|
966
|
+
The app includes these live components in `app/server/live/`:
|
|
967
|
+
|
|
968
|
+
| Component | Description | Features |
|
|
969
|
+
|-----------|-------------|----------|
|
|
970
|
+
| `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')` |
|
|
973
|
+
| `LiveForm` | Reactive form with server validation | `setValue`, `validate`, `submit` |
|
|
974
|
+
| `LivePingPong` | Binary codec demo (msgpack) | Typed `PingRoom`, round-trip timing |
|
|
975
|
+
| `LiveRoomChat` | Multi-room chat with directory | `ChatRoom`, `DirectoryRoom`, password rooms |
|
|
976
|
+
| `LiveProtectedChat` | Auth-required chat | `static auth`, `static actionAuth`, roles |
|
|
977
|
+
| `LiveAdminPanel` | Admin panel with RBAC | Component + per-action auth, audit trail |
|
|
978
|
+
| `LiveUpload` | Chunked file upload via WebSocket | Filename validation, progress tracking |
|
|
979
|
+
|
|
980
|
+
## Component Organization
|
|
981
|
+
|
|
982
|
+
```
|
|
983
|
+
app/server/live/
|
|
984
|
+
├── LiveCounter.ts # Shared counter with room events
|
|
985
|
+
├── LiveLocalCounter.ts # Local counter (no room)
|
|
986
|
+
├── LiveForm.ts # Reactive form
|
|
987
|
+
├── LivePingPong.ts # Binary codec demo
|
|
988
|
+
├── LiveSharedCounter.ts # Typed room counter
|
|
989
|
+
├── LiveRoomChat.ts # Multi-room chat
|
|
990
|
+
├── LiveProtectedChat.ts # Auth-required chat
|
|
991
|
+
├── LiveAdminPanel.ts # Admin panel with RBAC
|
|
992
|
+
├── LiveUpload.ts # Chunked file upload
|
|
993
|
+
├── rooms/ # Typed LiveRoom definitions
|
|
994
|
+
│ ├── ChatRoom.ts
|
|
995
|
+
│ ├── CounterRoom.ts
|
|
996
|
+
│ ├── DirectoryRoom.ts
|
|
997
|
+
│ └── PingRoom.ts
|
|
998
|
+
└── auto-generated-components.ts # Auto-generated registration
|
|
999
|
+
|
|
1000
|
+
app/client/src/live/
|
|
1001
|
+
├── CounterDemo.tsx
|
|
1002
|
+
├── FormDemo.tsx
|
|
1003
|
+
├── RoomChatDemo.tsx
|
|
1004
|
+
├── SharedCounterDemo.tsx
|
|
1005
|
+
├── PingPongDemo.tsx
|
|
1006
|
+
├── UploadDemo.tsx
|
|
1007
|
+
└── ...
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
Each server file contains:
|
|
1011
|
+
- `static componentName` - Component identifier
|
|
1012
|
+
- `static publicActions` - **REQUIRED** whitelist of client-callable methods
|
|
1013
|
+
- `static defaultState` - Initial state object
|
|
1014
|
+
- `static logging` - Per-component console log control (optional)
|
|
1015
|
+
- Component class extending `LiveComponent`
|
|
1016
|
+
- Client link via `import type { Demo as _Client }`
|
|
1017
|
+
|
|
1018
|
+
## Advanced: Component Options
|
|
1019
|
+
|
|
1020
|
+
```typescript
|
|
1021
|
+
export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
|
|
1022
|
+
static $options = {
|
|
1023
|
+
deepDiff: true, // Enable deep diff for plain objects (default: true)
|
|
1024
|
+
roomDeepDiff: true, // Enable deep diff for room state (default: true)
|
|
1025
|
+
deepDiffDepth: 3, // Max recursion depth (default: 3)
|
|
1026
|
+
serverOnlyRoomState: false, // When true, client ROOM_STATE_SET is rejected
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
## Critical Rules
|
|
1032
|
+
|
|
1033
|
+
**ALWAYS:**
|
|
1034
|
+
- Define `static componentName` matching class name
|
|
1035
|
+
- Define `static publicActions` listing ALL client-callable methods (MANDATORY)
|
|
1036
|
+
- Define `static defaultState` inside the class
|
|
1037
|
+
- Use `typeof ClassName.defaultState` for type parameter
|
|
1038
|
+
- Use `declare` for each state property (TypeScript type hint)
|
|
1039
|
+
- Use `onMount()` for async initialization (rooms, auth, data fetching)
|
|
1040
|
+
- Use `onDestroy()` for cleanup (timers, connections) -- sync only
|
|
1041
|
+
- Use `emitRoomEventWithState` for state changes in rooms
|
|
1042
|
+
- Handle errors in actions (throw Error)
|
|
1043
|
+
- Add client link: `import type { Demo as _Client } from '@client/...'`
|
|
1044
|
+
- Use `$persistent` for data that should survive HMR reloads
|
|
1045
|
+
- Use `static singleton = true` for shared cross-client state
|
|
1046
|
+
|
|
1047
|
+
**NEVER:**
|
|
1048
|
+
- Omit `static publicActions` (component will deny ALL remote actions)
|
|
1049
|
+
- Export separate `defaultState` constant (use static)
|
|
1050
|
+
- Create constructor just to call super() (not needed)
|
|
1051
|
+
- Forget `static componentName` (breaks minification)
|
|
1052
|
+
- Override `destroy()` directly -- use `onDestroy()` instead (prefer lifecycle hooks)
|
|
1053
|
+
- Emit room events without subscribing first
|
|
1054
|
+
- Store non-serializable data in state
|
|
1055
|
+
- Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, $persistent, broadcastToRoom, roomType)
|
|
1056
|
+
- Include `setValue` in `publicActions` unless you trust clients to modify any state key
|
|
1057
|
+
- Store sensitive data (tokens, API keys, secrets) in `state` -- use `$private` instead
|
|
1058
|
+
|
|
1059
|
+
**STATE UPDATES -- all auto-sync via Proxy:**
|
|
1060
|
+
```typescript
|
|
1061
|
+
// Direct access (1 prop -> 1 STATE_DELTA)
|
|
1062
|
+
declare count: number
|
|
1063
|
+
this.count++
|
|
1064
|
+
|
|
1065
|
+
// Also works (same proxy underneath)
|
|
1066
|
+
this.state.count++
|
|
1067
|
+
|
|
1068
|
+
// Multiple properties -> use setState (1 STATE_DELTA for all)
|
|
1069
|
+
this.setState({ a: 1, b: 2, c: 3 })
|
|
1070
|
+
|
|
1071
|
+
// Don't use setState for single property (unnecessary)
|
|
1072
|
+
// this.setState({ count: this.count + 1 })
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
---
|
|
1076
|
+
|
|
1077
|
+
## Live Upload (Chunked Upload via WebSocket)
|
|
1078
|
+
|
|
1079
|
+
This project includes a Live Component-based upload system that streams file chunks
|
|
1080
|
+
over the Live Components WebSocket. The client uses a chunked upload hook; the server
|
|
1081
|
+
tracks progress and assembles the file in `uploads/`.
|
|
1082
|
+
|
|
1083
|
+
### Server: LiveUpload Component
|
|
1084
|
+
|
|
1085
|
+
```typescript
|
|
1086
|
+
// app/server/live/LiveUpload.ts
|
|
1087
|
+
import { LiveComponent } from '@core/types/types'
|
|
1088
|
+
|
|
1089
|
+
export class LiveUpload extends LiveComponent<typeof LiveUpload.defaultState> {
|
|
1090
|
+
static componentName = 'LiveUpload'
|
|
1091
|
+
static publicActions = ['startUpload', 'updateProgress', 'completeUpload', 'failUpload', 'reset'] as const
|
|
1092
|
+
static defaultState = {
|
|
1093
|
+
status: 'idle' as 'idle' | 'uploading' | 'complete' | 'error',
|
|
1094
|
+
progress: 0,
|
|
1095
|
+
fileName: '',
|
|
1096
|
+
fileSize: 0,
|
|
1097
|
+
fileType: '',
|
|
1098
|
+
fileUrl: '',
|
|
1099
|
+
bytesUploaded: 0,
|
|
1100
|
+
totalBytes: 0,
|
|
1101
|
+
error: null as string | null
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) {
|
|
1105
|
+
const fileName = payload.fileName
|
|
1106
|
+
|
|
1107
|
+
// Validate filename length
|
|
1108
|
+
if (!fileName || fileName.length > 255) {
|
|
1109
|
+
throw new Error('Invalid file name: must be 1-255 characters')
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Block path traversal, null bytes, and control characters
|
|
1113
|
+
if (/[\x00-\x1f]/.test(fileName) || fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
|
|
1114
|
+
throw new Error('Invalid file name: contains forbidden characters')
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Block Windows reserved names
|
|
1118
|
+
const baseName = fileName.split('.')[0].toUpperCase()
|
|
1119
|
+
const reserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', 'LPT2', 'LPT3']
|
|
1120
|
+
if (reserved.includes(baseName)) {
|
|
1121
|
+
throw new Error('Invalid file name: reserved name')
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
this.setState({
|
|
1125
|
+
status: 'uploading',
|
|
1126
|
+
progress: 0,
|
|
1127
|
+
fileName: payload.fileName,
|
|
1128
|
+
fileSize: payload.fileSize,
|
|
1129
|
+
fileType: payload.fileType,
|
|
1130
|
+
fileUrl: '',
|
|
1131
|
+
bytesUploaded: 0,
|
|
1132
|
+
totalBytes: payload.fileSize,
|
|
1133
|
+
error: null
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
return { success: true }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ... updateProgress, completeUpload, failUpload, reset
|
|
1140
|
+
}
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
### Client: useLiveUpload + Widget
|
|
1144
|
+
|
|
1145
|
+
```typescript
|
|
1146
|
+
// app/client/src/live/UploadDemo.tsx
|
|
1147
|
+
import { useLiveUpload } from './useLiveUpload'
|
|
1148
|
+
import { LiveUploadWidget } from '../components/LiveUploadWidget'
|
|
1149
|
+
|
|
1150
|
+
export function UploadDemo() {
|
|
1151
|
+
const { live } = useLiveUpload()
|
|
1152
|
+
|
|
1153
|
+
return (
|
|
1154
|
+
<LiveUploadWidget live={live} />
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
### Chunked Upload Flow
|
|
1160
|
+
|
|
1161
|
+
1. Client calls `startUpload()` (Live Component action).
|
|
1162
|
+
2. Client streams file chunks over WebSocket with `useChunkedUpload`.
|
|
1163
|
+
3. Server assembles file in `uploads/` and returns `/uploads/...`.
|
|
1164
|
+
4. Client maps to `/api/uploads/...` for access.
|
|
1165
|
+
|
|
1166
|
+
### Error Handling
|
|
1167
|
+
|
|
1168
|
+
- If an action throws, the error surfaces in `live.$error` on the client.
|
|
1169
|
+
- The widget shows `localError || state.error || $error`.
|
|
1170
|
+
|
|
1171
|
+
### Files Involved
|
|
1172
|
+
|
|
1173
|
+
**Server**
|
|
1174
|
+
- `app/server/live/LiveUpload.ts`
|
|
1175
|
+
- `core/server/live/FileUploadManager.ts` (chunk handling + file assembly)
|
|
1176
|
+
- `core/server/live/websocket-plugin.ts` (upload message routing)
|
|
1177
|
+
|
|
1178
|
+
**Client**
|
|
1179
|
+
- `core/client/hooks/useChunkedUpload.ts` (streaming chunks)
|
|
1180
|
+
- `core/client/hooks/useLiveUpload.ts` (Live Component wrapper)
|
|
1181
|
+
- `app/client/src/components/LiveUploadWidget.tsx` (UI)
|
|
1182
|
+
|
|
1183
|
+
## Related
|
|
1184
|
+
|
|
1185
|
+
- [Live Auth](./live-auth.md) - Authentication for Live Components
|
|
1186
|
+
- [Live Logging](./live-logging.md) - Per-component logging control
|
|
1187
|
+
- [Live Rooms](./live-rooms.md) - Multi-room real-time communication
|
|
1188
|
+
- [Live Upload](./live-upload.md) - Chunked file upload
|
|
1189
|
+
- [Live Binary Delta](./live-binary-delta.md) - High-frequency binary state sync
|
|
1190
|
+
- [Project Structure](../patterns/project-structure.md)
|
|
1191
|
+
- [Type Safety Patterns](../patterns/type-safety.md)
|
|
1192
|
+
- [WebSocket Plugin](../core/plugin-system.md)
|