@mars-stack/cli 0.2.0 → 1.0.2

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.
Files changed (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,733 @@
1
+ # Skill: Configure Realtime
2
+
3
+ Add realtime features to a MARS application using SSE, Pusher, or Ably, with a provider abstraction so the transport can be swapped without rewriting feature code.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add realtime updates, live notifications, websockets, SSE, Pusher, Ably, live collaboration, chat, or activity feeds (e.g., "add live updates", "add realtime notifications", "set up Pusher").
8
+
9
+ ## Prerequisites
10
+
11
+ - Read `src/config/app.config.ts` to check current service configuration.
12
+ - Decide on a provider: **SSE** (zero dependencies, serverless-limited), **Pusher** (managed, scales well), or **Ably** (managed, richer SDK).
13
+
14
+ ## Architecture Overview
15
+
16
+ ```
17
+ app.config.ts (services.realtime.provider: 'sse' | 'pusher' | 'ably')
18
+
19
+ Provider Abstraction (src/features/realtime/server/index.ts)
20
+
21
+ ┌──────────┬──────────┬──────────┐
22
+ │ SSE │ Pusher │ Ably │
23
+ └──────────┴──────────┴──────────┘
24
+
25
+ Client Hooks (src/features/realtime/hooks/)
26
+ ```
27
+
28
+ ## Step 1: Feature Structure
29
+
30
+ ```
31
+ src/features/realtime/
32
+ ├── server/
33
+ │ ├── index.ts # Provider abstraction + factory
34
+ │ ├── sse.ts # SSE provider implementation
35
+ │ ├── pusher.ts # Pusher provider implementation
36
+ │ └── ably.ts # Ably provider implementation
37
+ ├── hooks/
38
+ │ ├── index.ts # Barrel export
39
+ │ ├── use-event-source.ts # SSE client hook
40
+ │ ├── use-pusher.ts # Pusher client hooks
41
+ │ └── use-ably.ts # Ably client hooks
42
+ ├── types.ts # Shared types
43
+ └── validation/
44
+ └── schemas.ts
45
+ ```
46
+
47
+ ## Step 2: Types
48
+
49
+ Create `src/features/realtime/types.ts`:
50
+
51
+ ```typescript
52
+ export interface RealtimeProvider {
53
+ publish(channel: string, event: string, data: unknown): Promise<void>;
54
+ authorizChannel?(socketId: string, channel: string, userId: string): Promise<unknown>;
55
+ }
56
+
57
+ export interface RealtimeEvent<T = unknown> {
58
+ channel: string;
59
+ event: string;
60
+ data: T;
61
+ timestamp: number;
62
+ }
63
+
64
+ export type RealtimeProviderType = 'sse' | 'pusher' | 'ably';
65
+ ```
66
+
67
+ ## Step 3: Provider Abstraction
68
+
69
+ Create `src/features/realtime/server/index.ts`:
70
+
71
+ ```typescript
72
+ import 'server-only';
73
+
74
+ import { appConfig } from '@/config/app.config';
75
+ import type { RealtimeProvider } from '../types';
76
+
77
+ let _provider: RealtimeProvider | null = null;
78
+
79
+ export async function getRealtimeProvider(): Promise<RealtimeProvider> {
80
+ if (_provider) return _provider;
81
+
82
+ const providerType = appConfig.services?.realtime?.provider ?? 'sse';
83
+
84
+ switch (providerType) {
85
+ case 'pusher': {
86
+ const { PusherProvider } = await import('./pusher');
87
+ _provider = new PusherProvider();
88
+ break;
89
+ }
90
+ case 'ably': {
91
+ const { AblyProvider } = await import('./ably');
92
+ _provider = new AblyProvider();
93
+ break;
94
+ }
95
+ case 'sse':
96
+ default: {
97
+ const { SSEProvider } = await import('./sse');
98
+ _provider = new SSEProvider();
99
+ break;
100
+ }
101
+ }
102
+
103
+ return _provider;
104
+ }
105
+
106
+ export async function publish(channel: string, event: string, data: unknown) {
107
+ const provider = await getRealtimeProvider();
108
+ return provider.publish(channel, event, data);
109
+ }
110
+ ```
111
+
112
+ ## Step 4: SSE Provider
113
+
114
+ ### Server — `src/features/realtime/server/sse.ts`
115
+
116
+ ```typescript
117
+ import 'server-only';
118
+
119
+ import type { RealtimeProvider, RealtimeEvent } from '../types';
120
+
121
+ type Subscriber = (event: RealtimeEvent) => void;
122
+
123
+ const subscribers = new Map<string, Set<Subscriber>>();
124
+
125
+ export class SSEProvider implements RealtimeProvider {
126
+ async publish(channel: string, event: string, data: unknown): Promise<void> {
127
+ const channelSubs = subscribers.get(channel);
128
+ if (!channelSubs) return;
129
+
130
+ const realtimeEvent: RealtimeEvent = {
131
+ channel,
132
+ event,
133
+ data,
134
+ timestamp: Date.now(),
135
+ };
136
+
137
+ for (const subscriber of channelSubs) {
138
+ subscriber(realtimeEvent);
139
+ }
140
+ }
141
+ }
142
+
143
+ export function subscribe(channel: string, callback: Subscriber): () => void {
144
+ if (!subscribers.has(channel)) {
145
+ subscribers.set(channel, new Set());
146
+ }
147
+ subscribers.get(channel)!.add(callback);
148
+
149
+ return () => {
150
+ subscribers.get(channel)?.delete(callback);
151
+ if (subscribers.get(channel)?.size === 0) {
152
+ subscribers.delete(channel);
153
+ }
154
+ };
155
+ }
156
+ ```
157
+
158
+ ### SSE API Route — `src/app/api/protected/realtime/stream/route.ts`
159
+
160
+ ```typescript
161
+ import { verifySessionForAPI } from '@/lib/mars';
162
+ import { subscribe } from '@/features/realtime/server/sse';
163
+ import { NextRequest } from 'next/server';
164
+
165
+ export const dynamic = 'force-dynamic';
166
+
167
+ export async function GET(request: NextRequest) {
168
+ const session = await verifySessionForAPI(request);
169
+ if (!session) {
170
+ return new Response('Unauthorized', { status: 401 });
171
+ }
172
+
173
+ const channel = request.nextUrl.searchParams.get('channel');
174
+ if (!channel) {
175
+ return new Response('Channel parameter required', { status: 400 });
176
+ }
177
+
178
+ const encoder = new TextEncoder();
179
+ const stream = new ReadableStream({
180
+ start(controller) {
181
+ controller.enqueue(encoder.encode(': connected\n\n'));
182
+
183
+ const unsubscribe = subscribe(channel, (event) => {
184
+ const payload = `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`;
185
+ controller.enqueue(encoder.encode(payload));
186
+ });
187
+
188
+ request.signal.addEventListener('abort', () => {
189
+ unsubscribe();
190
+ controller.close();
191
+ });
192
+ },
193
+ });
194
+
195
+ return new Response(stream, {
196
+ headers: {
197
+ 'Content-Type': 'text/event-stream',
198
+ 'Cache-Control': 'no-cache, no-transform',
199
+ Connection: 'keep-alive',
200
+ 'Transfer-Encoding': 'chunked',
201
+ },
202
+ });
203
+ }
204
+ ```
205
+
206
+ **Serverless caveat:** SSE connections time out on serverless platforms (max ~30s on Vercel). The client hook must handle reconnection. For production use with many concurrent users, prefer Pusher or Ably.
207
+
208
+ ### SSE Client Hook — `src/features/realtime/hooks/use-event-source.ts`
209
+
210
+ ```typescript
211
+ 'use client';
212
+
213
+ import { useEffect, useRef, useCallback, useState } from 'react';
214
+
215
+ interface UseEventSourceOptions {
216
+ channel: string;
217
+ events: string[];
218
+ onEvent: (event: string, data: unknown) => void;
219
+ enabled?: boolean;
220
+ }
221
+
222
+ export function useEventSource({ channel, events, onEvent, enabled = true }: UseEventSourceOptions) {
223
+ const [connected, setConnected] = useState(false);
224
+ const eventSourceRef = useRef<EventSource | null>(null);
225
+ const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
226
+ const retriesRef = useRef(0);
227
+
228
+ const connect = useCallback(() => {
229
+ if (eventSourceRef.current) {
230
+ eventSourceRef.current.close();
231
+ }
232
+
233
+ const url = `/api/protected/realtime/stream?channel=${encodeURIComponent(channel)}`;
234
+ const es = new EventSource(url, { withCredentials: true });
235
+
236
+ es.onopen = () => {
237
+ setConnected(true);
238
+ retriesRef.current = 0;
239
+ };
240
+
241
+ for (const eventName of events) {
242
+ es.addEventListener(eventName, (e: MessageEvent) => {
243
+ try {
244
+ const data = JSON.parse(e.data);
245
+ onEvent(eventName, data);
246
+ } catch {
247
+ onEvent(eventName, e.data);
248
+ }
249
+ });
250
+ }
251
+
252
+ es.onerror = () => {
253
+ setConnected(false);
254
+ es.close();
255
+
256
+ const delay = Math.min(1000 * 2 ** retriesRef.current, 30000);
257
+ retriesRef.current += 1;
258
+
259
+ reconnectTimeoutRef.current = setTimeout(connect, delay);
260
+ };
261
+
262
+ eventSourceRef.current = es;
263
+ }, [channel, events, onEvent]);
264
+
265
+ useEffect(() => {
266
+ if (!enabled) return;
267
+ connect();
268
+
269
+ return () => {
270
+ eventSourceRef.current?.close();
271
+ if (reconnectTimeoutRef.current) {
272
+ clearTimeout(reconnectTimeoutRef.current);
273
+ }
274
+ };
275
+ }, [connect, enabled]);
276
+
277
+ return { connected };
278
+ }
279
+ ```
280
+
281
+ ## Step 5: Pusher Provider
282
+
283
+ ### Install
284
+
285
+ ```bash
286
+ yarn add pusher pusher-js
287
+ ```
288
+
289
+ ### Environment Variables
290
+
291
+ ```bash
292
+ PUSHER_APP_ID="your_app_id"
293
+ PUSHER_KEY="your_key"
294
+ PUSHER_SECRET="your_secret"
295
+ PUSHER_CLUSTER="us2"
296
+ NEXT_PUBLIC_PUSHER_KEY="your_key"
297
+ NEXT_PUBLIC_PUSHER_CLUSTER="us2"
298
+ ```
299
+
300
+ ### Server — `src/features/realtime/server/pusher.ts`
301
+
302
+ ```typescript
303
+ import 'server-only';
304
+
305
+ import Pusher from 'pusher';
306
+ import type { RealtimeProvider } from '../types';
307
+
308
+ let _pusher: Pusher | null = null;
309
+
310
+ function getPusher(): Pusher {
311
+ if (_pusher) return _pusher;
312
+
313
+ const appId = process.env.PUSHER_APP_ID;
314
+ const key = process.env.PUSHER_KEY;
315
+ const secret = process.env.PUSHER_SECRET;
316
+ const cluster = process.env.PUSHER_CLUSTER;
317
+
318
+ if (!appId || !key || !secret || !cluster) {
319
+ throw new Error(
320
+ 'Pusher credentials not set.\n'
321
+ + ' → Set PUSHER_APP_ID, PUSHER_KEY, PUSHER_SECRET, PUSHER_CLUSTER in .env\n'
322
+ + ' → Get credentials from https://dashboard.pusher.com',
323
+ );
324
+ }
325
+
326
+ _pusher = new Pusher({ appId, key, secret, cluster, useTLS: true });
327
+ return _pusher;
328
+ }
329
+
330
+ export class PusherProvider implements RealtimeProvider {
331
+ async publish(channel: string, event: string, data: unknown): Promise<void> {
332
+ await getPusher().trigger(channel, event, data);
333
+ }
334
+
335
+ async authorizChannel(socketId: string, channel: string, userId: string) {
336
+ const pusher = getPusher();
337
+
338
+ if (channel.startsWith('private-')) {
339
+ return pusher.authorizeChannel(socketId, channel);
340
+ }
341
+
342
+ if (channel.startsWith('presence-')) {
343
+ return pusher.authorizeChannel(socketId, channel, {
344
+ user_id: userId,
345
+ });
346
+ }
347
+
348
+ throw new Error(`Cannot authorize public channel: ${channel}`);
349
+ }
350
+ }
351
+ ```
352
+
353
+ ### Pusher Auth Route — `src/app/api/protected/realtime/pusher-auth/route.ts`
354
+
355
+ ```typescript
356
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
357
+ import { PusherProvider } from '@/features/realtime/server/pusher';
358
+ import { NextResponse } from 'next/server';
359
+ import { z } from 'zod';
360
+
361
+ const authSchema = z.object({
362
+ socket_id: z.string().min(1),
363
+ channel_name: z.string().min(1),
364
+ });
365
+
366
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
367
+ try {
368
+ const body = authSchema.parse(await request.json());
369
+ const provider = new PusherProvider();
370
+ const auth = await provider.authorizChannel(
371
+ body.socket_id,
372
+ body.channel_name,
373
+ request.session.userId,
374
+ );
375
+ return NextResponse.json(auth);
376
+ } catch (error) {
377
+ return handleApiError(error, { endpoint: '/api/protected/realtime/pusher-auth' });
378
+ }
379
+ });
380
+ ```
381
+
382
+ ### Pusher Client Hooks — `src/features/realtime/hooks/use-pusher.ts`
383
+
384
+ ```typescript
385
+ 'use client';
386
+
387
+ import { useEffect, useRef, useState, useCallback } from 'react';
388
+ import PusherClient from 'pusher-js';
389
+ import type { Channel } from 'pusher-js';
390
+
391
+ let _pusherClient: PusherClient | null = null;
392
+
393
+ function getPusherClient(): PusherClient {
394
+ if (_pusherClient) return _pusherClient;
395
+
396
+ _pusherClient = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
397
+ cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
398
+ channelAuthorization: {
399
+ endpoint: '/api/protected/realtime/pusher-auth',
400
+ transport: 'ajax',
401
+ },
402
+ });
403
+
404
+ return _pusherClient;
405
+ }
406
+
407
+ export function useChannel(channelName: string): Channel | null {
408
+ const [channel, setChannel] = useState<Channel | null>(null);
409
+
410
+ useEffect(() => {
411
+ const pusher = getPusherClient();
412
+ const ch = pusher.subscribe(channelName);
413
+ setChannel(ch);
414
+
415
+ return () => {
416
+ pusher.unsubscribe(channelName);
417
+ setChannel(null);
418
+ };
419
+ }, [channelName]);
420
+
421
+ return channel;
422
+ }
423
+
424
+ export function useEvent<T = unknown>(
425
+ channel: Channel | null,
426
+ eventName: string,
427
+ callback: (data: T) => void,
428
+ ) {
429
+ const callbackRef = useRef(callback);
430
+ callbackRef.current = callback;
431
+
432
+ useEffect(() => {
433
+ if (!channel) return;
434
+
435
+ const handler = (data: T) => callbackRef.current(data);
436
+ channel.bind(eventName, handler);
437
+
438
+ return () => {
439
+ channel.unbind(eventName, handler);
440
+ };
441
+ }, [channel, eventName]);
442
+ }
443
+ ```
444
+
445
+ ## Step 6: Ably Provider
446
+
447
+ ### Install
448
+
449
+ ```bash
450
+ yarn add ably
451
+ ```
452
+
453
+ ### Environment Variables
454
+
455
+ ```bash
456
+ ABLY_API_KEY="your_api_key"
457
+ NEXT_PUBLIC_ABLY_KEY="your_publishable_key"
458
+ ```
459
+
460
+ ### Server — `src/features/realtime/server/ably.ts`
461
+
462
+ ```typescript
463
+ import 'server-only';
464
+
465
+ import Ably from 'ably';
466
+ import type { RealtimeProvider } from '../types';
467
+
468
+ let _ably: Ably.Rest | null = null;
469
+
470
+ function getAbly(): Ably.Rest {
471
+ if (_ably) return _ably;
472
+
473
+ const apiKey = process.env.ABLY_API_KEY;
474
+ if (!apiKey) {
475
+ throw new Error(
476
+ 'ABLY_API_KEY is not set.\n'
477
+ + ' → Get your key from https://ably.com/accounts\n'
478
+ + ' → Add it to your .env file',
479
+ );
480
+ }
481
+
482
+ _ably = new Ably.Rest({ key: apiKey });
483
+ return _ably;
484
+ }
485
+
486
+ export class AblyProvider implements RealtimeProvider {
487
+ async publish(channel: string, event: string, data: unknown): Promise<void> {
488
+ const ably = getAbly();
489
+ const ch = ably.channels.get(channel);
490
+ await ch.publish(event, data);
491
+ }
492
+
493
+ async authorizChannel(_socketId: string, _channel: string, userId: string) {
494
+ const ably = getAbly();
495
+ return ably.auth.createTokenRequest({ clientId: userId });
496
+ }
497
+ }
498
+ ```
499
+
500
+ ### Ably Token Auth Route — `src/app/api/protected/realtime/ably-auth/route.ts`
501
+
502
+ ```typescript
503
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
504
+ import { AblyProvider } from '@/features/realtime/server/ably';
505
+ import { NextResponse } from 'next/server';
506
+
507
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
508
+ try {
509
+ const provider = new AblyProvider();
510
+ const tokenRequest = await provider.authorizChannel('', '', request.session.userId);
511
+ return NextResponse.json(tokenRequest);
512
+ } catch (error) {
513
+ return handleApiError(error, { endpoint: '/api/protected/realtime/ably-auth' });
514
+ }
515
+ });
516
+ ```
517
+
518
+ ### Ably Client Hooks — `src/features/realtime/hooks/use-ably.ts`
519
+
520
+ ```typescript
521
+ 'use client';
522
+
523
+ import { useEffect, useRef, useState } from 'react';
524
+ import * as Ably from 'ably';
525
+ import type { RealtimeChannel } from 'ably';
526
+
527
+ let _ablyClient: Ably.Realtime | null = null;
528
+
529
+ function getAblyClient(): Ably.Realtime {
530
+ if (_ablyClient) return _ablyClient;
531
+
532
+ _ablyClient = new Ably.Realtime({
533
+ authUrl: '/api/protected/realtime/ably-auth',
534
+ authMethod: 'POST',
535
+ });
536
+
537
+ return _ablyClient;
538
+ }
539
+
540
+ export function useAblyChannel(channelName: string): RealtimeChannel | null {
541
+ const [channel, setChannel] = useState<RealtimeChannel | null>(null);
542
+
543
+ useEffect(() => {
544
+ const ably = getAblyClient();
545
+ const ch = ably.channels.get(channelName);
546
+ setChannel(ch);
547
+
548
+ return () => {
549
+ ch.detach();
550
+ setChannel(null);
551
+ };
552
+ }, [channelName]);
553
+
554
+ return channel;
555
+ }
556
+
557
+ export function useAblyEvent<T = unknown>(
558
+ channel: RealtimeChannel | null,
559
+ eventName: string,
560
+ callback: (data: T) => void,
561
+ ) {
562
+ const callbackRef = useRef(callback);
563
+ callbackRef.current = callback;
564
+
565
+ useEffect(() => {
566
+ if (!channel) return;
567
+
568
+ const handler = (message: Ably.Message) => {
569
+ callbackRef.current(message.data as T);
570
+ };
571
+
572
+ channel.subscribe(eventName, handler);
573
+
574
+ return () => {
575
+ channel.unsubscribe(eventName, handler);
576
+ };
577
+ }, [channel, eventName]);
578
+ }
579
+ ```
580
+
581
+ ## Step 7: Service Config Integration
582
+
583
+ Add to `src/config/app.config.ts`:
584
+
585
+ ```typescript
586
+ services: {
587
+ // ... existing services
588
+ realtime: {
589
+ provider: 'pusher', // 'sse' | 'pusher' | 'ably'
590
+ },
591
+ },
592
+
593
+ features: {
594
+ // ... existing flags
595
+ realtime: true,
596
+ }
597
+ ```
598
+
599
+ ## Step 8: Common Patterns
600
+
601
+ ### Notification Feed
602
+
603
+ ```typescript
604
+ // Server: publish when something happens
605
+ import { publish } from '@/features/realtime/server';
606
+
607
+ await publish(`user-${userId}`, 'notification', {
608
+ title: 'New comment',
609
+ body: 'Someone commented on your post',
610
+ url: '/posts/123',
611
+ });
612
+
613
+ // Client: listen for notifications
614
+ import { useChannel, useEvent } from '@/features/realtime/hooks/use-pusher';
615
+
616
+ function NotificationBell({ userId }: { userId: string }) {
617
+ const [notifications, setNotifications] = useState<Notification[]>([]);
618
+ const channel = useChannel(`private-user-${userId}`);
619
+
620
+ useEvent(channel, 'notification', (data: Notification) => {
621
+ setNotifications((prev) => [data, ...prev]);
622
+ });
623
+
624
+ return <NotificationList items={notifications} />;
625
+ }
626
+ ```
627
+
628
+ ### Live Collaboration Cursors
629
+
630
+ ```typescript
631
+ // Broadcast cursor position
632
+ import { publish } from '@/features/realtime/server';
633
+
634
+ await publish(`document-${docId}`, 'cursor-move', {
635
+ userId,
636
+ x: cursor.x,
637
+ y: cursor.y,
638
+ color: userColor,
639
+ });
640
+ ```
641
+
642
+ ### Chat Messages
643
+
644
+ ```typescript
645
+ // Server: broadcast new message
646
+ await publish(`chat-${roomId}`, 'message', {
647
+ id: message.id,
648
+ userId: message.userId,
649
+ text: message.text,
650
+ createdAt: message.createdAt,
651
+ });
652
+ ```
653
+
654
+ ### Activity Stream
655
+
656
+ ```typescript
657
+ // Server: publish activity events
658
+ await publish(`org-${orgId}`, 'activity', {
659
+ actor: { id: userId, name: userName },
660
+ action: 'created',
661
+ target: { type: 'document', id: docId, name: docName },
662
+ timestamp: Date.now(),
663
+ });
664
+ ```
665
+
666
+ ## Provider Comparison
667
+
668
+ | Feature | SSE | Pusher | Ably |
669
+ |---------|-----|--------|------|
670
+ | Dependencies | None | `pusher`, `pusher-js` | `ably` |
671
+ | Serverless-friendly | Limited (30s timeout) | Yes | Yes |
672
+ | Private channels | Manual auth | Built-in | Built-in |
673
+ | Presence | Manual | Built-in | Built-in |
674
+ | Max connections | Process-bound | 100+ concurrent (free) | 200+ concurrent (free) |
675
+ | Cost | Free | Free tier, then paid | Free tier, then paid |
676
+ | Complexity | Low | Medium | Medium |
677
+
678
+ **Recommendation:** Use SSE for prototyping or low-traffic internal tools. Use Pusher for most production apps (best DX). Use Ably if you need advanced features like message history or guaranteed ordering.
679
+
680
+ ## Tests
681
+
682
+ ```typescript
683
+ import { describe, it, expect, vi } from 'vitest';
684
+ import { SSEProvider } from './sse';
685
+
686
+ describe('SSEProvider', () => {
687
+ it('publishes events to subscribers', async () => {
688
+ const provider = new SSEProvider();
689
+ const callback = vi.fn();
690
+
691
+ const { subscribe } = await import('./sse');
692
+ const unsubscribe = subscribe('test-channel', callback);
693
+
694
+ await provider.publish('test-channel', 'test-event', { hello: 'world' });
695
+
696
+ expect(callback).toHaveBeenCalledWith(
697
+ expect.objectContaining({
698
+ channel: 'test-channel',
699
+ event: 'test-event',
700
+ data: { hello: 'world' },
701
+ }),
702
+ );
703
+
704
+ unsubscribe();
705
+ });
706
+ });
707
+ ```
708
+
709
+ Mock the provider for feature tests:
710
+
711
+ ```typescript
712
+ vi.mock('@/features/realtime/server', () => ({
713
+ publish: vi.fn(),
714
+ getRealtimeProvider: vi.fn(() => ({
715
+ publish: vi.fn(),
716
+ })),
717
+ }));
718
+ ```
719
+
720
+ ## Checklist
721
+
722
+ - [ ] Feature structure created (`src/features/realtime/`)
723
+ - [ ] Provider type added to `types.ts`
724
+ - [ ] Provider abstraction with factory in `server/index.ts`
725
+ - [ ] SSE provider implemented with stream route and client hook
726
+ - [ ] Pusher provider implemented with auth route and client hooks (if using Pusher)
727
+ - [ ] Ably provider implemented with token route and client hooks (if using Ably)
728
+ - [ ] Environment variables documented and added to `.env`
729
+ - [ ] `app.config.ts` updated with `services.realtime` and `features.realtime`
730
+ - [ ] Client hooks handle reconnection and cleanup
731
+ - [ ] Auth enforced on all realtime endpoints
732
+ - [ ] Tests written for provider abstraction and SSE provider
733
+ - [ ] Serverless timeout caveat documented for SSE