@proveanything/smartlinks 2.0.54 → 2.0.56

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.
@@ -1,765 +1,118 @@
1
- # Real-Time Messaging with Ably
2
-
3
- This guide covers adding Ably real-time messaging to SmartLinks apps that need live updates, chat, or presence features.
4
-
5
- ---
6
-
7
- ## Overview
8
-
9
- Ably provides real-time pub/sub messaging over WebSockets. It's ideal for:
10
-
11
- - **Live chat** between users
12
- - **Real-time vote/poll results** that update instantly
13
- - **Presence indicators** showing who's online
14
- - **Live activity feeds** and notifications
15
- - **Collaborative features** without polling
16
-
17
- ### Why It's Not in the Base Template
18
-
19
- Ably is intentionally excluded from the base template because:
20
-
21
- 1. **Bundle size**: Ably SDK adds ~50KB+ to the bundle
22
- 2. **Connection overhead**: Initializing Ably opens a WebSocket connection
23
- 3. **Cost**: Ably charges per connection/message
24
- 4. **Not always needed**: Most apps (pamphlets, manuals, warranties) don't need real-time
25
-
26
- Apps that need real-time features should add Ably on-demand.
27
-
28
- ---
29
-
30
- ## Installation
31
-
32
- Add Ably to your app:
33
-
34
- ```bash
35
- npm install ably
36
- ```
37
-
38
- This adds the Ably SDK with React hooks support.
39
-
40
- ---
41
-
42
- ## Quick Start
43
-
44
- ### 1. Create the Ably Client
45
-
46
- ```typescript
47
- // src/lib/ably.ts
48
- import Ably from 'ably';
49
-
50
- export const createAblyClient = (apiKey: string) => {
51
- return new Ably.Realtime({ key: apiKey });
52
- };
53
- ```
54
-
55
- ### 2. Add the Provider
56
-
57
- Wrap your app with the Ably provider:
58
-
59
- ```typescript
60
- // In PublicApp.tsx or AdminApp.tsx
61
- import { AblyProvider } from 'ably/react';
62
- import { createAblyClient } from '@/lib/ably';
63
-
64
- const ablyClient = createAblyClient(import.meta.env.VITE_ABLY_API_KEY);
65
-
66
- function App() {
67
- return (
68
- <AblyProvider client={ablyClient}>
69
- <YourApp />
70
- </AblyProvider>
71
- );
72
- }
73
- ```
74
-
75
- ### 3. Subscribe to a Channel
76
-
77
- ```typescript
78
- import { useChannel } from 'ably/react';
79
- import { useState } from 'react';
80
-
81
- export const LiveUpdates = ({ collectionId }: { collectionId: string }) => {
82
- const [updates, setUpdates] = useState<string[]>([]);
83
-
84
- const { channel } = useChannel(
85
- `collection:${collectionId}:updates`,
86
- (message) => {
87
- setUpdates(prev => [...prev, message.data.text]);
88
- }
89
- );
90
-
91
- const sendUpdate = (text: string) => {
92
- channel.publish('update', { text });
93
- };
94
-
95
- return (
96
- <div>
97
- {updates.map((update, i) => (
98
- <p key={i}>{update}</p>
99
- ))}
100
- <button onClick={() => sendUpdate('Hello!')}>
101
- Send Update
102
- </button>
103
- </div>
104
- );
105
- };
106
- ```
107
-
108
- ---
109
-
110
- ## Setup Patterns
111
-
112
- ### Option A: Direct API Key (Development)
113
-
114
- For development or simple apps where the API key can be in environment variables:
115
-
116
- ```typescript
117
- // src/lib/ably.ts
118
- import Ably from 'ably';
119
-
120
- export const createAblyClient = () => {
121
- const apiKey = import.meta.env.VITE_ABLY_API_KEY;
122
-
123
- if (!apiKey) {
124
- console.warn('VITE_ABLY_API_KEY not set - real-time features disabled');
125
- return null;
126
- }
127
-
128
- return new Ably.Realtime({ key: apiKey });
129
- };
130
- ```
131
-
132
- Add to your `.env`:
133
-
134
- ```env
135
- VITE_ABLY_API_KEY=your-ably-api-key
136
- ```
137
-
138
- ### Option B: Token Auth (Production) - SmartLinks SDK
139
-
140
- For production apps, use the SmartLinks SDK's built-in token authentication:
141
-
142
- ```typescript
143
- // src/lib/ably.ts
144
- import Ably from 'ably';
145
- import * as SL from '@proveanything/smartlinks';
146
-
147
- /**
148
- * Create an Ably client using SmartLinks token authentication.
149
- * This is the recommended approach for production apps.
150
- */
151
- export const createAblyClientWithSmartLinks = (collectionId: string, appId?: string) => {
152
- return new Ably.Realtime({
153
- authCallback: async (tokenParams, callback) => {
154
- try {
155
- // Use the SmartLinks SDK to get a scoped token
156
- const tokenRequest = await SL.realtime.getPublicToken({
157
- collectionId,
158
- appId,
159
- });
160
- callback(null, tokenRequest);
161
- } catch (error) {
162
- callback(error as Error, null);
163
- }
164
- }
165
- });
166
- };
167
-
168
- /**
169
- * Create an Ably client for admin real-time features.
170
- * Provides subscribe-only access to interaction channels.
171
- */
172
- export const createAblyClientForAdmin = () => {
173
- return new Ably.Realtime({
174
- authCallback: async (tokenParams, callback) => {
175
- try {
176
- const tokenRequest = await SL.realtime.getAdminToken();
177
- callback(null, tokenRequest);
178
- } catch (error) {
179
- callback(error as Error, null);
180
- }
181
- }
182
- });
183
- };
184
- ```
185
-
186
- ### SmartLinks SDK Real-Time Functions
187
-
188
- The SDK provides two token endpoints:
189
-
190
- | Function | Use Case | Parameters |
191
- |----------|----------|------------|
192
- | `SL.realtime.getPublicToken()` | User-scoped real-time (chat, votes, presence) | `collectionId` (required), `appId` (optional) |
193
- | `SL.realtime.getAdminToken()` | Admin real-time (interaction monitoring) | None |
194
-
195
- Both require the user to be authenticated via the parent SmartLinks platform.
196
-
197
- Token auth benefits:
198
- - API key never exposed to client
199
- - Tokens can have capability restrictions
200
- - Tokens expire automatically
201
-
202
- ### Conditional Provider Setup
203
-
204
- Handle cases where Ably isn't configured:
205
-
206
- ```typescript
207
- // src/providers/AblyProvider.tsx
208
- import { AblyProvider as AblyReactProvider } from 'ably/react';
209
- import { createAblyClient } from '@/lib/ably';
210
- import { ReactNode, useMemo } from 'react';
211
-
212
- interface Props {
213
- children: ReactNode;
214
- }
215
-
216
- export const AblyProvider = ({ children }: Props) => {
217
- const client = useMemo(() => createAblyClient(), []);
218
-
219
- // If no client (no API key), render children without provider
220
- if (!client) {
221
- return <>{children}</>;
222
- }
223
-
224
- return (
225
- <AblyReactProvider client={client}>
226
- {children}
227
- </AblyReactProvider>
228
- );
229
- };
230
- ```
231
-
232
- ---
233
-
234
- ## Common Patterns
235
-
236
- ### Subscribing to a Channel
237
-
238
- ```typescript
239
- import { useChannel } from 'ably/react';
240
-
241
- const MyComponent = () => {
242
- const { channel } = useChannel('my-channel', (message) => {
243
- console.log('Received:', message.name, message.data);
244
- });
245
-
246
- return <div>Listening...</div>;
247
- };
248
- ```
249
-
250
- ### Publishing Messages
251
-
252
- ```typescript
253
- const { channel } = useChannel('my-channel', handleMessage);
254
-
255
- const sendMessage = (text: string) => {
256
- channel.publish('message', {
257
- text,
258
- timestamp: Date.now(),
259
- sender: userId
260
- });
261
- };
262
- ```
263
-
264
- ### Presence (Who's Online)
265
-
266
- ```typescript
267
- import { usePresence } from 'ably/react';
268
-
269
- const OnlineUsers = ({ roomId }: { roomId: string }) => {
270
- const { presenceData, updateStatus } = usePresence(roomId, {
271
- id: currentUser.id,
272
- name: currentUser.name,
273
- });
274
-
275
- return (
276
- <div>
277
- <h3>Online ({presenceData.length})</h3>
278
- <ul>
279
- {presenceData.map((member) => (
280
- <li key={member.clientId}>{member.data.name}</li>
281
- ))}
282
- </ul>
283
- </div>
284
- );
285
- };
286
- ```
287
-
288
- ### Connection State
289
-
290
- ```typescript
291
- import { useConnectionStateListener } from 'ably/react';
292
-
293
- const ConnectionStatus = () => {
294
- const [status, setStatus] = useState('connecting');
295
-
296
- useConnectionStateListener((stateChange) => {
297
- setStatus(stateChange.current);
298
- });
299
-
300
- return (
301
- <div className={status === 'connected' ? 'text-green-500' : 'text-yellow-500'}>
302
- {status}
303
- </div>
304
- );
305
- };
306
- ```
307
-
308
- ---
309
-
310
- ## SmartLinks Integration
311
-
312
- ### Channel Naming Conventions
313
-
314
- Use consistent channel names that include SmartLinks context:
315
-
316
- | Scope | Pattern | Example |
317
- |-------|---------|---------|
318
- | Collection | `collection:${collectionId}:${feature}` | `collection:abc123:updates` |
319
- | Product | `product:${collectionId}:${productId}:${feature}` | `product:abc123:prod456:chat` |
320
- | Proof | `proof:${collectionId}:${productId}:${proofId}:${feature}` | `proof:abc123:prod456:prf789:activity` |
321
-
322
- ### Helper for Channel Names
323
-
324
- ```typescript
325
- // src/lib/ably-channels.ts
326
- export const channels = {
327
- collectionUpdates: (collectionId: string) =>
328
- `collection:${collectionId}:updates`,
329
-
330
- productChat: (collectionId: string, productId: string) =>
331
- `product:${collectionId}:${productId}:chat`,
332
-
333
- proofActivity: (collectionId: string, productId: string, proofId: string) =>
334
- `proof:${collectionId}:${productId}:${proofId}:activity`,
335
-
336
- voteResults: (collectionId: string, appId: string, interactionId: string) =>
337
- `votes:${collectionId}:${appId}:${interactionId}`,
338
- };
339
- ```
340
-
341
- ### Usage with SmartLinks Context
342
-
343
- ```typescript
344
- import { useChannel } from 'ably/react';
345
- import { usePersistentQueryParams } from '@/hooks/usePersistentQueryParams';
346
- import { channels } from '@/lib/ably-channels';
347
-
348
- const LiveProductChat = () => {
349
- const { persistentQueryParams } = usePersistentQueryParams();
350
- const { collectionId, productId } = persistentQueryParams;
351
-
352
- const channelName = channels.productChat(collectionId!, productId!);
353
-
354
- const { channel } = useChannel(channelName, (message) => {
355
- // Handle incoming chat message
356
- });
357
-
358
- // ...
359
- };
360
- ```
361
-
362
- ---
363
-
364
- ## Example: Live Chat Component
365
-
366
- Full working example of a chat component:
367
-
368
- ```typescript
369
- // src/components/LiveChat.tsx
370
- import { useChannel, usePresence } from 'ably/react';
371
- import { useState, useRef, useEffect } from 'react';
372
- import { Button } from '@/components/ui/button';
373
- import { Input } from '@/components/ui/input';
374
- import { ScrollArea } from '@/components/ui/scroll-area';
375
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
376
-
377
- interface ChatMessage {
378
- id: string;
379
- text: string;
380
- sender: string;
381
- senderName: string;
382
- timestamp: number;
383
- }
384
-
385
- interface LiveChatProps {
386
- channelName: string;
387
- userId: string;
388
- userName: string;
389
- }
390
-
391
- export const LiveChat = ({ channelName, userId, userName }: LiveChatProps) => {
392
- const [messages, setMessages] = useState<ChatMessage[]>([]);
393
- const [inputValue, setInputValue] = useState('');
394
- const scrollRef = useRef<HTMLDivElement>(null);
395
-
396
- // Subscribe to chat messages
397
- const { channel } = useChannel(channelName, (message) => {
398
- if (message.name === 'chat') {
399
- setMessages(prev => [...prev, message.data as ChatMessage]);
400
- }
401
- });
402
-
403
- // Track presence
404
- const { presenceData } = usePresence(channelName, {
405
- id: userId,
406
- name: userName,
407
- });
408
-
409
- // Auto-scroll to bottom
410
- useEffect(() => {
411
- scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
412
- }, [messages]);
413
-
414
- const sendMessage = () => {
415
- if (!inputValue.trim()) return;
416
-
417
- const message: ChatMessage = {
418
- id: `${userId}-${Date.now()}`,
419
- text: inputValue.trim(),
420
- sender: userId,
421
- senderName: userName,
422
- timestamp: Date.now(),
423
- };
424
-
425
- channel.publish('chat', message);
426
- setInputValue('');
427
- };
428
-
429
- return (
430
- <div className="flex flex-col h-[400px] border rounded-lg">
431
- {/* Header with presence */}
432
- <div className="p-3 border-b bg-muted/50">
433
- <div className="flex items-center gap-2">
434
- <span className="text-sm font-medium">Live Chat</span>
435
- <span className="text-xs text-muted-foreground">
436
- ({presenceData.length} online)
437
- </span>
438
- </div>
439
- </div>
440
-
441
- {/* Messages */}
442
- <ScrollArea className="flex-1 p-4">
443
- <div className="space-y-4">
444
- {messages.map((msg) => (
445
- <div
446
- key={msg.id}
447
- className={`flex gap-2 ${
448
- msg.sender === userId ? 'flex-row-reverse' : ''
449
- }`}
450
- >
451
- <Avatar className="h-8 w-8">
452
- <AvatarFallback>
453
- {msg.senderName.charAt(0).toUpperCase()}
454
- </AvatarFallback>
455
- </Avatar>
456
- <div
457
- className={`rounded-lg px-3 py-2 max-w-[70%] ${
458
- msg.sender === userId
459
- ? 'bg-primary text-primary-foreground'
460
- : 'bg-muted'
461
- }`}
462
- >
463
- <p className="text-sm">{msg.text}</p>
464
- <span className="text-xs opacity-70">
465
- {new Date(msg.timestamp).toLocaleTimeString()}
466
- </span>
467
- </div>
468
- </div>
469
- ))}
470
- <div ref={scrollRef} />
471
- </div>
472
- </ScrollArea>
473
-
474
- {/* Input */}
475
- <div className="p-3 border-t">
476
- <form
477
- onSubmit={(e) => {
478
- e.preventDefault();
479
- sendMessage();
480
- }}
481
- className="flex gap-2"
482
- >
483
- <Input
484
- value={inputValue}
485
- onChange={(e) => setInputValue(e.target.value)}
486
- placeholder="Type a message..."
487
- className="flex-1"
488
- />
489
- <Button type="submit" size="sm">
490
- Send
491
- </Button>
492
- </form>
493
- </div>
494
- </div>
495
- );
496
- };
497
- ```
498
-
499
- ---
500
-
501
- ## Example: Live Vote Counter
502
-
503
- Display real-time vote tallies:
504
-
505
- ```typescript
506
- // src/components/LiveVoteCounter.tsx
507
- import { useChannel } from 'ably/react';
508
- import { useState, useEffect } from 'react';
509
- import { Progress } from '@/components/ui/progress';
510
- import * as SL from '@proveanything/smartlinks';
511
-
512
- interface VoteCounts {
513
- [option: string]: number;
514
- }
515
-
516
- interface LiveVoteCounterProps {
517
- collectionId: string;
518
- appId: string;
519
- interactionId: string;
520
- options: string[];
521
- }
522
-
523
- export const LiveVoteCounter = ({
524
- collectionId,
525
- appId,
526
- interactionId,
527
- options,
528
- }: LiveVoteCounterProps) => {
529
- const [votes, setVotes] = useState<VoteCounts>({});
530
- const channelName = `votes:${collectionId}:${appId}:${interactionId}`;
531
-
532
- // Fetch initial counts
533
- useEffect(() => {
534
- const fetchCounts = async () => {
535
- const counts = await SL.interactions.countsByOutcome(collectionId, {
536
- appId,
537
- interactionId,
538
- });
539
- setVotes(counts || {});
540
- };
541
- fetchCounts();
542
- }, [collectionId, appId, interactionId]);
543
-
544
- // Subscribe to real-time vote updates
545
- useChannel(channelName, (message) => {
546
- if (message.name === 'vote') {
547
- const { option } = message.data;
548
- setVotes(prev => ({
549
- ...prev,
550
- [option]: (prev[option] || 0) + 1,
551
- }));
552
- }
553
- });
554
-
555
- const totalVotes = Object.values(votes).reduce((sum, n) => sum + n, 0);
556
-
557
- return (
558
- <div className="space-y-4 p-4">
559
- <h3 className="font-semibold">Live Results</h3>
560
-
561
- {options.map((option) => {
562
- const count = votes[option] || 0;
563
- const percentage = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
564
-
565
- return (
566
- <div key={option} className="space-y-1">
567
- <div className="flex justify-between text-sm">
568
- <span>{option}</span>
569
- <span className="text-muted-foreground">
570
- {count} ({percentage.toFixed(1)}%)
571
- </span>
572
- </div>
573
- <Progress value={percentage} className="h-2" />
574
- </div>
575
- );
576
- })}
577
-
578
- <p className="text-sm text-muted-foreground text-center">
579
- Total votes: {totalVotes}
580
- </p>
581
- </div>
582
- );
583
- };
584
- ```
585
-
586
- ### Publishing Vote Updates
587
-
588
- When a user votes, publish to the channel:
589
-
590
- ```typescript
591
- const submitVote = async (option: string) => {
592
- // Record in SmartLinks
593
- // 'poll' must already exist as an interaction type definition for this collection.
594
- await SL.interactions.submitPublicEvent(collectionId, {
595
- appId,
596
- interactionId: 'poll',
597
- outcome: option,
598
- });
599
-
600
- // Publish to Ably for real-time updates
601
- channel.publish('vote', { option });
602
- };
603
- ```
604
-
605
- ---
606
-
607
- ## Best Practices
608
-
609
- ### 1. Clean Up Subscriptions
610
-
611
- React hooks automatically handle cleanup, but for manual subscriptions:
612
-
613
- ```typescript
614
- useEffect(() => {
615
- const channel = ably.channels.get('my-channel');
616
-
617
- const handler = (message: Ably.Message) => {
618
- // Handle message
619
- };
620
-
621
- channel.subscribe('event', handler);
622
-
623
- return () => {
624
- channel.unsubscribe('event', handler);
625
- };
626
- }, []);
627
- ```
628
-
629
- ### 2. Handle Connection States
630
-
631
- ```typescript
632
- const { channel } = useChannel('my-channel', handleMessage);
633
-
634
- useConnectionStateListener((stateChange) => {
635
- if (stateChange.current === 'disconnected') {
636
- // Show reconnecting UI
637
- }
638
- if (stateChange.current === 'connected') {
639
- // Clear reconnecting UI, maybe refetch missed messages
640
- }
641
- });
642
- ```
643
-
644
- ### 3. Debounce Rapid Updates
645
-
646
- For typing indicators or frequent updates:
647
-
648
- ```typescript
649
- import { useDebouncedCallback } from 'use-debounce';
650
-
651
- const debouncedPublish = useDebouncedCallback((data) => {
652
- channel.publish('typing', data);
653
- }, 300);
654
- ```
655
-
656
- ### 4. Message Size Limits
657
-
658
- Ably has a 64KB message size limit. For large data:
659
- - Store in SmartLinks/database
660
- - Send only the ID via Ably
661
- - Fetch full data on receive
662
-
663
- ```typescript
664
- // Publishing
665
- channel.publish('update', { recordId: '123' });
666
-
667
- // Receiving
668
- useChannel(channelName, async (message) => {
669
- const fullData = await SL.appConfiguration.getDataItem({
670
- collectionId,
671
- appId,
672
- itemId: message.data.recordId,
673
- });
674
- // Use fullData
675
- });
676
- ```
677
-
678
- ### 5. Error Handling
679
-
680
- ```typescript
681
- try {
682
- await channel.publish('event', data);
683
- } catch (error) {
684
- if (error instanceof Ably.ErrorInfo) {
685
- console.error('Ably error:', error.code, error.message);
686
- // Handle specific error codes
687
- }
688
- }
689
- ```
690
-
691
- ---
692
-
693
- ## Environment Variables
694
-
695
- For development, add to your local `.env`:
696
-
697
- ```env
698
- VITE_ABLY_API_KEY=your-api-key-here
699
- ```
700
-
701
- For production, use Lovable Cloud secrets or your deployment platform's environment variables.
702
-
703
- ---
704
-
705
- ## TypeScript Types
706
-
707
- Ably provides full TypeScript support. Key types:
708
-
709
- ```typescript
710
- import Ably from 'ably';
711
-
712
- // Message type
713
- interface CustomMessage {
714
- text: string;
715
- sender: string;
716
- timestamp: number;
717
- }
718
-
719
- // Typed channel usage
720
- const { channel } = useChannel<CustomMessage>('my-channel', (message) => {
721
- const data: CustomMessage = message.data;
722
- console.log(data.text); // Typed!
723
- });
724
-
725
- // Presence data type
726
- interface PresenceData {
727
- id: string;
728
- name: string;
729
- status: 'active' | 'away';
730
- }
731
-
732
- const { presenceData } = usePresence<PresenceData>(channelName, {
733
- id: userId,
734
- name: userName,
735
- status: 'active',
736
- });
737
- ```
738
-
739
- ---
740
-
741
- ## Troubleshooting
742
-
743
- ### "Ably is not defined"
744
-
745
- Ensure you've installed the package:
746
- ```bash
747
- npm install ably
748
- ```
749
-
750
- ### "No API key provided"
751
-
752
- Check that `VITE_ABLY_API_KEY` is set in your environment.
753
-
754
- ### Messages not received
755
-
756
- 1. Check channel names match exactly
757
- 2. Verify connection state is 'connected'
758
- 3. Check browser console for Ably errors
759
- 4. Ensure you're subscribed before publishing
760
-
761
- ### Too many connections
762
-
763
- Each browser tab opens a new connection. In development:
764
- - Close unused tabs
765
- - Use the same client instance across components (provider pattern)
1
+ # SmartLinks Realtime
2
+
3
+ Live updates for apps: a big screen showing the raffle draw, an admin list that updates as records
4
+ arrive, a menu that refreshes when an app is installed. **No provider library, no keys, no channel
5
+ strings in your app.** SmartLinks hands out a stream for exactly what the viewer may see, and messages
6
+ you send go through SmartLinks, which checks the channel's rule.
7
+
8
+ There are two kinds of realtime.
9
+
10
+ | | What it is | Use it for |
11
+ |---|---|---|
12
+ | **Change events** | "this changed, here's its id": ids only, never data | refetching a list or a record when it changes |
13
+ | **App channels** | messages your app sends, on channels it declares | a draw result, a live status, a chat line |
14
+
15
+ ## Change events: refetch when something changes
16
+
17
+ ```ts
18
+ import { realtime } from '@proveanything/smartlinks'
19
+
20
+ // Refetch the raffle's entries whenever one is added, changed or removed.
21
+ const off = realtime.onChange(
22
+ { collectionId },
23
+ { type: 'record', appId: 'quickRaffle', recordType: `entry:${raffleId}` },
24
+ () => refetchEntries(),
25
+ )
26
+ // …later
27
+ off()
28
+ ```
29
+
30
+ - **Types:** `product`, `proof`, `asset`, `record`, `collection`, `collection.apps` (an app was
31
+ installed, removed or switched), `settings`.
32
+ - **Filter fields:** `type`, `id`, `appId`, `recordType`, `productId`, `proofId`. Every field you give
33
+ must match.
34
+ - **Who hears what:**
35
+ - a collection's **admins** hear every change;
36
+ - **everyone else** hears changes to public things (public app records, products).
37
+ - **A change carries ids, never data.** Refetch through the normal API, which applies the normal
38
+ permissions, so realtime can never show someone data they couldn't read anyway.
39
+
40
+ With React Query, invalidate on change:
41
+
42
+ ```ts
43
+ useEffect(() => realtime.onChange({ collectionId }, { type: 'record', appId }, () => {
44
+ queryClient.invalidateQueries({ queryKey: ['records', appId] })
45
+ }), [collectionId, appId])
46
+ ```
47
+
48
+ ## App channels: declare them, then subscribe and publish
49
+
50
+ **1. Declare** each channel in `app.manifest.json`, with who may listen and who may send:
51
+
52
+ ```json
53
+ "realtime": {
54
+ "channels": [
55
+ { "name": "draw", "subscribe": "public", "publish": "admin" },
56
+ { "name": "chat", "subscribe": "signedIn", "publish": "signedIn" },
57
+ { "name": "result","subscribe": "public", "publish": "server" }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ | Level | Who |
63
+ |---|---|
64
+ | `public` | anyone, signed in or not: a big screen, a kiosk |
65
+ | `signedIn` | anyone signed in |
66
+ | `admin` | the collection's admins |
67
+ | `server` (publish only) | only your app's server functions: the authoritative way to announce a result |
68
+
69
+ - **Names:** lowercase letters, numbers, `-` and `_`, up to 40 characters.
70
+ - **Defaults:** if a level is left out, `subscribe` defaults to `admin` and `publish` to `server`.
71
+ - **Undeclared channels don't exist,** and anything sent to them is refused.
72
+
73
+ **2. Subscribe and publish** from your app:
74
+
75
+ ```ts
76
+ const draw = realtime.channel({ collectionId, appId }, 'draw')
77
+
78
+ const off = draw.subscribe('status', (msg) => setStatus(msg.data)) // or '*' for every event
79
+ await draw.publish('status', { phase: 'spinning' }) // 403 unless the rule allows it
80
+ ```
81
+
82
+ **3. From a server function**, publish to any of your declared channels. This needs the
83
+ `sl:realtime:write` capability:
84
+
85
+ ```ts
86
+ await ctx.sl.realtime.publish('result', 'winner', { number: 42 })
87
+ ```
88
+
89
+ Messages are limited to 32 KB. Keep them small: send ids and status, and let clients fetch the rest.
90
+
91
+ ## Messages for one person
92
+
93
+ `realtime.onMine({ collectionId }, (msg) => …)` hears messages meant only for the signed-in person. For
94
+ example, `scan`: a tag they just scanned with Sidekick on another device.
95
+
96
+ ## How it behaves
97
+
98
+ - **One connection per page** (per collection and app), shared by everything listening. It opens with the
99
+ first listener and closes after the last.
100
+ - **It reconnects on its own** and renews its access before it expires.
101
+ - **Anonymous viewers** get public channels only. That's enough for a big screen, with no polling.
102
+ - **Outside a browser** (SSR, tests) there's no stream: listeners simply never fire.
103
+ - **Delivery is best effort.** A message sent while a viewer was offline isn't replayed, so on
104
+ reconnect, refetch what you show.
105
+
106
+ ## Don't
107
+
108
+ - **Don't add a realtime provider's library or put an API key in your app.** Apps never talk to the
109
+ provider directly. A key in a bundle is a key for everyone.
110
+ - **Don't announce authoritative results from the browser.** Use a `server` channel and publish from a
111
+ server function.
112
+ - **Don't put personal data in messages.** Publish an id; fetch the details with permissions.
113
+
114
+ ## Legacy
115
+
116
+ `realtime.getPublicToken()` and `realtime.getAdminToken()` return raw provider tokens for the old
117
+ `collection:{id}:*` and `interaction:{uid}` channels. They still work, but publishing is now limited to
118
+ the collection's admins. Move to `channel` / `onChange` / `onMine`.